@nxuss/lemma 1.22.1 → 1.24.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 (176) hide show
  1. package/README.md +33 -7
  2. package/dist/cjs/cli/lemma-proxy.d.ts.map +1 -1
  3. package/dist/cjs/cli/lemma-proxy.js +15 -3
  4. package/dist/cjs/cli/lemma-proxy.js.map +1 -1
  5. package/dist/cjs/cloud/store/FileBrainStore.d.ts.map +1 -1
  6. package/dist/cjs/cloud/store/FileBrainStore.js +5 -2
  7. package/dist/cjs/cloud/store/FileBrainStore.js.map +1 -1
  8. package/dist/cjs/infra/errorCause.d.ts +36 -0
  9. package/dist/cjs/infra/errorCause.d.ts.map +1 -0
  10. package/dist/cjs/infra/errorCause.js +143 -0
  11. package/dist/cjs/infra/errorCause.js.map +1 -0
  12. package/dist/cjs/infra/providers/JenkinsProvider.d.ts.map +1 -1
  13. package/dist/cjs/infra/providers/JenkinsProvider.js +5 -0
  14. package/dist/cjs/infra/providers/JenkinsProvider.js.map +1 -1
  15. package/dist/cjs/infra/providers/OKDProvider.d.ts.map +1 -1
  16. package/dist/cjs/infra/providers/OKDProvider.js +5 -1
  17. package/dist/cjs/infra/providers/OKDProvider.js.map +1 -1
  18. package/dist/cjs/infra/providers/TerraformProvider.d.ts.map +1 -1
  19. package/dist/cjs/infra/providers/TerraformProvider.js +3 -1
  20. package/dist/cjs/infra/providers/TerraformProvider.js.map +1 -1
  21. package/dist/cjs/mcp/index.js +21 -0
  22. package/dist/cjs/mcp/index.js.map +1 -1
  23. package/dist/cjs/mcp/tool-profiles.d.ts +6 -4
  24. package/dist/cjs/mcp/tool-profiles.d.ts.map +1 -1
  25. package/dist/cjs/mcp/tool-profiles.js +3 -0
  26. package/dist/cjs/mcp/tool-profiles.js.map +1 -1
  27. package/dist/cjs/mcp/tools/context-tools.d.ts.map +1 -1
  28. package/dist/cjs/mcp/tools/context-tools.js +3 -1
  29. package/dist/cjs/mcp/tools/context-tools.js.map +1 -1
  30. package/dist/cjs/mcp/tools/memory.d.ts.map +1 -1
  31. package/dist/cjs/mcp/tools/memory.js +255 -15
  32. package/dist/cjs/mcp/tools/memory.js.map +1 -1
  33. package/dist/cjs/mcp/tools/misc.d.ts.map +1 -1
  34. package/dist/cjs/mcp/tools/misc.js +21 -9
  35. package/dist/cjs/mcp/tools/misc.js.map +1 -1
  36. package/dist/cjs/mcp/tools/squeeze-cache.d.ts +6 -0
  37. package/dist/cjs/mcp/tools/squeeze-cache.d.ts.map +1 -1
  38. package/dist/cjs/mcp/tools/squeeze-cache.js +57 -17
  39. package/dist/cjs/mcp/tools/squeeze-cache.js.map +1 -1
  40. package/dist/cjs/mcp/tools/workspace.d.ts.map +1 -1
  41. package/dist/cjs/mcp/tools/workspace.js +7 -2
  42. package/dist/cjs/mcp/tools/workspace.js.map +1 -1
  43. package/dist/cjs/mcp/tools.d.ts +18 -0
  44. package/dist/cjs/mcp/tools.d.ts.map +1 -1
  45. package/dist/cjs/mcp/tools.js +97 -16
  46. package/dist/cjs/mcp/tools.js.map +1 -1
  47. package/dist/cjs/mcp/utils.d.ts.map +1 -1
  48. package/dist/cjs/mcp/utils.js +18 -4
  49. package/dist/cjs/mcp/utils.js.map +1 -1
  50. package/dist/cjs/mcp/workspaceShared.d.ts +1 -0
  51. package/dist/cjs/mcp/workspaceShared.d.ts.map +1 -1
  52. package/dist/cjs/mcp/workspaceShared.js +27 -0
  53. package/dist/cjs/mcp/workspaceShared.js.map +1 -1
  54. package/dist/cjs/security/scrubForStorage.d.ts +16 -0
  55. package/dist/cjs/security/scrubForStorage.d.ts.map +1 -0
  56. package/dist/cjs/security/scrubForStorage.js +35 -0
  57. package/dist/cjs/security/scrubForStorage.js.map +1 -0
  58. package/dist/cjs/subconscious/BrainEncryption.d.ts +38 -0
  59. package/dist/cjs/subconscious/BrainEncryption.d.ts.map +1 -0
  60. package/dist/cjs/subconscious/BrainEncryption.js +137 -0
  61. package/dist/cjs/subconscious/BrainEncryption.js.map +1 -0
  62. package/dist/cjs/subconscious/TheBrainV2.d.ts +337 -0
  63. package/dist/cjs/subconscious/TheBrainV2.d.ts.map +1 -1
  64. package/dist/cjs/subconscious/TheBrainV2.js +1149 -118
  65. package/dist/cjs/subconscious/TheBrainV2.js.map +1 -1
  66. package/dist/cjs/utils/AnomalyInventory.d.ts +29 -0
  67. package/dist/cjs/utils/AnomalyInventory.d.ts.map +1 -0
  68. package/dist/cjs/utils/AnomalyInventory.js +58 -0
  69. package/dist/cjs/utils/AnomalyInventory.js.map +1 -0
  70. package/dist/cjs/utils/ExecutiveReport.d.ts +10 -0
  71. package/dist/cjs/utils/ExecutiveReport.d.ts.map +1 -1
  72. package/dist/cjs/utils/ExecutiveReport.js +17 -1
  73. package/dist/cjs/utils/ExecutiveReport.js.map +1 -1
  74. package/dist/cjs/utils/SavingsLedger.d.ts +45 -2
  75. package/dist/cjs/utils/SavingsLedger.d.ts.map +1 -1
  76. package/dist/cjs/utils/SavingsLedger.js +89 -5
  77. package/dist/cjs/utils/SavingsLedger.js.map +1 -1
  78. package/dist/cjs/utils/SessionEvidence.d.ts +28 -0
  79. package/dist/cjs/utils/SessionEvidence.d.ts.map +1 -0
  80. package/dist/cjs/utils/SessionEvidence.js +55 -0
  81. package/dist/cjs/utils/SessionEvidence.js.map +1 -0
  82. package/dist/cjs/utils/StateHashCache.d.ts +10 -0
  83. package/dist/cjs/utils/StateHashCache.d.ts.map +1 -1
  84. package/dist/cjs/utils/StateHashCache.js +15 -0
  85. package/dist/cjs/utils/StateHashCache.js.map +1 -1
  86. package/dist/cjs/utils/reportSavings.d.ts +4 -0
  87. package/dist/cjs/utils/reportSavings.d.ts.map +1 -1
  88. package/dist/cjs/utils/reportSavings.js.map +1 -1
  89. package/dist/esm/cli/lemma-proxy.d.ts.map +1 -1
  90. package/dist/esm/cli/lemma-proxy.js +15 -3
  91. package/dist/esm/cli/lemma-proxy.js.map +1 -1
  92. package/dist/esm/cloud/store/FileBrainStore.d.ts.map +1 -1
  93. package/dist/esm/cloud/store/FileBrainStore.js +5 -2
  94. package/dist/esm/cloud/store/FileBrainStore.js.map +1 -1
  95. package/dist/esm/infra/errorCause.d.ts +36 -0
  96. package/dist/esm/infra/errorCause.d.ts.map +1 -0
  97. package/dist/esm/infra/errorCause.js +138 -0
  98. package/dist/esm/infra/errorCause.js.map +1 -0
  99. package/dist/esm/infra/providers/JenkinsProvider.d.ts.map +1 -1
  100. package/dist/esm/infra/providers/JenkinsProvider.js +5 -0
  101. package/dist/esm/infra/providers/JenkinsProvider.js.map +1 -1
  102. package/dist/esm/infra/providers/OKDProvider.d.ts.map +1 -1
  103. package/dist/esm/infra/providers/OKDProvider.js +5 -1
  104. package/dist/esm/infra/providers/OKDProvider.js.map +1 -1
  105. package/dist/esm/infra/providers/TerraformProvider.d.ts.map +1 -1
  106. package/dist/esm/infra/providers/TerraformProvider.js +3 -1
  107. package/dist/esm/infra/providers/TerraformProvider.js.map +1 -1
  108. package/dist/esm/mcp/index.js +21 -0
  109. package/dist/esm/mcp/index.js.map +1 -1
  110. package/dist/esm/mcp/tool-profiles.d.ts +6 -4
  111. package/dist/esm/mcp/tool-profiles.d.ts.map +1 -1
  112. package/dist/esm/mcp/tool-profiles.js +3 -0
  113. package/dist/esm/mcp/tool-profiles.js.map +1 -1
  114. package/dist/esm/mcp/tools/context-tools.d.ts.map +1 -1
  115. package/dist/esm/mcp/tools/context-tools.js +3 -1
  116. package/dist/esm/mcp/tools/context-tools.js.map +1 -1
  117. package/dist/esm/mcp/tools/memory.d.ts.map +1 -1
  118. package/dist/esm/mcp/tools/memory.js +255 -15
  119. package/dist/esm/mcp/tools/memory.js.map +1 -1
  120. package/dist/esm/mcp/tools/misc.d.ts.map +1 -1
  121. package/dist/esm/mcp/tools/misc.js +21 -9
  122. package/dist/esm/mcp/tools/misc.js.map +1 -1
  123. package/dist/esm/mcp/tools/squeeze-cache.d.ts +6 -0
  124. package/dist/esm/mcp/tools/squeeze-cache.d.ts.map +1 -1
  125. package/dist/esm/mcp/tools/squeeze-cache.js +56 -17
  126. package/dist/esm/mcp/tools/squeeze-cache.js.map +1 -1
  127. package/dist/esm/mcp/tools/workspace.d.ts.map +1 -1
  128. package/dist/esm/mcp/tools/workspace.js +8 -3
  129. package/dist/esm/mcp/tools/workspace.js.map +1 -1
  130. package/dist/esm/mcp/tools.d.ts +18 -0
  131. package/dist/esm/mcp/tools.d.ts.map +1 -1
  132. package/dist/esm/mcp/tools.js +96 -17
  133. package/dist/esm/mcp/tools.js.map +1 -1
  134. package/dist/esm/mcp/utils.d.ts.map +1 -1
  135. package/dist/esm/mcp/utils.js +18 -4
  136. package/dist/esm/mcp/utils.js.map +1 -1
  137. package/dist/esm/mcp/workspaceShared.d.ts +1 -0
  138. package/dist/esm/mcp/workspaceShared.d.ts.map +1 -1
  139. package/dist/esm/mcp/workspaceShared.js +26 -0
  140. package/dist/esm/mcp/workspaceShared.js.map +1 -1
  141. package/dist/esm/security/scrubForStorage.d.ts +16 -0
  142. package/dist/esm/security/scrubForStorage.d.ts.map +1 -0
  143. package/dist/esm/security/scrubForStorage.js +32 -0
  144. package/dist/esm/security/scrubForStorage.js.map +1 -0
  145. package/dist/esm/subconscious/BrainEncryption.d.ts +38 -0
  146. package/dist/esm/subconscious/BrainEncryption.d.ts.map +1 -0
  147. package/dist/esm/subconscious/BrainEncryption.js +126 -0
  148. package/dist/esm/subconscious/BrainEncryption.js.map +1 -0
  149. package/dist/esm/subconscious/TheBrainV2.d.ts +337 -0
  150. package/dist/esm/subconscious/TheBrainV2.d.ts.map +1 -1
  151. package/dist/esm/subconscious/TheBrainV2.js +1147 -118
  152. package/dist/esm/subconscious/TheBrainV2.js.map +1 -1
  153. package/dist/esm/utils/AnomalyInventory.d.ts +29 -0
  154. package/dist/esm/utils/AnomalyInventory.d.ts.map +1 -0
  155. package/dist/esm/utils/AnomalyInventory.js +49 -0
  156. package/dist/esm/utils/AnomalyInventory.js.map +1 -0
  157. package/dist/esm/utils/ExecutiveReport.d.ts +10 -0
  158. package/dist/esm/utils/ExecutiveReport.d.ts.map +1 -1
  159. package/dist/esm/utils/ExecutiveReport.js +17 -1
  160. package/dist/esm/utils/ExecutiveReport.js.map +1 -1
  161. package/dist/esm/utils/SavingsLedger.d.ts +45 -2
  162. package/dist/esm/utils/SavingsLedger.d.ts.map +1 -1
  163. package/dist/esm/utils/SavingsLedger.js +89 -5
  164. package/dist/esm/utils/SavingsLedger.js.map +1 -1
  165. package/dist/esm/utils/SessionEvidence.d.ts +28 -0
  166. package/dist/esm/utils/SessionEvidence.d.ts.map +1 -0
  167. package/dist/esm/utils/SessionEvidence.js +49 -0
  168. package/dist/esm/utils/SessionEvidence.js.map +1 -0
  169. package/dist/esm/utils/StateHashCache.d.ts +10 -0
  170. package/dist/esm/utils/StateHashCache.d.ts.map +1 -1
  171. package/dist/esm/utils/StateHashCache.js +14 -0
  172. package/dist/esm/utils/StateHashCache.js.map +1 -1
  173. package/dist/esm/utils/reportSavings.d.ts +4 -0
  174. package/dist/esm/utils/reportSavings.d.ts.map +1 -1
  175. package/dist/esm/utils/reportSavings.js.map +1 -1
  176. package/package.json +1 -1
@@ -16,6 +16,8 @@ import crypto from 'crypto';
16
16
  import { execSync } from 'child_process';
17
17
  import { LocalFsResolver } from './freshness/LocalFsResolver.js';
18
18
  import { EmbeddingSidecar, embeddingsEnabled, semanticRerank } from './BrainEmbeddings.js';
19
+ import { scrubSecretsForStorage } from '../security/scrubForStorage.js';
20
+ import { BRAIN_ENC_MAGIC, decryptEnvelope, encryptEnvelope, encryptionActive, resolveBrainKey, } from './BrainEncryption.js';
19
21
  /**
20
22
  * Default resolver for the local MCP path. Behaviour is byte-for-byte identical to
21
23
  * the pre-refactor inline `fs.readFileSync` hashing — it never returns `null`, so
@@ -75,13 +77,20 @@ const LOCK_FILE = path.join(BRAIN_DIR, '.write.lock');
75
77
  /** Sidecar vector cache for the optional semantic re-rank. See BrainEmbeddings.ts. */
76
78
  const EMBEDDINGS_FILE = path.join(BRAIN_DIR, 'embeddings.ndjson');
77
79
  /**
78
- * Append-only archive of everything forget() destroyed. Deletion is the one Brain operation
79
- * with no undo, and the deleted body is by definition the only copy of that text — telling the
80
- * caller "nothing else can recover it" and then handing back metadata only is the worst of both
81
- * worlds. The id stays tombstoned (a deliberate deletion must not come back through a merge),
82
- * so this file is an archive to read, not a bundle to re-import.
80
+ * The trash: an append-only archive of everything forget() removed, and the restore path
81
+ * for it (see restoreForgotten/purgeForgotten). The id stays tombstoned (a deliberate
82
+ * deletion must not come back through a merge), so this file is read as history and
83
+ * restored from by id never re-imported as a bundle.
83
84
  */
84
85
  const FORGOTTEN_FILE = path.join(BRAIN_DIR, 'forgotten.ndjson');
86
+ /** Whole-file sha256 of entries.ndjson, written alongside it on every save. */
87
+ const ENTRIES_SHA_FILE = `${ENTRIES_FILE}.sha256`;
88
+ /** Last known-good copy of entries.ndjson, rotated on every clean save. */
89
+ const ENTRIES_PREV_FILE = `${ENTRIES_FILE}.prev`;
90
+ /** Timestamped full-corpus copies taken before destructive operations. See snapshotCorpus(). */
91
+ const SNAP_DIR = path.join(BRAIN_DIR, 'snapshots');
92
+ /** How many snapshots to keep — a rollback history, not an archive. */
93
+ const MAX_SNAPSHOTS = 10;
85
94
  // ─── Cross-process durability ─────────────────────────────────────────────────
86
95
  //
87
96
  // Every MCP client session starts its own server process, and each one holds the whole
@@ -176,6 +185,95 @@ function writeFileAtomic(target, data) {
176
185
  throw err;
177
186
  }
178
187
  }
188
+ /**
189
+ * Disk protection: every byte that reaches the corpus files passes through protectForDisk
190
+ * (encrypted when a key resolves, plaintext otherwise), and every byte read back passes
191
+ * through unprotectFromDisk. An envelope without a resolvable key — or one that fails
192
+ * authentication — returns null, which the checked loader treats exactly like corruption:
193
+ * fall back, never serve half a corpus.
194
+ */
195
+ function protectForDisk(plain) {
196
+ const key = brainDiskKey();
197
+ return key ? encryptEnvelope(key, plain) : plain;
198
+ }
199
+ function unprotectFromDisk(stored) {
200
+ if (!stored.startsWith(BRAIN_ENC_MAGIC))
201
+ return stored;
202
+ const key = brainDiskKey();
203
+ if (!key)
204
+ return null;
205
+ return decryptEnvelope(key, stored);
206
+ }
207
+ /**
208
+ * Process-wide data key, resolved once from env/key-file (see BrainEncryption) and
209
+ * validated eagerly by the constructor — a malformed key configuration fails loud at
210
+ * startup, never as silent plaintext mid-session.
211
+ */
212
+ let cachedDiskKey = undefined;
213
+ function brainDiskKey() {
214
+ if (cachedDiskKey === undefined)
215
+ cachedDiskKey = resolveBrainKey(BRAIN_DIR);
216
+ return cachedDiskKey;
217
+ }
218
+ /** Test seam: drop the cached key so a test can rotate env/key-file mid-process. */
219
+ export function resetBrainKeyCache() {
220
+ cachedDiskKey = undefined;
221
+ }
222
+ function sha256hex(text) {
223
+ return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
224
+ }
225
+ /**
226
+ * Read a corpus file with whole-file verification. No sidecar hash (files written before
227
+ * this existed) means legacy trust: parse it as before. A sidecar mismatch means the file
228
+ * changed outside a save() — bitrot, a killed process mid-rename, a foreign write — and
229
+ * the raw bytes still come back (verified:false) so the caller can salvage intact lines;
230
+ * only an unreadable or absent file comes back with text:null.
231
+ */
232
+ function readStoredText(file) {
233
+ let raw;
234
+ try {
235
+ raw = fs.readFileSync(file, 'utf8');
236
+ }
237
+ catch {
238
+ return { text: null, verified: true, present: false }; // absent file: nothing to distrust
239
+ }
240
+ let expected = null;
241
+ try {
242
+ expected = fs.readFileSync(`${file}.sha256`, 'utf8').trim() || null;
243
+ }
244
+ catch { /* legacy file without a sidecar */ }
245
+ if (!expected)
246
+ return { text: raw, verified: true, present: true };
247
+ return sha256hex(raw) === expected
248
+ ? { text: raw, verified: true, present: true }
249
+ : { text: raw, verified: false, present: true };
250
+ }
251
+ /** Move an untrustworthy corpus file aside instead of deleting it — it may hold recoverable text. */
252
+ function quarantineFile(file) {
253
+ try {
254
+ if (!fs.existsSync(file))
255
+ return null;
256
+ const dest = path.join(path.dirname(file), `corrupt-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}.ndjson`);
257
+ fs.renameSync(file, dest);
258
+ try {
259
+ fs.unlinkSync(`${file}.sha256`);
260
+ }
261
+ catch { /* sidecar already describes garbage */ }
262
+ return dest;
263
+ }
264
+ catch {
265
+ return null;
266
+ }
267
+ }
268
+ /** Names of rollback snapshots on disk, newest last. */
269
+ export function listSnapshotNames() {
270
+ try {
271
+ return fs.readdirSync(SNAP_DIR).filter((n) => n.endsWith('.ndjson')).sort();
272
+ }
273
+ catch {
274
+ return [];
275
+ }
276
+ }
179
277
  function stampOf(file) {
180
278
  try {
181
279
  const s = fs.statSync(file);
@@ -190,32 +288,131 @@ function sameStamp(a, b) {
190
288
  return a === b;
191
289
  return a.mtimeMs === b.mtimeMs && a.size === b.size;
192
290
  }
193
- /** Parses entries.ndjson exactly the way load() does, without touching instance state. */
194
- function readEntriesFile() {
291
+ /** Parse NDJSON corpus text into entries, counting skipped lines. Pure: no filesystem. */
292
+ function parseEntriesWithStats(raw) {
195
293
  const out = new Map();
196
- let raw;
197
- try {
198
- raw = fs.readFileSync(ENTRIES_FILE, 'utf8');
199
- }
200
- catch {
201
- return out;
202
- }
294
+ let skipped = 0;
203
295
  for (const line of raw.split('\n')) {
204
296
  if (!line)
205
297
  continue;
206
298
  try {
207
299
  const entry = JSON.parse(line);
208
- if (!entry || typeof entry.id !== 'string')
300
+ if (!entry || typeof entry.id !== 'string') {
301
+ skipped++;
209
302
  continue;
303
+ }
210
304
  // Entries written before the format was compacted still carry `terms`; newer ones
211
305
  // don't. Rebuilding from termFreq covers both without a migration step.
212
306
  if (!Array.isArray(entry.terms))
213
307
  entry.terms = Object.keys(entry.termFreq || {});
308
+ // Entries written before clear-path persistence keep only hashes: backfill the
309
+ // labels from the hash keys so old memories become navigable on first read.
310
+ // Next save() persists the backfill — no separate migration step, no rewrite.
311
+ syncClearEvidencePaths(entry);
312
+ for (const claim of entry.claims || [])
313
+ syncClearEvidencePaths(claim);
214
314
  out.set(entry.id, entry);
215
315
  }
216
- catch { /* skip corrupt lines */ }
316
+ catch {
317
+ skipped++;
318
+ }
217
319
  }
218
- return out;
320
+ return { entries: out, skipped };
321
+ }
322
+ /** Parse NDJSON corpus text into entries, skipping corrupt lines. Pure: no filesystem. */
323
+ function parseEntriesText(raw) {
324
+ return parseEntriesWithStats(raw).entries;
325
+ }
326
+ /** Parses entries.ndjson exactly the way load() does, without touching instance state. */
327
+ function readEntriesFile() {
328
+ try {
329
+ return parseEntriesText(fs.readFileSync(ENTRIES_FILE, 'utf8'));
330
+ }
331
+ catch {
332
+ return new Map();
333
+ }
334
+ }
335
+ /**
336
+ * Load the corpus with verification and graceful degradation. Policy, in order:
337
+ *
338
+ * 1. Verified file (or legacy file without a sidecar): serve everything, no incident.
339
+ * 2. Changed out-of-band but with intact lines: serve the survivors and record a
340
+ * `partial-corpus` incident — discarding good memories because one line rotted would
341
+ * be worse than the rot. The next save() rewrites the file clean and heals this.
342
+ * 3. Total loss (nothing parses, or the envelope won't decrypt): fall back to the .prev
343
+ * backup, or start empty if there is none — quarantining the bad bytes either way
344
+ * when asked, never deleting them.
345
+ * 4. No main file at all: a fresh canvas. The .prev backup is deliberately NOT consulted
346
+ * here — absence of the corpus is a state (fresh install, clear()), not damage, and
347
+ * resurrecting an old backup over it would undo exactly that.
348
+ *
349
+ * Only load() — the startup path — quarantines: a long-lived process that quarantined on
350
+ * every racing read could destroy the corpus a concurrent writer is mid-merge on. save()
351
+ * and syncIfChanged() record the issue and heal on their own terms instead.
352
+ */
353
+ function loadCorpusChecked(opts) {
354
+ const at = new Date().toISOString();
355
+ const main = readStoredText(ENTRIES_FILE);
356
+ if (!main.present || main.text === null) {
357
+ return { entries: new Map(), issue: null, diskUsable: true };
358
+ }
359
+ const plain = unprotectFromDisk(main.text);
360
+ const parsed = plain !== null ? parseEntriesWithStats(plain) : { entries: new Map(), skipped: Number.MAX_SAFE_INTEGER };
361
+ if (main.verified && plain !== null && parsed.skipped === 0) {
362
+ return { entries: parsed.entries, issue: null, diskUsable: true };
363
+ }
364
+ if (parsed.entries.size > 0) {
365
+ return {
366
+ entries: parsed.entries,
367
+ issue: {
368
+ at,
369
+ file: ENTRIES_FILE,
370
+ action: 'partial-corpus',
371
+ detail: plain === null
372
+ ? 'entries.ndjson could not be decrypted with the available key; serving intact lines only.'
373
+ : `entries.ndjson changed outside a save() (${parsed.skipped} unreadable line(s) skipped); serving the intact entries. The next save rewrites the file clean.`,
374
+ },
375
+ diskUsable: true,
376
+ };
377
+ }
378
+ // Total loss: fall back to the backup, which has no sidecar of its own — acceptance is
379
+ // parse-ability, and a backup that parses to entries is strictly better than bytes that
380
+ // verify to nothing.
381
+ let prevEntries = null;
382
+ try {
383
+ const prevRaw = fs.readFileSync(ENTRIES_PREV_FILE, 'utf8');
384
+ const prevPlain = unprotectFromDisk(prevRaw);
385
+ if (prevPlain !== null) {
386
+ const prevParsed = parseEntriesText(prevPlain);
387
+ if (prevParsed.size > 0)
388
+ prevEntries = prevParsed;
389
+ }
390
+ }
391
+ catch { /* no usable backup */ }
392
+ if (opts.quarantine)
393
+ quarantineFile(ENTRIES_FILE);
394
+ if (prevEntries) {
395
+ return {
396
+ entries: prevEntries,
397
+ issue: {
398
+ at,
399
+ file: ENTRIES_FILE,
400
+ action: 'fallback-to-backup',
401
+ detail: 'entries.ndjson is entirely unreadable; serving the last known-good backup instead. The bad file was quarantined, not deleted.',
402
+ },
403
+ diskUsable: true,
404
+ };
405
+ }
406
+ return {
407
+ entries: new Map(),
408
+ issue: {
409
+ at,
410
+ file: ENTRIES_FILE,
411
+ action: 'fresh-start',
412
+ detail: 'entries.ndjson is entirely unreadable and no usable backup exists; starting empty. The bad file was quarantined, not deleted.',
413
+ },
414
+ diskUsable: false,
415
+ };
219
416
  }
220
417
  /** Reads the tombstone map straight off disk, for absorbing a peer process's deletions. */
221
418
  function readTombstonesFile() {
@@ -264,11 +461,19 @@ export function mergeEntryMaps(mine, theirs, tombstones = new Set()) {
264
461
  return;
265
462
  }
266
463
  const winner = effectiveTime(entry) > effectiveTime(existing) ? entry : existing;
267
- merged.set(entry.id, {
464
+ const mergedEntry = {
268
465
  ...winner,
269
466
  hits: Math.max(entry.hits || 0, existing.hits || 0),
270
467
  demerits: Math.max(entry.demerits || 0, existing.demerits || 0),
271
- });
468
+ };
469
+ // ROI is a monotonic tally like hits: keep the best either side collected, and keep
470
+ // the field absent (not zero) when neither side ever saved anything.
471
+ const bestSavings = Math.max(entry.tokensSaved || 0, existing.tokensSaved || 0);
472
+ if (bestSavings > 0)
473
+ mergedEntry.tokensSaved = bestSavings;
474
+ else
475
+ delete mergedEntry.tokensSaved;
476
+ merged.set(entry.id, mergedEntry);
272
477
  };
273
478
  for (const entry of mine.values())
274
479
  put(entry);
@@ -888,16 +1093,38 @@ export function trackedClaimArtifactCount(claim) {
888
1093
  return Object.keys(claim.fileHashes || {}).length + Object.keys(claim.symbolHashes || {}).length;
889
1094
  }
890
1095
  /** Builds the persisted `Claim[]` for an entry from the caller-supplied claim inputs. */
1096
+ /**
1097
+ * Re-derive the clear-path labels from the hash keys they describe. One-way only:
1098
+ * hashes are the source of truth, these fields are the navigable label. Called on
1099
+ * every path that (re)writes evidence — store(), reanchorEvidence(), and the load
1100
+ * backfill — so the three can never disagree about which file a memory means.
1101
+ */
1102
+ function syncClearEvidencePaths(target) {
1103
+ const files = target.fileHashes ? Object.keys(target.fileHashes).sort() : [];
1104
+ if (files.length > 0)
1105
+ target.filePaths = files;
1106
+ else
1107
+ delete target.filePaths;
1108
+ const syms = target.symbolHashes ? Object.keys(target.symbolHashes).sort() : [];
1109
+ if (syms.length > 0)
1110
+ target.symbols = syms;
1111
+ else
1112
+ delete target.symbols;
1113
+ }
891
1114
  function buildClaims(inputs) {
892
1115
  return inputs
893
1116
  .filter((c) => c?.text)
894
- .map((c) => ({
895
- id: crypto.createHash('sha1').update(c.text.trim().toLowerCase()).digest('hex').substring(0, 10),
896
- text: c.text.trim(),
897
- fileHashes: c.filePaths && c.filePaths.length > 0 ? hashFilesForFreshness(c.filePaths) : undefined,
898
- symbolHashes: c.symbols && c.symbols.length > 0 ? hashSymbolsForFreshness(c.symbols) : undefined,
899
- symbolNormalizedHashes: c.symbols && c.symbols.length > 0 ? hashSymbolsNormalizedForFreshness(c.symbols) : undefined,
900
- }));
1117
+ .map((c) => {
1118
+ const claim = {
1119
+ id: crypto.createHash('sha1').update(c.text.trim().toLowerCase()).digest('hex').substring(0, 10),
1120
+ text: c.text.trim(),
1121
+ fileHashes: c.filePaths && c.filePaths.length > 0 ? hashFilesForFreshness(c.filePaths) : undefined,
1122
+ symbolHashes: c.symbols && c.symbols.length > 0 ? hashSymbolsForFreshness(c.symbols) : undefined,
1123
+ symbolNormalizedHashes: c.symbols && c.symbols.length > 0 ? hashSymbolsNormalizedForFreshness(c.symbols) : undefined,
1124
+ };
1125
+ syncClearEvidencePaths(claim);
1126
+ return claim;
1127
+ });
901
1128
  }
902
1129
  /**
903
1130
  * Same hash-compare as checkEntryFreshness, scoped to one claim's own evidence — a claim
@@ -975,6 +1202,13 @@ export class TheBrainV2 {
975
1202
  this.diskStamp = null;
976
1203
  /** id -> ISO deletion time, for ids that must not come back through a merge. */
977
1204
  this.tombstones = new Map();
1205
+ /**
1206
+ * Last integrity incident on the corpus files (corrupt main file, undecryptable
1207
+ * envelope, skipped sync). Set by load()/save()/syncIfChanged(), never cleared
1208
+ * except by a clean verified read — health() and brain_stats surface it so a silent
1209
+ * fallback never looks like a healthy Brain.
1210
+ */
1211
+ this.integrityIssue = null;
978
1212
  /**
979
1213
  * id -> terms of that entry's own `query` field. Derived state, never persisted: `terms`
980
1214
  * covers query+response together, so scoring the query field on its own (see the
@@ -986,6 +1220,10 @@ export class TheBrainV2 {
986
1220
  this.queryTermCache = new Map();
987
1221
  this.sidecar = null;
988
1222
  this.ensureDir();
1223
+ // Validate key configuration before touching the corpus: a malformed LEMMA_BRAIN_KEY
1224
+ // or corrupt key file throws here, loud, instead of degrading into silent plaintext
1225
+ // (or an unreadable corpus) mid-session.
1226
+ brainDiskKey();
989
1227
  this.load();
990
1228
  }
991
1229
  ensureDir() {
@@ -1003,7 +1241,12 @@ export class TheBrainV2 {
1003
1241
  this.avgDocLength = meta.avgDocLength || 0;
1004
1242
  this.tombstones = readTombstones(meta);
1005
1243
  }
1006
- this.entries = readEntriesFile();
1244
+ // Checked: a corrupt main file falls back to the .prev backup (quarantining the
1245
+ // bad bytes) instead of silently starting empty and letting the next save()
1246
+ // legitimize the data loss with a fresh sidecar hash.
1247
+ const checked = loadCorpusChecked({ quarantine: true });
1248
+ this.integrityIssue = checked.issue;
1249
+ this.entries = checked.entries;
1007
1250
  for (const id of this.tombstones.keys())
1008
1251
  this.entries.delete(id);
1009
1252
  this.diskStamp = stampOf(ENTRIES_FILE);
@@ -1064,10 +1307,23 @@ export class TheBrainV2 {
1064
1307
  return;
1065
1308
  try {
1066
1309
  this.absorbPeerTombstones();
1067
- const onDisk = readEntriesFile();
1310
+ // No quarantine here (see loadCorpusChecked): a mismatch on a live read is most
1311
+ // likely a racing writer, so record it, keep serving memory, and let save()/load()
1312
+ // do the healing. The stamp still advances — re-reading the same bad bytes on
1313
+ // every search would turn one incident into a permanent slowdown.
1314
+ const checked = loadCorpusChecked({ quarantine: false });
1315
+ if (checked.issue) {
1316
+ this.integrityIssue = { ...checked.issue, action: 'sync-skipped' };
1317
+ this.diskStamp = stamp;
1318
+ return;
1319
+ }
1320
+ const onDisk = checked.entries;
1068
1321
  const before = this.entries.size;
1069
1322
  this.entries = mergeEntryMaps(this.entries, onDisk, new Set(this.tombstones.keys()));
1070
1323
  this.diskStamp = stamp;
1324
+ // A clean verified read heals a previously recorded incident — the next save()
1325
+ // rewrites the sidecar, so whatever was wrong is gone, not just unnoticed.
1326
+ this.integrityIssue = null;
1071
1327
  if (this.entries.size !== before || onDisk.size > 0) {
1072
1328
  this.rebuildIndex();
1073
1329
  this.recalcAvgDocLength();
@@ -1097,8 +1353,25 @@ export class TheBrainV2 {
1097
1353
  this.save();
1098
1354
  }, 500);
1099
1355
  }
1356
+ /**
1357
+ * Synchronously persist pending writes, if any. The debounced scheduleSave() above
1358
+ * is correct for a long-lived server but loses data when the process is about to
1359
+ * die: one-shot clients (lemma-call, the Muse skill path) kill the MCP server right
1360
+ * after the tool response, so without this every one-shot store/update/forget is
1361
+ * silently dropped. Installed as a SIGTERM/SIGINT handler by the MCP bootstrap —
1362
+ * never called on the hot path, where the debounce still applies.
1363
+ */
1364
+ flushSync() {
1365
+ if (this.flushTimer) {
1366
+ clearTimeout(this.flushTimer);
1367
+ this.flushTimer = null;
1368
+ }
1369
+ if (this.dirty)
1370
+ this.save();
1371
+ }
1100
1372
  save() {
1101
1373
  const fd = acquireLock();
1374
+ let wroteClean = false;
1102
1375
  try {
1103
1376
  this.ensureDir();
1104
1377
  // Merge before writing. A full rewrite of what this process happens to hold would
@@ -1106,8 +1379,15 @@ export class TheBrainV2 {
1106
1379
  // this whole section exists to close. After the merge the file we write is a superset
1107
1380
  // of both views, so a writer can only ever add.
1108
1381
  this.absorbPeerTombstones();
1109
- const onDisk = readEntriesFile();
1110
- if (onDisk.size > 0) {
1382
+ const checked = loadCorpusChecked({ quarantine: false });
1383
+ if (checked.issue) {
1384
+ // The file on disk doesn't verify. Merge only what the fallback recovered (the
1385
+ // backup, never the corrupt bytes), surface the issue, and — critically — do NOT
1386
+ // rotate .prev below: the corrupt main file must not become the "last good" copy.
1387
+ this.integrityIssue = checked.issue;
1388
+ }
1389
+ const onDisk = checked.issue && checked.issue.action === 'fresh-start' ? new Map() : checked.entries;
1390
+ if (checked.diskUsable && onDisk.size > 0) {
1111
1391
  this.entries = mergeEntryMaps(this.entries, onDisk, new Set(this.tombstones.keys()));
1112
1392
  this.rebuildIndex();
1113
1393
  this.recalcAvgDocLength();
@@ -1116,13 +1396,39 @@ export class TheBrainV2 {
1116
1396
  // Post-merge, so the file we write respects the cap even when the merge pulled in
1117
1397
  // entries a peer had already evicted.
1118
1398
  this.evictIfOverCapacity();
1399
+ // Total loss on disk (nothing parseable, or an envelope this key cannot open):
1400
+ // preserve those bytes before overwriting. They may be a corpus under a different
1401
+ // key, and an overwrite is forever while a quarantine is a rename. No-op when
1402
+ // load() already quarantined them at startup.
1403
+ if (checked.issue && (checked.issue.action === 'fresh-start' || checked.issue.action === 'fallback-to-backup')) {
1404
+ quarantineFile(ENTRIES_FILE);
1405
+ }
1406
+ // Rotate the backup BEFORE overwriting, but only when the file being replaced is
1407
+ // itself trustworthy — rotating a corrupt file would destroy the last good copy.
1408
+ if (!checked.issue) {
1409
+ try {
1410
+ if (fs.existsSync(ENTRIES_FILE))
1411
+ fs.copyFileSync(ENTRIES_FILE, ENTRIES_PREV_FILE);
1412
+ }
1413
+ catch { /* backup rotation is best-effort; the write below is what matters */ }
1414
+ }
1119
1415
  // Write NDJSON entries. `terms` is dropped: it is exactly Object.keys(termFreq),
1120
1416
  // and persisting both made the entry file 38% redundant bytes that every session
1121
1417
  // re-read at startup. load() reconstructs it.
1122
1418
  const ndjson = Array.from(this.entries.values())
1123
1419
  .map(({ terms: _terms, ...persisted }) => JSON.stringify(persisted))
1124
1420
  .join('\n');
1125
- writeFileAtomic(ENTRIES_FILE, ndjson);
1421
+ const stored = protectForDisk(ndjson);
1422
+ writeFileAtomic(ENTRIES_FILE, stored);
1423
+ // Sidecar hash over the exact bytes on disk, so any out-of-band change fails the
1424
+ // next checked read instead of being served as corpus.
1425
+ try {
1426
+ writeFileAtomic(ENTRIES_SHA_FILE, sha256hex(stored));
1427
+ // Whatever was wrong before is gone: what is on disk now is exactly this
1428
+ // process's verified corpus, so a recorded incident must not linger past it.
1429
+ wroteClean = true;
1430
+ }
1431
+ catch { /* an unverified corpus still beats no corpus */ }
1126
1432
  // Write inverted index. load() rebuilds this from the entries rather than reading it
1127
1433
  // back, so it is now purely an inspection artifact for the dashboard and for anyone
1128
1434
  // poking at the brain directory — kept because removing a file other tooling may read
@@ -1147,6 +1453,8 @@ export class TheBrainV2 {
1147
1453
  finally {
1148
1454
  releaseLock(fd);
1149
1455
  }
1456
+ if (wroteClean)
1457
+ this.integrityIssue = null;
1150
1458
  }
1151
1459
  /**
1152
1460
  * Pull in deletions made by other processes.
@@ -1204,6 +1512,22 @@ export class TheBrainV2 {
1204
1512
  // Another session may have stored this exact thing since we loaded; without the sync
1205
1513
  // the dedup check below would miss it and write a second copy.
1206
1514
  this.syncIfChanged();
1515
+ // Secrets never reach the corpus: redact before dedup, indexing, and persistence
1516
+ // alike, so the inverted index and the NDJSON see only the redacted text. Two fixes
1517
+ // differing only in their pasted API keys correctly dedup to one entry afterwards.
1518
+ const scrubbedQuery = scrubSecretsForStorage(query);
1519
+ const scrubbedResponse = scrubSecretsForStorage(response);
1520
+ query = scrubbedQuery.text;
1521
+ response = scrubbedResponse.text;
1522
+ let redactions = scrubbedQuery.redactions + scrubbedResponse.redactions;
1523
+ let scrubbedClaims = claimInputs;
1524
+ if (claimInputs && claimInputs.length > 0) {
1525
+ scrubbedClaims = claimInputs.map((c) => {
1526
+ const s = scrubSecretsForStorage(c.text);
1527
+ redactions += s.redactions;
1528
+ return s.redactions > 0 ? { ...c, text: s.text } : c;
1529
+ });
1530
+ }
1207
1531
  // Quick bloom check
1208
1532
  const queryKey = query.trim().toLowerCase().substring(0, 200);
1209
1533
  if (this.bloom.has(queryKey)) {
@@ -1214,6 +1538,7 @@ export class TheBrainV2 {
1214
1538
  stored: false,
1215
1539
  reason: `Duplicate detected (${(existing[0].similarity * 100).toFixed(1)}% similar)`,
1216
1540
  duplicate: existing[0],
1541
+ redactions,
1217
1542
  };
1218
1543
  }
1219
1544
  }
@@ -1221,24 +1546,10 @@ export class TheBrainV2 {
1221
1546
  // outcome, is worth flagging before this one is even written — two memories about one
1222
1547
  // function disagreeing on whether an approach works is a trap for whoever searches next.
1223
1548
  // Not a block, just a warning: the newer entry may legitimately supersede the older one.
1224
- let conflicts;
1225
- if (symbolRefs && symbolRefs.length > 0) {
1226
- const newKeys = new Set(symbolRefs.map((r) => `${path.resolve(r.filePath)}::${r.symbolName}`));
1227
- const newOutcome = outcome === 'failed' ? 'failed' : 'confirmed';
1228
- const found = [];
1229
- for (const existingEntry of this.entries.values()) {
1230
- if (!existingEntry.symbolHashes)
1231
- continue;
1232
- const existingOutcome = existingEntry.outcome === 'failed' ? 'failed' : 'confirmed';
1233
- if (existingOutcome === newOutcome)
1234
- continue;
1235
- const sharesSymbol = Object.keys(existingEntry.symbolHashes).some((k) => newKeys.has(k));
1236
- if (sharesSymbol)
1237
- found.push({ id: existingEntry.id, query: existingEntry.query, outcome: existingOutcome });
1238
- }
1239
- if (found.length > 0)
1240
- conflicts = found;
1241
- }
1549
+ const foundConflicts = symbolRefs && symbolRefs.length > 0
1550
+ ? this.findOutcomeConflicts(symbolRefs, outcome === 'failed' ? 'failed' : 'confirmed')
1551
+ : [];
1552
+ const conflicts = foundConflicts.length > 0 ? foundConflicts : undefined;
1242
1553
  const terms = tokenize(query + ' ' + response);
1243
1554
  const termFreq = termFrequencies(terms);
1244
1555
  // Salted with random bytes, not just the clock: the id used to be sha1(queryKey + now),
@@ -1273,13 +1584,17 @@ export class TheBrainV2 {
1273
1584
  (symbolRefs && symbolRefs.length > 0 ? hashSymbolsForFreshness(symbolRefs) : undefined),
1274
1585
  symbolNormalizedHashes: options.evidence?.symbolsNormalized ??
1275
1586
  (symbolRefs && symbolRefs.length > 0 ? hashSymbolsNormalizedForFreshness(symbolRefs) : undefined),
1276
- claims: claimInputs && claimInputs.length > 0 ? buildClaims(claimInputs) : undefined,
1587
+ claims: scrubbedClaims && scrubbedClaims.length > 0 ? buildClaims(scrubbedClaims) : undefined,
1588
+ ...(redactions > 0 ? { redactions } : {}),
1277
1589
  domain,
1278
1590
  derivedFrom: derivedFrom && derivedFrom.length > 0 ? derivedFrom : undefined,
1279
1591
  source: options.source || 'manual',
1280
1592
  ...(git.commit ? { gitCommit: git.commit } : {}),
1281
1593
  ...(git.branch ? { gitBranch: git.branch } : {}),
1282
1594
  };
1595
+ // Persist the tracked paths in clear next to the hashes (AN-03) — the labels
1596
+ // display and navigation read from here; freshness keeps reading the hashes.
1597
+ syncClearEvidencePaths(entry);
1283
1598
  this.entries.set(id, entry);
1284
1599
  // Update inverted index
1285
1600
  for (const term of entry.terms) {
@@ -1293,7 +1608,7 @@ export class TheBrainV2 {
1293
1608
  this.recalcAvgDocLength();
1294
1609
  this.evictIfOverCapacity();
1295
1610
  this.scheduleSave();
1296
- return { stored: true, reason: 'Stored successfully', id, conflicts };
1611
+ return { stored: true, reason: 'Stored successfully', id, conflicts, redactions };
1297
1612
  }
1298
1613
  /**
1299
1614
  * Evict the lowest-value entries once the Brain is over capacity. Value = hits (proven
@@ -1392,6 +1707,11 @@ export class TheBrainV2 {
1392
1707
  const entry = this.entries.get(id);
1393
1708
  if (!entry)
1394
1709
  continue;
1710
+ // A superseded entry is a known-wrong conclusion with a named successor (AN-22) — it
1711
+ // must never rank as if still live, unlike a downvote which only demotes. get_memory
1712
+ // still resolves it directly and points at the successor; search() just never offers it.
1713
+ if (entry.supersededBy)
1714
+ continue;
1395
1715
  const bm25 = bm25Score(queryTerms, entry.termFreq, entry.terms.length, this.avgDocLength || 10, idf);
1396
1716
  const docTermSet = new Set(entry.terms);
1397
1717
  const jaccard = jaccardSimilarity(queryTermSet, docTermSet);
@@ -1425,25 +1745,41 @@ export class TheBrainV2 {
1425
1745
  let similarity = exactPrompt
1426
1746
  ? 1
1427
1747
  : (s.bm25 / maxBm25) * BM25_WEIGHT + s.jaccard * JACCARD_WEIGHT + queryFieldMatch * QUERY_FIELD_WEIGHT;
1748
+ // whyShown: the human-readable half of the adjustments below. A memory the caller
1749
+ // can't audit is a memory the caller can't trust — and a downvote without a visible
1750
+ // reason teaches the ranker nothing. Only signals that actually fired are listed.
1751
+ const whyShown = [];
1752
+ if (exactPrompt)
1753
+ whyShown.push('exact-prompt-match');
1428
1754
  // Popularity prior: entries other searches actually reused are more likely to be
1429
1755
  // reused again. Log-scaled and capped at +0.08 so a handful of hits can't outrank a
1430
1756
  // much better textual match.
1431
- similarity += Math.min(Math.log2(s.entry.hits + 1) * 0.02, 0.08);
1757
+ const popularityBonus = Math.min(Math.log2(s.entry.hits + 1) * 0.02, 0.08);
1758
+ similarity += popularityBonus;
1759
+ if (s.entry.hits > 0)
1760
+ whyShown.push(`reused ${s.entry.hits}x (+${popularityBonus.toFixed(2)})`);
1432
1761
  // A documented dead end ranking above a working fix is actively harmful — it reads as
1433
1762
  // a suggestion even with the "STALE"/"FAILED" label attached downstream. Demote, don't
1434
1763
  // hide: the warning is still worth surfacing, just not first.
1435
- if (s.entry.outcome === 'failed')
1764
+ if (s.entry.outcome === 'failed') {
1436
1765
  similarity -= 0.15;
1766
+ whyShown.push('failed-attempt (-0.15)');
1767
+ }
1437
1768
  // Soft domain preference (see projectId scoping above for why this stays soft): an
1438
1769
  // entry tagged with the requested domain is more likely relevant, but an untagged or
1439
1770
  // cross-domain entry may still be the right answer.
1440
- if (options.domain && s.entry.domain === options.domain)
1771
+ if (options.domain && s.entry.domain === options.domain) {
1441
1772
  similarity += 0.05;
1773
+ whyShown.push(`domain '${options.domain}' match (+0.05)`);
1774
+ }
1442
1775
  // Explicit negative feedback from downvote() — a caller saying "this was wrong" is a
1443
1776
  // stronger, more deliberate signal than the absence of hits, so it outweighs the
1444
1777
  // popularity prior above rather than just canceling it out.
1445
- if (s.entry.demerits)
1446
- similarity -= Math.min(s.entry.demerits * 0.06, 0.25);
1778
+ if (s.entry.demerits) {
1779
+ const demeritPenalty = Math.min(s.entry.demerits * 0.06, 0.25);
1780
+ similarity -= demeritPenalty;
1781
+ whyShown.push(`downvoted x${s.entry.demerits} (-${demeritPenalty.toFixed(2)})`);
1782
+ }
1447
1783
  // Age decay. Every other signal here is about the memory's track record; none of them
1448
1784
  // notice that the codebase it describes has been rewritten twice since. A confirmed
1449
1785
  // memory from a year ago is not as likely to be current as yesterday's, and until now
@@ -1452,9 +1788,20 @@ export class TheBrainV2 {
1452
1788
  // which is the point of refresh(): a re-verified memory really is current again.
1453
1789
  const ageDays = (Date.now() - effectiveTime(s.entry)) / 86400000;
1454
1790
  if (Number.isFinite(ageDays) && ageDays > RECENCY_GRACE_DAYS) {
1455
- similarity -= Math.min((ageDays - RECENCY_GRACE_DAYS) / RECENCY_FULL_DECAY_DAYS, 1) * MAX_RECENCY_PENALTY;
1791
+ const agePenalty = Math.min((ageDays - RECENCY_GRACE_DAYS) / RECENCY_FULL_DECAY_DAYS, 1) * MAX_RECENCY_PENALTY;
1792
+ similarity -= agePenalty;
1793
+ whyShown.push(`age ${Math.floor(ageDays)}d (-${agePenalty.toFixed(2)})`);
1456
1794
  }
1457
- return { entry: s.entry, similarity: Math.max(0, similarity) };
1795
+ if (s.entry.refreshedAt)
1796
+ whyShown.push(`re-verified ${s.entry.refreshedAt.slice(0, 10)}`);
1797
+ // Evidence the freshness verdict was judged against: absolute tracked paths and
1798
+ // path::symbol keys. Handlers relativize these for display; the Brain keeps them
1799
+ // absolute because cwd is a caller-side notion.
1800
+ const evidence = [
1801
+ ...Object.keys(s.entry.fileHashes || {}),
1802
+ ...Object.keys(s.entry.symbolHashes || {}),
1803
+ ];
1804
+ return { entry: s.entry, similarity: Math.max(0, similarity), whyShown, evidence };
1458
1805
  });
1459
1806
  // 6. Sort and filter
1460
1807
  combined.sort((a, b) => b.similarity - a.similarity);
@@ -1480,6 +1827,10 @@ export class TheBrainV2 {
1480
1827
  provider: r.entry.provider,
1481
1828
  timestamp: r.entry.timestamp,
1482
1829
  fresh,
1830
+ ...(r.entry.hits ? { hits: r.entry.hits } : {}),
1831
+ ...(r.entry.tokensSaved ? { tokensSaved: r.entry.tokensSaved } : {}),
1832
+ ...(r.whyShown.length > 0 ? { whyShown: r.whyShown } : {}),
1833
+ ...(r.evidence.length > 0 ? { evidence: r.evidence } : {}),
1483
1834
  ...(fresh ? {} : { staleFiles, ...(unverified ? {} : attributeStaleness(r.entry, git)) }),
1484
1835
  ...(unverifiedFiles.length > 0 ? { unverifiedFiles } : {}),
1485
1836
  ...(r.entry.outcome ? { outcome: r.entry.outcome } : {}),
@@ -1575,6 +1926,28 @@ export class TheBrainV2 {
1575
1926
  getEntriesForProject(projectId) {
1576
1927
  return Array.from(this.entries.values()).filter((e) => e.projectId === undefined || e.projectId === projectId);
1577
1928
  }
1929
+ /**
1930
+ * Opposite-outcome entries tracking any of these symbols. Shared by store() (warn before
1931
+ * writing) and previewImport() (warn before importing): two memories about one function
1932
+ * disagreeing on whether an approach works is a trap for whoever searches next.
1933
+ */
1934
+ findOutcomeConflicts(symbolRefs, outcome, excludeIds = new Set()) {
1935
+ const newKeys = new Set(symbolRefs.map((r) => `${path.resolve(r.filePath)}::${r.symbolName}`));
1936
+ const found = [];
1937
+ for (const existingEntry of this.entries.values()) {
1938
+ if (excludeIds.has(existingEntry.id))
1939
+ continue;
1940
+ if (!existingEntry.symbolHashes)
1941
+ continue;
1942
+ const existingOutcome = existingEntry.outcome === 'failed' ? 'failed' : 'confirmed';
1943
+ if (existingOutcome === outcome)
1944
+ continue;
1945
+ const sharesSymbol = Object.keys(existingEntry.symbolHashes).some((k) => newKeys.has(k));
1946
+ if (sharesSymbol)
1947
+ found.push({ id: existingEntry.id, query: existingEntry.query, outcome: existingOutcome });
1948
+ }
1949
+ return found;
1950
+ }
1578
1951
  // ─── Dedup Check ─────────────────────────────────────────────────────────
1579
1952
  /**
1580
1953
  * Check if a query is likely a duplicate before storing.
@@ -1652,7 +2025,44 @@ export class TheBrainV2 {
1652
2025
  this.scheduleSave();
1653
2026
  return { ok: true, message: `Recorded negative feedback on entry "${id}" (demerits: ${entry.demerits}). It will rank lower and be evicted sooner.` };
1654
2027
  }
2028
+ /**
2029
+ * Book tokens saved by reusing a memory, called by search_memory on a fresh hit — the
2030
+ * same event that books the savings ledger, so the two can never disagree about
2031
+ * whether a reuse happened. Unknown ids fail silently (a peer may have forgotten the
2032
+ * entry since the search ranked it); savings attribution must never break a search.
2033
+ */
2034
+ creditSavings(id, tokens) {
2035
+ if (!Number.isFinite(tokens) || tokens <= 0)
2036
+ return;
2037
+ const entry = this.entries.get(id);
2038
+ if (!entry)
2039
+ return;
2040
+ entry.tokensSaved = (entry.tokensSaved || 0) + Math.floor(tokens);
2041
+ this.scheduleSave();
2042
+ }
1655
2043
  // ─── Deletion ───────────────────────────────────────────────────────────────
2044
+ /**
2045
+ * Archive one entry to the trash, drop it from the corpus, and tombstone the id so no
2046
+ * merge resurrects it. Shared by forget() (one memory) and mergeEntries() (folded
2047
+ * fragments). Returns whether the archive write succeeded — a failure never blocks the
2048
+ * removal itself, it only narrows the restore paths.
2049
+ */
2050
+ dropWithArchive(entry) {
2051
+ try {
2052
+ this.ensureDir();
2053
+ const { terms: _terms, ...persisted } = entry;
2054
+ fs.appendFileSync(FORGOTTEN_FILE, JSON.stringify({ ...persisted, forgottenAt: new Date().toISOString() }) + '\n', 'utf8');
2055
+ }
2056
+ catch {
2057
+ // An unwritable trash must not block a removal the caller asked for.
2058
+ this.dropEntry(entry);
2059
+ this.tombstone(entry.id);
2060
+ return false;
2061
+ }
2062
+ this.dropEntry(entry);
2063
+ this.tombstone(entry.id);
2064
+ return true;
2065
+ }
1656
2066
  /**
1657
2067
  * Permanently remove one memory.
1658
2068
  *
@@ -1672,21 +2082,13 @@ export class TheBrainV2 {
1672
2082
  const entry = this.entries.get(id);
1673
2083
  if (!entry)
1674
2084
  return { ok: false, message: `No entry with id "${id}" in the Brain.` };
2085
+ // Snapshot BEFORE destroying: forget() with no restore point is exactly the data-loss
2086
+ // story snapshots exist to prevent.
2087
+ this.snapshotCorpus('forget');
1675
2088
  // Archived before it is dropped: the body is the only copy of that text anywhere, and
1676
- // "nothing else can recover it" has to be a description of the archive, not of a gap.
1677
- let archivePath;
1678
- try {
1679
- this.ensureDir();
1680
- const { terms: _terms, ...persisted } = entry;
1681
- fs.appendFileSync(FORGOTTEN_FILE, JSON.stringify({ ...persisted, forgottenAt: new Date().toISOString() }) + '\n', 'utf8');
1682
- archivePath = FORGOTTEN_FILE;
1683
- }
1684
- catch {
1685
- // An unwritable archive must not block a deletion the user asked for — the body still
1686
- // comes back in the result below, which is the copy that matters in the moment.
1687
- }
1688
- this.dropEntry(entry);
1689
- this.tombstone(id);
2089
+ // the trash is what makes this deletion restorable.
2090
+ const archived = this.dropWithArchive(entry);
2091
+ const archivePath = archived ? FORGOTTEN_FILE : undefined;
1690
2092
  this.recalcAvgDocLength();
1691
2093
  this.scheduleSave();
1692
2094
  return {
@@ -1700,6 +2102,320 @@ export class TheBrainV2 {
1700
2102
  ...(archivePath ? { archivePath } : {}),
1701
2103
  };
1702
2104
  }
2105
+ // ─── Consolidation ──────────────────────────────────────────────────────────
2106
+ //
2107
+ // Long-lived Brains accumulate near-duplicate fragments about the same topic — each
2108
+ // stored in a different session, each partially overlapping. findMergeCandidates()
2109
+ // finds the pairs; this folds them: every folded fragment becomes a claim on the
2110
+ // keeper (carrying its own evidence, so per-claim freshness survives the merge), the
2111
+ // keeper inherits the counters, and the fragments leave through the same trash door
2112
+ // as forget(). The keeper's own text is untouched — merging rewrites nothing, it only
2113
+ // attaches.
2114
+ /**
2115
+ * Fold entries into a keeper. All-or-nothing on validation (unknown keeper, unknown or
2116
+ * repeated fragment, keeper listed as its own fragment all fail before anything
2117
+ * changes), then one snapshot covers the whole operation.
2118
+ */
2119
+ mergeEntries(keepId, foldIds) {
2120
+ this.syncIfChanged();
2121
+ const keeper = this.entries.get(keepId);
2122
+ if (!keeper)
2123
+ return { ok: false, message: `No memory with id "${keepId}" in the Brain.` };
2124
+ const folds = [...new Set(foldIds || [])].filter((id) => id !== keepId);
2125
+ if (folds.length === 0) {
2126
+ return { ok: false, message: `Nothing to fold into "${keepId}" — name at least one other memory id.` };
2127
+ }
2128
+ const missing = folds.filter((id) => !this.entries.has(id));
2129
+ if (missing.length > 0) {
2130
+ return { ok: false, message: `Cannot merge: ${missing.map((id) => `"${id}"`).join(', ')} ${missing.length === 1 ? 'is' : 'are'} not in the Brain. Nothing was changed.` };
2131
+ }
2132
+ this.snapshotCorpus('merge');
2133
+ // Out of the index before mutating, same as update(): postings built from the old
2134
+ // text would keep serving the keeper under words the merge removed.
2135
+ this.dropEntry(keeper);
2136
+ const newClaims = [];
2137
+ for (const foldId of folds) {
2138
+ const folded = this.entries.get(foldId);
2139
+ newClaims.push({
2140
+ id: crypto.createHash('sha1').update(`merge:${keepId}:${foldId}:${Date.now()}:${crypto.randomBytes(4).toString('hex')}`).digest('hex').substring(0, 10),
2141
+ text: `[merged from ${foldId} — "${folded.query.slice(0, 120)}"] ${folded.response}`,
2142
+ ...(folded.fileHashes ? { fileHashes: { ...folded.fileHashes } } : {}),
2143
+ ...(folded.symbolHashes ? { symbolHashes: { ...folded.symbolHashes } } : {}),
2144
+ ...(folded.symbolNormalizedHashes ? { symbolNormalizedHashes: { ...folded.symbolNormalizedHashes } } : {}),
2145
+ });
2146
+ keeper.hits += folded.hits || 0;
2147
+ keeper.demerits = Math.max(keeper.demerits || 0, folded.demerits || 0);
2148
+ keeper.tokensSaved = (keeper.tokensSaved || 0) + (folded.tokensSaved || 0);
2149
+ if (keeper.tokensSaved === 0)
2150
+ delete keeper.tokensSaved;
2151
+ if (folded.redactions)
2152
+ keeper.redactions = (keeper.redactions || 0) + folded.redactions;
2153
+ keeper.mergedFrom = [...(keeper.mergedFrom || []), foldId];
2154
+ // Folded fragments leave through the trash door: individually restorable, and the
2155
+ // merge snapshot restores the pre-merge corpus wholesale. No per-fragment snapshot
2156
+ // here — one snapshot already covers the operation.
2157
+ this.dropWithArchive(folded);
2158
+ }
2159
+ keeper.claims = [...(keeper.claims || []), ...newClaims];
2160
+ // The curator just reviewed this memory against the folded fragments: its age clock
2161
+ // restarts, exactly as refresh() does after a human re-verification.
2162
+ keeper.refreshedAt = new Date().toISOString();
2163
+ const terms = tokenize(keeper.query + ' ' + keeper.response);
2164
+ keeper.terms = [...new Set(terms)];
2165
+ keeper.termFreq = termFrequencies(terms);
2166
+ keeper.charCount = keeper.query.length + keeper.response.length;
2167
+ this.entries.set(keepId, keeper);
2168
+ for (const term of keeper.terms) {
2169
+ let ids = this.invertedIndex.get(term);
2170
+ if (!ids) {
2171
+ ids = new Set();
2172
+ this.invertedIndex.set(term, ids);
2173
+ }
2174
+ ids.add(keepId);
2175
+ }
2176
+ this.bloom.add(keeper.query.trim().toLowerCase().substring(0, 200));
2177
+ this.recalcAvgDocLength();
2178
+ this.scheduleSave();
2179
+ return {
2180
+ ok: true,
2181
+ kept: keepId,
2182
+ folded: folds,
2183
+ claimsAdded: newClaims.length,
2184
+ message: `Merged ${folds.length} ${folds.length === 1 ? 'fragment' : 'fragments'} into "${keepId}" as independently-verifiable claims (evidence carried over). The fragments are in the trash individually, and the pre-merge corpus is snapshotted — both undo paths work.`,
2185
+ };
2186
+ }
2187
+ // ─── Rollback: snapshots + trash ──────────────────────────────────────────
2188
+ //
2189
+ // forget() used to be a one-way door with the archive as its only safety net, and
2190
+ // import/clear had no net at all. Snapshots are automatic, timestamped, pruned full
2191
+ // copies taken before every destructive operation; the trash (forgotten.ndjson) is the
2192
+ // per-memory undo. Neither is consulted by freshness, ranking, or export — rollback
2193
+ // state must never leak into retrieval.
2194
+ /**
2195
+ * Persist the current in-memory corpus as a timestamped snapshot. Best-effort and
2196
+ * synchronous like save(): a failed snapshot must never block the operation it
2197
+ * protects. Returns the snapshot name, or null when nothing was written.
2198
+ */
2199
+ snapshotCorpus(reason) {
2200
+ try {
2201
+ this.ensureDir();
2202
+ fs.mkdirSync(SNAP_DIR, { recursive: true });
2203
+ const safeReason = reason.replace(/[^a-z0-9-]+/gi, '-').slice(0, 24) || 'manual';
2204
+ const name = `${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}-${crypto.randomBytes(3).toString('hex')}-${safeReason}.ndjson`;
2205
+ const ndjson = Array.from(this.entries.values())
2206
+ .map(({ terms: _terms, ...persisted }) => JSON.stringify(persisted))
2207
+ .join('\n');
2208
+ writeFileAtomic(path.join(SNAP_DIR, name), protectForDisk(ndjson));
2209
+ // Prune oldest, newest-last ordering: a rollback history, not an archive.
2210
+ const names = listSnapshotNames();
2211
+ for (const old of names.slice(0, Math.max(0, names.length - MAX_SNAPSHOTS))) {
2212
+ try {
2213
+ fs.unlinkSync(path.join(SNAP_DIR, old));
2214
+ }
2215
+ catch { /* keep the rest */ }
2216
+ }
2217
+ return name;
2218
+ }
2219
+ catch {
2220
+ return null;
2221
+ }
2222
+ }
2223
+ /** Rollback points on disk, newest last: name, size, and modification time. */
2224
+ listSnapshots() {
2225
+ return listSnapshotNames().map((name) => {
2226
+ try {
2227
+ const st = fs.statSync(path.join(SNAP_DIR, name));
2228
+ return { name, bytes: st.size, mtime: st.mtime.toISOString() };
2229
+ }
2230
+ catch {
2231
+ return { name, bytes: 0, mtime: '' };
2232
+ }
2233
+ });
2234
+ }
2235
+ /**
2236
+ * Replace the corpus with a snapshot. The current state is snapshotted first
2237
+ * (`pre-restore`), so a restore is itself undoable. Deliberately does NOT tombstone
2238
+ * the ids that vanish: a peer session holding unflushed memories would lose them on
2239
+ * its next read, and "restore never destroys" beats "restore is total" — whatever a
2240
+ * peer still holds comes back on its next flush, honestly, through the normal merge.
2241
+ */
2242
+ restoreSnapshot(name) {
2243
+ if (typeof name !== 'string' || name.includes('/') || name.includes('\\') || !name.endsWith('.ndjson')) {
2244
+ return { ok: false, message: `Refusing to read "${name}": not a snapshot name.` };
2245
+ }
2246
+ if (!listSnapshotNames().includes(name)) {
2247
+ return { ok: false, message: `No snapshot named "${name}". Use brain_trash action=snapshots to list them.` };
2248
+ }
2249
+ let raw;
2250
+ try {
2251
+ raw = fs.readFileSync(path.join(SNAP_DIR, name), 'utf8');
2252
+ }
2253
+ catch {
2254
+ return { ok: false, message: `Could not read snapshot "${name}".` };
2255
+ }
2256
+ const plain = unprotectFromDisk(raw);
2257
+ if (plain === null) {
2258
+ return { ok: false, message: `Snapshot "${name}" cannot be decrypted with the available key — refusing to wipe the live corpus for bytes I cannot read.` };
2259
+ }
2260
+ const { entries } = parseEntriesWithStats(plain);
2261
+ if (entries.size === 0) {
2262
+ return { ok: false, message: `Snapshot "${name}" parses to zero entries — refusing to replace a live corpus with an empty one.` };
2263
+ }
2264
+ this.syncIfChanged();
2265
+ this.snapshotCorpus('pre-restore');
2266
+ this.entries = entries;
2267
+ this.rebuildIndex();
2268
+ this.queryTermCache.clear();
2269
+ this.recalcAvgDocLength();
2270
+ // Fresh bloom: the filter cannot un-add the deleted queries, and a restored corpus
2271
+ // with a stale filter would keep reporting ghosts as possible duplicates.
2272
+ this.bloom = new BloomFilter();
2273
+ for (const entry of this.entries.values()) {
2274
+ this.bloom.add(entry.query.trim().toLowerCase().substring(0, 200));
2275
+ }
2276
+ // A restored id was deliberately brought back — its old tombstone must not suppress it.
2277
+ for (const id of this.entries.keys())
2278
+ this.tombstones.delete(id);
2279
+ this.scheduleSave();
2280
+ return { ok: true, restored: entries.size, message: `Restored ${entries.size} memories from snapshot "${name}". The pre-restore state was snapshotted, so this is undoable too.` };
2281
+ }
2282
+ /** What the trash holds: one row per archived deletion, newest last. */
2283
+ listForgotten() {
2284
+ const out = [];
2285
+ let raw;
2286
+ try {
2287
+ raw = fs.readFileSync(FORGOTTEN_FILE, 'utf8');
2288
+ }
2289
+ catch {
2290
+ return out;
2291
+ }
2292
+ for (const line of raw.split('\n')) {
2293
+ if (!line)
2294
+ continue;
2295
+ try {
2296
+ const archived = JSON.parse(line);
2297
+ if (!archived || typeof archived.id !== 'string')
2298
+ continue;
2299
+ out.push({
2300
+ id: archived.id,
2301
+ query: typeof archived.query === 'string' ? archived.query.slice(0, 120) : '',
2302
+ timestamp: archived.timestamp || '',
2303
+ hits: typeof archived.hits === 'number' ? archived.hits : 0,
2304
+ forgottenAt: archived.forgottenAt || '',
2305
+ });
2306
+ }
2307
+ catch { /* a half-written archive line is not worth failing the listing */ }
2308
+ }
2309
+ return out;
2310
+ }
2311
+ /**
2312
+ * Bring a forgotten memory back. The archived copy becomes a live entry again under its
2313
+ * original id (so any external reference to the id keeps working), its old tombstone is
2314
+ * lifted, and freshness is re-judged on the next search like any other entry. The
2315
+ * archive line stays as history — the trash is append-only, restore doesn't rewrite it.
2316
+ */
2317
+ restoreForgotten(id) {
2318
+ this.syncIfChanged();
2319
+ if (this.entries.has(id)) {
2320
+ return { ok: false, message: `Memory "${id}" is already in the Brain — nothing to restore.` };
2321
+ }
2322
+ let raw;
2323
+ try {
2324
+ raw = fs.readFileSync(FORGOTTEN_FILE, 'utf8');
2325
+ }
2326
+ catch {
2327
+ return { ok: false, message: `The trash is empty — nothing to restore.` };
2328
+ }
2329
+ let found = null;
2330
+ for (const line of raw.split('\n')) {
2331
+ if (!line)
2332
+ continue;
2333
+ try {
2334
+ const archived = JSON.parse(line);
2335
+ if (archived && archived.id === id && typeof archived.query === 'string')
2336
+ found = archived;
2337
+ }
2338
+ catch { /* skip */ }
2339
+ }
2340
+ if (!found)
2341
+ return { ok: false, message: `No forgotten memory with id "${id}" in the trash.` };
2342
+ const { forgottenAt: _forgottenAt, ...revived } = found;
2343
+ if (!Array.isArray(revived.terms))
2344
+ revived.terms = Object.keys(revived.termFreq || {});
2345
+ if (revived.terms.length === 0) {
2346
+ const terms = tokenize(revived.query + ' ' + (revived.response || ''));
2347
+ revived.terms = [...new Set(terms)];
2348
+ revived.termFreq = termFrequencies(terms);
2349
+ }
2350
+ this.entries.set(id, revived);
2351
+ for (const term of revived.terms) {
2352
+ let ids = this.invertedIndex.get(term);
2353
+ if (!ids) {
2354
+ ids = new Set();
2355
+ this.invertedIndex.set(term, ids);
2356
+ }
2357
+ ids.add(id);
2358
+ }
2359
+ this.bloom.add(revived.query.trim().toLowerCase().substring(0, 200));
2360
+ this.tombstones.delete(id);
2361
+ this.recalcAvgDocLength();
2362
+ this.scheduleSave();
2363
+ return { ok: true, message: `Restored memory "${id}" from the trash (${revived.hits || 0} prior reuse(s) kept). Its freshness will be re-judged on the next search.` };
2364
+ }
2365
+ /**
2366
+ * Permanently drop archived deletions. Without a filter this empties the whole trash;
2367
+ * with olderThanDays it keeps recent deletions restorable. Tombstones are untouched —
2368
+ * purging the archive removes the restore path, not the deletion itself, so purged ids
2369
+ * still cannot come back through a merge or an import.
2370
+ */
2371
+ purgeForgotten(olderThanDays) {
2372
+ let raw = '';
2373
+ try {
2374
+ raw = fs.readFileSync(FORGOTTEN_FILE, 'utf8');
2375
+ }
2376
+ catch {
2377
+ return { purged: 0, remaining: 0 };
2378
+ }
2379
+ const cutoff = typeof olderThanDays === 'number' && Number.isFinite(olderThanDays) && olderThanDays > 0
2380
+ ? Date.now() - olderThanDays * 86400000
2381
+ : null;
2382
+ const kept = [];
2383
+ let purged = 0;
2384
+ for (const line of raw.split('\n')) {
2385
+ if (!line)
2386
+ continue;
2387
+ let drop = false;
2388
+ try {
2389
+ const archived = JSON.parse(line);
2390
+ if (!archived || typeof archived.id !== 'string')
2391
+ drop = true; // useless line, not a memory
2392
+ else if (cutoff !== null) {
2393
+ const at = Date.parse(archived.forgottenAt || '');
2394
+ drop = Number.isFinite(at) && at < cutoff;
2395
+ }
2396
+ else
2397
+ drop = true;
2398
+ }
2399
+ catch {
2400
+ drop = true; // half-written line: un-restorable, so purging loses nothing
2401
+ }
2402
+ if (drop)
2403
+ purged++;
2404
+ else
2405
+ kept.push(line);
2406
+ }
2407
+ if (purged > 0) {
2408
+ const fd = acquireLock();
2409
+ try {
2410
+ writeFileAtomic(FORGOTTEN_FILE, kept.join('\n') + (kept.length > 0 ? '\n' : ''));
2411
+ }
2412
+ catch { /* best-effort; counts below still report honestly */ }
2413
+ finally {
2414
+ releaseLock(fd);
2415
+ }
2416
+ }
2417
+ return { purged, remaining: kept.length };
2418
+ }
1703
2419
  // ─── Re-anchoring ───────────────────────────────────────────────────────────
1704
2420
  /**
1705
2421
  * Re-verify a memory against the code as it stands now, keeping its identity.
@@ -1779,6 +2495,11 @@ export class TheBrainV2 {
1779
2495
  }
1780
2496
  }
1781
2497
  }
2498
+ // Hashes were just rewritten — re-derive the clear labels so they track the same
2499
+ // files (covers both the re-point and the re-anchor branches above, and update()).
2500
+ syncClearEvidencePaths(entry);
2501
+ for (const claim of entry.claims || [])
2502
+ syncClearEvidencePaths(claim);
1782
2503
  return retracked;
1783
2504
  }
1784
2505
  refresh(id, opts = {}) {
@@ -1830,6 +2551,38 @@ export class TheBrainV2 {
1830
2551
  const entry = this.entries.get(id);
1831
2552
  if (!entry)
1832
2553
  return { ok: false, message: `No entry with id "${id}" in the Brain.` };
2554
+ if (patch.supersededBy !== undefined) {
2555
+ const successorId = patch.supersededBy.trim();
2556
+ if (successorId && successorId === id) {
2557
+ return { ok: false, message: `"${id}" cannot supersede itself.` };
2558
+ }
2559
+ if (successorId && !this.entries.has(successorId)) {
2560
+ return { ok: false, message: `supersededBy target "${successorId}" is not an entry in the Brain — check the id with get_memory first.` };
2561
+ }
2562
+ entry.supersededBy = successorId || undefined;
2563
+ }
2564
+ // Same one-way redaction as store(): a corrected response pasted with a fresh secret
2565
+ // must not be the one write path that persists credentials.
2566
+ let updateRedactions = 0;
2567
+ if (typeof patch.query === 'string' && patch.query) {
2568
+ const s = scrubSecretsForStorage(patch.query);
2569
+ patch.query = s.text;
2570
+ updateRedactions += s.redactions;
2571
+ }
2572
+ if (typeof patch.response === 'string' && patch.response) {
2573
+ const s = scrubSecretsForStorage(patch.response);
2574
+ patch.response = s.text;
2575
+ updateRedactions += s.redactions;
2576
+ }
2577
+ if (patch.claims !== undefined) {
2578
+ patch.claims = patch.claims.map((c) => {
2579
+ const s = scrubSecretsForStorage(c.text);
2580
+ updateRedactions += s.redactions;
2581
+ return s.redactions > 0 ? { ...c, text: s.text } : c;
2582
+ });
2583
+ }
2584
+ if (updateRedactions > 0)
2585
+ entry.redactions = (entry.redactions || 0) + updateRedactions;
1833
2586
  const beforeChars = entry.charCount;
1834
2587
  // Out of the index before its terms change: a posting list built from the old text would
1835
2588
  // keep serving this entry under words the new text no longer contains.
@@ -1955,6 +2708,126 @@ export class TheBrainV2 {
1955
2708
  this.syncIfChanged();
1956
2709
  return findBlastRadius(this.entries, filePath, symbolName);
1957
2710
  }
2711
+ // ─── Upkeep: one command to tend the corpus ───────────────────────────────
2712
+ //
2713
+ // Every maintenance primitive existed (verify/refresh/forget/downvote/stats) but each
2714
+ // one needed the agent to remember it, name an id, and interpret the result — so in
2715
+ // practice nobody maintained anything and the corpus rotted by default. upkeep() inverts
2716
+ // that: one call revalidates what gets reused most, surfaces duplicates and dead
2717
+ // weight, and reports the savings ledger. It never deletes, merges, or refreshes
2718
+ // anything itself — refresh() records a human assertion no automation can make, and a
2719
+ // janitor that destroys memories on its own schedule is a data-loss vector, not care.
2720
+ /**
2721
+ * Near-duplicate entry pairs worth consolidating with brain_merge (see Fase E). Pairs
2722
+ * at or above `hi` are excluded: those should have been refused at store time, so a
2723
+ * surviving one is a `forget one of them` case, not a merge — report those separately
2724
+ * via the same call with lo=hi. Bounded: at most `sample` entries each issue one
2725
+ * search, and at most 20 pairs come back.
2726
+ */
2727
+ findMergeCandidates(opts = {}) {
2728
+ const lo = opts.lo ?? 0.6;
2729
+ const hi = opts.hi ?? 0.92;
2730
+ const sample = Math.max(0, opts.sample ?? 100);
2731
+ this.syncIfChanged();
2732
+ const pool = Array.from(this.entries.values())
2733
+ .sort((a, b) => (b.hits - a.hits) || (effectiveTime(b) - effectiveTime(a)))
2734
+ .slice(0, sample);
2735
+ const seen = new Set();
2736
+ const out = [];
2737
+ for (const entry of pool) {
2738
+ // Internal sweep, not a question: must not move hit counters or the ledger.
2739
+ const results = this.search(entry.query, 6, lo, { countStats: false });
2740
+ for (const r of results) {
2741
+ if (r.id === entry.id)
2742
+ continue;
2743
+ if (r.similarity >= hi)
2744
+ continue;
2745
+ const key = [entry.id, r.id].sort().join('|');
2746
+ if (seen.has(key))
2747
+ continue;
2748
+ seen.add(key);
2749
+ const other = this.entries.get(r.id);
2750
+ out.push({
2751
+ ids: [entry.id, r.id],
2752
+ similarity: Number(r.similarity.toFixed(3)),
2753
+ queries: [entry.query.slice(0, 100), (other?.query || '').slice(0, 100)],
2754
+ });
2755
+ if (out.length >= 20)
2756
+ return out;
2757
+ }
2758
+ }
2759
+ return out;
2760
+ }
2761
+ /**
2762
+ * Full upkeep pass. Read-only over the corpus: revalidates the top-N most-reused
2763
+ * memories by hash-compare, lists exact duplicates and merge candidates, dead weight,
2764
+ * and the savings ledger — with a concrete suggested action per finding.
2765
+ */
2766
+ upkeep(opts = {}) {
2767
+ this.syncIfChanged();
2768
+ const topN = Math.max(0, opts.topN ?? 20);
2769
+ const top = Array.from(this.entries.values())
2770
+ .sort((a, b) => (b.hits - a.hits) || (effectiveTime(b) - effectiveTime(a)))
2771
+ .slice(0, topN);
2772
+ const verdicts = this.verifyByIds(top.map((e) => e.id));
2773
+ const queryOf = (id) => this.entries.get(id)?.query.slice(0, 100) || '';
2774
+ const revalidated = { checked: top.length, fresh: [], stale: [], untracked: [], unverified: [] };
2775
+ for (const v of verdicts) {
2776
+ if (v.status === 'fresh')
2777
+ revalidated.fresh.push(v.id);
2778
+ else if (v.status === 'stale') {
2779
+ revalidated.stale.push({ id: v.id, query: queryOf(v.id), staleFiles: v.staleFiles || [], action: 'refresh_memory after checking it is still true' });
2780
+ }
2781
+ else if (v.status === 'untracked') {
2782
+ revalidated.untracked.push({ id: v.id, query: queryOf(v.id), action: 'update_memory with filePaths/symbols so it becomes verifiable' });
2783
+ }
2784
+ else if (v.status === 'unverified') {
2785
+ revalidated.unverified.push({ id: v.id, query: queryOf(v.id), action: 'check the tracked paths exist on this machine, then refresh_memory' });
2786
+ }
2787
+ }
2788
+ // Exact duplicates (>= store threshold) slipped in through imports or forced stores:
2789
+ // merging paraphrases is brain_merge's job, but a 98%-identical pair just needs one
2790
+ // of them forgotten.
2791
+ const duplicatePairs = this.findMergeCandidates({ sample: opts.sample ?? 50, lo: 0.92, hi: 1.01 })
2792
+ .map((c) => ({ ...c, action: 'forget_memory one of the pair (they are the same memory twice)' }));
2793
+ const mergeCandidates = this.findMergeCandidates({ sample: opts.sample ?? 50, lo: 0.75, hi: 0.92 })
2794
+ .map((c) => ({ ...c, action: 'brain_merge the pair into one canonical memory' }));
2795
+ const neverHitIds = [];
2796
+ const downvoted = [];
2797
+ for (const entry of this.entries.values()) {
2798
+ if (!entry.hits && neverHitIds.length < 10)
2799
+ neverHitIds.push(entry.id);
2800
+ if (entry.demerits)
2801
+ downvoted.push({ id: entry.id, demerits: entry.demerits });
2802
+ }
2803
+ downvoted.sort((a, b) => b.demerits - a.demerits);
2804
+ const stats = this.getStats({ deep: opts.deep === true });
2805
+ const report = {
2806
+ checkedAt: new Date().toISOString(),
2807
+ totalEntries: this.entries.size,
2808
+ revalidated,
2809
+ duplicatePairs,
2810
+ mergeCandidates,
2811
+ deadWeight: {
2812
+ neverHit: stats.neverHit || 0,
2813
+ neverHitSample: neverHitIds,
2814
+ downvoted,
2815
+ action: 'forget_memory entries that are both never reused and downvoted; leave the rest — an unused memory costs bytes, a deleted one costs knowledge',
2816
+ },
2817
+ savings: {
2818
+ totalTokensSaved: stats.totalTokensSaved || 0,
2819
+ topSavers: (stats.topSavers || []).slice(0, 3),
2820
+ },
2821
+ };
2822
+ if (stats.staleEntries !== undefined) {
2823
+ report.deep = {
2824
+ staleEntries: stats.staleEntries,
2825
+ staleRatio: stats.staleRatio || 0,
2826
+ action: 'work the revalidated.stale list above oldest-first; refresh_memory what still holds, forget_memory what does not',
2827
+ };
2828
+ }
2829
+ return report;
2830
+ }
1958
2831
  // ─── Portability ────────────────────────────────────────────────────────────
1959
2832
  /**
1960
2833
  * Serialize memories to a portable NDJSON bundle: a header line, then one entry per line.
@@ -1994,6 +2867,149 @@ export class TheBrainV2 {
1994
2867
  const body = selected.map(({ terms: _t, ...persisted }) => JSON.stringify(persisted));
1995
2868
  return { text: [header, ...body].join('\n'), count: selected.length, skippedStale };
1996
2869
  }
2870
+ /**
2871
+ * Classify one bundle line without touching the corpus. The single planning step behind
2872
+ * both importBundle() (which then applies the plan) and previewImport() (which only
2873
+ * reports it) — one classifier means a dry run can never disagree with the real thing
2874
+ * about what would happen. Scrubbing the freshly-parsed object is safe: it is not
2875
+ * corpus state yet.
2876
+ */
2877
+ planImportLine(line, markSource) {
2878
+ let entry;
2879
+ try {
2880
+ entry = JSON.parse(line);
2881
+ }
2882
+ catch {
2883
+ return { disposition: 'skip', reason: 'unparseable line' };
2884
+ }
2885
+ if (!entry || typeof entry.id !== 'string' || typeof entry.query !== 'string') {
2886
+ return { disposition: 'skip', reason: 'missing id/query' };
2887
+ }
2888
+ if (this.tombstones.has(entry.id)) {
2889
+ return { disposition: 'skip', reason: 'id was deliberately forgotten here', entry, tombstoned: true };
2890
+ }
2891
+ // Bundles from pre-scrub Brains (or hand-written ones) can carry live secrets;
2892
+ // redact on the way in. Already-scrubbed `[NAME_n]` spans pass through untouched.
2893
+ if (typeof entry.query === 'string')
2894
+ entry.query = scrubSecretsForStorage(entry.query).text;
2895
+ if (typeof entry.response === 'string')
2896
+ entry.response = scrubSecretsForStorage(entry.response).text;
2897
+ if (Array.isArray(entry.claims)) {
2898
+ for (const claim of entry.claims) {
2899
+ if (claim && typeof claim.text === 'string')
2900
+ claim.text = scrubSecretsForStorage(claim.text).text;
2901
+ }
2902
+ }
2903
+ if (!Array.isArray(entry.terms))
2904
+ entry.terms = Object.keys(entry.termFreq || {});
2905
+ if (entry.terms.length === 0) {
2906
+ // A bundle from a version that persisted neither terms nor termFreq — re-tokenize
2907
+ // rather than admit an entry the inverted index could never retrieve.
2908
+ const terms = tokenize(entry.query + ' ' + (entry.response || ''));
2909
+ entry.terms = [...new Set(terms)];
2910
+ entry.termFreq = termFrequencies(terms);
2911
+ }
2912
+ if (markSource)
2913
+ entry.source = 'import';
2914
+ const existing = this.entries.get(entry.id);
2915
+ if (existing) {
2916
+ if (effectiveTime(entry) <= effectiveTime(existing)) {
2917
+ return { disposition: 'skip', reason: 'local copy is newer or same age', entry };
2918
+ }
2919
+ return { disposition: 'update', reason: 'bundle copy is newer', entry };
2920
+ }
2921
+ return { disposition: 'import', reason: 'new id', entry };
2922
+ }
2923
+ /** Fold the other side's counters into ours without losing feedback either side collected. */
2924
+ absorbImportCounters(existing, incoming) {
2925
+ existing.hits = Math.max(existing.hits || 0, incoming.hits || 0);
2926
+ existing.demerits = Math.max(existing.demerits || 0, incoming.demerits || 0);
2927
+ const bestSavings = Math.max(existing.tokensSaved || 0, incoming.tokensSaved || 0);
2928
+ if (bestSavings > 0)
2929
+ existing.tokensSaved = bestSavings;
2930
+ else
2931
+ delete existing.tokensSaved;
2932
+ }
2933
+ /** Split a bundle into candidate lines. No header means raw entries — see below. */
2934
+ static splitBundleLines(text) {
2935
+ const lines = text.split('\n').filter(Boolean);
2936
+ if (lines.length === 0)
2937
+ return { lines: [] };
2938
+ try {
2939
+ const header = JSON.parse(lines[0]);
2940
+ if (header && header.lemmaBrainExport)
2941
+ return { lines: lines.slice(1) };
2942
+ }
2943
+ catch {
2944
+ // No header — treat the whole file as entries. A raw entries.ndjson copied off another
2945
+ // machine is a perfectly reasonable thing to hand this, and rejecting it would be
2946
+ // pedantry rather than safety.
2947
+ }
2948
+ return { lines };
2949
+ }
2950
+ /**
2951
+ * What importBundle() WOULD do, without writing anything: counts, the first 50 items,
2952
+ * and opposite-outcome conflicts against the live corpus. Run this before importing a
2953
+ * bundle from a teammate or another machine — especially a large one — so a flood of
2954
+ * stale, wrong-project memories is a preview, not a surprise.
2955
+ */
2956
+ previewImport(text, opts = {}) {
2957
+ this.syncIfChanged();
2958
+ const { lines } = TheBrainV2.splitBundleLines(text);
2959
+ const empty = { ok: false, wouldImport: 0, wouldUpdate: 0, wouldSkip: 0, items: [], truncated: false, conflicts: [], message: 'Bundle is empty.' };
2960
+ if (lines.length === 0)
2961
+ return empty;
2962
+ const markSource = opts.markSource !== false;
2963
+ let wouldImport = 0;
2964
+ let wouldUpdate = 0;
2965
+ let wouldSkip = 0;
2966
+ const items = [];
2967
+ const conflicts = [];
2968
+ const seenConflicts = new Set();
2969
+ for (const line of lines) {
2970
+ const plan = this.planImportLine(line, markSource);
2971
+ if (plan.disposition === 'import')
2972
+ wouldImport++;
2973
+ else if (plan.disposition === 'update')
2974
+ wouldUpdate++;
2975
+ else
2976
+ wouldSkip++;
2977
+ if (items.length < 50) {
2978
+ items.push({
2979
+ id: plan.entry?.id ?? '(unparseable)',
2980
+ query: (plan.entry?.query || '').slice(0, 100),
2981
+ action: plan.disposition,
2982
+ reason: plan.reason,
2983
+ });
2984
+ }
2985
+ if (plan.entry && plan.disposition !== 'skip' && plan.entry.symbolHashes) {
2986
+ const refs = Object.keys(plan.entry.symbolHashes).map((key) => {
2987
+ const sep = key.lastIndexOf('::');
2988
+ return { filePath: key.substring(0, sep), symbolName: key.substring(sep + 2) };
2989
+ });
2990
+ const outcome = plan.entry.outcome === 'failed' ? 'failed' : 'confirmed';
2991
+ for (const c of this.findOutcomeConflicts(refs, outcome, new Set([plan.entry.id]))) {
2992
+ const ck = `${c.id}|${plan.entry.id}`;
2993
+ if (seenConflicts.has(ck))
2994
+ continue;
2995
+ seenConflicts.add(ck);
2996
+ conflicts.push({ ...c, against: plan.entry.id });
2997
+ }
2998
+ }
2999
+ }
3000
+ return {
3001
+ ok: true,
3002
+ wouldImport,
3003
+ wouldUpdate,
3004
+ wouldSkip,
3005
+ items,
3006
+ truncated: lines.length > items.length,
3007
+ conflicts,
3008
+ message: `Dry run: would import ${wouldImport}, update ${wouldUpdate}, skip ${wouldSkip}.` +
3009
+ (conflicts.length > 0 ? ` ${conflicts.length} opposite-outcome conflict(s) with live memories — review before importing.` : '') +
3010
+ ` Nothing was written.`,
3011
+ };
3012
+ }
1997
3013
  /**
1998
3014
  * Merge a bundle produced by exportBundle into this Brain.
1999
3015
  *
@@ -2009,64 +3025,35 @@ export class TheBrainV2 {
2009
3025
  */
2010
3026
  importBundle(text, opts = {}) {
2011
3027
  this.syncIfChanged();
2012
- const lines = text.split('\n').filter(Boolean);
3028
+ const { lines } = TheBrainV2.splitBundleLines(text);
2013
3029
  if (lines.length === 0)
2014
3030
  return { ok: false, imported: 0, updated: 0, skipped: 0, message: 'Bundle is empty.' };
2015
- let start = 0;
2016
- try {
2017
- const header = JSON.parse(lines[0]);
2018
- if (header && header.lemmaBrainExport)
2019
- start = 1;
2020
- }
2021
- catch {
2022
- // No header — treat the whole file as entries. A raw entries.ndjson copied off another
2023
- // machine is a perfectly reasonable thing to hand this, and rejecting it would be
2024
- // pedantry rather than safety.
2025
- }
3031
+ // Snapshot before merging foreign entries: an import is the easiest way to flood a
3032
+ // clean corpus with hundreds of stale, wrong-project memories.
3033
+ this.snapshotCorpus('import');
2026
3034
  let imported = 0;
2027
3035
  let updated = 0;
2028
3036
  let skipped = 0;
2029
3037
  const markSource = opts.markSource !== false;
2030
- for (const line of lines.slice(start)) {
2031
- let entry;
2032
- try {
2033
- entry = JSON.parse(line);
2034
- }
2035
- catch {
2036
- skipped++;
2037
- continue;
2038
- }
2039
- if (!entry || typeof entry.id !== 'string' || typeof entry.query !== 'string') {
2040
- skipped++;
2041
- continue;
2042
- }
2043
- if (this.tombstones.has(entry.id)) {
3038
+ for (const line of lines) {
3039
+ const plan = this.planImportLine(line, markSource);
3040
+ if (plan.disposition === 'skip') {
3041
+ // Tombstoned skips excepted: a deliberately forgotten id gets no counter updates,
3042
+ // exactly as before the planner existed — the deletion stands completely.
3043
+ if (plan.entry && !plan.tombstoned) {
3044
+ const existing = this.entries.get(plan.entry.id);
3045
+ // Older or same age: keep ours, but never lose feedback the other side collected.
3046
+ if (existing)
3047
+ this.absorbImportCounters(existing, plan.entry);
3048
+ }
2044
3049
  skipped++;
2045
3050
  continue;
2046
3051
  }
2047
- if (!Array.isArray(entry.terms))
2048
- entry.terms = Object.keys(entry.termFreq || {});
2049
- if (entry.terms.length === 0) {
2050
- // A bundle from a version that persisted neither terms nor termFreq — re-tokenize
2051
- // rather than admit an entry the inverted index could never retrieve.
2052
- const terms = tokenize(entry.query + ' ' + (entry.response || ''));
2053
- entry.terms = [...new Set(terms)];
2054
- entry.termFreq = termFrequencies(terms);
2055
- }
2056
- if (markSource)
2057
- entry.source = 'import';
3052
+ const entry = plan.entry;
2058
3053
  const existing = this.entries.get(entry.id);
2059
3054
  if (existing) {
2060
- if (effectiveTime(entry) <= effectiveTime(existing)) {
2061
- // Older or same age: keep ours, but never lose feedback the other side collected.
2062
- existing.hits = Math.max(existing.hits || 0, entry.hits || 0);
2063
- existing.demerits = Math.max(existing.demerits || 0, entry.demerits || 0);
2064
- skipped++;
2065
- continue;
2066
- }
2067
3055
  this.dropEntry(existing);
2068
- entry.hits = Math.max(existing.hits || 0, entry.hits || 0);
2069
- entry.demerits = Math.max(existing.demerits || 0, entry.demerits || 0);
3056
+ this.absorbImportCounters(entry, existing);
2070
3057
  updated++;
2071
3058
  }
2072
3059
  else {
@@ -2119,7 +3106,10 @@ export class TheBrainV2 {
2119
3106
  let downvoted = 0;
2120
3107
  let unscoped = 0;
2121
3108
  let untracked = 0;
3109
+ let withClearPaths = 0;
2122
3110
  let oldest = '';
3111
+ let totalTokensSaved = 0;
3112
+ const savers = [];
2123
3113
  const bySource = {};
2124
3114
  const projects = new Set();
2125
3115
  for (const entry of this.entries.values()) {
@@ -2133,17 +3123,30 @@ export class TheBrainV2 {
2133
3123
  projects.add(entry.projectId);
2134
3124
  if (trackedArtifactCount(entry) === 0)
2135
3125
  untracked++;
3126
+ else if (entry.filePaths !== undefined || entry.symbols !== undefined ||
3127
+ (entry.claims || []).some((c) => c.filePaths !== undefined || c.symbols !== undefined))
3128
+ withClearPaths++;
3129
+ if (entry.tokensSaved) {
3130
+ totalTokensSaved += entry.tokensSaved;
3131
+ savers.push({ id: entry.id, query: entry.query.slice(0, 80), tokensSaved: entry.tokensSaved, hits: entry.hits });
3132
+ }
2136
3133
  const src = entrySource(entry);
2137
3134
  bySource[src] = (bySource[src] || 0) + 1;
2138
3135
  if (entry.timestamp && (!oldest || entry.timestamp < oldest))
2139
3136
  oldest = entry.timestamp;
2140
3137
  }
3138
+ savers.sort((a, b) => b.tokensSaved - a.tokensSaved);
3139
+ base.totalTokensSaved = totalTokensSaved;
3140
+ if (savers.length > 0)
3141
+ base.topSavers = savers.slice(0, 5);
3142
+ base.integrity = this.health();
2141
3143
  base.neverHit = neverHit;
2142
3144
  base.downvoted = downvoted;
2143
3145
  base.bySource = bySource;
2144
3146
  base.projects = projects.size;
2145
3147
  base.unscopedEntries = unscoped;
2146
3148
  base.untrackedEntries = untracked;
3149
+ base.withClearPaths = withClearPaths;
2147
3150
  base.tombstones = this.tombstones.size;
2148
3151
  if (oldest)
2149
3152
  base.oldestEntry = oldest;
@@ -2166,7 +3169,26 @@ export class TheBrainV2 {
2166
3169
  }
2167
3170
  return base;
2168
3171
  }
3172
+ /**
3173
+ * Integrity + rollback posture in one cheap call (no hashing, no disk reads beyond a
3174
+ * directory listing). `ok` is false while an unverified read, a backup fallback, or an
3175
+ * undecryptable envelope is the reason this process is serving what it serves.
3176
+ */
3177
+ health() {
3178
+ this.syncIfChanged();
3179
+ return {
3180
+ ok: this.integrityIssue === null,
3181
+ lastIssue: this.integrityIssue,
3182
+ snapshots: listSnapshotNames().length,
3183
+ backups: fs.existsSync(ENTRIES_PREV_FILE),
3184
+ encrypted: encryptionActive(BRAIN_DIR).enabled,
3185
+ };
3186
+ }
2169
3187
  clear() {
3188
+ // Even a deliberate wipe gets a restore point: "I didn't mean that clear" is a
3189
+ // support ticket, "I didn't mean that clear and there is no snapshot" is data loss.
3190
+ if (this.entries.size > 0)
3191
+ this.snapshotCorpus('clear');
2170
3192
  this.entries.clear();
2171
3193
  this.invertedIndex.clear();
2172
3194
  this.bloom = new BloomFilter();
@@ -2176,9 +3198,14 @@ export class TheBrainV2 {
2176
3198
  this.tombstones.clear();
2177
3199
  this.diskStamp = null;
2178
3200
  this.sidecar = null;
3201
+ this.integrityIssue = null;
2179
3202
  try {
2180
3203
  if (fs.existsSync(ENTRIES_FILE))
2181
3204
  fs.unlinkSync(ENTRIES_FILE);
3205
+ if (fs.existsSync(ENTRIES_SHA_FILE))
3206
+ fs.unlinkSync(ENTRIES_SHA_FILE);
3207
+ if (fs.existsSync(ENTRIES_PREV_FILE))
3208
+ fs.unlinkSync(ENTRIES_PREV_FILE);
2182
3209
  if (fs.existsSync(INDEX_FILE))
2183
3210
  fs.unlinkSync(INDEX_FILE);
2184
3211
  if (fs.existsSync(META_FILE))
@@ -2187,6 +3214,8 @@ export class TheBrainV2 {
2187
3214
  fs.unlinkSync(LOCK_FILE);
2188
3215
  if (fs.existsSync(EMBEDDINGS_FILE))
2189
3216
  fs.unlinkSync(EMBEDDINGS_FILE);
3217
+ // Snapshots deliberately survive clear(): they are the rollback history, and a wipe
3218
+ // that also destroys every restore point is not a wipe, it is data loss.
2190
3219
  }
2191
3220
  catch { /* ignore */ }
2192
3221
  }