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
@@ -31,36 +31,7 @@ export function cmdStatus(hippoRoot) {
31
31
  const entries = loadAllEntries(hippoRoot);
32
32
  const stats = loadStats(hippoRoot);
33
33
  const now = evalNow();
34
- const byLayer = {
35
- [Layer.Buffer]: 0,
36
- [Layer.Episodic]: 0,
37
- [Layer.Semantic]: 0,
38
- [Layer.Trace]: 0,
39
- };
40
- const byConfidence = {
41
- verified: 0,
42
- observed: 0,
43
- inferred: 0,
44
- stale: 0,
45
- };
46
- let totalStrength = 0;
47
- let pinned = 0;
48
- let atRisk = 0; // strength < 0.2
49
- let agedOut = 0;
50
- for (const e of entries) {
51
- const s = calculateStrength(e, now);
52
- byLayer[e.layer] = (byLayer[e.layer] ?? 0) + 1;
53
- totalStrength += s;
54
- if (e.pinned)
55
- pinned++;
56
- if (s < 0.2)
57
- atRisk++;
58
- const facets = confidenceFacets(e, now);
59
- byConfidence[facets.tier] = (byConfidence[facets.tier] ?? 0) + 1;
60
- if (facets.agedOut)
61
- agedOut++;
62
- }
63
- const avgStrength = entries.length > 0 ? totalStrength / entries.length : 0;
34
+ const { byLayer, byConfidence, pinned, atRisk, agedOut, avgStrength } = tallyStatus(entries, now);
64
35
  console.log('Hippo Status');
65
36
  console.log('---------------------------');
66
37
  console.log(`Total memories: ${entries.length}`);
@@ -92,7 +63,44 @@ export function cmdStatus(hippoRoot) {
92
63
  else {
93
64
  console.log(`Last sleep: never`);
94
65
  }
95
- // Embedding status (provider-aware)
66
+ printEmbeddingStatus(hippoRoot, entries);
67
+ printPhysicsStatus(hippoRoot);
68
+ }
69
+ function tallyStatus(entries, now) {
70
+ const byLayer = {
71
+ [Layer.Buffer]: 0,
72
+ [Layer.Episodic]: 0,
73
+ [Layer.Semantic]: 0,
74
+ [Layer.Trace]: 0,
75
+ };
76
+ const byConfidence = {
77
+ verified: 0,
78
+ observed: 0,
79
+ inferred: 0,
80
+ stale: 0,
81
+ };
82
+ let totalStrength = 0;
83
+ let pinned = 0;
84
+ let atRisk = 0; // strength < 0.2
85
+ let agedOut = 0;
86
+ for (const e of entries) {
87
+ const s = calculateStrength(e, now);
88
+ byLayer[e.layer] = (byLayer[e.layer] ?? 0) + 1;
89
+ totalStrength += s;
90
+ if (e.pinned)
91
+ pinned++;
92
+ if (s < 0.2)
93
+ atRisk++;
94
+ const facets = confidenceFacets(e, now);
95
+ byConfidence[facets.tier] = (byConfidence[facets.tier] ?? 0) + 1;
96
+ if (facets.agedOut)
97
+ agedOut++;
98
+ }
99
+ const avgStrength = entries.length > 0 ? totalStrength / entries.length : 0;
100
+ return { byLayer, byConfidence, pinned, atRisk, agedOut, avgStrength };
101
+ }
102
+ // Embedding status (provider-aware)
103
+ function printEmbeddingStatus(hippoRoot, entries) {
96
104
  const embedProvider = (() => {
97
105
  try {
98
106
  return resolveEmbeddingProvider(hippoRoot);
@@ -105,42 +113,43 @@ export function cmdStatus(hippoRoot) {
105
113
  console.log('');
106
114
  if (!embedProvider) {
107
115
  console.log(`Embeddings: misconfigured (check embeddings.provider / apiBaseUrl), BM25 only`);
116
+ return;
117
+ }
118
+ const embeddingsDisabled = loadConfig(hippoRoot).embeddings.enabled === false;
119
+ const embAvail = embedProvider.isAvailable();
120
+ if (embeddingsDisabled) {
121
+ console.log(`Embeddings: disabled in config (embeddings.enabled = false), BM25 only`);
122
+ }
123
+ else if (embedProvider.kind === 'local') {
124
+ console.log(`Embeddings: ${embAvail ? `available [${embedProvider.id}]` : 'not installed (BM25 only)'}`);
125
+ }
126
+ else if (embAvail) {
127
+ console.log(`Embeddings: ${embedProvider.kind} api [${embedProvider.id}]`);
108
128
  }
109
129
  else {
110
- const embeddingsDisabled = loadConfig(hippoRoot).embeddings.enabled === false;
111
- const embAvail = embedProvider.isAvailable();
112
- if (embeddingsDisabled) {
113
- console.log(`Embeddings: disabled in config (embeddings.enabled = false), BM25 only`);
114
- }
115
- else if (embedProvider.kind === 'local') {
116
- console.log(`Embeddings: ${embAvail ? `available [${embedProvider.id}]` : 'not installed (BM25 only)'}`);
117
- }
118
- else if (embAvail) {
119
- console.log(`Embeddings: ${embedProvider.kind} api [${embedProvider.id}]`);
120
- }
121
- else {
122
- console.log(`Embeddings: ${embedProvider.kind} configured but ${embedProvider.keyEnv} not set (BM25 only)`);
123
- }
124
- // Show cached counts whenever vectors exist on disk (even when disabled or
125
- // the key was removed), so the user still sees what is already indexed.
126
- const embIndex = loadEmbeddingIndex(hippoRoot);
127
- if (embAvail || Object.keys(embIndex).length > 0) {
128
- const activeIds = new Set(entries.map((e) => e.id));
129
- const activeEmbedded = Object.keys(embIndex).filter((id) => activeIds.has(id)).length;
130
- const orphaned = Object.keys(embIndex).length - activeEmbedded;
131
- const dims = Object.values(embIndex)[0]?.length;
132
- let line = `Embedded: ${activeEmbedded}/${entries.length} memories`;
133
- if (dims)
134
- line += ` (${dims}-dim)`;
135
- if (orphaned > 0)
136
- line += ` (${orphaned} orphaned, run \`hippo embed\` to prune)`;
137
- console.log(line);
138
- if (embeddingModelRequiresReindex(hippoRoot, embedProvider.id, embIndex)) {
139
- console.log(` model changed, run \`hippo embed\` to reindex`);
140
- }
141
- }
130
+ console.log(`Embeddings: ${embedProvider.kind} configured but ${embedProvider.keyEnv} not set (BM25 only)`);
131
+ }
132
+ // Show cached counts whenever vectors exist on disk (even when disabled or
133
+ // the key was removed), so the user still sees what is already indexed.
134
+ const embIndex = loadEmbeddingIndex(hippoRoot);
135
+ if (!embAvail && Object.keys(embIndex).length === 0)
136
+ return;
137
+ const activeIds = new Set(entries.map((e) => e.id));
138
+ const activeEmbedded = Object.keys(embIndex).filter((id) => activeIds.has(id)).length;
139
+ const orphaned = Object.keys(embIndex).length - activeEmbedded;
140
+ const dims = Object.values(embIndex)[0]?.length;
141
+ let line = `Embedded: ${activeEmbedded}/${entries.length} memories`;
142
+ if (dims)
143
+ line += ` (${dims}-dim)`;
144
+ if (orphaned > 0)
145
+ line += ` (${orphaned} orphaned, run \`hippo embed\` to prune)`;
146
+ console.log(line);
147
+ if (embeddingModelRequiresReindex(hippoRoot, embedProvider.id, embIndex)) {
148
+ console.log(` model changed, run \`hippo embed\` to reindex`);
142
149
  }
143
- // Physics status
150
+ }
151
+ // Physics status
152
+ function printPhysicsStatus(hippoRoot) {
144
153
  try {
145
154
  const db = openHippoDb(hippoRoot);
146
155
  try {
@@ -112,18 +112,8 @@ export function cmdImport(hippoRoot, args, flags) {
112
112
  ? [String(flags['tag'])]
113
113
  : [];
114
114
  const targetRoot = useGlobal ? getGlobalRoot() : hippoRoot;
115
- if (flags['agents']) {
116
- const opts = { machine: currentMachine(), dryRun };
117
- // A folder without a store of its own imports as session end would there, so its notes are not hidden.
118
- const report = useGlobal || isInitialized(hippoRoot)
119
- ? importForStore(useGlobal ? getGlobalRoot() : hippoRoot, opts)
120
- : importAtSessionEnd(process.cwd(), undefined, opts);
121
- for (const line of detailLines(report, dryRun))
122
- console.log(line);
123
- for (const warning of report.warnings)
124
- printError(`hippo: agent memories: ${warning}`);
125
- return;
126
- }
115
+ if (flags['agents'])
116
+ return importAgentMemories(hippoRoot, useGlobal, dryRun);
127
117
  if (useGlobal) {
128
118
  initGlobal();
129
119
  }
@@ -142,93 +132,29 @@ export function cmdImport(hippoRoot, args, flags) {
142
132
  // It writes through api.remember/archiveRaw which are tenant-scoped, so we
143
133
  // resolve the tenant and pass it through. --global is not supported for
144
134
  // vault import (the connector raw-archive path is tenant-local).
145
- if (flags['vault']) {
146
- const folderPath = String(flags['vault']);
147
- if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) {
148
- printError(`Vault folder not found (or not a directory): ${folderPath}`);
149
- process.exit(1);
150
- }
151
- if (useGlobal) {
152
- printError('hippo import --vault does not support --global (raw rows are tenant-local).');
153
- process.exit(1);
154
- }
155
- if (typeof flags['name'] !== 'string' || !flags['name'].trim()) {
156
- // --name is the vault identity key for the destructive source-deletion sync;
157
- // inferring it from the folder basename let same-basename vaults collide and
158
- // clobber each other (codex R10 P2). A valueless `--name` parses as boolean
159
- // true, and String(true) === "true" would silently import under vault:true:*
160
- // - reject a non-string so it fails fast instead (codex R11 P2).
161
- printError('hippo import --vault requires --name <vault> (a non-empty identity key for source-deletion sync).');
162
- process.exit(1);
163
- }
164
- if (flags['scope'] !== undefined && (typeof flags['scope'] !== 'string' || !flags['scope'].trim())) {
165
- // Same valueless-flag trap: a bare `--scope` must not become scope "true".
166
- // Example uses the source-prefixed private form, since a bare `private` scope
167
- // is NOT treated as private by recall and importVault rejects it (R13 P2).
168
- printError('hippo import --vault: --scope requires a value (e.g. --scope vault:private:notes).');
169
- process.exit(1);
170
- }
171
- const tenantId = resolveTenantId({});
172
- const vaultOptions = {
173
- ...importOptions,
174
- tenantId,
175
- name: flags['name'] ? String(flags['name']) : undefined,
176
- scope: flags['scope'] ? String(flags['scope']) : undefined,
177
- };
178
- const vaultResult = importVault(folderPath, vaultOptions);
179
- console.log(`\nImport Vault: ${folderPath}${dryRun ? ' (dry run - no writes)' : ''}`);
180
- console.log(` Notes found: ${vaultResult.total}`);
181
- console.log(` ${dryRun ? 'Would import: ' : 'Imported: '}${vaultResult.imported}`);
182
- console.log(` Skipped (unchanged): ${vaultResult.skipped}`);
183
- if ((vaultResult.rejected ?? 0) > 0) {
184
- console.log(` Rejected (tombstoned): ${vaultResult.rejected}`);
185
- }
186
- warnRedacted(vaultResult.redacted);
187
- console.log(` ${dryRun ? 'Would archive: ' : 'Archived (removed): '}${vaultResult.archived ?? 0}`);
188
- console.log(` Store: ${hippoRoot}`);
189
- // Batch producer, same contract as the single-file import below: vault rows
190
- // write through api.remember (which never embeds), so backfill them here.
191
- // Floating promise is deliberate; see the comment at the single-file site.
192
- if (!dryRun && vaultResult.imported >= 1) {
193
- void embedAll(hippoRoot).catch(() => { });
194
- }
195
- return;
196
- }
197
- // Determine which importer to use based on flag
198
- let filePath;
199
- let importer;
200
- let importerName = '';
201
- if (flags['chatgpt']) {
202
- filePath = String(flags['chatgpt']);
203
- importer = importChatGPT;
204
- importerName = 'ChatGPT';
205
- }
206
- else if (flags['claude']) {
207
- filePath = String(flags['claude']);
208
- importer = importClaude;
209
- importerName = 'Claude';
210
- }
211
- else if (flags['cursor']) {
212
- filePath = String(flags['cursor']);
213
- importer = importCursor;
214
- importerName = 'Cursor';
215
- }
216
- else if (flags['file']) {
217
- filePath = String(flags['file']);
218
- importer = importGenericFile;
219
- importerName = 'File';
220
- }
221
- else if (flags['markdown']) {
222
- filePath = String(flags['markdown']);
223
- importer = importMarkdown;
224
- importerName = 'Markdown';
225
- }
226
- else if (args[0]) {
227
- // Positional: try to auto-detect from extension
228
- filePath = args[0];
229
- importer = importGenericFile;
230
- importerName = 'File';
231
- }
135
+ if (flags['vault'])
136
+ return importVaultFolder(hippoRoot, flags, importOptions, useGlobal, dryRun);
137
+ importFromFile(targetRoot, args, flags, importOptions, useGlobal, dryRun);
138
+ }
139
+ // Determine which importer to use based on flag
140
+ function pickImporter(args, flags) {
141
+ if (flags['chatgpt'])
142
+ return { filePath: String(flags['chatgpt']), importer: importChatGPT, importerName: 'ChatGPT' };
143
+ if (flags['claude'])
144
+ return { filePath: String(flags['claude']), importer: importClaude, importerName: 'Claude' };
145
+ if (flags['cursor'])
146
+ return { filePath: String(flags['cursor']), importer: importCursor, importerName: 'Cursor' };
147
+ if (flags['file'])
148
+ return { filePath: String(flags['file']), importer: importGenericFile, importerName: 'File' };
149
+ if (flags['markdown'])
150
+ return { filePath: String(flags['markdown']), importer: importMarkdown, importerName: 'Markdown' };
151
+ // Positional: try to auto-detect from extension
152
+ if (args[0])
153
+ return { filePath: args[0], importer: importGenericFile, importerName: 'File' };
154
+ return { filePath: undefined, importer: undefined, importerName: '' };
155
+ }
156
+ function importFromFile(targetRoot, args, flags, importOptions, useGlobal, dryRun) {
157
+ const { filePath, importer, importerName } = pickImporter(args, flags);
232
158
  if (!filePath || !importer) {
233
159
  printError('Usage: hippo import <--chatgpt|--claude|--cursor|--file|--markdown|--vault> <path>, or hippo import --agents [--dry-run]');
234
160
  process.exit(1);
@@ -272,6 +198,66 @@ export function cmdImport(hippoRoot, args, flags) {
272
198
  console.log(` Store: ${storeLabel}`);
273
199
  }
274
200
  }
201
+ function importAgentMemories(hippoRoot, useGlobal, dryRun) {
202
+ const opts = { machine: currentMachine(), dryRun };
203
+ // A folder without a store of its own imports as session end would there, so its notes are not hidden.
204
+ const report = useGlobal || isInitialized(hippoRoot)
205
+ ? importForStore(useGlobal ? getGlobalRoot() : hippoRoot, opts)
206
+ : importAtSessionEnd(process.cwd(), undefined, opts);
207
+ for (const line of detailLines(report, dryRun))
208
+ console.log(line);
209
+ for (const warning of report.warnings)
210
+ printError(`hippo: agent memories: ${warning}`);
211
+ }
212
+ function importVaultFolder(hippoRoot, flags, importOptions, useGlobal, dryRun) {
213
+ const folderPath = String(flags['vault']);
214
+ if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) {
215
+ printError(`Vault folder not found (or not a directory): ${folderPath}`);
216
+ process.exit(1);
217
+ }
218
+ if (useGlobal) {
219
+ printError('hippo import --vault does not support --global (raw rows are tenant-local).');
220
+ process.exit(1);
221
+ }
222
+ if (typeof flags['name'] !== 'string' || !flags['name'].trim()) {
223
+ // --name is the vault identity key for the destructive source-deletion sync; inferring it from the folder basename let
224
+ // same-basename vaults collide and clobber each other (codex R10 P2). A valueless `--name` parses as boolean true, and
225
+ // String(true) === "true" would silently import under vault:true:* - reject a non-string so it fails fast instead (codex R11 P2).
226
+ printError('hippo import --vault requires --name <vault> (a non-empty identity key for source-deletion sync).');
227
+ process.exit(1);
228
+ }
229
+ if (flags['scope'] !== undefined && (typeof flags['scope'] !== 'string' || !flags['scope'].trim())) {
230
+ // Same valueless-flag trap: a bare `--scope` must not become scope "true".
231
+ // Example uses the source-prefixed private form, since a bare `private` scope
232
+ // is NOT treated as private by recall and importVault rejects it (R13 P2).
233
+ printError('hippo import --vault: --scope requires a value (e.g. --scope vault:private:notes).');
234
+ process.exit(1);
235
+ }
236
+ const tenantId = resolveTenantId({});
237
+ const vaultOptions = {
238
+ ...importOptions,
239
+ tenantId,
240
+ name: flags['name'] ? String(flags['name']) : undefined,
241
+ scope: flags['scope'] ? String(flags['scope']) : undefined,
242
+ };
243
+ const vaultResult = importVault(folderPath, vaultOptions);
244
+ console.log(`\nImport Vault: ${folderPath}${dryRun ? ' (dry run - no writes)' : ''}`);
245
+ console.log(` Notes found: ${vaultResult.total}`);
246
+ console.log(` ${dryRun ? 'Would import: ' : 'Imported: '}${vaultResult.imported}`);
247
+ console.log(` Skipped (unchanged): ${vaultResult.skipped}`);
248
+ if ((vaultResult.rejected ?? 0) > 0) {
249
+ console.log(` Rejected (tombstoned): ${vaultResult.rejected}`);
250
+ }
251
+ warnRedacted(vaultResult.redacted);
252
+ console.log(` ${dryRun ? 'Would archive: ' : 'Archived (removed): '}${vaultResult.archived ?? 0}`);
253
+ console.log(` Store: ${hippoRoot}`);
254
+ // Batch producer, same contract as the single-file import below: vault rows
255
+ // write through api.remember (which never embeds), so backfill them here.
256
+ // Floating promise is deliberate; see the comment at the single-file site.
257
+ if (!dryRun && vaultResult.imported >= 1) {
258
+ void embedAll(hippoRoot).catch(() => { });
259
+ }
260
+ }
275
261
  // ---------------------------------------------------------------------------
276
262
  // Promote command
277
263
  // ---------------------------------------------------------------------------
@@ -25,8 +25,6 @@ export interface CompactionRecord {
25
25
  status: CompactionStatus;
26
26
  }
27
27
  export type Log = (message: string) => void;
28
- /** Where the session ran: rows written through the global store keep the project the session was in. */
29
- export declare function compactionOrigin(hippoRoot: string, cwd: string | null): string;
30
28
  interface ScrubbedSummary {
31
29
  summary: string;
32
30
  items: string[];
@@ -30,7 +30,7 @@ const CLAIMED_SUFFIX = '.claimed';
30
30
  /** The marker redactSecretsStrict writes; an item holding it was a secret before it was stored. */
31
31
  const REDACTED = '[REDACTED]';
32
32
  /** Where the session ran: rows written through the global store keep the project the session was in. */
33
- export function compactionOrigin(hippoRoot, cwd) {
33
+ function compactionOrigin(hippoRoot, cwd) {
34
34
  if (!isGlobalStoreRoot(hippoRoot))
35
35
  return deriveOriginProject(path.dirname(hippoRoot));
36
36
  // No cwd means user-global, as stampOriginProject gives the global store; undefined would fall back to the hook's own cwd.
@@ -94,9 +94,79 @@ const NOTE_COLS = `
94
94
  function buildNoteContent(customer, note) {
95
95
  return `${customer}\n\n${note}`;
96
96
  }
97
- // ---------------------------------------------------------------------------
98
- // Public API
99
- // ---------------------------------------------------------------------------
97
+ // Preflight the supersede target BEFORE inserting the new row (so the new
98
+ // autoincrement id can never be its own supersede target); read the
99
+ // predecessor version in the same SELECT for server-derived versioning.
100
+ // Mirrors saveProjectBrief / saveSkill (codex P1 2026-05-28).
101
+ function preflightNoteSupersede(db, tenantId, supersedesId) {
102
+ // SAFETY: SELECT projects exactly status, version; .get() returns that
103
+ // shape for the matching row, or undefined when no note/tenant pair matches.
104
+ const pred = db.prepare(`SELECT status, version FROM customer_notes WHERE id = ? AND tenant_id = ?`).get(supersedesId, tenantId);
105
+ if (!pred) {
106
+ throw new NotFoundError(`saveCustomerNote: note ${supersedesId} to supersede not found for tenant ${tenantId}`);
107
+ }
108
+ if (pred.status !== 'active') {
109
+ throw new ConflictError(`saveCustomerNote: note ${supersedesId} is not active (status='${pred.status}'); only active notes can be superseded.`);
110
+ }
111
+ return pred.version + 1;
112
+ }
113
+ function insertNoteRow(db, memoryId, w, version) {
114
+ const result = db.prepare(`
115
+ INSERT INTO customer_notes(
116
+ memory_id, tenant_id, customer, note, version,
117
+ status, superseded_by, superseded_at, change_summary, closed_at, created_at
118
+ ) VALUES (?, ?, ?, ?, ?, 'active', NULL, NULL, ?, NULL, ?)
119
+ `).run(memoryId, w.tenantId, w.customer, w.note, version, w.changeSummary, w.now);
120
+ return Number(result.lastInsertRowid ?? 0);
121
+ }
122
+ function supersedeNoteRow(db, w, supersedesId, noteId, version) {
123
+ const sup = db.prepare(`
124
+ UPDATE customer_notes
125
+ SET status = 'superseded', superseded_by = ?, superseded_at = ?
126
+ WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
127
+ `).run(noteId, w.now, supersedesId, w.tenantId, noteId);
128
+ if (sup.changes === 0) {
129
+ throw new ConflictError(`saveCustomerNote: note ${supersedesId} could not be superseded (no longer active or self-reference).`);
130
+ }
131
+ appendAuditEvent(db, {
132
+ tenantId: w.tenantId,
133
+ actor: w.actor,
134
+ op: 'customer_note_supersede',
135
+ targetId: String(supersedesId),
136
+ metadata: {
137
+ note_id: supersedesId,
138
+ superseded_by: noteId,
139
+ new_version: version,
140
+ },
141
+ });
142
+ }
143
+ /** The afterWrite body: preflight, INSERT, supersede, reload, create audit, all in one SAVEPOINT. */
144
+ function writeNoteRow(db, memoryId, w) {
145
+ const version = w.supersedesId !== undefined ? preflightNoteSupersede(db, w.tenantId, w.supersedesId) : 1;
146
+ const noteId = insertNoteRow(db, memoryId, w, version);
147
+ if (w.supersedesId !== undefined)
148
+ supersedeNoteRow(db, w, w.supersedesId, noteId, version);
149
+ // SAFETY: SELECT ${NOTE_COLS} projects exactly the CustomerNoteRow columns;
150
+ // .get() returns that row, or undefined only if the just-inserted id can't
151
+ // be found.
152
+ const row = db.prepare(`SELECT ${NOTE_COLS} FROM customer_notes WHERE id = ?`)
153
+ .get(noteId);
154
+ if (!row)
155
+ throw new Error('saveCustomerNote: failed to reload saved note row');
156
+ // GDPR-light metadata: ids + flags only, no note text.
157
+ appendAuditEvent(db, {
158
+ tenantId: w.tenantId,
159
+ actor: w.actor,
160
+ op: 'customer_note_create',
161
+ targetId: String(noteId),
162
+ metadata: {
163
+ note_id: noteId,
164
+ customer: w.customer,
165
+ version,
166
+ },
167
+ });
168
+ return row;
169
+ }
100
170
  /**
101
171
  * Create a customer_note (or a new version that supersedes an existing one). Writes
102
172
  * the memory mirror + the customer_notes row atomically inside writeEntry's SAVEPOINT.
@@ -125,75 +195,14 @@ export function saveCustomerNote(hippoRoot, tenantId, opts, actor = 'cli') {
125
195
  baseHalfLifeDays: objectHalfLifeDays(hippoRoot),
126
196
  tenantId,
127
197
  });
198
+ const w = {
199
+ tenantId, actor, customer, note: opts.note, changeSummary, supersedesId: opts.supersedesNoteId, now,
200
+ };
128
201
  let savedRow;
129
202
  writeEntry(hippoRoot, mem, {
130
203
  actor,
131
204
  afterWrite: (db, memoryId) => {
132
- // Preflight the supersede target BEFORE inserting the new row (so the new
133
- // autoincrement id can never be its own supersede target); read the
134
- // predecessor version in the same SELECT for server-derived versioning.
135
- // Mirrors saveProjectBrief / saveSkill (codex P1 2026-05-28).
136
- let version = 1;
137
- if (opts.supersedesNoteId !== undefined) {
138
- // SAFETY: SELECT projects exactly status, version; .get() returns that
139
- // shape for the matching row, or undefined when no note/tenant pair matches.
140
- const pred = db.prepare(`SELECT status, version FROM customer_notes WHERE id = ? AND tenant_id = ?`).get(opts.supersedesNoteId, tenantId);
141
- if (!pred) {
142
- throw new NotFoundError(`saveCustomerNote: note ${opts.supersedesNoteId} to supersede not found for tenant ${tenantId}`);
143
- }
144
- if (pred.status !== 'active') {
145
- throw new ConflictError(`saveCustomerNote: note ${opts.supersedesNoteId} is not active (status='${pred.status}'); only active notes can be superseded.`);
146
- }
147
- version = pred.version + 1;
148
- }
149
- const result = db.prepare(`
150
- INSERT INTO customer_notes(
151
- memory_id, tenant_id, customer, note, version,
152
- status, superseded_by, superseded_at, change_summary, closed_at, created_at
153
- ) VALUES (?, ?, ?, ?, ?, 'active', NULL, NULL, ?, NULL, ?)
154
- `).run(memoryId, tenantId, customer, opts.note, version, changeSummary, now);
155
- const noteId = Number(result.lastInsertRowid ?? 0);
156
- if (opts.supersedesNoteId !== undefined) {
157
- const sup = db.prepare(`
158
- UPDATE customer_notes
159
- SET status = 'superseded', superseded_by = ?, superseded_at = ?
160
- WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
161
- `).run(noteId, now, opts.supersedesNoteId, tenantId, noteId);
162
- if (sup.changes === 0) {
163
- throw new ConflictError(`saveCustomerNote: note ${opts.supersedesNoteId} could not be superseded (no longer active or self-reference).`);
164
- }
165
- appendAuditEvent(db, {
166
- tenantId,
167
- actor,
168
- op: 'customer_note_supersede',
169
- targetId: String(opts.supersedesNoteId),
170
- metadata: {
171
- note_id: opts.supersedesNoteId,
172
- superseded_by: noteId,
173
- new_version: version,
174
- },
175
- });
176
- }
177
- // SAFETY: SELECT ${NOTE_COLS} projects exactly the CustomerNoteRow columns;
178
- // .get() returns that row, or undefined only if the just-inserted id can't
179
- // be found.
180
- const row = db.prepare(`SELECT ${NOTE_COLS} FROM customer_notes WHERE id = ?`)
181
- .get(noteId);
182
- if (!row)
183
- throw new Error('saveCustomerNote: failed to reload saved note row');
184
- savedRow = row;
185
- // GDPR-light metadata: ids + flags only, no note text.
186
- appendAuditEvent(db, {
187
- tenantId,
188
- actor,
189
- op: 'customer_note_create',
190
- targetId: String(noteId),
191
- metadata: {
192
- note_id: noteId,
193
- customer,
194
- version,
195
- },
196
- });
205
+ savedRow = writeNoteRow(db, memoryId, w);
197
206
  },
198
207
  afterCommit: () => markGraphDirty(hippoRoot, tenantId, mem.id),
199
208
  });