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/doctor.js
CHANGED
|
@@ -15,7 +15,7 @@ import { openHippoDbReadOnly, closeHippoDb, getSchemaVersion, getCurrentSchemaVe
|
|
|
15
15
|
import { REPLAY_AFTER_MS, TRANSCRIPT_FILL_WINDOW_MS } from './compaction-record.js';
|
|
16
16
|
import { isEmbeddingAvailable } from './local-embedding.js';
|
|
17
17
|
import { CODEX_TRUST_LINE, codexHomeDir, isCodexPresent, isJsonObject } from './hooks/shared.js';
|
|
18
|
-
import {
|
|
18
|
+
import { planProjectRepair } from './project-merge.js';
|
|
19
19
|
import { resolveTenantId } from './tenant.js';
|
|
20
20
|
/** Minimum Node.js version hippo supports (package.json engines). */
|
|
21
21
|
export const MIN_NODE = '22.16.0';
|
|
@@ -131,16 +131,20 @@ function compactionsCheck(db, now) {
|
|
|
131
131
|
: { id: 'compactions', status: 'warn', detail: `cannot read the compaction records: ${message}` };
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
-
/**
|
|
134
|
+
/** Old project tags in the global store, counted by the repair's own plan so a truly user-global merge never warns. */
|
|
135
135
|
function projectsCheck(globalRoot) {
|
|
136
136
|
let db = null;
|
|
137
137
|
try {
|
|
138
138
|
db = openHippoDbReadOnly(globalRoot);
|
|
139
|
-
const r =
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
const r = planProjectRepair(db, globalRoot, resolveTenantId({}));
|
|
140
|
+
const found = [
|
|
141
|
+
r.copies.length > 0 ? `${r.copies.length} imported notes copied under the wrong project` : '',
|
|
142
|
+
r.folds.length > 0 ? `${r.folds.length} old project names that now resolve to another project` : '',
|
|
143
|
+
r.toProject.length + r.setAside.length > 0 ? `${r.toProject.length + r.setAside.length} merged memories tagged user-global` : '',
|
|
144
|
+
].filter((s) => s !== '');
|
|
145
|
+
return found.length === 0
|
|
146
|
+
? { id: 'projects', status: 'pass', detail: 'no duplicate or out-of-date project tags in the global store' }
|
|
147
|
+
: { id: 'projects', status: 'warn', detail: `global store: ${found.join('; ')}`, fix: 'hippo projects repair --global (dry run; add --apply to write)' };
|
|
144
148
|
}
|
|
145
149
|
catch (err) {
|
|
146
150
|
return { id: 'projects', status: 'info', detail: `project tags not checked (${err instanceof Error ? err.message : String(err)})` };
|
package/dist/goals.js
CHANGED
|
@@ -159,6 +159,101 @@ export function getActiveGoalsWithDb(db, opts) {
|
|
|
159
159
|
`).all(opts.tenantId, opts.sessionId);
|
|
160
160
|
return rows.map(rowToGoal);
|
|
161
161
|
}
|
|
162
|
+
// Load retrieval_policy rows for active goals so per-policy multipliers
|
|
163
|
+
// can compose onto the base goal-tag boost. Composed result is hard-capped
|
|
164
|
+
// at MAX_FINAL_MULTIPLIER (3.0x) BEFORE applying to score -- even an
|
|
165
|
+
// `errorPriority: 9.0` policy cannot exceed 3.0x.
|
|
166
|
+
function loadGoalPolicies(db, active) {
|
|
167
|
+
const policiesByGoalId = new Map();
|
|
168
|
+
for (const g of active) {
|
|
169
|
+
if (!g.retrievalPolicyId)
|
|
170
|
+
continue;
|
|
171
|
+
// SAFETY: the row comes from the SELECT above, which projects exactly
|
|
172
|
+
// these seven retrieval_policy columns.
|
|
173
|
+
const row = db.prepare(`
|
|
174
|
+
SELECT id, goal_id, policy_type, weight_schema_fit, weight_recency, weight_outcome, error_priority
|
|
175
|
+
FROM retrieval_policy WHERE id = ?
|
|
176
|
+
`).get(g.retrievalPolicyId);
|
|
177
|
+
if (row) {
|
|
178
|
+
policiesByGoalId.set(g.id, {
|
|
179
|
+
id: row.id,
|
|
180
|
+
goalId: row.goal_id,
|
|
181
|
+
policyType: row.policy_type,
|
|
182
|
+
weightSchemaFit: row.weight_schema_fit,
|
|
183
|
+
weightRecency: row.weight_recency,
|
|
184
|
+
weightOutcome: row.weight_outcome,
|
|
185
|
+
errorPriority: row.error_priority,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return policiesByGoalId;
|
|
190
|
+
}
|
|
191
|
+
/** The capped boost for one row whose tags match `matches` active goals. */
|
|
192
|
+
function goalBoostMultiplier(entry, tags, matches, goalsByTag, policiesByGoalId) {
|
|
193
|
+
// Base 2.0x for first match, +0.5x per additional, capped at 3.0x.
|
|
194
|
+
let multiplier = Math.min(2.0 + 0.5 * (matches.length - 1), MAX_FINAL_MULTIPLIER);
|
|
195
|
+
// Compose per-policy multipliers per matched tag.
|
|
196
|
+
for (const tag of matches) {
|
|
197
|
+
const goal = goalsByTag.get(tag);
|
|
198
|
+
const policy = policiesByGoalId.get(goal.id);
|
|
199
|
+
if (!policy)
|
|
200
|
+
continue;
|
|
201
|
+
if (policy.policyType === 'error-prioritized' && tags.includes('error')) {
|
|
202
|
+
multiplier *= policy.errorPriority;
|
|
203
|
+
}
|
|
204
|
+
else if (policy.policyType === 'schema-fit-biased') {
|
|
205
|
+
// Linearly weight schema_fit in [0,1] up to (weightSchemaFit)x.
|
|
206
|
+
// Default 1.0 is a no-op.
|
|
207
|
+
multiplier *=
|
|
208
|
+
1.0 +
|
|
209
|
+
Math.max(0, policy.weightSchemaFit - 1.0) *
|
|
210
|
+
(entry.schema_fit ?? 0.5);
|
|
211
|
+
}
|
|
212
|
+
else if (policy.policyType === 'recency-first') {
|
|
213
|
+
multiplier *= policy.weightRecency;
|
|
214
|
+
}
|
|
215
|
+
else if (policy.policyType === 'hybrid') {
|
|
216
|
+
multiplier *= policy.weightOutcome;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Hard cap AFTER all composition.
|
|
220
|
+
return Math.min(multiplier, MAX_FINAL_MULTIPLIER);
|
|
221
|
+
}
|
|
222
|
+
/** Log rows for the top `limit` boosted rows, one per matched goal. */
|
|
223
|
+
function buildGoalRecallLog(db, boosted, matchesByEntryId, goalsByTag, opts) {
|
|
224
|
+
const { sessionId, tenantId, limit } = opts;
|
|
225
|
+
// Filter to local memories only -- global memory IDs aren't in this DB's
|
|
226
|
+
// memories table, so the FK on goal_recall_log.memory_id would fail.
|
|
227
|
+
// dlPFC depth's outcome propagation is session-scoped to local; boost on
|
|
228
|
+
// ranking still applies to global results, just no log row -> no
|
|
229
|
+
// propagation.
|
|
230
|
+
const topKIds = boosted.slice(0, limit).map((r) => r.entry.id);
|
|
231
|
+
const localIds = new Set();
|
|
232
|
+
if (topKIds.length > 0) {
|
|
233
|
+
const placeholders = topKIds.map(() => '?').join(',');
|
|
234
|
+
// SAFETY: rows come from the SELECT above, which projects exactly one
|
|
235
|
+
// column, `id`, from memories.
|
|
236
|
+
const localRows = db.prepare(`SELECT id FROM memories WHERE id IN (${placeholders})`).all(...topKIds);
|
|
237
|
+
for (const row of localRows)
|
|
238
|
+
localIds.add(row.id);
|
|
239
|
+
}
|
|
240
|
+
const recalledAt = new Date().toISOString();
|
|
241
|
+
const log = [];
|
|
242
|
+
for (const r of boosted.slice(0, limit)) {
|
|
243
|
+
if (!localIds.has(r.entry.id))
|
|
244
|
+
continue; // global -> skip log insert
|
|
245
|
+
const matches = matchesByEntryId.get(r.entry.id);
|
|
246
|
+
if (!matches || matches.length === 0)
|
|
247
|
+
continue;
|
|
248
|
+
for (const tag of matches) {
|
|
249
|
+
const goal = goalsByTag.get(tag);
|
|
250
|
+
if (!goal)
|
|
251
|
+
continue;
|
|
252
|
+
log.push({ goalId: goal.id, memoryId: r.entry.id, tenantId, sessionId, recalledAt, score: r.score });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return log;
|
|
256
|
+
}
|
|
162
257
|
/**
|
|
163
258
|
* dlPFC goal-stack boost. Applies the multi-goal boost to entry-backed scored
|
|
164
259
|
* rows when (tenant, session) has active goals and returns the log rows to
|
|
@@ -179,37 +274,12 @@ export function getActiveGoalsWithDb(db, opts) {
|
|
|
179
274
|
* @internal Recall ranking helper. Subject to change.
|
|
180
275
|
*/
|
|
181
276
|
export function computeGoalStackBoost(db, results, opts) {
|
|
182
|
-
const { sessionId, tenantId,
|
|
277
|
+
const { sessionId, tenantId, trace } = opts;
|
|
183
278
|
const active = getActiveGoalsWithDb(db, { sessionId, tenantId });
|
|
184
279
|
if (active.length === 0)
|
|
185
280
|
return { results, log: [] };
|
|
186
281
|
const goalsByTag = new Map(active.map((g) => [g.goalName, g]));
|
|
187
|
-
|
|
188
|
-
// can compose onto the base goal-tag boost. Composed result is hard-capped
|
|
189
|
-
// at MAX_FINAL_MULTIPLIER (3.0x) BEFORE applying to score -- even an
|
|
190
|
-
// `errorPriority: 9.0` policy cannot exceed 3.0x.
|
|
191
|
-
const policiesByGoalId = new Map();
|
|
192
|
-
for (const g of active) {
|
|
193
|
-
if (!g.retrievalPolicyId)
|
|
194
|
-
continue;
|
|
195
|
-
// SAFETY: the row comes from the SELECT above, which projects exactly
|
|
196
|
-
// these seven retrieval_policy columns.
|
|
197
|
-
const row = db.prepare(`
|
|
198
|
-
SELECT id, goal_id, policy_type, weight_schema_fit, weight_recency, weight_outcome, error_priority
|
|
199
|
-
FROM retrieval_policy WHERE id = ?
|
|
200
|
-
`).get(g.retrievalPolicyId);
|
|
201
|
-
if (row) {
|
|
202
|
-
policiesByGoalId.set(g.id, {
|
|
203
|
-
id: row.id,
|
|
204
|
-
goalId: row.goal_id,
|
|
205
|
-
policyType: row.policy_type,
|
|
206
|
-
weightSchemaFit: row.weight_schema_fit,
|
|
207
|
-
weightRecency: row.weight_recency,
|
|
208
|
-
weightOutcome: row.weight_outcome,
|
|
209
|
-
errorPriority: row.error_priority,
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
}
|
|
282
|
+
const policiesByGoalId = loadGoalPolicies(db, active);
|
|
213
283
|
// Goal-tag matches per boosted row, keyed by entry id. Kept as a side table
|
|
214
284
|
// (rather than a spread-on `_goalMatches` marker property) so `boosted`
|
|
215
285
|
// stays exactly R[] end to end, with no cast-tag-then-strip round trip.
|
|
@@ -220,34 +290,7 @@ export function computeGoalStackBoost(db, results, opts) {
|
|
|
220
290
|
const matches = tags.filter((t) => goalsByTag.has(t));
|
|
221
291
|
if (matches.length === 0)
|
|
222
292
|
return r;
|
|
223
|
-
|
|
224
|
-
let multiplier = Math.min(2.0 + 0.5 * (matches.length - 1), MAX_FINAL_MULTIPLIER);
|
|
225
|
-
// Compose per-policy multipliers per matched tag.
|
|
226
|
-
for (const tag of matches) {
|
|
227
|
-
const goal = goalsByTag.get(tag);
|
|
228
|
-
const policy = policiesByGoalId.get(goal.id);
|
|
229
|
-
if (!policy)
|
|
230
|
-
continue;
|
|
231
|
-
if (policy.policyType === 'error-prioritized' && tags.includes('error')) {
|
|
232
|
-
multiplier *= policy.errorPriority;
|
|
233
|
-
}
|
|
234
|
-
else if (policy.policyType === 'schema-fit-biased') {
|
|
235
|
-
// Linearly weight schema_fit in [0,1] up to (weightSchemaFit)x.
|
|
236
|
-
// Default 1.0 is a no-op.
|
|
237
|
-
multiplier *=
|
|
238
|
-
1.0 +
|
|
239
|
-
Math.max(0, policy.weightSchemaFit - 1.0) *
|
|
240
|
-
(r.entry.schema_fit ?? 0.5);
|
|
241
|
-
}
|
|
242
|
-
else if (policy.policyType === 'recency-first') {
|
|
243
|
-
multiplier *= policy.weightRecency;
|
|
244
|
-
}
|
|
245
|
-
else if (policy.policyType === 'hybrid') {
|
|
246
|
-
multiplier *= policy.weightOutcome;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
// Hard cap AFTER all composition.
|
|
250
|
-
multiplier = Math.min(multiplier, MAX_FINAL_MULTIPLIER);
|
|
293
|
+
const multiplier = goalBoostMultiplier(r.entry, tags, matches, goalsByTag, policiesByGoalId);
|
|
251
294
|
// A7 recall-trace side-channel: record the goal-boost step BEFORE the
|
|
252
295
|
// score is mutated, keyed by entry id. Pure read of r.score here; the
|
|
253
296
|
// mutation below is byte-identical to pre-A7.
|
|
@@ -271,37 +314,7 @@ export function computeGoalStackBoost(db, results, opts) {
|
|
|
271
314
|
// inherits its determinism via sort stability, and ties preserve the
|
|
272
315
|
// prior (meaningful) rank instead of reordering by content.
|
|
273
316
|
.sort((a, b) => b.score - a.score);
|
|
274
|
-
|
|
275
|
-
// memories table, so the FK on goal_recall_log.memory_id would fail.
|
|
276
|
-
// dlPFC depth's outcome propagation is session-scoped to local; boost on
|
|
277
|
-
// ranking still applies to global results, just no log row -> no
|
|
278
|
-
// propagation.
|
|
279
|
-
const topKIds = boosted.slice(0, limit).map((r) => r.entry.id);
|
|
280
|
-
const localIds = new Set();
|
|
281
|
-
if (topKIds.length > 0) {
|
|
282
|
-
const placeholders = topKIds.map(() => '?').join(',');
|
|
283
|
-
// SAFETY: rows come from the SELECT above, which projects exactly one
|
|
284
|
-
// column, `id`, from memories.
|
|
285
|
-
const localRows = db.prepare(`SELECT id FROM memories WHERE id IN (${placeholders})`).all(...topKIds);
|
|
286
|
-
for (const row of localRows)
|
|
287
|
-
localIds.add(row.id);
|
|
288
|
-
}
|
|
289
|
-
const recalledAt = new Date().toISOString();
|
|
290
|
-
const log = [];
|
|
291
|
-
for (const r of boosted.slice(0, limit)) {
|
|
292
|
-
if (!localIds.has(r.entry.id))
|
|
293
|
-
continue; // global -> skip log insert
|
|
294
|
-
const matches = matchesByEntryId.get(r.entry.id);
|
|
295
|
-
if (!matches || matches.length === 0)
|
|
296
|
-
continue;
|
|
297
|
-
for (const tag of matches) {
|
|
298
|
-
const goal = goalsByTag.get(tag);
|
|
299
|
-
if (!goal)
|
|
300
|
-
continue;
|
|
301
|
-
log.push({ goalId: goal.id, memoryId: r.entry.id, tenantId, sessionId, recalledAt, score: r.score });
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
return { results: boosted, log };
|
|
317
|
+
return { results: boosted, log: buildGoalRecallLog(db, boosted, matchesByEntryId, goalsByTag, opts) };
|
|
305
318
|
}
|
|
306
319
|
/**
|
|
307
320
|
* Writes goal-boost log rows. INSERT OR IGNORE because UNIQUE(memory_id, goal_id)
|
package/dist/incidents.js
CHANGED
|
@@ -77,6 +77,50 @@ const INCIDENT_COLS = `
|
|
|
77
77
|
// ---------------------------------------------------------------------------
|
|
78
78
|
// Public API
|
|
79
79
|
// ---------------------------------------------------------------------------
|
|
80
|
+
// Validate every linked receipt BEFORE inserting the row. Each must be a
|
|
81
|
+
// memory in the SAME tenant; a cross-tenant or nonexistent id rejects the
|
|
82
|
+
// whole write rather than recording an unverifiable receipt.
|
|
83
|
+
function validateLinkedMemoryIds(db, tenantId, linkInput) {
|
|
84
|
+
const validated = [];
|
|
85
|
+
for (const linkId of linkInput) {
|
|
86
|
+
// SAFETY: row shape matches the single `id` column named in the SELECT above.
|
|
87
|
+
const exists = db.prepare(`SELECT id FROM memories WHERE id = ? AND tenant_id = ?`).get(linkId, tenantId);
|
|
88
|
+
if (!exists) {
|
|
89
|
+
throw new NotFoundError(`saveIncident: linked memory ${linkId} not found for tenant ${tenantId}`);
|
|
90
|
+
}
|
|
91
|
+
validated.push(linkId);
|
|
92
|
+
}
|
|
93
|
+
return validated;
|
|
94
|
+
}
|
|
95
|
+
/** The afterWrite body: link validation, INSERT, reload, open audit, all in one SAVEPOINT. */
|
|
96
|
+
function writeIncidentRow(db, memoryId, tenantId, opts, actor, now) {
|
|
97
|
+
const validated = validateLinkedMemoryIds(db, tenantId, opts.linkedMemoryIds ?? []);
|
|
98
|
+
const result = db.prepare(`
|
|
99
|
+
INSERT INTO incidents(
|
|
100
|
+
memory_id, tenant_id, incident_text, context,
|
|
101
|
+
status, resolution_text, resolved_at, closed_at, linked_memory_ids, created_at
|
|
102
|
+
) VALUES (?, ?, ?, ?, 'open', NULL, NULL, NULL, ?, ?)
|
|
103
|
+
`).run(memoryId, tenantId, opts.incidentText, opts.context ?? null, JSON.stringify(validated), now);
|
|
104
|
+
const incidentId = Number(result.lastInsertRowid ?? 0);
|
|
105
|
+
// SAFETY: row's shape matches the columns named in INCIDENT_COLS above.
|
|
106
|
+
const row = db.prepare(`SELECT ${INCIDENT_COLS} FROM incidents WHERE id = ?`)
|
|
107
|
+
.get(incidentId);
|
|
108
|
+
if (!row)
|
|
109
|
+
throw new Error('saveIncident: failed to reload saved incident row');
|
|
110
|
+
// GDPR-light metadata: id + flag only, no incident_text.
|
|
111
|
+
appendAuditEvent(db, {
|
|
112
|
+
tenantId,
|
|
113
|
+
actor,
|
|
114
|
+
op: 'incident_open',
|
|
115
|
+
targetId: String(incidentId),
|
|
116
|
+
metadata: {
|
|
117
|
+
incident_id: incidentId,
|
|
118
|
+
has_context: opts.context !== undefined && opts.context !== null && opts.context !== '',
|
|
119
|
+
linked_memory_count: validated.length,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
return row;
|
|
123
|
+
}
|
|
80
124
|
/**
|
|
81
125
|
* Create an incident. Writes the memory mirror + the incidents row atomically
|
|
82
126
|
* inside writeEntry's SAVEPOINT 'write_entry'.
|
|
@@ -106,50 +150,13 @@ export function saveIncident(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
|
106
150
|
baseHalfLifeDays: objectHalfLifeDays(hippoRoot),
|
|
107
151
|
tenantId,
|
|
108
152
|
});
|
|
109
|
-
const linkInput = opts.linkedMemoryIds ?? [];
|
|
110
153
|
// Populated inside afterWrite so the linked-id validation, the INSERT, and the
|
|
111
154
|
// memory write all share one SAVEPOINT.
|
|
112
155
|
let savedRow;
|
|
113
156
|
writeEntry(hippoRoot, mem, {
|
|
114
157
|
actor,
|
|
115
158
|
afterWrite: (db, memoryId) => {
|
|
116
|
-
|
|
117
|
-
// memory in the SAME tenant; a cross-tenant or nonexistent id rejects the
|
|
118
|
-
// whole write rather than recording an unverifiable receipt.
|
|
119
|
-
const validated = [];
|
|
120
|
-
for (const linkId of linkInput) {
|
|
121
|
-
// SAFETY: row shape matches the single `id` column named in the SELECT above.
|
|
122
|
-
const exists = db.prepare(`SELECT id FROM memories WHERE id = ? AND tenant_id = ?`).get(linkId, tenantId);
|
|
123
|
-
if (!exists) {
|
|
124
|
-
throw new NotFoundError(`saveIncident: linked memory ${linkId} not found for tenant ${tenantId}`);
|
|
125
|
-
}
|
|
126
|
-
validated.push(linkId);
|
|
127
|
-
}
|
|
128
|
-
const result = db.prepare(`
|
|
129
|
-
INSERT INTO incidents(
|
|
130
|
-
memory_id, tenant_id, incident_text, context,
|
|
131
|
-
status, resolution_text, resolved_at, closed_at, linked_memory_ids, created_at
|
|
132
|
-
) VALUES (?, ?, ?, ?, 'open', NULL, NULL, NULL, ?, ?)
|
|
133
|
-
`).run(memoryId, tenantId, opts.incidentText, opts.context ?? null, JSON.stringify(validated), now);
|
|
134
|
-
const incidentId = Number(result.lastInsertRowid ?? 0);
|
|
135
|
-
// SAFETY: row's shape matches the columns named in INCIDENT_COLS above.
|
|
136
|
-
const row = db.prepare(`SELECT ${INCIDENT_COLS} FROM incidents WHERE id = ?`)
|
|
137
|
-
.get(incidentId);
|
|
138
|
-
if (!row)
|
|
139
|
-
throw new Error('saveIncident: failed to reload saved incident row');
|
|
140
|
-
savedRow = row;
|
|
141
|
-
// GDPR-light metadata: id + flag only, no incident_text.
|
|
142
|
-
appendAuditEvent(db, {
|
|
143
|
-
tenantId,
|
|
144
|
-
actor,
|
|
145
|
-
op: 'incident_open',
|
|
146
|
-
targetId: String(incidentId),
|
|
147
|
-
metadata: {
|
|
148
|
-
incident_id: incidentId,
|
|
149
|
-
has_context: opts.context !== undefined && opts.context !== null && opts.context !== '',
|
|
150
|
-
linked_memory_count: validated.length,
|
|
151
|
-
},
|
|
152
|
-
});
|
|
159
|
+
savedRow = writeIncidentRow(db, memoryId, tenantId, opts, actor, now);
|
|
153
160
|
},
|
|
154
161
|
});
|
|
155
162
|
if (!savedRow) {
|
package/dist/policies.js
CHANGED
|
@@ -111,9 +111,79 @@ function buildPolicyContent(policyName, policyText, validFrom, validTo) {
|
|
|
111
111
|
const range = validTo ? `${validFrom} to ${validTo}` : `${validFrom} onward`;
|
|
112
112
|
return `${policyName}\n\n${policyText}\n\nEffective: ${range}`;
|
|
113
113
|
}
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
114
|
+
// Preflight the supersede target BEFORE inserting the new row (so the new
|
|
115
|
+
// autoincrement id can never be its own supersede target); read the
|
|
116
|
+
// predecessor version in the same SELECT for server-derived versioning.
|
|
117
|
+
// Mirrors saveProcess / saveDecision (codex P1 2026-05-28).
|
|
118
|
+
function preflightPolicySupersede(db, tenantId, supersedesId) {
|
|
119
|
+
// SAFETY: SELECT projects exactly status, version; .get() returns that
|
|
120
|
+
// shape for the matching row, or undefined when no policy/tenant pair matches.
|
|
121
|
+
const pred = db.prepare(`SELECT status, version FROM policies WHERE id = ? AND tenant_id = ?`).get(supersedesId, tenantId);
|
|
122
|
+
if (!pred) {
|
|
123
|
+
throw new NotFoundError(`savePolicy: policy ${supersedesId} to supersede not found for tenant ${tenantId}`);
|
|
124
|
+
}
|
|
125
|
+
if (pred.status !== 'active') {
|
|
126
|
+
throw new ConflictError(`savePolicy: policy ${supersedesId} is not active (status='${pred.status}'); only active policies can be superseded.`);
|
|
127
|
+
}
|
|
128
|
+
return pred.version + 1;
|
|
129
|
+
}
|
|
130
|
+
function insertPolicyRow(db, memoryId, w, version) {
|
|
131
|
+
const result = db.prepare(`
|
|
132
|
+
INSERT INTO policies(
|
|
133
|
+
memory_id, tenant_id, policy_name, policy_text, valid_from, valid_to,
|
|
134
|
+
version, status, superseded_by, superseded_at, change_summary, closed_at, created_at
|
|
135
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, NULL, ?)
|
|
136
|
+
`).run(memoryId, w.tenantId, w.policyName, w.policyText, w.validFrom, w.validTo, version, w.changeSummary, w.now);
|
|
137
|
+
return Number(result.lastInsertRowid ?? 0);
|
|
138
|
+
}
|
|
139
|
+
function supersedePolicyRow(db, w, supersedesId, policyId, version) {
|
|
140
|
+
const sup = db.prepare(`
|
|
141
|
+
UPDATE policies
|
|
142
|
+
SET status = 'superseded', superseded_by = ?, superseded_at = ?
|
|
143
|
+
WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
|
|
144
|
+
`).run(policyId, w.now, supersedesId, w.tenantId, policyId);
|
|
145
|
+
if (sup.changes === 0) {
|
|
146
|
+
throw new ConflictError(`savePolicy: policy ${supersedesId} could not be superseded (no longer active or self-reference).`);
|
|
147
|
+
}
|
|
148
|
+
appendAuditEvent(db, {
|
|
149
|
+
tenantId: w.tenantId,
|
|
150
|
+
actor: w.actor,
|
|
151
|
+
op: 'policy_supersede',
|
|
152
|
+
targetId: String(supersedesId),
|
|
153
|
+
metadata: {
|
|
154
|
+
policy_id: supersedesId,
|
|
155
|
+
superseded_by: policyId,
|
|
156
|
+
new_version: version,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/** The afterWrite body: preflight, INSERT, supersede, reload, create audit, all in one SAVEPOINT. */
|
|
161
|
+
function writePolicyRow(db, memoryId, w) {
|
|
162
|
+
const version = w.supersedesId !== undefined ? preflightPolicySupersede(db, w.tenantId, w.supersedesId) : 1;
|
|
163
|
+
const policyId = insertPolicyRow(db, memoryId, w, version);
|
|
164
|
+
if (w.supersedesId !== undefined)
|
|
165
|
+
supersedePolicyRow(db, w, w.supersedesId, policyId, version);
|
|
166
|
+
// SAFETY: SELECT ${POLICY_COLS} projects exactly the PolicyRow columns;
|
|
167
|
+
// .get() returns that row, or undefined only if the just-inserted id can't
|
|
168
|
+
// be found.
|
|
169
|
+
const row = db.prepare(`SELECT ${POLICY_COLS} FROM policies WHERE id = ?`)
|
|
170
|
+
.get(policyId);
|
|
171
|
+
if (!row)
|
|
172
|
+
throw new Error('savePolicy: failed to reload saved policy row');
|
|
173
|
+
// GDPR-light metadata: ids + flags only, no policy text.
|
|
174
|
+
appendAuditEvent(db, {
|
|
175
|
+
tenantId: w.tenantId,
|
|
176
|
+
actor: w.actor,
|
|
177
|
+
op: 'policy_create',
|
|
178
|
+
targetId: String(policyId),
|
|
179
|
+
metadata: {
|
|
180
|
+
policy_id: policyId,
|
|
181
|
+
version,
|
|
182
|
+
open_ended: w.validTo === null,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
return row;
|
|
186
|
+
}
|
|
117
187
|
/**
|
|
118
188
|
* Create a policy (or a new version that supersedes an existing one). Writes the
|
|
119
189
|
* memory mirror + the policies row atomically inside writeEntry's SAVEPOINT.
|
|
@@ -151,75 +221,22 @@ export function savePolicy(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
|
151
221
|
baseHalfLifeDays: objectHalfLifeDays(hippoRoot),
|
|
152
222
|
tenantId,
|
|
153
223
|
});
|
|
224
|
+
const w = {
|
|
225
|
+
tenantId,
|
|
226
|
+
actor,
|
|
227
|
+
policyName: opts.policyName,
|
|
228
|
+
policyText: opts.policyText,
|
|
229
|
+
validFrom,
|
|
230
|
+
validTo,
|
|
231
|
+
changeSummary,
|
|
232
|
+
supersedesId: opts.supersedesPolicyId,
|
|
233
|
+
now,
|
|
234
|
+
};
|
|
154
235
|
let savedRow;
|
|
155
236
|
writeEntry(hippoRoot, mem, {
|
|
156
237
|
actor,
|
|
157
238
|
afterWrite: (db, memoryId) => {
|
|
158
|
-
|
|
159
|
-
// autoincrement id can never be its own supersede target); read the
|
|
160
|
-
// predecessor version in the same SELECT for server-derived versioning.
|
|
161
|
-
// Mirrors saveProcess / saveDecision (codex P1 2026-05-28).
|
|
162
|
-
let version = 1;
|
|
163
|
-
if (opts.supersedesPolicyId !== undefined) {
|
|
164
|
-
// SAFETY: SELECT projects exactly status, version; .get() returns that
|
|
165
|
-
// shape for the matching row, or undefined when no policy/tenant pair matches.
|
|
166
|
-
const pred = db.prepare(`SELECT status, version FROM policies WHERE id = ? AND tenant_id = ?`).get(opts.supersedesPolicyId, tenantId);
|
|
167
|
-
if (!pred) {
|
|
168
|
-
throw new NotFoundError(`savePolicy: policy ${opts.supersedesPolicyId} to supersede not found for tenant ${tenantId}`);
|
|
169
|
-
}
|
|
170
|
-
if (pred.status !== 'active') {
|
|
171
|
-
throw new ConflictError(`savePolicy: policy ${opts.supersedesPolicyId} is not active (status='${pred.status}'); only active policies can be superseded.`);
|
|
172
|
-
}
|
|
173
|
-
version = pred.version + 1;
|
|
174
|
-
}
|
|
175
|
-
const result = db.prepare(`
|
|
176
|
-
INSERT INTO policies(
|
|
177
|
-
memory_id, tenant_id, policy_name, policy_text, valid_from, valid_to,
|
|
178
|
-
version, status, superseded_by, superseded_at, change_summary, closed_at, created_at
|
|
179
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, NULL, ?)
|
|
180
|
-
`).run(memoryId, tenantId, opts.policyName, opts.policyText, validFrom, validTo, version, changeSummary, now);
|
|
181
|
-
const policyId = Number(result.lastInsertRowid ?? 0);
|
|
182
|
-
if (opts.supersedesPolicyId !== undefined) {
|
|
183
|
-
const sup = db.prepare(`
|
|
184
|
-
UPDATE policies
|
|
185
|
-
SET status = 'superseded', superseded_by = ?, superseded_at = ?
|
|
186
|
-
WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
|
|
187
|
-
`).run(policyId, now, opts.supersedesPolicyId, tenantId, policyId);
|
|
188
|
-
if (sup.changes === 0) {
|
|
189
|
-
throw new ConflictError(`savePolicy: policy ${opts.supersedesPolicyId} could not be superseded (no longer active or self-reference).`);
|
|
190
|
-
}
|
|
191
|
-
appendAuditEvent(db, {
|
|
192
|
-
tenantId,
|
|
193
|
-
actor,
|
|
194
|
-
op: 'policy_supersede',
|
|
195
|
-
targetId: String(opts.supersedesPolicyId),
|
|
196
|
-
metadata: {
|
|
197
|
-
policy_id: opts.supersedesPolicyId,
|
|
198
|
-
superseded_by: policyId,
|
|
199
|
-
new_version: version,
|
|
200
|
-
},
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
// SAFETY: SELECT ${POLICY_COLS} projects exactly the PolicyRow columns;
|
|
204
|
-
// .get() returns that row, or undefined only if the just-inserted id can't
|
|
205
|
-
// be found.
|
|
206
|
-
const row = db.prepare(`SELECT ${POLICY_COLS} FROM policies WHERE id = ?`)
|
|
207
|
-
.get(policyId);
|
|
208
|
-
if (!row)
|
|
209
|
-
throw new Error('savePolicy: failed to reload saved policy row');
|
|
210
|
-
savedRow = row;
|
|
211
|
-
// GDPR-light metadata: ids + flags only, no policy text.
|
|
212
|
-
appendAuditEvent(db, {
|
|
213
|
-
tenantId,
|
|
214
|
-
actor,
|
|
215
|
-
op: 'policy_create',
|
|
216
|
-
targetId: String(policyId),
|
|
217
|
-
metadata: {
|
|
218
|
-
policy_id: policyId,
|
|
219
|
-
version,
|
|
220
|
-
open_ended: validTo === null,
|
|
221
|
-
},
|
|
222
|
-
});
|
|
239
|
+
savedRow = writePolicyRow(db, memoryId, w);
|
|
223
240
|
},
|
|
224
241
|
afterCommit: () => markGraphDirty(hippoRoot, tenantId, mem.id),
|
|
225
242
|
});
|