hippo-memory 1.61.0 → 1.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +28 -53
  2. package/dist/agent-memories/apply.d.ts +1 -1
  3. package/dist/agent-memories/claude-code.d.ts +3 -1
  4. package/dist/agent-memories/claude-code.js +53 -9
  5. package/dist/agent-memories/sync.d.ts +3 -3
  6. package/dist/agent-memories/sync.js +14 -6
  7. package/dist/agent-memories/types.d.ts +0 -2
  8. package/dist/api/assemble.js +55 -54
  9. package/dist/api/context-select.d.ts +49 -0
  10. package/dist/api/context-select.js +344 -0
  11. package/dist/api/context.d.ts +2 -2
  12. package/dist/api/context.js +195 -522
  13. package/dist/api/drill-down.js +36 -33
  14. package/dist/api/promote.js +55 -66
  15. package/dist/api/recall.js +303 -438
  16. package/dist/api/sleep.js +203 -218
  17. package/dist/capture/compact.d.ts +1 -1
  18. package/dist/capture/compact.js +2 -2
  19. package/dist/cli/briefs.js +324 -306
  20. package/dist/cli/context.js +44 -34
  21. package/dist/cli/continuity.js +283 -271
  22. package/dist/cli/curate.js +35 -34
  23. package/dist/cli/decisions.js +333 -333
  24. package/dist/cli/explain.js +66 -60
  25. package/dist/cli/maintenance.js +62 -51
  26. package/dist/cli/playbooks.js +387 -370
  27. package/dist/cli/projects.js +8 -5
  28. package/dist/cli/recall.js +28 -43
  29. package/dist/cli/remember.js +113 -70
  30. package/dist/cli/session-hooks.js +100 -90
  31. package/dist/cli/setup.js +267 -246
  32. package/dist/cli/status.js +73 -64
  33. package/dist/cli/transfer.js +85 -99
  34. package/dist/compaction-record.d.ts +0 -2
  35. package/dist/compaction-record.js +1 -1
  36. package/dist/customer-notes.js +77 -68
  37. package/dist/dag.js +222 -186
  38. package/dist/decisions.js +93 -76
  39. package/dist/doctor.js +11 -7
  40. package/dist/goals.js +99 -86
  41. package/dist/incidents.js +45 -38
  42. package/dist/policies.js +85 -68
  43. package/dist/processes.js +87 -71
  44. package/dist/project-briefs.js +135 -108
  45. package/dist/project-merge.d.ts +12 -4
  46. package/dist/project-merge.js +130 -45
  47. package/dist/shared.d.ts +9 -0
  48. package/dist/shared.js +10 -8
  49. package/dist/skills.js +81 -65
  50. package/dist/store/search-rows.d.ts +2 -2
  51. package/dist/store/search-rows.js +15 -9
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  55. package/extensions/openclaw-plugin/package.json +1 -1
  56. package/openclaw.plugin.json +1 -1
  57. package/package.json +1 -1
package/dist/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
- // Public API
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
- // Preflight the supersede target BEFORE inserting the new row. The new
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) {
@@ -103,9 +103,83 @@ const BRIEF_COLS = `
103
103
  function buildBriefContent(repo, summary) {
104
104
  return `${repo}\n\n${summary}`;
105
105
  }
106
- // ---------------------------------------------------------------------------
107
- // Public API
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
- // Preflight the supersede target BEFORE inserting the new row (so the new
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
- * Assemble the repo's recent receipts into a deterministic markdown digest, and
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
- receipts = db.prepare(`
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
- // 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.
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
- const omitted = receipts.length - receiptLines.length;
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 ${receipts.length} receipt(s)._`
446
- : `_Auto-assembled from ${receipts.length} receipt(s)._`);
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 (receipts.length === 0) {
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
  /**
@@ -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 can call it on a read-only handle: what the repair would do to each user-global merged row. */
46
- export declare function planUserGlobalRepair(db: DatabaseSyncLike, tenantId: string): Omit<RepairResult, 'backup'>;
47
- /** Re-tags sleep's merged rows saved as user-global before the fix, by the projects of their parents. */
48
- export declare function repairUserGlobalMerges(db: DatabaseSyncLike, hippoRoot: string, opts: {
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;