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/processes.js
CHANGED
|
@@ -130,9 +130,82 @@ function buildProcessContent(processName, steps, description) {
|
|
|
130
130
|
content += `\n\nDescription: ${description}`;
|
|
131
131
|
return content;
|
|
132
132
|
}
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
133
|
+
// Preflight the supersede target BEFORE inserting the new row. The new
|
|
134
|
+
// row's autoincrement id could otherwise collide with a non-existent
|
|
135
|
+
// supersedesProcessId (e.g. superseding id 1 on an empty store), making
|
|
136
|
+
// the row supersede itself. Validating first means the new row is never a
|
|
137
|
+
// candidate for its own supersede UPDATE. Mirrors saveDecision (codex P1
|
|
138
|
+
// 2026-05-28). The same SELECT reads the predecessor version so the
|
|
139
|
+
// successor's version is server-derived, never client-supplied.
|
|
140
|
+
function preflightProcessSupersede(db, tenantId, supersedesId) {
|
|
141
|
+
// SAFETY: SELECT status, version FROM processes; row shape matches
|
|
142
|
+
// the two selected columns 1:1.
|
|
143
|
+
const pred = db.prepare(`SELECT status, version FROM processes WHERE id = ? AND tenant_id = ?`).get(supersedesId, tenantId);
|
|
144
|
+
if (!pred) {
|
|
145
|
+
throw new NotFoundError(`saveProcess: process ${supersedesId} to supersede not found for tenant ${tenantId}`);
|
|
146
|
+
}
|
|
147
|
+
if (pred.status !== 'active') {
|
|
148
|
+
throw new ConflictError(`saveProcess: process ${supersedesId} is not active (status='${pred.status}'); only active processes can be superseded.`);
|
|
149
|
+
}
|
|
150
|
+
return pred.version + 1;
|
|
151
|
+
}
|
|
152
|
+
function insertProcessRow(db, memoryId, w, version) {
|
|
153
|
+
const result = db.prepare(`
|
|
154
|
+
INSERT INTO processes(
|
|
155
|
+
memory_id, tenant_id, process_name, description, steps, version,
|
|
156
|
+
status, superseded_by, superseded_at, change_summary, closed_at, created_at
|
|
157
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, NULL, ?)
|
|
158
|
+
`).run(memoryId, w.tenantId, w.processName, w.description ?? null, JSON.stringify(w.steps), version, w.changeSummary, w.now);
|
|
159
|
+
return Number(result.lastInsertRowid ?? 0);
|
|
160
|
+
}
|
|
161
|
+
function supersedeProcessRow(db, w, supersedesId, processId, version) {
|
|
162
|
+
const sup = db.prepare(`
|
|
163
|
+
UPDATE processes
|
|
164
|
+
SET status = 'superseded', superseded_by = ?, superseded_at = ?
|
|
165
|
+
WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
|
|
166
|
+
`).run(processId, w.now, supersedesId, w.tenantId, processId);
|
|
167
|
+
if (sup.changes === 0) {
|
|
168
|
+
throw new ConflictError(`saveProcess: process ${supersedesId} could not be superseded (no longer active or self-reference).`);
|
|
169
|
+
}
|
|
170
|
+
appendAuditEvent(db, {
|
|
171
|
+
tenantId: w.tenantId,
|
|
172
|
+
actor: w.actor,
|
|
173
|
+
op: 'process_supersede',
|
|
174
|
+
targetId: String(supersedesId),
|
|
175
|
+
metadata: {
|
|
176
|
+
process_id: supersedesId,
|
|
177
|
+
superseded_by: processId,
|
|
178
|
+
new_version: version,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/** The afterWrite body: preflight, INSERT, supersede, reload, create audit, all in one SAVEPOINT. */
|
|
183
|
+
function writeProcessRow(db, memoryId, w) {
|
|
184
|
+
const version = w.supersedesId !== undefined ? preflightProcessSupersede(db, w.tenantId, w.supersedesId) : 1;
|
|
185
|
+
const processId = insertProcessRow(db, memoryId, w, version);
|
|
186
|
+
if (w.supersedesId !== undefined)
|
|
187
|
+
supersedeProcessRow(db, w, w.supersedesId, processId, version);
|
|
188
|
+
// SAFETY: SELECT ${PROCESS_COLS} enumerates every ProcessRow field
|
|
189
|
+
// 1:1 (see PROCESS_COLS above).
|
|
190
|
+
const row = db.prepare(`SELECT ${PROCESS_COLS} FROM processes WHERE id = ?`)
|
|
191
|
+
.get(processId);
|
|
192
|
+
if (!row)
|
|
193
|
+
throw new Error('saveProcess: failed to reload saved process row');
|
|
194
|
+
// GDPR-light metadata: ids + counts only, no process_name / step text.
|
|
195
|
+
appendAuditEvent(db, {
|
|
196
|
+
tenantId: w.tenantId,
|
|
197
|
+
actor: w.actor,
|
|
198
|
+
op: 'process_create',
|
|
199
|
+
targetId: String(processId),
|
|
200
|
+
metadata: {
|
|
201
|
+
process_id: processId,
|
|
202
|
+
version,
|
|
203
|
+
step_count: w.steps.length,
|
|
204
|
+
has_description: w.description !== undefined && w.description !== null && w.description !== '',
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
return row;
|
|
208
|
+
}
|
|
136
209
|
/**
|
|
137
210
|
* Create a process (or a new version that supersedes an existing one). Writes
|
|
138
211
|
* the memory mirror + the processes row atomically inside writeEntry's SAVEPOINT
|
|
@@ -163,78 +236,21 @@ export function saveProcess(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
|
163
236
|
baseHalfLifeDays: objectHalfLifeDays(hippoRoot),
|
|
164
237
|
tenantId,
|
|
165
238
|
});
|
|
239
|
+
const w = {
|
|
240
|
+
tenantId,
|
|
241
|
+
actor,
|
|
242
|
+
processName: opts.processName,
|
|
243
|
+
description: opts.description,
|
|
244
|
+
steps,
|
|
245
|
+
changeSummary,
|
|
246
|
+
supersedesId: opts.supersedesProcessId,
|
|
247
|
+
now,
|
|
248
|
+
};
|
|
166
249
|
let savedRow;
|
|
167
250
|
writeEntry(hippoRoot, mem, {
|
|
168
251
|
actor,
|
|
169
252
|
afterWrite: (db, memoryId) => {
|
|
170
|
-
|
|
171
|
-
// row's autoincrement id could otherwise collide with a non-existent
|
|
172
|
-
// supersedesProcessId (e.g. superseding id 1 on an empty store), making
|
|
173
|
-
// the row supersede itself. Validating first means the new row is never a
|
|
174
|
-
// candidate for its own supersede UPDATE. Mirrors saveDecision (codex P1
|
|
175
|
-
// 2026-05-28). The same SELECT reads the predecessor version so the
|
|
176
|
-
// successor's version is server-derived, never client-supplied.
|
|
177
|
-
let version = 1;
|
|
178
|
-
if (opts.supersedesProcessId !== undefined) {
|
|
179
|
-
// SAFETY: SELECT status, version FROM processes; row shape matches
|
|
180
|
-
// the two selected columns 1:1.
|
|
181
|
-
const pred = db.prepare(`SELECT status, version FROM processes WHERE id = ? AND tenant_id = ?`).get(opts.supersedesProcessId, tenantId);
|
|
182
|
-
if (!pred) {
|
|
183
|
-
throw new NotFoundError(`saveProcess: process ${opts.supersedesProcessId} to supersede not found for tenant ${tenantId}`);
|
|
184
|
-
}
|
|
185
|
-
if (pred.status !== 'active') {
|
|
186
|
-
throw new ConflictError(`saveProcess: process ${opts.supersedesProcessId} is not active (status='${pred.status}'); only active processes can be superseded.`);
|
|
187
|
-
}
|
|
188
|
-
version = pred.version + 1;
|
|
189
|
-
}
|
|
190
|
-
const result = db.prepare(`
|
|
191
|
-
INSERT INTO processes(
|
|
192
|
-
memory_id, tenant_id, process_name, description, steps, version,
|
|
193
|
-
status, superseded_by, superseded_at, change_summary, closed_at, created_at
|
|
194
|
-
) VALUES (?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, NULL, ?)
|
|
195
|
-
`).run(memoryId, tenantId, opts.processName, opts.description ?? null, JSON.stringify(steps), version, changeSummary, now);
|
|
196
|
-
const processId = Number(result.lastInsertRowid ?? 0);
|
|
197
|
-
if (opts.supersedesProcessId !== undefined) {
|
|
198
|
-
const sup = db.prepare(`
|
|
199
|
-
UPDATE processes
|
|
200
|
-
SET status = 'superseded', superseded_by = ?, superseded_at = ?
|
|
201
|
-
WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
|
|
202
|
-
`).run(processId, now, opts.supersedesProcessId, tenantId, processId);
|
|
203
|
-
if (sup.changes === 0) {
|
|
204
|
-
throw new ConflictError(`saveProcess: process ${opts.supersedesProcessId} could not be superseded (no longer active or self-reference).`);
|
|
205
|
-
}
|
|
206
|
-
appendAuditEvent(db, {
|
|
207
|
-
tenantId,
|
|
208
|
-
actor,
|
|
209
|
-
op: 'process_supersede',
|
|
210
|
-
targetId: String(opts.supersedesProcessId),
|
|
211
|
-
metadata: {
|
|
212
|
-
process_id: opts.supersedesProcessId,
|
|
213
|
-
superseded_by: processId,
|
|
214
|
-
new_version: version,
|
|
215
|
-
},
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
// SAFETY: SELECT ${PROCESS_COLS} enumerates every ProcessRow field
|
|
219
|
-
// 1:1 (see PROCESS_COLS above).
|
|
220
|
-
const row = db.prepare(`SELECT ${PROCESS_COLS} FROM processes WHERE id = ?`)
|
|
221
|
-
.get(processId);
|
|
222
|
-
if (!row)
|
|
223
|
-
throw new Error('saveProcess: failed to reload saved process row');
|
|
224
|
-
savedRow = row;
|
|
225
|
-
// GDPR-light metadata: ids + counts only, no process_name / step text.
|
|
226
|
-
appendAuditEvent(db, {
|
|
227
|
-
tenantId,
|
|
228
|
-
actor,
|
|
229
|
-
op: 'process_create',
|
|
230
|
-
targetId: String(processId),
|
|
231
|
-
metadata: {
|
|
232
|
-
process_id: processId,
|
|
233
|
-
version,
|
|
234
|
-
step_count: steps.length,
|
|
235
|
-
has_description: opts.description !== undefined && opts.description !== null && opts.description !== '',
|
|
236
|
-
},
|
|
237
|
-
});
|
|
253
|
+
savedRow = writeProcessRow(db, memoryId, w);
|
|
238
254
|
},
|
|
239
255
|
});
|
|
240
256
|
if (!savedRow) {
|
package/dist/project-briefs.js
CHANGED
|
@@ -103,9 +103,83 @@ const BRIEF_COLS = `
|
|
|
103
103
|
function buildBriefContent(repo, summary) {
|
|
104
104
|
return `${repo}\n\n${summary}`;
|
|
105
105
|
}
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
106
|
+
// Preflight the supersede target BEFORE inserting the new row (so the new
|
|
107
|
+
// autoincrement id can never be its own supersede target); read the
|
|
108
|
+
// predecessor version in the same SELECT for server-derived versioning.
|
|
109
|
+
// Mirrors saveSkill / saveProcess (codex P1 2026-05-28).
|
|
110
|
+
function preflightBriefSupersede(db, tenantId, supersedesId) {
|
|
111
|
+
// SAFETY: SELECT projects exactly status, version; .get() returns that
|
|
112
|
+
// shape for the matching row, or undefined when no brief/tenant pair matches.
|
|
113
|
+
const pred = db.prepare(`SELECT status, version FROM project_briefs WHERE id = ? AND tenant_id = ?`).get(supersedesId, tenantId);
|
|
114
|
+
if (!pred) {
|
|
115
|
+
throw new NotFoundError(`saveProjectBrief: brief ${supersedesId} to supersede not found for tenant ${tenantId}`);
|
|
116
|
+
}
|
|
117
|
+
if (pred.status !== 'active') {
|
|
118
|
+
throw new ConflictError(`saveProjectBrief: brief ${supersedesId} is not active (status='${pred.status}'); only active briefs can be superseded.`);
|
|
119
|
+
}
|
|
120
|
+
return pred.version + 1;
|
|
121
|
+
}
|
|
122
|
+
function insertBriefRow(db, memoryId, w, version) {
|
|
123
|
+
const result = db.prepare(`
|
|
124
|
+
INSERT INTO project_briefs(
|
|
125
|
+
memory_id, tenant_id, repo, summary, version,
|
|
126
|
+
status, superseded_by, superseded_at, change_summary, closed_at, created_at
|
|
127
|
+
) VALUES (?, ?, ?, ?, ?, 'active', NULL, NULL, ?, NULL, ?)
|
|
128
|
+
`).run(memoryId, w.tenantId, w.repo, w.summary, version, w.changeSummary, w.now);
|
|
129
|
+
return Number(result.lastInsertRowid ?? 0);
|
|
130
|
+
}
|
|
131
|
+
function supersedeBriefRow(db, w, supersedesId, briefId, version) {
|
|
132
|
+
const sup = db.prepare(`
|
|
133
|
+
UPDATE project_briefs
|
|
134
|
+
SET status = 'superseded', superseded_by = ?, superseded_at = ?
|
|
135
|
+
WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
|
|
136
|
+
`).run(briefId, w.now, supersedesId, w.tenantId, briefId);
|
|
137
|
+
if (sup.changes === 0) {
|
|
138
|
+
throw new ConflictError(`saveProjectBrief: brief ${supersedesId} could not be superseded (no longer active or self-reference).`);
|
|
139
|
+
}
|
|
140
|
+
appendAuditEvent(db, {
|
|
141
|
+
tenantId: w.tenantId,
|
|
142
|
+
actor: w.actor,
|
|
143
|
+
op: 'project_brief_supersede',
|
|
144
|
+
targetId: String(supersedesId),
|
|
145
|
+
metadata: {
|
|
146
|
+
brief_id: supersedesId,
|
|
147
|
+
superseded_by: briefId,
|
|
148
|
+
new_version: version,
|
|
149
|
+
refreshed: w.isRefresh,
|
|
150
|
+
...w.refreshAuditExtra,
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/** The afterWrite body: preflight, INSERT, supersede, reload, create audit, all in one SAVEPOINT. */
|
|
155
|
+
function writeBriefRow(db, memoryId, w) {
|
|
156
|
+
const version = w.supersedesId !== undefined ? preflightBriefSupersede(db, w.tenantId, w.supersedesId) : 1;
|
|
157
|
+
const briefId = insertBriefRow(db, memoryId, w, version);
|
|
158
|
+
if (w.supersedesId !== undefined)
|
|
159
|
+
supersedeBriefRow(db, w, w.supersedesId, briefId, version);
|
|
160
|
+
// SAFETY: SELECT ${BRIEF_COLS} projects exactly the ProjectBriefRow
|
|
161
|
+
// columns; .get() returns that row, or undefined only if the
|
|
162
|
+
// just-inserted id can't be found.
|
|
163
|
+
const row = db.prepare(`SELECT ${BRIEF_COLS} FROM project_briefs WHERE id = ?`)
|
|
164
|
+
.get(briefId);
|
|
165
|
+
if (!row)
|
|
166
|
+
throw new Error('saveProjectBrief: failed to reload saved brief row');
|
|
167
|
+
// GDPR-light metadata: ids + flags only, no brief text.
|
|
168
|
+
appendAuditEvent(db, {
|
|
169
|
+
tenantId: w.tenantId,
|
|
170
|
+
actor: w.actor,
|
|
171
|
+
op: 'project_brief_create',
|
|
172
|
+
targetId: String(briefId),
|
|
173
|
+
metadata: {
|
|
174
|
+
brief_id: briefId,
|
|
175
|
+
repo: w.repo,
|
|
176
|
+
version,
|
|
177
|
+
refreshed: w.isRefresh,
|
|
178
|
+
...w.refreshAuditExtra,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
return row;
|
|
182
|
+
}
|
|
109
183
|
/**
|
|
110
184
|
* Create a project_brief (or a new version that supersedes an existing one). Writes
|
|
111
185
|
* the memory mirror + the project_briefs row atomically inside writeEntry's
|
|
@@ -133,79 +207,22 @@ export function saveProjectBrief(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
|
133
207
|
baseHalfLifeDays: objectHalfLifeDays(hippoRoot),
|
|
134
208
|
tenantId,
|
|
135
209
|
});
|
|
210
|
+
const w = {
|
|
211
|
+
tenantId,
|
|
212
|
+
actor,
|
|
213
|
+
repo,
|
|
214
|
+
summary: opts.summary,
|
|
215
|
+
changeSummary,
|
|
216
|
+
supersedesId: opts.supersedesBriefId,
|
|
217
|
+
isRefresh,
|
|
218
|
+
refreshAuditExtra,
|
|
219
|
+
now,
|
|
220
|
+
};
|
|
136
221
|
let savedRow;
|
|
137
222
|
writeEntry(hippoRoot, mem, {
|
|
138
223
|
actor,
|
|
139
224
|
afterWrite: (db, memoryId) => {
|
|
140
|
-
|
|
141
|
-
// autoincrement id can never be its own supersede target); read the
|
|
142
|
-
// predecessor version in the same SELECT for server-derived versioning.
|
|
143
|
-
// Mirrors saveSkill / saveProcess (codex P1 2026-05-28).
|
|
144
|
-
let version = 1;
|
|
145
|
-
if (opts.supersedesBriefId !== undefined) {
|
|
146
|
-
// SAFETY: SELECT projects exactly status, version; .get() returns that
|
|
147
|
-
// shape for the matching row, or undefined when no brief/tenant pair matches.
|
|
148
|
-
const pred = db.prepare(`SELECT status, version FROM project_briefs WHERE id = ? AND tenant_id = ?`).get(opts.supersedesBriefId, tenantId);
|
|
149
|
-
if (!pred) {
|
|
150
|
-
throw new NotFoundError(`saveProjectBrief: brief ${opts.supersedesBriefId} to supersede not found for tenant ${tenantId}`);
|
|
151
|
-
}
|
|
152
|
-
if (pred.status !== 'active') {
|
|
153
|
-
throw new ConflictError(`saveProjectBrief: brief ${opts.supersedesBriefId} is not active (status='${pred.status}'); only active briefs can be superseded.`);
|
|
154
|
-
}
|
|
155
|
-
version = pred.version + 1;
|
|
156
|
-
}
|
|
157
|
-
const result = db.prepare(`
|
|
158
|
-
INSERT INTO project_briefs(
|
|
159
|
-
memory_id, tenant_id, repo, summary, version,
|
|
160
|
-
status, superseded_by, superseded_at, change_summary, closed_at, created_at
|
|
161
|
-
) VALUES (?, ?, ?, ?, ?, 'active', NULL, NULL, ?, NULL, ?)
|
|
162
|
-
`).run(memoryId, tenantId, repo, opts.summary, version, changeSummary, now);
|
|
163
|
-
const briefId = Number(result.lastInsertRowid ?? 0);
|
|
164
|
-
if (opts.supersedesBriefId !== undefined) {
|
|
165
|
-
const sup = db.prepare(`
|
|
166
|
-
UPDATE project_briefs
|
|
167
|
-
SET status = 'superseded', superseded_by = ?, superseded_at = ?
|
|
168
|
-
WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
|
|
169
|
-
`).run(briefId, now, opts.supersedesBriefId, tenantId, briefId);
|
|
170
|
-
if (sup.changes === 0) {
|
|
171
|
-
throw new ConflictError(`saveProjectBrief: brief ${opts.supersedesBriefId} could not be superseded (no longer active or self-reference).`);
|
|
172
|
-
}
|
|
173
|
-
appendAuditEvent(db, {
|
|
174
|
-
tenantId,
|
|
175
|
-
actor,
|
|
176
|
-
op: 'project_brief_supersede',
|
|
177
|
-
targetId: String(opts.supersedesBriefId),
|
|
178
|
-
metadata: {
|
|
179
|
-
brief_id: opts.supersedesBriefId,
|
|
180
|
-
superseded_by: briefId,
|
|
181
|
-
new_version: version,
|
|
182
|
-
refreshed: isRefresh,
|
|
183
|
-
...refreshAuditExtra,
|
|
184
|
-
},
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
// SAFETY: SELECT ${BRIEF_COLS} projects exactly the ProjectBriefRow
|
|
188
|
-
// columns; .get() returns that row, or undefined only if the
|
|
189
|
-
// just-inserted id can't be found.
|
|
190
|
-
const row = db.prepare(`SELECT ${BRIEF_COLS} FROM project_briefs WHERE id = ?`)
|
|
191
|
-
.get(briefId);
|
|
192
|
-
if (!row)
|
|
193
|
-
throw new Error('saveProjectBrief: failed to reload saved brief row');
|
|
194
|
-
savedRow = row;
|
|
195
|
-
// GDPR-light metadata: ids + flags only, no brief text.
|
|
196
|
-
appendAuditEvent(db, {
|
|
197
|
-
tenantId,
|
|
198
|
-
actor,
|
|
199
|
-
op: 'project_brief_create',
|
|
200
|
-
targetId: String(briefId),
|
|
201
|
-
metadata: {
|
|
202
|
-
brief_id: briefId,
|
|
203
|
-
repo,
|
|
204
|
-
version,
|
|
205
|
-
refreshed: isRefresh,
|
|
206
|
-
...refreshAuditExtra,
|
|
207
|
-
},
|
|
208
|
-
});
|
|
225
|
+
savedRow = writeBriefRow(db, memoryId, w);
|
|
209
226
|
},
|
|
210
227
|
// afterCommit covers refreshBrief (delegates here) + brief new/supersede.
|
|
211
228
|
afterCommit: () => markGraphDirty(hippoRoot, tenantId, mem.id),
|
|
@@ -371,34 +388,16 @@ function receiptHeadline(content) {
|
|
|
371
388
|
? `${trimmed.slice(0, MAX_RECEIPT_HEADLINE_LEN)}...`
|
|
372
389
|
: trimmed;
|
|
373
390
|
}
|
|
374
|
-
/**
|
|
375
|
-
|
|
376
|
-
* return it WITH the receipt count (the count feeds refreshBrief's change_summary +
|
|
377
|
-
* audit metadata). NO LLM. Always returns a non-empty, valid summary (a brief
|
|
378
|
-
* `summary` is NOT NULL), including the zero-receipts case.
|
|
379
|
-
*
|
|
380
|
-
* A "receipt" = a tenant memory row carrying the repo's `path:<repo>` tag. The
|
|
381
|
-
* brief's OWN memory mirror (source='project_brief') is excluded so a brief never
|
|
382
|
-
* becomes its own receipt on the next refresh. The match is against the JSON-array
|
|
383
|
-
* serialization (each element is a double-quoted string `"path:hippo"`); the
|
|
384
|
-
* surrounding quotes are load-bearing — they stop `hip` matching `path:hippo`.
|
|
385
|
-
* `repo` is LIKE-escaped + parameterized (operator-supplied; security.md).
|
|
386
|
-
*/
|
|
387
|
-
export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
|
|
388
|
-
assertTenantId('assembleBriefFromReceipts', tenantId);
|
|
389
|
-
const normalizedRepo = (repo ?? '').trim();
|
|
390
|
-
if (normalizedRepo.length === 0) {
|
|
391
|
-
throw new BadRequestError('assembleBriefFromReceipts: repo is required');
|
|
392
|
-
}
|
|
391
|
+
/** The repo's receipt rows, newest first, capped at MAX_BRIEF_RECEIPTS. */
|
|
392
|
+
function loadBriefReceipts(hippoRoot, tenantId, normalizedRepo) {
|
|
393
393
|
const tag = `path:${normalizedRepo.toLowerCase()}`;
|
|
394
394
|
const likeParam = `%"${escapeLike(tag)}"%`;
|
|
395
395
|
const denyPlaceholders = RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ');
|
|
396
396
|
const db = openHippoDb(hippoRoot);
|
|
397
|
-
let receipts;
|
|
398
397
|
try {
|
|
399
398
|
// SAFETY: SELECT projects exactly id, created, source, content (the
|
|
400
399
|
// ReceiptRow columns); .all() returns rows in that shape.
|
|
401
|
-
|
|
400
|
+
return db.prepare(`
|
|
402
401
|
SELECT id, created, source, content FROM memories
|
|
403
402
|
WHERE tenant_id = ?
|
|
404
403
|
AND source != 'project_brief'
|
|
@@ -411,17 +410,19 @@ export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
|
|
|
411
410
|
finally {
|
|
412
411
|
closeHippoDb(db);
|
|
413
412
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
413
|
+
}
|
|
414
|
+
// NOTE on ordering: the `id DESC` tiebreak is lexical on a random-ish memory id
|
|
415
|
+
// (e.g. `sem_<hex>`), NOT chronological — within the same `created` timestamp the
|
|
416
|
+
// order is stable-but-arbitrary, not insertion order. `created DESC` is the real
|
|
417
|
+
// recency ordering. (plan-eng-critic 2026-05-30, med.)
|
|
418
|
+
//
|
|
419
|
+
// Budget-aware assembly (codex-review-critic 2026-05-30, P2): the digest is the
|
|
420
|
+
// brief `summary`, which saveProjectBrief caps at MAX_BRIEF_SUMMARY_LEN. The
|
|
421
|
+
// receipt/headline caps (50 x ~200) could otherwise build an ~11KB body that the
|
|
422
|
+
// store then REJECTS, breaking refresh for inputs within the advertised caps. So
|
|
423
|
+
// include receipt lines newest-first only while they fit under the cap (reserving
|
|
424
|
+
// slack for the header + an omission footer), and note the omitted remainder.
|
|
425
|
+
function fitReceiptLines(receipts) {
|
|
425
426
|
const buildReceiptLine = (r) => `- ${(r.created ?? '').slice(0, 10)} [${r.source}] ${receiptHeadline(r.content)}`;
|
|
426
427
|
const receiptLines = [];
|
|
427
428
|
if (receipts.length > 0) {
|
|
@@ -437,17 +438,20 @@ export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
|
|
|
437
438
|
bodyBudget -= line.length + 1;
|
|
438
439
|
}
|
|
439
440
|
}
|
|
440
|
-
|
|
441
|
+
return receiptLines;
|
|
442
|
+
}
|
|
443
|
+
function renderBriefDigest(normalizedRepo, receiptCount, receiptLines) {
|
|
444
|
+
const omitted = receiptCount - receiptLines.length;
|
|
441
445
|
const lines = [];
|
|
442
446
|
lines.push(`# Project Brief: ${normalizedRepo}`);
|
|
443
447
|
lines.push('');
|
|
444
448
|
lines.push(omitted > 0
|
|
445
|
-
? `_Auto-assembled from ${receiptLines.length} of ${
|
|
446
|
-
: `_Auto-assembled from ${
|
|
449
|
+
? `_Auto-assembled from ${receiptLines.length} of ${receiptCount} receipt(s)._`
|
|
450
|
+
: `_Auto-assembled from ${receiptCount} receipt(s)._`);
|
|
447
451
|
lines.push('');
|
|
448
452
|
lines.push('## Recent receipts');
|
|
449
453
|
lines.push('');
|
|
450
|
-
if (
|
|
454
|
+
if (receiptCount === 0) {
|
|
451
455
|
lines.push(`_No receipts found for ${normalizedRepo}._`);
|
|
452
456
|
}
|
|
453
457
|
else {
|
|
@@ -464,6 +468,29 @@ export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
|
|
|
464
468
|
if (markdown.length > MAX_BRIEF_SUMMARY_LEN) {
|
|
465
469
|
markdown = markdown.slice(0, MAX_BRIEF_SUMMARY_LEN);
|
|
466
470
|
}
|
|
471
|
+
return markdown;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Assemble the repo's recent receipts into a deterministic markdown digest, and
|
|
475
|
+
* return it WITH the receipt count (the count feeds refreshBrief's change_summary +
|
|
476
|
+
* audit metadata). NO LLM. Always returns a non-empty, valid summary (a brief
|
|
477
|
+
* `summary` is NOT NULL), including the zero-receipts case.
|
|
478
|
+
*
|
|
479
|
+
* A "receipt" = a tenant memory row carrying the repo's `path:<repo>` tag. The
|
|
480
|
+
* brief's OWN memory mirror (source='project_brief') is excluded so a brief never
|
|
481
|
+
* becomes its own receipt on the next refresh. The match is against the JSON-array
|
|
482
|
+
* serialization (each element is a double-quoted string `"path:hippo"`); the
|
|
483
|
+
* surrounding quotes are load-bearing — they stop `hip` matching `path:hippo`.
|
|
484
|
+
* `repo` is LIKE-escaped + parameterized (operator-supplied; security.md).
|
|
485
|
+
*/
|
|
486
|
+
export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
|
|
487
|
+
assertTenantId('assembleBriefFromReceipts', tenantId);
|
|
488
|
+
const normalizedRepo = (repo ?? '').trim();
|
|
489
|
+
if (normalizedRepo.length === 0) {
|
|
490
|
+
throw new BadRequestError('assembleBriefFromReceipts: repo is required');
|
|
491
|
+
}
|
|
492
|
+
const receipts = loadBriefReceipts(hippoRoot, tenantId, normalizedRepo);
|
|
493
|
+
const markdown = renderBriefDigest(normalizedRepo, receipts.length, fitReceiptLines(receipts));
|
|
467
494
|
return { markdown, receiptCount: receipts.length };
|
|
468
495
|
}
|
|
469
496
|
/**
|
package/dist/project-merge.d.ts
CHANGED
|
@@ -18,7 +18,15 @@ export interface MergeResult {
|
|
|
18
18
|
readonly compactions: number;
|
|
19
19
|
readonly backup: string | null;
|
|
20
20
|
}
|
|
21
|
+
export interface ProjectFold {
|
|
22
|
+
readonly from: string;
|
|
23
|
+
readonly into: string;
|
|
24
|
+
}
|
|
21
25
|
export interface RepairResult {
|
|
26
|
+
/** Imported notes filed under the wrong project, or under a project name when a user-global import holds the same text: set aside. */
|
|
27
|
+
readonly copies: readonly string[];
|
|
28
|
+
/** Names whose recorded session folders all resolve to one other project today, folded as `merge` would. */
|
|
29
|
+
readonly folds: readonly ProjectFold[];
|
|
22
30
|
readonly toProject: ReadonlyArray<{
|
|
23
31
|
readonly id: string;
|
|
24
32
|
readonly origin: string;
|
|
@@ -42,10 +50,10 @@ export declare function mergeProjects(db: DatabaseSyncLike, hippoRoot: string, o
|
|
|
42
50
|
into: string;
|
|
43
51
|
dryRun: boolean;
|
|
44
52
|
}): MergeResult;
|
|
45
|
-
/** Reads only, so doctor
|
|
46
|
-
export declare function
|
|
47
|
-
/**
|
|
48
|
-
export declare function
|
|
53
|
+
/** Reads only, so doctor and a dry run take no write lock; merged rows are planned before any fold, so a few may re-tag differently once folds apply. */
|
|
54
|
+
export declare function planProjectRepair(db: DatabaseSyncLike, hippoRoot: string, tenantId: string): Omit<RepairResult, 'backup'>;
|
|
55
|
+
/** Sets aside stray imports, folds the names the resolver now maps elsewhere, then re-tags sleep's user-global merges by their parents; a dry run only plans. */
|
|
56
|
+
export declare function repairProjects(db: DatabaseSyncLike, hippoRoot: string, opts: {
|
|
49
57
|
tenantId: string;
|
|
50
58
|
dryRun: boolean;
|
|
51
59
|
}): RepairResult;
|