@nxuss/lemma 1.22.1 → 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -7
- package/dist/cjs/cloud/store/FileBrainStore.d.ts.map +1 -1
- package/dist/cjs/cloud/store/FileBrainStore.js +5 -2
- package/dist/cjs/cloud/store/FileBrainStore.js.map +1 -1
- package/dist/cjs/mcp/tool-profiles.d.ts.map +1 -1
- package/dist/cjs/mcp/tool-profiles.js +3 -0
- package/dist/cjs/mcp/tool-profiles.js.map +1 -1
- package/dist/cjs/mcp/tools/memory.d.ts.map +1 -1
- package/dist/cjs/mcp/tools/memory.js +212 -7
- package/dist/cjs/mcp/tools/memory.js.map +1 -1
- package/dist/cjs/mcp/tools.d.ts +9 -0
- package/dist/cjs/mcp/tools.d.ts.map +1 -1
- package/dist/cjs/mcp/tools.js +25 -4
- package/dist/cjs/mcp/tools.js.map +1 -1
- package/dist/cjs/security/scrubForStorage.d.ts +16 -0
- package/dist/cjs/security/scrubForStorage.d.ts.map +1 -0
- package/dist/cjs/security/scrubForStorage.js +35 -0
- package/dist/cjs/security/scrubForStorage.js.map +1 -0
- package/dist/cjs/subconscious/BrainEncryption.d.ts +38 -0
- package/dist/cjs/subconscious/BrainEncryption.d.ts.map +1 -0
- package/dist/cjs/subconscious/BrainEncryption.js +137 -0
- package/dist/cjs/subconscious/BrainEncryption.js.map +1 -0
- package/dist/cjs/subconscious/TheBrainV2.d.ts +294 -0
- package/dist/cjs/subconscious/TheBrainV2.d.ts.map +1 -1
- package/dist/cjs/subconscious/TheBrainV2.js +1070 -111
- package/dist/cjs/subconscious/TheBrainV2.js.map +1 -1
- package/dist/esm/cloud/store/FileBrainStore.d.ts.map +1 -1
- package/dist/esm/cloud/store/FileBrainStore.js +5 -2
- package/dist/esm/cloud/store/FileBrainStore.js.map +1 -1
- package/dist/esm/mcp/tool-profiles.d.ts.map +1 -1
- package/dist/esm/mcp/tool-profiles.js +3 -0
- package/dist/esm/mcp/tool-profiles.js.map +1 -1
- package/dist/esm/mcp/tools/memory.d.ts.map +1 -1
- package/dist/esm/mcp/tools/memory.js +212 -7
- package/dist/esm/mcp/tools/memory.js.map +1 -1
- package/dist/esm/mcp/tools.d.ts +9 -0
- package/dist/esm/mcp/tools.d.ts.map +1 -1
- package/dist/esm/mcp/tools.js +25 -5
- package/dist/esm/mcp/tools.js.map +1 -1
- package/dist/esm/security/scrubForStorage.d.ts +16 -0
- package/dist/esm/security/scrubForStorage.d.ts.map +1 -0
- package/dist/esm/security/scrubForStorage.js +32 -0
- package/dist/esm/security/scrubForStorage.js.map +1 -0
- package/dist/esm/subconscious/BrainEncryption.d.ts +38 -0
- package/dist/esm/subconscious/BrainEncryption.d.ts.map +1 -0
- package/dist/esm/subconscious/BrainEncryption.js +126 -0
- package/dist/esm/subconscious/BrainEncryption.js.map +1 -0
- package/dist/esm/subconscious/TheBrainV2.d.ts +294 -0
- package/dist/esm/subconscious/TheBrainV2.d.ts.map +1 -1
- package/dist/esm/subconscious/TheBrainV2.js +1068 -111
- package/dist/esm/subconscious/TheBrainV2.js.map +1 -1
- 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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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,125 @@ function sameStamp(a, b) {
|
|
|
190
288
|
return a === b;
|
|
191
289
|
return a.mtimeMs === b.mtimeMs && a.size === b.size;
|
|
192
290
|
}
|
|
193
|
-
/**
|
|
194
|
-
function
|
|
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
|
|
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 || {});
|
|
214
308
|
out.set(entry.id, entry);
|
|
215
309
|
}
|
|
216
|
-
catch {
|
|
310
|
+
catch {
|
|
311
|
+
skipped++;
|
|
312
|
+
}
|
|
217
313
|
}
|
|
218
|
-
return out;
|
|
314
|
+
return { entries: out, skipped };
|
|
315
|
+
}
|
|
316
|
+
/** Parse NDJSON corpus text into entries, skipping corrupt lines. Pure: no filesystem. */
|
|
317
|
+
function parseEntriesText(raw) {
|
|
318
|
+
return parseEntriesWithStats(raw).entries;
|
|
319
|
+
}
|
|
320
|
+
/** Parses entries.ndjson exactly the way load() does, without touching instance state. */
|
|
321
|
+
function readEntriesFile() {
|
|
322
|
+
try {
|
|
323
|
+
return parseEntriesText(fs.readFileSync(ENTRIES_FILE, 'utf8'));
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return new Map();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Load the corpus with verification and graceful degradation. Policy, in order:
|
|
331
|
+
*
|
|
332
|
+
* 1. Verified file (or legacy file without a sidecar): serve everything, no incident.
|
|
333
|
+
* 2. Changed out-of-band but with intact lines: serve the survivors and record a
|
|
334
|
+
* `partial-corpus` incident — discarding good memories because one line rotted would
|
|
335
|
+
* be worse than the rot. The next save() rewrites the file clean and heals this.
|
|
336
|
+
* 3. Total loss (nothing parses, or the envelope won't decrypt): fall back to the .prev
|
|
337
|
+
* backup, or start empty if there is none — quarantining the bad bytes either way
|
|
338
|
+
* when asked, never deleting them.
|
|
339
|
+
* 4. No main file at all: a fresh canvas. The .prev backup is deliberately NOT consulted
|
|
340
|
+
* here — absence of the corpus is a state (fresh install, clear()), not damage, and
|
|
341
|
+
* resurrecting an old backup over it would undo exactly that.
|
|
342
|
+
*
|
|
343
|
+
* Only load() — the startup path — quarantines: a long-lived process that quarantined on
|
|
344
|
+
* every racing read could destroy the corpus a concurrent writer is mid-merge on. save()
|
|
345
|
+
* and syncIfChanged() record the issue and heal on their own terms instead.
|
|
346
|
+
*/
|
|
347
|
+
function loadCorpusChecked(opts) {
|
|
348
|
+
const at = new Date().toISOString();
|
|
349
|
+
const main = readStoredText(ENTRIES_FILE);
|
|
350
|
+
if (!main.present || main.text === null) {
|
|
351
|
+
return { entries: new Map(), issue: null, diskUsable: true };
|
|
352
|
+
}
|
|
353
|
+
const plain = unprotectFromDisk(main.text);
|
|
354
|
+
const parsed = plain !== null ? parseEntriesWithStats(plain) : { entries: new Map(), skipped: Number.MAX_SAFE_INTEGER };
|
|
355
|
+
if (main.verified && plain !== null && parsed.skipped === 0) {
|
|
356
|
+
return { entries: parsed.entries, issue: null, diskUsable: true };
|
|
357
|
+
}
|
|
358
|
+
if (parsed.entries.size > 0) {
|
|
359
|
+
return {
|
|
360
|
+
entries: parsed.entries,
|
|
361
|
+
issue: {
|
|
362
|
+
at,
|
|
363
|
+
file: ENTRIES_FILE,
|
|
364
|
+
action: 'partial-corpus',
|
|
365
|
+
detail: plain === null
|
|
366
|
+
? 'entries.ndjson could not be decrypted with the available key; serving intact lines only.'
|
|
367
|
+
: `entries.ndjson changed outside a save() (${parsed.skipped} unreadable line(s) skipped); serving the intact entries. The next save rewrites the file clean.`,
|
|
368
|
+
},
|
|
369
|
+
diskUsable: true,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
// Total loss: fall back to the backup, which has no sidecar of its own — acceptance is
|
|
373
|
+
// parse-ability, and a backup that parses to entries is strictly better than bytes that
|
|
374
|
+
// verify to nothing.
|
|
375
|
+
let prevEntries = null;
|
|
376
|
+
try {
|
|
377
|
+
const prevRaw = fs.readFileSync(ENTRIES_PREV_FILE, 'utf8');
|
|
378
|
+
const prevPlain = unprotectFromDisk(prevRaw);
|
|
379
|
+
if (prevPlain !== null) {
|
|
380
|
+
const prevParsed = parseEntriesText(prevPlain);
|
|
381
|
+
if (prevParsed.size > 0)
|
|
382
|
+
prevEntries = prevParsed;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch { /* no usable backup */ }
|
|
386
|
+
if (opts.quarantine)
|
|
387
|
+
quarantineFile(ENTRIES_FILE);
|
|
388
|
+
if (prevEntries) {
|
|
389
|
+
return {
|
|
390
|
+
entries: prevEntries,
|
|
391
|
+
issue: {
|
|
392
|
+
at,
|
|
393
|
+
file: ENTRIES_FILE,
|
|
394
|
+
action: 'fallback-to-backup',
|
|
395
|
+
detail: 'entries.ndjson is entirely unreadable; serving the last known-good backup instead. The bad file was quarantined, not deleted.',
|
|
396
|
+
},
|
|
397
|
+
diskUsable: true,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
entries: new Map(),
|
|
402
|
+
issue: {
|
|
403
|
+
at,
|
|
404
|
+
file: ENTRIES_FILE,
|
|
405
|
+
action: 'fresh-start',
|
|
406
|
+
detail: 'entries.ndjson is entirely unreadable and no usable backup exists; starting empty. The bad file was quarantined, not deleted.',
|
|
407
|
+
},
|
|
408
|
+
diskUsable: false,
|
|
409
|
+
};
|
|
219
410
|
}
|
|
220
411
|
/** Reads the tombstone map straight off disk, for absorbing a peer process's deletions. */
|
|
221
412
|
function readTombstonesFile() {
|
|
@@ -264,11 +455,19 @@ export function mergeEntryMaps(mine, theirs, tombstones = new Set()) {
|
|
|
264
455
|
return;
|
|
265
456
|
}
|
|
266
457
|
const winner = effectiveTime(entry) > effectiveTime(existing) ? entry : existing;
|
|
267
|
-
|
|
458
|
+
const mergedEntry = {
|
|
268
459
|
...winner,
|
|
269
460
|
hits: Math.max(entry.hits || 0, existing.hits || 0),
|
|
270
461
|
demerits: Math.max(entry.demerits || 0, existing.demerits || 0),
|
|
271
|
-
}
|
|
462
|
+
};
|
|
463
|
+
// ROI is a monotonic tally like hits: keep the best either side collected, and keep
|
|
464
|
+
// the field absent (not zero) when neither side ever saved anything.
|
|
465
|
+
const bestSavings = Math.max(entry.tokensSaved || 0, existing.tokensSaved || 0);
|
|
466
|
+
if (bestSavings > 0)
|
|
467
|
+
mergedEntry.tokensSaved = bestSavings;
|
|
468
|
+
else
|
|
469
|
+
delete mergedEntry.tokensSaved;
|
|
470
|
+
merged.set(entry.id, mergedEntry);
|
|
272
471
|
};
|
|
273
472
|
for (const entry of mine.values())
|
|
274
473
|
put(entry);
|
|
@@ -975,6 +1174,13 @@ export class TheBrainV2 {
|
|
|
975
1174
|
this.diskStamp = null;
|
|
976
1175
|
/** id -> ISO deletion time, for ids that must not come back through a merge. */
|
|
977
1176
|
this.tombstones = new Map();
|
|
1177
|
+
/**
|
|
1178
|
+
* Last integrity incident on the corpus files (corrupt main file, undecryptable
|
|
1179
|
+
* envelope, skipped sync). Set by load()/save()/syncIfChanged(), never cleared
|
|
1180
|
+
* except by a clean verified read — health() and brain_stats surface it so a silent
|
|
1181
|
+
* fallback never looks like a healthy Brain.
|
|
1182
|
+
*/
|
|
1183
|
+
this.integrityIssue = null;
|
|
978
1184
|
/**
|
|
979
1185
|
* id -> terms of that entry's own `query` field. Derived state, never persisted: `terms`
|
|
980
1186
|
* covers query+response together, so scoring the query field on its own (see the
|
|
@@ -986,6 +1192,10 @@ export class TheBrainV2 {
|
|
|
986
1192
|
this.queryTermCache = new Map();
|
|
987
1193
|
this.sidecar = null;
|
|
988
1194
|
this.ensureDir();
|
|
1195
|
+
// Validate key configuration before touching the corpus: a malformed LEMMA_BRAIN_KEY
|
|
1196
|
+
// or corrupt key file throws here, loud, instead of degrading into silent plaintext
|
|
1197
|
+
// (or an unreadable corpus) mid-session.
|
|
1198
|
+
brainDiskKey();
|
|
989
1199
|
this.load();
|
|
990
1200
|
}
|
|
991
1201
|
ensureDir() {
|
|
@@ -1003,7 +1213,12 @@ export class TheBrainV2 {
|
|
|
1003
1213
|
this.avgDocLength = meta.avgDocLength || 0;
|
|
1004
1214
|
this.tombstones = readTombstones(meta);
|
|
1005
1215
|
}
|
|
1006
|
-
|
|
1216
|
+
// Checked: a corrupt main file falls back to the .prev backup (quarantining the
|
|
1217
|
+
// bad bytes) instead of silently starting empty and letting the next save()
|
|
1218
|
+
// legitimize the data loss with a fresh sidecar hash.
|
|
1219
|
+
const checked = loadCorpusChecked({ quarantine: true });
|
|
1220
|
+
this.integrityIssue = checked.issue;
|
|
1221
|
+
this.entries = checked.entries;
|
|
1007
1222
|
for (const id of this.tombstones.keys())
|
|
1008
1223
|
this.entries.delete(id);
|
|
1009
1224
|
this.diskStamp = stampOf(ENTRIES_FILE);
|
|
@@ -1064,10 +1279,23 @@ export class TheBrainV2 {
|
|
|
1064
1279
|
return;
|
|
1065
1280
|
try {
|
|
1066
1281
|
this.absorbPeerTombstones();
|
|
1067
|
-
|
|
1282
|
+
// No quarantine here (see loadCorpusChecked): a mismatch on a live read is most
|
|
1283
|
+
// likely a racing writer, so record it, keep serving memory, and let save()/load()
|
|
1284
|
+
// do the healing. The stamp still advances — re-reading the same bad bytes on
|
|
1285
|
+
// every search would turn one incident into a permanent slowdown.
|
|
1286
|
+
const checked = loadCorpusChecked({ quarantine: false });
|
|
1287
|
+
if (checked.issue) {
|
|
1288
|
+
this.integrityIssue = { ...checked.issue, action: 'sync-skipped' };
|
|
1289
|
+
this.diskStamp = stamp;
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
const onDisk = checked.entries;
|
|
1068
1293
|
const before = this.entries.size;
|
|
1069
1294
|
this.entries = mergeEntryMaps(this.entries, onDisk, new Set(this.tombstones.keys()));
|
|
1070
1295
|
this.diskStamp = stamp;
|
|
1296
|
+
// A clean verified read heals a previously recorded incident — the next save()
|
|
1297
|
+
// rewrites the sidecar, so whatever was wrong is gone, not just unnoticed.
|
|
1298
|
+
this.integrityIssue = null;
|
|
1071
1299
|
if (this.entries.size !== before || onDisk.size > 0) {
|
|
1072
1300
|
this.rebuildIndex();
|
|
1073
1301
|
this.recalcAvgDocLength();
|
|
@@ -1099,6 +1327,7 @@ export class TheBrainV2 {
|
|
|
1099
1327
|
}
|
|
1100
1328
|
save() {
|
|
1101
1329
|
const fd = acquireLock();
|
|
1330
|
+
let wroteClean = false;
|
|
1102
1331
|
try {
|
|
1103
1332
|
this.ensureDir();
|
|
1104
1333
|
// Merge before writing. A full rewrite of what this process happens to hold would
|
|
@@ -1106,8 +1335,15 @@ export class TheBrainV2 {
|
|
|
1106
1335
|
// this whole section exists to close. After the merge the file we write is a superset
|
|
1107
1336
|
// of both views, so a writer can only ever add.
|
|
1108
1337
|
this.absorbPeerTombstones();
|
|
1109
|
-
const
|
|
1110
|
-
if (
|
|
1338
|
+
const checked = loadCorpusChecked({ quarantine: false });
|
|
1339
|
+
if (checked.issue) {
|
|
1340
|
+
// The file on disk doesn't verify. Merge only what the fallback recovered (the
|
|
1341
|
+
// backup, never the corrupt bytes), surface the issue, and — critically — do NOT
|
|
1342
|
+
// rotate .prev below: the corrupt main file must not become the "last good" copy.
|
|
1343
|
+
this.integrityIssue = checked.issue;
|
|
1344
|
+
}
|
|
1345
|
+
const onDisk = checked.issue && checked.issue.action === 'fresh-start' ? new Map() : checked.entries;
|
|
1346
|
+
if (checked.diskUsable && onDisk.size > 0) {
|
|
1111
1347
|
this.entries = mergeEntryMaps(this.entries, onDisk, new Set(this.tombstones.keys()));
|
|
1112
1348
|
this.rebuildIndex();
|
|
1113
1349
|
this.recalcAvgDocLength();
|
|
@@ -1116,13 +1352,39 @@ export class TheBrainV2 {
|
|
|
1116
1352
|
// Post-merge, so the file we write respects the cap even when the merge pulled in
|
|
1117
1353
|
// entries a peer had already evicted.
|
|
1118
1354
|
this.evictIfOverCapacity();
|
|
1355
|
+
// Total loss on disk (nothing parseable, or an envelope this key cannot open):
|
|
1356
|
+
// preserve those bytes before overwriting. They may be a corpus under a different
|
|
1357
|
+
// key, and an overwrite is forever while a quarantine is a rename. No-op when
|
|
1358
|
+
// load() already quarantined them at startup.
|
|
1359
|
+
if (checked.issue && (checked.issue.action === 'fresh-start' || checked.issue.action === 'fallback-to-backup')) {
|
|
1360
|
+
quarantineFile(ENTRIES_FILE);
|
|
1361
|
+
}
|
|
1362
|
+
// Rotate the backup BEFORE overwriting, but only when the file being replaced is
|
|
1363
|
+
// itself trustworthy — rotating a corrupt file would destroy the last good copy.
|
|
1364
|
+
if (!checked.issue) {
|
|
1365
|
+
try {
|
|
1366
|
+
if (fs.existsSync(ENTRIES_FILE))
|
|
1367
|
+
fs.copyFileSync(ENTRIES_FILE, ENTRIES_PREV_FILE);
|
|
1368
|
+
}
|
|
1369
|
+
catch { /* backup rotation is best-effort; the write below is what matters */ }
|
|
1370
|
+
}
|
|
1119
1371
|
// Write NDJSON entries. `terms` is dropped: it is exactly Object.keys(termFreq),
|
|
1120
1372
|
// and persisting both made the entry file 38% redundant bytes that every session
|
|
1121
1373
|
// re-read at startup. load() reconstructs it.
|
|
1122
1374
|
const ndjson = Array.from(this.entries.values())
|
|
1123
1375
|
.map(({ terms: _terms, ...persisted }) => JSON.stringify(persisted))
|
|
1124
1376
|
.join('\n');
|
|
1125
|
-
|
|
1377
|
+
const stored = protectForDisk(ndjson);
|
|
1378
|
+
writeFileAtomic(ENTRIES_FILE, stored);
|
|
1379
|
+
// Sidecar hash over the exact bytes on disk, so any out-of-band change fails the
|
|
1380
|
+
// next checked read instead of being served as corpus.
|
|
1381
|
+
try {
|
|
1382
|
+
writeFileAtomic(ENTRIES_SHA_FILE, sha256hex(stored));
|
|
1383
|
+
// Whatever was wrong before is gone: what is on disk now is exactly this
|
|
1384
|
+
// process's verified corpus, so a recorded incident must not linger past it.
|
|
1385
|
+
wroteClean = true;
|
|
1386
|
+
}
|
|
1387
|
+
catch { /* an unverified corpus still beats no corpus */ }
|
|
1126
1388
|
// Write inverted index. load() rebuilds this from the entries rather than reading it
|
|
1127
1389
|
// back, so it is now purely an inspection artifact for the dashboard and for anyone
|
|
1128
1390
|
// poking at the brain directory — kept because removing a file other tooling may read
|
|
@@ -1147,6 +1409,8 @@ export class TheBrainV2 {
|
|
|
1147
1409
|
finally {
|
|
1148
1410
|
releaseLock(fd);
|
|
1149
1411
|
}
|
|
1412
|
+
if (wroteClean)
|
|
1413
|
+
this.integrityIssue = null;
|
|
1150
1414
|
}
|
|
1151
1415
|
/**
|
|
1152
1416
|
* Pull in deletions made by other processes.
|
|
@@ -1204,6 +1468,22 @@ export class TheBrainV2 {
|
|
|
1204
1468
|
// Another session may have stored this exact thing since we loaded; without the sync
|
|
1205
1469
|
// the dedup check below would miss it and write a second copy.
|
|
1206
1470
|
this.syncIfChanged();
|
|
1471
|
+
// Secrets never reach the corpus: redact before dedup, indexing, and persistence
|
|
1472
|
+
// alike, so the inverted index and the NDJSON see only the redacted text. Two fixes
|
|
1473
|
+
// differing only in their pasted API keys correctly dedup to one entry afterwards.
|
|
1474
|
+
const scrubbedQuery = scrubSecretsForStorage(query);
|
|
1475
|
+
const scrubbedResponse = scrubSecretsForStorage(response);
|
|
1476
|
+
query = scrubbedQuery.text;
|
|
1477
|
+
response = scrubbedResponse.text;
|
|
1478
|
+
let redactions = scrubbedQuery.redactions + scrubbedResponse.redactions;
|
|
1479
|
+
let scrubbedClaims = claimInputs;
|
|
1480
|
+
if (claimInputs && claimInputs.length > 0) {
|
|
1481
|
+
scrubbedClaims = claimInputs.map((c) => {
|
|
1482
|
+
const s = scrubSecretsForStorage(c.text);
|
|
1483
|
+
redactions += s.redactions;
|
|
1484
|
+
return s.redactions > 0 ? { ...c, text: s.text } : c;
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1207
1487
|
// Quick bloom check
|
|
1208
1488
|
const queryKey = query.trim().toLowerCase().substring(0, 200);
|
|
1209
1489
|
if (this.bloom.has(queryKey)) {
|
|
@@ -1214,6 +1494,7 @@ export class TheBrainV2 {
|
|
|
1214
1494
|
stored: false,
|
|
1215
1495
|
reason: `Duplicate detected (${(existing[0].similarity * 100).toFixed(1)}% similar)`,
|
|
1216
1496
|
duplicate: existing[0],
|
|
1497
|
+
redactions,
|
|
1217
1498
|
};
|
|
1218
1499
|
}
|
|
1219
1500
|
}
|
|
@@ -1221,24 +1502,10 @@ export class TheBrainV2 {
|
|
|
1221
1502
|
// outcome, is worth flagging before this one is even written — two memories about one
|
|
1222
1503
|
// function disagreeing on whether an approach works is a trap for whoever searches next.
|
|
1223
1504
|
// Not a block, just a warning: the newer entry may legitimately supersede the older one.
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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
|
-
}
|
|
1505
|
+
const foundConflicts = symbolRefs && symbolRefs.length > 0
|
|
1506
|
+
? this.findOutcomeConflicts(symbolRefs, outcome === 'failed' ? 'failed' : 'confirmed')
|
|
1507
|
+
: [];
|
|
1508
|
+
const conflicts = foundConflicts.length > 0 ? foundConflicts : undefined;
|
|
1242
1509
|
const terms = tokenize(query + ' ' + response);
|
|
1243
1510
|
const termFreq = termFrequencies(terms);
|
|
1244
1511
|
// Salted with random bytes, not just the clock: the id used to be sha1(queryKey + now),
|
|
@@ -1273,7 +1540,8 @@ export class TheBrainV2 {
|
|
|
1273
1540
|
(symbolRefs && symbolRefs.length > 0 ? hashSymbolsForFreshness(symbolRefs) : undefined),
|
|
1274
1541
|
symbolNormalizedHashes: options.evidence?.symbolsNormalized ??
|
|
1275
1542
|
(symbolRefs && symbolRefs.length > 0 ? hashSymbolsNormalizedForFreshness(symbolRefs) : undefined),
|
|
1276
|
-
claims:
|
|
1543
|
+
claims: scrubbedClaims && scrubbedClaims.length > 0 ? buildClaims(scrubbedClaims) : undefined,
|
|
1544
|
+
...(redactions > 0 ? { redactions } : {}),
|
|
1277
1545
|
domain,
|
|
1278
1546
|
derivedFrom: derivedFrom && derivedFrom.length > 0 ? derivedFrom : undefined,
|
|
1279
1547
|
source: options.source || 'manual',
|
|
@@ -1293,7 +1561,7 @@ export class TheBrainV2 {
|
|
|
1293
1561
|
this.recalcAvgDocLength();
|
|
1294
1562
|
this.evictIfOverCapacity();
|
|
1295
1563
|
this.scheduleSave();
|
|
1296
|
-
return { stored: true, reason: 'Stored successfully', id, conflicts };
|
|
1564
|
+
return { stored: true, reason: 'Stored successfully', id, conflicts, redactions };
|
|
1297
1565
|
}
|
|
1298
1566
|
/**
|
|
1299
1567
|
* Evict the lowest-value entries once the Brain is over capacity. Value = hits (proven
|
|
@@ -1425,25 +1693,41 @@ export class TheBrainV2 {
|
|
|
1425
1693
|
let similarity = exactPrompt
|
|
1426
1694
|
? 1
|
|
1427
1695
|
: (s.bm25 / maxBm25) * BM25_WEIGHT + s.jaccard * JACCARD_WEIGHT + queryFieldMatch * QUERY_FIELD_WEIGHT;
|
|
1696
|
+
// whyShown: the human-readable half of the adjustments below. A memory the caller
|
|
1697
|
+
// can't audit is a memory the caller can't trust — and a downvote without a visible
|
|
1698
|
+
// reason teaches the ranker nothing. Only signals that actually fired are listed.
|
|
1699
|
+
const whyShown = [];
|
|
1700
|
+
if (exactPrompt)
|
|
1701
|
+
whyShown.push('exact-prompt-match');
|
|
1428
1702
|
// Popularity prior: entries other searches actually reused are more likely to be
|
|
1429
1703
|
// reused again. Log-scaled and capped at +0.08 so a handful of hits can't outrank a
|
|
1430
1704
|
// much better textual match.
|
|
1431
|
-
|
|
1705
|
+
const popularityBonus = Math.min(Math.log2(s.entry.hits + 1) * 0.02, 0.08);
|
|
1706
|
+
similarity += popularityBonus;
|
|
1707
|
+
if (s.entry.hits > 0)
|
|
1708
|
+
whyShown.push(`reused ${s.entry.hits}x (+${popularityBonus.toFixed(2)})`);
|
|
1432
1709
|
// A documented dead end ranking above a working fix is actively harmful — it reads as
|
|
1433
1710
|
// a suggestion even with the "STALE"/"FAILED" label attached downstream. Demote, don't
|
|
1434
1711
|
// hide: the warning is still worth surfacing, just not first.
|
|
1435
|
-
if (s.entry.outcome === 'failed')
|
|
1712
|
+
if (s.entry.outcome === 'failed') {
|
|
1436
1713
|
similarity -= 0.15;
|
|
1714
|
+
whyShown.push('failed-attempt (-0.15)');
|
|
1715
|
+
}
|
|
1437
1716
|
// Soft domain preference (see projectId scoping above for why this stays soft): an
|
|
1438
1717
|
// entry tagged with the requested domain is more likely relevant, but an untagged or
|
|
1439
1718
|
// cross-domain entry may still be the right answer.
|
|
1440
|
-
if (options.domain && s.entry.domain === options.domain)
|
|
1719
|
+
if (options.domain && s.entry.domain === options.domain) {
|
|
1441
1720
|
similarity += 0.05;
|
|
1721
|
+
whyShown.push(`domain '${options.domain}' match (+0.05)`);
|
|
1722
|
+
}
|
|
1442
1723
|
// Explicit negative feedback from downvote() — a caller saying "this was wrong" is a
|
|
1443
1724
|
// stronger, more deliberate signal than the absence of hits, so it outweighs the
|
|
1444
1725
|
// popularity prior above rather than just canceling it out.
|
|
1445
|
-
if (s.entry.demerits)
|
|
1446
|
-
|
|
1726
|
+
if (s.entry.demerits) {
|
|
1727
|
+
const demeritPenalty = Math.min(s.entry.demerits * 0.06, 0.25);
|
|
1728
|
+
similarity -= demeritPenalty;
|
|
1729
|
+
whyShown.push(`downvoted x${s.entry.demerits} (-${demeritPenalty.toFixed(2)})`);
|
|
1730
|
+
}
|
|
1447
1731
|
// Age decay. Every other signal here is about the memory's track record; none of them
|
|
1448
1732
|
// notice that the codebase it describes has been rewritten twice since. A confirmed
|
|
1449
1733
|
// memory from a year ago is not as likely to be current as yesterday's, and until now
|
|
@@ -1452,9 +1736,20 @@ export class TheBrainV2 {
|
|
|
1452
1736
|
// which is the point of refresh(): a re-verified memory really is current again.
|
|
1453
1737
|
const ageDays = (Date.now() - effectiveTime(s.entry)) / 86400000;
|
|
1454
1738
|
if (Number.isFinite(ageDays) && ageDays > RECENCY_GRACE_DAYS) {
|
|
1455
|
-
|
|
1739
|
+
const agePenalty = Math.min((ageDays - RECENCY_GRACE_DAYS) / RECENCY_FULL_DECAY_DAYS, 1) * MAX_RECENCY_PENALTY;
|
|
1740
|
+
similarity -= agePenalty;
|
|
1741
|
+
whyShown.push(`age ${Math.floor(ageDays)}d (-${agePenalty.toFixed(2)})`);
|
|
1456
1742
|
}
|
|
1457
|
-
|
|
1743
|
+
if (s.entry.refreshedAt)
|
|
1744
|
+
whyShown.push(`re-verified ${s.entry.refreshedAt.slice(0, 10)}`);
|
|
1745
|
+
// Evidence the freshness verdict was judged against: absolute tracked paths and
|
|
1746
|
+
// path::symbol keys. Handlers relativize these for display; the Brain keeps them
|
|
1747
|
+
// absolute because cwd is a caller-side notion.
|
|
1748
|
+
const evidence = [
|
|
1749
|
+
...Object.keys(s.entry.fileHashes || {}),
|
|
1750
|
+
...Object.keys(s.entry.symbolHashes || {}),
|
|
1751
|
+
];
|
|
1752
|
+
return { entry: s.entry, similarity: Math.max(0, similarity), whyShown, evidence };
|
|
1458
1753
|
});
|
|
1459
1754
|
// 6. Sort and filter
|
|
1460
1755
|
combined.sort((a, b) => b.similarity - a.similarity);
|
|
@@ -1480,6 +1775,10 @@ export class TheBrainV2 {
|
|
|
1480
1775
|
provider: r.entry.provider,
|
|
1481
1776
|
timestamp: r.entry.timestamp,
|
|
1482
1777
|
fresh,
|
|
1778
|
+
...(r.entry.hits ? { hits: r.entry.hits } : {}),
|
|
1779
|
+
...(r.entry.tokensSaved ? { tokensSaved: r.entry.tokensSaved } : {}),
|
|
1780
|
+
...(r.whyShown.length > 0 ? { whyShown: r.whyShown } : {}),
|
|
1781
|
+
...(r.evidence.length > 0 ? { evidence: r.evidence } : {}),
|
|
1483
1782
|
...(fresh ? {} : { staleFiles, ...(unverified ? {} : attributeStaleness(r.entry, git)) }),
|
|
1484
1783
|
...(unverifiedFiles.length > 0 ? { unverifiedFiles } : {}),
|
|
1485
1784
|
...(r.entry.outcome ? { outcome: r.entry.outcome } : {}),
|
|
@@ -1575,6 +1874,28 @@ export class TheBrainV2 {
|
|
|
1575
1874
|
getEntriesForProject(projectId) {
|
|
1576
1875
|
return Array.from(this.entries.values()).filter((e) => e.projectId === undefined || e.projectId === projectId);
|
|
1577
1876
|
}
|
|
1877
|
+
/**
|
|
1878
|
+
* Opposite-outcome entries tracking any of these symbols. Shared by store() (warn before
|
|
1879
|
+
* writing) and previewImport() (warn before importing): two memories about one function
|
|
1880
|
+
* disagreeing on whether an approach works is a trap for whoever searches next.
|
|
1881
|
+
*/
|
|
1882
|
+
findOutcomeConflicts(symbolRefs, outcome, excludeIds = new Set()) {
|
|
1883
|
+
const newKeys = new Set(symbolRefs.map((r) => `${path.resolve(r.filePath)}::${r.symbolName}`));
|
|
1884
|
+
const found = [];
|
|
1885
|
+
for (const existingEntry of this.entries.values()) {
|
|
1886
|
+
if (excludeIds.has(existingEntry.id))
|
|
1887
|
+
continue;
|
|
1888
|
+
if (!existingEntry.symbolHashes)
|
|
1889
|
+
continue;
|
|
1890
|
+
const existingOutcome = existingEntry.outcome === 'failed' ? 'failed' : 'confirmed';
|
|
1891
|
+
if (existingOutcome === outcome)
|
|
1892
|
+
continue;
|
|
1893
|
+
const sharesSymbol = Object.keys(existingEntry.symbolHashes).some((k) => newKeys.has(k));
|
|
1894
|
+
if (sharesSymbol)
|
|
1895
|
+
found.push({ id: existingEntry.id, query: existingEntry.query, outcome: existingOutcome });
|
|
1896
|
+
}
|
|
1897
|
+
return found;
|
|
1898
|
+
}
|
|
1578
1899
|
// ─── Dedup Check ─────────────────────────────────────────────────────────
|
|
1579
1900
|
/**
|
|
1580
1901
|
* Check if a query is likely a duplicate before storing.
|
|
@@ -1652,7 +1973,44 @@ export class TheBrainV2 {
|
|
|
1652
1973
|
this.scheduleSave();
|
|
1653
1974
|
return { ok: true, message: `Recorded negative feedback on entry "${id}" (demerits: ${entry.demerits}). It will rank lower and be evicted sooner.` };
|
|
1654
1975
|
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Book tokens saved by reusing a memory, called by search_memory on a fresh hit — the
|
|
1978
|
+
* same event that books the savings ledger, so the two can never disagree about
|
|
1979
|
+
* whether a reuse happened. Unknown ids fail silently (a peer may have forgotten the
|
|
1980
|
+
* entry since the search ranked it); savings attribution must never break a search.
|
|
1981
|
+
*/
|
|
1982
|
+
creditSavings(id, tokens) {
|
|
1983
|
+
if (!Number.isFinite(tokens) || tokens <= 0)
|
|
1984
|
+
return;
|
|
1985
|
+
const entry = this.entries.get(id);
|
|
1986
|
+
if (!entry)
|
|
1987
|
+
return;
|
|
1988
|
+
entry.tokensSaved = (entry.tokensSaved || 0) + Math.floor(tokens);
|
|
1989
|
+
this.scheduleSave();
|
|
1990
|
+
}
|
|
1655
1991
|
// ─── Deletion ───────────────────────────────────────────────────────────────
|
|
1992
|
+
/**
|
|
1993
|
+
* Archive one entry to the trash, drop it from the corpus, and tombstone the id so no
|
|
1994
|
+
* merge resurrects it. Shared by forget() (one memory) and mergeEntries() (folded
|
|
1995
|
+
* fragments). Returns whether the archive write succeeded — a failure never blocks the
|
|
1996
|
+
* removal itself, it only narrows the restore paths.
|
|
1997
|
+
*/
|
|
1998
|
+
dropWithArchive(entry) {
|
|
1999
|
+
try {
|
|
2000
|
+
this.ensureDir();
|
|
2001
|
+
const { terms: _terms, ...persisted } = entry;
|
|
2002
|
+
fs.appendFileSync(FORGOTTEN_FILE, JSON.stringify({ ...persisted, forgottenAt: new Date().toISOString() }) + '\n', 'utf8');
|
|
2003
|
+
}
|
|
2004
|
+
catch {
|
|
2005
|
+
// An unwritable trash must not block a removal the caller asked for.
|
|
2006
|
+
this.dropEntry(entry);
|
|
2007
|
+
this.tombstone(entry.id);
|
|
2008
|
+
return false;
|
|
2009
|
+
}
|
|
2010
|
+
this.dropEntry(entry);
|
|
2011
|
+
this.tombstone(entry.id);
|
|
2012
|
+
return true;
|
|
2013
|
+
}
|
|
1656
2014
|
/**
|
|
1657
2015
|
* Permanently remove one memory.
|
|
1658
2016
|
*
|
|
@@ -1672,21 +2030,13 @@ export class TheBrainV2 {
|
|
|
1672
2030
|
const entry = this.entries.get(id);
|
|
1673
2031
|
if (!entry)
|
|
1674
2032
|
return { ok: false, message: `No entry with id "${id}" in the Brain.` };
|
|
2033
|
+
// Snapshot BEFORE destroying: forget() with no restore point is exactly the data-loss
|
|
2034
|
+
// story snapshots exist to prevent.
|
|
2035
|
+
this.snapshotCorpus('forget');
|
|
1675
2036
|
// Archived before it is dropped: the body is the only copy of that text anywhere, and
|
|
1676
|
-
//
|
|
1677
|
-
|
|
1678
|
-
|
|
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);
|
|
2037
|
+
// the trash is what makes this deletion restorable.
|
|
2038
|
+
const archived = this.dropWithArchive(entry);
|
|
2039
|
+
const archivePath = archived ? FORGOTTEN_FILE : undefined;
|
|
1690
2040
|
this.recalcAvgDocLength();
|
|
1691
2041
|
this.scheduleSave();
|
|
1692
2042
|
return {
|
|
@@ -1700,6 +2050,320 @@ export class TheBrainV2 {
|
|
|
1700
2050
|
...(archivePath ? { archivePath } : {}),
|
|
1701
2051
|
};
|
|
1702
2052
|
}
|
|
2053
|
+
// ─── Consolidation ──────────────────────────────────────────────────────────
|
|
2054
|
+
//
|
|
2055
|
+
// Long-lived Brains accumulate near-duplicate fragments about the same topic — each
|
|
2056
|
+
// stored in a different session, each partially overlapping. findMergeCandidates()
|
|
2057
|
+
// finds the pairs; this folds them: every folded fragment becomes a claim on the
|
|
2058
|
+
// keeper (carrying its own evidence, so per-claim freshness survives the merge), the
|
|
2059
|
+
// keeper inherits the counters, and the fragments leave through the same trash door
|
|
2060
|
+
// as forget(). The keeper's own text is untouched — merging rewrites nothing, it only
|
|
2061
|
+
// attaches.
|
|
2062
|
+
/**
|
|
2063
|
+
* Fold entries into a keeper. All-or-nothing on validation (unknown keeper, unknown or
|
|
2064
|
+
* repeated fragment, keeper listed as its own fragment all fail before anything
|
|
2065
|
+
* changes), then one snapshot covers the whole operation.
|
|
2066
|
+
*/
|
|
2067
|
+
mergeEntries(keepId, foldIds) {
|
|
2068
|
+
this.syncIfChanged();
|
|
2069
|
+
const keeper = this.entries.get(keepId);
|
|
2070
|
+
if (!keeper)
|
|
2071
|
+
return { ok: false, message: `No memory with id "${keepId}" in the Brain.` };
|
|
2072
|
+
const folds = [...new Set(foldIds || [])].filter((id) => id !== keepId);
|
|
2073
|
+
if (folds.length === 0) {
|
|
2074
|
+
return { ok: false, message: `Nothing to fold into "${keepId}" — name at least one other memory id.` };
|
|
2075
|
+
}
|
|
2076
|
+
const missing = folds.filter((id) => !this.entries.has(id));
|
|
2077
|
+
if (missing.length > 0) {
|
|
2078
|
+
return { ok: false, message: `Cannot merge: ${missing.map((id) => `"${id}"`).join(', ')} ${missing.length === 1 ? 'is' : 'are'} not in the Brain. Nothing was changed.` };
|
|
2079
|
+
}
|
|
2080
|
+
this.snapshotCorpus('merge');
|
|
2081
|
+
// Out of the index before mutating, same as update(): postings built from the old
|
|
2082
|
+
// text would keep serving the keeper under words the merge removed.
|
|
2083
|
+
this.dropEntry(keeper);
|
|
2084
|
+
const newClaims = [];
|
|
2085
|
+
for (const foldId of folds) {
|
|
2086
|
+
const folded = this.entries.get(foldId);
|
|
2087
|
+
newClaims.push({
|
|
2088
|
+
id: crypto.createHash('sha1').update(`merge:${keepId}:${foldId}:${Date.now()}:${crypto.randomBytes(4).toString('hex')}`).digest('hex').substring(0, 10),
|
|
2089
|
+
text: `[merged from ${foldId} — "${folded.query.slice(0, 120)}"] ${folded.response}`,
|
|
2090
|
+
...(folded.fileHashes ? { fileHashes: { ...folded.fileHashes } } : {}),
|
|
2091
|
+
...(folded.symbolHashes ? { symbolHashes: { ...folded.symbolHashes } } : {}),
|
|
2092
|
+
...(folded.symbolNormalizedHashes ? { symbolNormalizedHashes: { ...folded.symbolNormalizedHashes } } : {}),
|
|
2093
|
+
});
|
|
2094
|
+
keeper.hits += folded.hits || 0;
|
|
2095
|
+
keeper.demerits = Math.max(keeper.demerits || 0, folded.demerits || 0);
|
|
2096
|
+
keeper.tokensSaved = (keeper.tokensSaved || 0) + (folded.tokensSaved || 0);
|
|
2097
|
+
if (keeper.tokensSaved === 0)
|
|
2098
|
+
delete keeper.tokensSaved;
|
|
2099
|
+
if (folded.redactions)
|
|
2100
|
+
keeper.redactions = (keeper.redactions || 0) + folded.redactions;
|
|
2101
|
+
keeper.mergedFrom = [...(keeper.mergedFrom || []), foldId];
|
|
2102
|
+
// Folded fragments leave through the trash door: individually restorable, and the
|
|
2103
|
+
// merge snapshot restores the pre-merge corpus wholesale. No per-fragment snapshot
|
|
2104
|
+
// here — one snapshot already covers the operation.
|
|
2105
|
+
this.dropWithArchive(folded);
|
|
2106
|
+
}
|
|
2107
|
+
keeper.claims = [...(keeper.claims || []), ...newClaims];
|
|
2108
|
+
// The curator just reviewed this memory against the folded fragments: its age clock
|
|
2109
|
+
// restarts, exactly as refresh() does after a human re-verification.
|
|
2110
|
+
keeper.refreshedAt = new Date().toISOString();
|
|
2111
|
+
const terms = tokenize(keeper.query + ' ' + keeper.response);
|
|
2112
|
+
keeper.terms = [...new Set(terms)];
|
|
2113
|
+
keeper.termFreq = termFrequencies(terms);
|
|
2114
|
+
keeper.charCount = keeper.query.length + keeper.response.length;
|
|
2115
|
+
this.entries.set(keepId, keeper);
|
|
2116
|
+
for (const term of keeper.terms) {
|
|
2117
|
+
let ids = this.invertedIndex.get(term);
|
|
2118
|
+
if (!ids) {
|
|
2119
|
+
ids = new Set();
|
|
2120
|
+
this.invertedIndex.set(term, ids);
|
|
2121
|
+
}
|
|
2122
|
+
ids.add(keepId);
|
|
2123
|
+
}
|
|
2124
|
+
this.bloom.add(keeper.query.trim().toLowerCase().substring(0, 200));
|
|
2125
|
+
this.recalcAvgDocLength();
|
|
2126
|
+
this.scheduleSave();
|
|
2127
|
+
return {
|
|
2128
|
+
ok: true,
|
|
2129
|
+
kept: keepId,
|
|
2130
|
+
folded: folds,
|
|
2131
|
+
claimsAdded: newClaims.length,
|
|
2132
|
+
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.`,
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
// ─── Rollback: snapshots + trash ──────────────────────────────────────────
|
|
2136
|
+
//
|
|
2137
|
+
// forget() used to be a one-way door with the archive as its only safety net, and
|
|
2138
|
+
// import/clear had no net at all. Snapshots are automatic, timestamped, pruned full
|
|
2139
|
+
// copies taken before every destructive operation; the trash (forgotten.ndjson) is the
|
|
2140
|
+
// per-memory undo. Neither is consulted by freshness, ranking, or export — rollback
|
|
2141
|
+
// state must never leak into retrieval.
|
|
2142
|
+
/**
|
|
2143
|
+
* Persist the current in-memory corpus as a timestamped snapshot. Best-effort and
|
|
2144
|
+
* synchronous like save(): a failed snapshot must never block the operation it
|
|
2145
|
+
* protects. Returns the snapshot name, or null when nothing was written.
|
|
2146
|
+
*/
|
|
2147
|
+
snapshotCorpus(reason) {
|
|
2148
|
+
try {
|
|
2149
|
+
this.ensureDir();
|
|
2150
|
+
fs.mkdirSync(SNAP_DIR, { recursive: true });
|
|
2151
|
+
const safeReason = reason.replace(/[^a-z0-9-]+/gi, '-').slice(0, 24) || 'manual';
|
|
2152
|
+
const name = `${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}-${crypto.randomBytes(3).toString('hex')}-${safeReason}.ndjson`;
|
|
2153
|
+
const ndjson = Array.from(this.entries.values())
|
|
2154
|
+
.map(({ terms: _terms, ...persisted }) => JSON.stringify(persisted))
|
|
2155
|
+
.join('\n');
|
|
2156
|
+
writeFileAtomic(path.join(SNAP_DIR, name), protectForDisk(ndjson));
|
|
2157
|
+
// Prune oldest, newest-last ordering: a rollback history, not an archive.
|
|
2158
|
+
const names = listSnapshotNames();
|
|
2159
|
+
for (const old of names.slice(0, Math.max(0, names.length - MAX_SNAPSHOTS))) {
|
|
2160
|
+
try {
|
|
2161
|
+
fs.unlinkSync(path.join(SNAP_DIR, old));
|
|
2162
|
+
}
|
|
2163
|
+
catch { /* keep the rest */ }
|
|
2164
|
+
}
|
|
2165
|
+
return name;
|
|
2166
|
+
}
|
|
2167
|
+
catch {
|
|
2168
|
+
return null;
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
/** Rollback points on disk, newest last: name, size, and modification time. */
|
|
2172
|
+
listSnapshots() {
|
|
2173
|
+
return listSnapshotNames().map((name) => {
|
|
2174
|
+
try {
|
|
2175
|
+
const st = fs.statSync(path.join(SNAP_DIR, name));
|
|
2176
|
+
return { name, bytes: st.size, mtime: st.mtime.toISOString() };
|
|
2177
|
+
}
|
|
2178
|
+
catch {
|
|
2179
|
+
return { name, bytes: 0, mtime: '' };
|
|
2180
|
+
}
|
|
2181
|
+
});
|
|
2182
|
+
}
|
|
2183
|
+
/**
|
|
2184
|
+
* Replace the corpus with a snapshot. The current state is snapshotted first
|
|
2185
|
+
* (`pre-restore`), so a restore is itself undoable. Deliberately does NOT tombstone
|
|
2186
|
+
* the ids that vanish: a peer session holding unflushed memories would lose them on
|
|
2187
|
+
* its next read, and "restore never destroys" beats "restore is total" — whatever a
|
|
2188
|
+
* peer still holds comes back on its next flush, honestly, through the normal merge.
|
|
2189
|
+
*/
|
|
2190
|
+
restoreSnapshot(name) {
|
|
2191
|
+
if (typeof name !== 'string' || name.includes('/') || name.includes('\\') || !name.endsWith('.ndjson')) {
|
|
2192
|
+
return { ok: false, message: `Refusing to read "${name}": not a snapshot name.` };
|
|
2193
|
+
}
|
|
2194
|
+
if (!listSnapshotNames().includes(name)) {
|
|
2195
|
+
return { ok: false, message: `No snapshot named "${name}". Use brain_trash action=snapshots to list them.` };
|
|
2196
|
+
}
|
|
2197
|
+
let raw;
|
|
2198
|
+
try {
|
|
2199
|
+
raw = fs.readFileSync(path.join(SNAP_DIR, name), 'utf8');
|
|
2200
|
+
}
|
|
2201
|
+
catch {
|
|
2202
|
+
return { ok: false, message: `Could not read snapshot "${name}".` };
|
|
2203
|
+
}
|
|
2204
|
+
const plain = unprotectFromDisk(raw);
|
|
2205
|
+
if (plain === null) {
|
|
2206
|
+
return { ok: false, message: `Snapshot "${name}" cannot be decrypted with the available key — refusing to wipe the live corpus for bytes I cannot read.` };
|
|
2207
|
+
}
|
|
2208
|
+
const { entries } = parseEntriesWithStats(plain);
|
|
2209
|
+
if (entries.size === 0) {
|
|
2210
|
+
return { ok: false, message: `Snapshot "${name}" parses to zero entries — refusing to replace a live corpus with an empty one.` };
|
|
2211
|
+
}
|
|
2212
|
+
this.syncIfChanged();
|
|
2213
|
+
this.snapshotCorpus('pre-restore');
|
|
2214
|
+
this.entries = entries;
|
|
2215
|
+
this.rebuildIndex();
|
|
2216
|
+
this.queryTermCache.clear();
|
|
2217
|
+
this.recalcAvgDocLength();
|
|
2218
|
+
// Fresh bloom: the filter cannot un-add the deleted queries, and a restored corpus
|
|
2219
|
+
// with a stale filter would keep reporting ghosts as possible duplicates.
|
|
2220
|
+
this.bloom = new BloomFilter();
|
|
2221
|
+
for (const entry of this.entries.values()) {
|
|
2222
|
+
this.bloom.add(entry.query.trim().toLowerCase().substring(0, 200));
|
|
2223
|
+
}
|
|
2224
|
+
// A restored id was deliberately brought back — its old tombstone must not suppress it.
|
|
2225
|
+
for (const id of this.entries.keys())
|
|
2226
|
+
this.tombstones.delete(id);
|
|
2227
|
+
this.scheduleSave();
|
|
2228
|
+
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.` };
|
|
2229
|
+
}
|
|
2230
|
+
/** What the trash holds: one row per archived deletion, newest last. */
|
|
2231
|
+
listForgotten() {
|
|
2232
|
+
const out = [];
|
|
2233
|
+
let raw;
|
|
2234
|
+
try {
|
|
2235
|
+
raw = fs.readFileSync(FORGOTTEN_FILE, 'utf8');
|
|
2236
|
+
}
|
|
2237
|
+
catch {
|
|
2238
|
+
return out;
|
|
2239
|
+
}
|
|
2240
|
+
for (const line of raw.split('\n')) {
|
|
2241
|
+
if (!line)
|
|
2242
|
+
continue;
|
|
2243
|
+
try {
|
|
2244
|
+
const archived = JSON.parse(line);
|
|
2245
|
+
if (!archived || typeof archived.id !== 'string')
|
|
2246
|
+
continue;
|
|
2247
|
+
out.push({
|
|
2248
|
+
id: archived.id,
|
|
2249
|
+
query: typeof archived.query === 'string' ? archived.query.slice(0, 120) : '',
|
|
2250
|
+
timestamp: archived.timestamp || '',
|
|
2251
|
+
hits: typeof archived.hits === 'number' ? archived.hits : 0,
|
|
2252
|
+
forgottenAt: archived.forgottenAt || '',
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
catch { /* a half-written archive line is not worth failing the listing */ }
|
|
2256
|
+
}
|
|
2257
|
+
return out;
|
|
2258
|
+
}
|
|
2259
|
+
/**
|
|
2260
|
+
* Bring a forgotten memory back. The archived copy becomes a live entry again under its
|
|
2261
|
+
* original id (so any external reference to the id keeps working), its old tombstone is
|
|
2262
|
+
* lifted, and freshness is re-judged on the next search like any other entry. The
|
|
2263
|
+
* archive line stays as history — the trash is append-only, restore doesn't rewrite it.
|
|
2264
|
+
*/
|
|
2265
|
+
restoreForgotten(id) {
|
|
2266
|
+
this.syncIfChanged();
|
|
2267
|
+
if (this.entries.has(id)) {
|
|
2268
|
+
return { ok: false, message: `Memory "${id}" is already in the Brain — nothing to restore.` };
|
|
2269
|
+
}
|
|
2270
|
+
let raw;
|
|
2271
|
+
try {
|
|
2272
|
+
raw = fs.readFileSync(FORGOTTEN_FILE, 'utf8');
|
|
2273
|
+
}
|
|
2274
|
+
catch {
|
|
2275
|
+
return { ok: false, message: `The trash is empty — nothing to restore.` };
|
|
2276
|
+
}
|
|
2277
|
+
let found = null;
|
|
2278
|
+
for (const line of raw.split('\n')) {
|
|
2279
|
+
if (!line)
|
|
2280
|
+
continue;
|
|
2281
|
+
try {
|
|
2282
|
+
const archived = JSON.parse(line);
|
|
2283
|
+
if (archived && archived.id === id && typeof archived.query === 'string')
|
|
2284
|
+
found = archived;
|
|
2285
|
+
}
|
|
2286
|
+
catch { /* skip */ }
|
|
2287
|
+
}
|
|
2288
|
+
if (!found)
|
|
2289
|
+
return { ok: false, message: `No forgotten memory with id "${id}" in the trash.` };
|
|
2290
|
+
const { forgottenAt: _forgottenAt, ...revived } = found;
|
|
2291
|
+
if (!Array.isArray(revived.terms))
|
|
2292
|
+
revived.terms = Object.keys(revived.termFreq || {});
|
|
2293
|
+
if (revived.terms.length === 0) {
|
|
2294
|
+
const terms = tokenize(revived.query + ' ' + (revived.response || ''));
|
|
2295
|
+
revived.terms = [...new Set(terms)];
|
|
2296
|
+
revived.termFreq = termFrequencies(terms);
|
|
2297
|
+
}
|
|
2298
|
+
this.entries.set(id, revived);
|
|
2299
|
+
for (const term of revived.terms) {
|
|
2300
|
+
let ids = this.invertedIndex.get(term);
|
|
2301
|
+
if (!ids) {
|
|
2302
|
+
ids = new Set();
|
|
2303
|
+
this.invertedIndex.set(term, ids);
|
|
2304
|
+
}
|
|
2305
|
+
ids.add(id);
|
|
2306
|
+
}
|
|
2307
|
+
this.bloom.add(revived.query.trim().toLowerCase().substring(0, 200));
|
|
2308
|
+
this.tombstones.delete(id);
|
|
2309
|
+
this.recalcAvgDocLength();
|
|
2310
|
+
this.scheduleSave();
|
|
2311
|
+
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.` };
|
|
2312
|
+
}
|
|
2313
|
+
/**
|
|
2314
|
+
* Permanently drop archived deletions. Without a filter this empties the whole trash;
|
|
2315
|
+
* with olderThanDays it keeps recent deletions restorable. Tombstones are untouched —
|
|
2316
|
+
* purging the archive removes the restore path, not the deletion itself, so purged ids
|
|
2317
|
+
* still cannot come back through a merge or an import.
|
|
2318
|
+
*/
|
|
2319
|
+
purgeForgotten(olderThanDays) {
|
|
2320
|
+
let raw = '';
|
|
2321
|
+
try {
|
|
2322
|
+
raw = fs.readFileSync(FORGOTTEN_FILE, 'utf8');
|
|
2323
|
+
}
|
|
2324
|
+
catch {
|
|
2325
|
+
return { purged: 0, remaining: 0 };
|
|
2326
|
+
}
|
|
2327
|
+
const cutoff = typeof olderThanDays === 'number' && Number.isFinite(olderThanDays) && olderThanDays > 0
|
|
2328
|
+
? Date.now() - olderThanDays * 86400000
|
|
2329
|
+
: null;
|
|
2330
|
+
const kept = [];
|
|
2331
|
+
let purged = 0;
|
|
2332
|
+
for (const line of raw.split('\n')) {
|
|
2333
|
+
if (!line)
|
|
2334
|
+
continue;
|
|
2335
|
+
let drop = false;
|
|
2336
|
+
try {
|
|
2337
|
+
const archived = JSON.parse(line);
|
|
2338
|
+
if (!archived || typeof archived.id !== 'string')
|
|
2339
|
+
drop = true; // useless line, not a memory
|
|
2340
|
+
else if (cutoff !== null) {
|
|
2341
|
+
const at = Date.parse(archived.forgottenAt || '');
|
|
2342
|
+
drop = Number.isFinite(at) && at < cutoff;
|
|
2343
|
+
}
|
|
2344
|
+
else
|
|
2345
|
+
drop = true;
|
|
2346
|
+
}
|
|
2347
|
+
catch {
|
|
2348
|
+
drop = true; // half-written line: un-restorable, so purging loses nothing
|
|
2349
|
+
}
|
|
2350
|
+
if (drop)
|
|
2351
|
+
purged++;
|
|
2352
|
+
else
|
|
2353
|
+
kept.push(line);
|
|
2354
|
+
}
|
|
2355
|
+
if (purged > 0) {
|
|
2356
|
+
const fd = acquireLock();
|
|
2357
|
+
try {
|
|
2358
|
+
writeFileAtomic(FORGOTTEN_FILE, kept.join('\n') + (kept.length > 0 ? '\n' : ''));
|
|
2359
|
+
}
|
|
2360
|
+
catch { /* best-effort; counts below still report honestly */ }
|
|
2361
|
+
finally {
|
|
2362
|
+
releaseLock(fd);
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
return { purged, remaining: kept.length };
|
|
2366
|
+
}
|
|
1703
2367
|
// ─── Re-anchoring ───────────────────────────────────────────────────────────
|
|
1704
2368
|
/**
|
|
1705
2369
|
* Re-verify a memory against the code as it stands now, keeping its identity.
|
|
@@ -1830,6 +2494,28 @@ export class TheBrainV2 {
|
|
|
1830
2494
|
const entry = this.entries.get(id);
|
|
1831
2495
|
if (!entry)
|
|
1832
2496
|
return { ok: false, message: `No entry with id "${id}" in the Brain.` };
|
|
2497
|
+
// Same one-way redaction as store(): a corrected response pasted with a fresh secret
|
|
2498
|
+
// must not be the one write path that persists credentials.
|
|
2499
|
+
let updateRedactions = 0;
|
|
2500
|
+
if (typeof patch.query === 'string' && patch.query) {
|
|
2501
|
+
const s = scrubSecretsForStorage(patch.query);
|
|
2502
|
+
patch.query = s.text;
|
|
2503
|
+
updateRedactions += s.redactions;
|
|
2504
|
+
}
|
|
2505
|
+
if (typeof patch.response === 'string' && patch.response) {
|
|
2506
|
+
const s = scrubSecretsForStorage(patch.response);
|
|
2507
|
+
patch.response = s.text;
|
|
2508
|
+
updateRedactions += s.redactions;
|
|
2509
|
+
}
|
|
2510
|
+
if (patch.claims !== undefined) {
|
|
2511
|
+
patch.claims = patch.claims.map((c) => {
|
|
2512
|
+
const s = scrubSecretsForStorage(c.text);
|
|
2513
|
+
updateRedactions += s.redactions;
|
|
2514
|
+
return s.redactions > 0 ? { ...c, text: s.text } : c;
|
|
2515
|
+
});
|
|
2516
|
+
}
|
|
2517
|
+
if (updateRedactions > 0)
|
|
2518
|
+
entry.redactions = (entry.redactions || 0) + updateRedactions;
|
|
1833
2519
|
const beforeChars = entry.charCount;
|
|
1834
2520
|
// Out of the index before its terms change: a posting list built from the old text would
|
|
1835
2521
|
// keep serving this entry under words the new text no longer contains.
|
|
@@ -1955,6 +2641,126 @@ export class TheBrainV2 {
|
|
|
1955
2641
|
this.syncIfChanged();
|
|
1956
2642
|
return findBlastRadius(this.entries, filePath, symbolName);
|
|
1957
2643
|
}
|
|
2644
|
+
// ─── Upkeep: one command to tend the corpus ───────────────────────────────
|
|
2645
|
+
//
|
|
2646
|
+
// Every maintenance primitive existed (verify/refresh/forget/downvote/stats) but each
|
|
2647
|
+
// one needed the agent to remember it, name an id, and interpret the result — so in
|
|
2648
|
+
// practice nobody maintained anything and the corpus rotted by default. upkeep() inverts
|
|
2649
|
+
// that: one call revalidates what gets reused most, surfaces duplicates and dead
|
|
2650
|
+
// weight, and reports the savings ledger. It never deletes, merges, or refreshes
|
|
2651
|
+
// anything itself — refresh() records a human assertion no automation can make, and a
|
|
2652
|
+
// janitor that destroys memories on its own schedule is a data-loss vector, not care.
|
|
2653
|
+
/**
|
|
2654
|
+
* Near-duplicate entry pairs worth consolidating with brain_merge (see Fase E). Pairs
|
|
2655
|
+
* at or above `hi` are excluded: those should have been refused at store time, so a
|
|
2656
|
+
* surviving one is a `forget one of them` case, not a merge — report those separately
|
|
2657
|
+
* via the same call with lo=hi. Bounded: at most `sample` entries each issue one
|
|
2658
|
+
* search, and at most 20 pairs come back.
|
|
2659
|
+
*/
|
|
2660
|
+
findMergeCandidates(opts = {}) {
|
|
2661
|
+
const lo = opts.lo ?? 0.6;
|
|
2662
|
+
const hi = opts.hi ?? 0.92;
|
|
2663
|
+
const sample = Math.max(0, opts.sample ?? 100);
|
|
2664
|
+
this.syncIfChanged();
|
|
2665
|
+
const pool = Array.from(this.entries.values())
|
|
2666
|
+
.sort((a, b) => (b.hits - a.hits) || (effectiveTime(b) - effectiveTime(a)))
|
|
2667
|
+
.slice(0, sample);
|
|
2668
|
+
const seen = new Set();
|
|
2669
|
+
const out = [];
|
|
2670
|
+
for (const entry of pool) {
|
|
2671
|
+
// Internal sweep, not a question: must not move hit counters or the ledger.
|
|
2672
|
+
const results = this.search(entry.query, 6, lo, { countStats: false });
|
|
2673
|
+
for (const r of results) {
|
|
2674
|
+
if (r.id === entry.id)
|
|
2675
|
+
continue;
|
|
2676
|
+
if (r.similarity >= hi)
|
|
2677
|
+
continue;
|
|
2678
|
+
const key = [entry.id, r.id].sort().join('|');
|
|
2679
|
+
if (seen.has(key))
|
|
2680
|
+
continue;
|
|
2681
|
+
seen.add(key);
|
|
2682
|
+
const other = this.entries.get(r.id);
|
|
2683
|
+
out.push({
|
|
2684
|
+
ids: [entry.id, r.id],
|
|
2685
|
+
similarity: Number(r.similarity.toFixed(3)),
|
|
2686
|
+
queries: [entry.query.slice(0, 100), (other?.query || '').slice(0, 100)],
|
|
2687
|
+
});
|
|
2688
|
+
if (out.length >= 20)
|
|
2689
|
+
return out;
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
return out;
|
|
2693
|
+
}
|
|
2694
|
+
/**
|
|
2695
|
+
* Full upkeep pass. Read-only over the corpus: revalidates the top-N most-reused
|
|
2696
|
+
* memories by hash-compare, lists exact duplicates and merge candidates, dead weight,
|
|
2697
|
+
* and the savings ledger — with a concrete suggested action per finding.
|
|
2698
|
+
*/
|
|
2699
|
+
upkeep(opts = {}) {
|
|
2700
|
+
this.syncIfChanged();
|
|
2701
|
+
const topN = Math.max(0, opts.topN ?? 20);
|
|
2702
|
+
const top = Array.from(this.entries.values())
|
|
2703
|
+
.sort((a, b) => (b.hits - a.hits) || (effectiveTime(b) - effectiveTime(a)))
|
|
2704
|
+
.slice(0, topN);
|
|
2705
|
+
const verdicts = this.verifyByIds(top.map((e) => e.id));
|
|
2706
|
+
const queryOf = (id) => this.entries.get(id)?.query.slice(0, 100) || '';
|
|
2707
|
+
const revalidated = { checked: top.length, fresh: [], stale: [], untracked: [], unverified: [] };
|
|
2708
|
+
for (const v of verdicts) {
|
|
2709
|
+
if (v.status === 'fresh')
|
|
2710
|
+
revalidated.fresh.push(v.id);
|
|
2711
|
+
else if (v.status === 'stale') {
|
|
2712
|
+
revalidated.stale.push({ id: v.id, query: queryOf(v.id), staleFiles: v.staleFiles || [], action: 'refresh_memory after checking it is still true' });
|
|
2713
|
+
}
|
|
2714
|
+
else if (v.status === 'untracked') {
|
|
2715
|
+
revalidated.untracked.push({ id: v.id, query: queryOf(v.id), action: 'update_memory with filePaths/symbols so it becomes verifiable' });
|
|
2716
|
+
}
|
|
2717
|
+
else if (v.status === 'unverified') {
|
|
2718
|
+
revalidated.unverified.push({ id: v.id, query: queryOf(v.id), action: 'check the tracked paths exist on this machine, then refresh_memory' });
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
// Exact duplicates (>= store threshold) slipped in through imports or forced stores:
|
|
2722
|
+
// merging paraphrases is brain_merge's job, but a 98%-identical pair just needs one
|
|
2723
|
+
// of them forgotten.
|
|
2724
|
+
const duplicatePairs = this.findMergeCandidates({ sample: opts.sample ?? 50, lo: 0.92, hi: 1.01 })
|
|
2725
|
+
.map((c) => ({ ...c, action: 'forget_memory one of the pair (they are the same memory twice)' }));
|
|
2726
|
+
const mergeCandidates = this.findMergeCandidates({ sample: opts.sample ?? 50, lo: 0.75, hi: 0.92 })
|
|
2727
|
+
.map((c) => ({ ...c, action: 'brain_merge the pair into one canonical memory' }));
|
|
2728
|
+
const neverHitIds = [];
|
|
2729
|
+
const downvoted = [];
|
|
2730
|
+
for (const entry of this.entries.values()) {
|
|
2731
|
+
if (!entry.hits && neverHitIds.length < 10)
|
|
2732
|
+
neverHitIds.push(entry.id);
|
|
2733
|
+
if (entry.demerits)
|
|
2734
|
+
downvoted.push({ id: entry.id, demerits: entry.demerits });
|
|
2735
|
+
}
|
|
2736
|
+
downvoted.sort((a, b) => b.demerits - a.demerits);
|
|
2737
|
+
const stats = this.getStats({ deep: opts.deep === true });
|
|
2738
|
+
const report = {
|
|
2739
|
+
checkedAt: new Date().toISOString(),
|
|
2740
|
+
totalEntries: this.entries.size,
|
|
2741
|
+
revalidated,
|
|
2742
|
+
duplicatePairs,
|
|
2743
|
+
mergeCandidates,
|
|
2744
|
+
deadWeight: {
|
|
2745
|
+
neverHit: stats.neverHit || 0,
|
|
2746
|
+
neverHitSample: neverHitIds,
|
|
2747
|
+
downvoted,
|
|
2748
|
+
action: 'forget_memory entries that are both never reused and downvoted; leave the rest — an unused memory costs bytes, a deleted one costs knowledge',
|
|
2749
|
+
},
|
|
2750
|
+
savings: {
|
|
2751
|
+
totalTokensSaved: stats.totalTokensSaved || 0,
|
|
2752
|
+
topSavers: (stats.topSavers || []).slice(0, 3),
|
|
2753
|
+
},
|
|
2754
|
+
};
|
|
2755
|
+
if (stats.staleEntries !== undefined) {
|
|
2756
|
+
report.deep = {
|
|
2757
|
+
staleEntries: stats.staleEntries,
|
|
2758
|
+
staleRatio: stats.staleRatio || 0,
|
|
2759
|
+
action: 'work the revalidated.stale list above oldest-first; refresh_memory what still holds, forget_memory what does not',
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
return report;
|
|
2763
|
+
}
|
|
1958
2764
|
// ─── Portability ────────────────────────────────────────────────────────────
|
|
1959
2765
|
/**
|
|
1960
2766
|
* Serialize memories to a portable NDJSON bundle: a header line, then one entry per line.
|
|
@@ -1994,6 +2800,149 @@ export class TheBrainV2 {
|
|
|
1994
2800
|
const body = selected.map(({ terms: _t, ...persisted }) => JSON.stringify(persisted));
|
|
1995
2801
|
return { text: [header, ...body].join('\n'), count: selected.length, skippedStale };
|
|
1996
2802
|
}
|
|
2803
|
+
/**
|
|
2804
|
+
* Classify one bundle line without touching the corpus. The single planning step behind
|
|
2805
|
+
* both importBundle() (which then applies the plan) and previewImport() (which only
|
|
2806
|
+
* reports it) — one classifier means a dry run can never disagree with the real thing
|
|
2807
|
+
* about what would happen. Scrubbing the freshly-parsed object is safe: it is not
|
|
2808
|
+
* corpus state yet.
|
|
2809
|
+
*/
|
|
2810
|
+
planImportLine(line, markSource) {
|
|
2811
|
+
let entry;
|
|
2812
|
+
try {
|
|
2813
|
+
entry = JSON.parse(line);
|
|
2814
|
+
}
|
|
2815
|
+
catch {
|
|
2816
|
+
return { disposition: 'skip', reason: 'unparseable line' };
|
|
2817
|
+
}
|
|
2818
|
+
if (!entry || typeof entry.id !== 'string' || typeof entry.query !== 'string') {
|
|
2819
|
+
return { disposition: 'skip', reason: 'missing id/query' };
|
|
2820
|
+
}
|
|
2821
|
+
if (this.tombstones.has(entry.id)) {
|
|
2822
|
+
return { disposition: 'skip', reason: 'id was deliberately forgotten here', entry, tombstoned: true };
|
|
2823
|
+
}
|
|
2824
|
+
// Bundles from pre-scrub Brains (or hand-written ones) can carry live secrets;
|
|
2825
|
+
// redact on the way in. Already-scrubbed `[NAME_n]` spans pass through untouched.
|
|
2826
|
+
if (typeof entry.query === 'string')
|
|
2827
|
+
entry.query = scrubSecretsForStorage(entry.query).text;
|
|
2828
|
+
if (typeof entry.response === 'string')
|
|
2829
|
+
entry.response = scrubSecretsForStorage(entry.response).text;
|
|
2830
|
+
if (Array.isArray(entry.claims)) {
|
|
2831
|
+
for (const claim of entry.claims) {
|
|
2832
|
+
if (claim && typeof claim.text === 'string')
|
|
2833
|
+
claim.text = scrubSecretsForStorage(claim.text).text;
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
if (!Array.isArray(entry.terms))
|
|
2837
|
+
entry.terms = Object.keys(entry.termFreq || {});
|
|
2838
|
+
if (entry.terms.length === 0) {
|
|
2839
|
+
// A bundle from a version that persisted neither terms nor termFreq — re-tokenize
|
|
2840
|
+
// rather than admit an entry the inverted index could never retrieve.
|
|
2841
|
+
const terms = tokenize(entry.query + ' ' + (entry.response || ''));
|
|
2842
|
+
entry.terms = [...new Set(terms)];
|
|
2843
|
+
entry.termFreq = termFrequencies(terms);
|
|
2844
|
+
}
|
|
2845
|
+
if (markSource)
|
|
2846
|
+
entry.source = 'import';
|
|
2847
|
+
const existing = this.entries.get(entry.id);
|
|
2848
|
+
if (existing) {
|
|
2849
|
+
if (effectiveTime(entry) <= effectiveTime(existing)) {
|
|
2850
|
+
return { disposition: 'skip', reason: 'local copy is newer or same age', entry };
|
|
2851
|
+
}
|
|
2852
|
+
return { disposition: 'update', reason: 'bundle copy is newer', entry };
|
|
2853
|
+
}
|
|
2854
|
+
return { disposition: 'import', reason: 'new id', entry };
|
|
2855
|
+
}
|
|
2856
|
+
/** Fold the other side's counters into ours without losing feedback either side collected. */
|
|
2857
|
+
absorbImportCounters(existing, incoming) {
|
|
2858
|
+
existing.hits = Math.max(existing.hits || 0, incoming.hits || 0);
|
|
2859
|
+
existing.demerits = Math.max(existing.demerits || 0, incoming.demerits || 0);
|
|
2860
|
+
const bestSavings = Math.max(existing.tokensSaved || 0, incoming.tokensSaved || 0);
|
|
2861
|
+
if (bestSavings > 0)
|
|
2862
|
+
existing.tokensSaved = bestSavings;
|
|
2863
|
+
else
|
|
2864
|
+
delete existing.tokensSaved;
|
|
2865
|
+
}
|
|
2866
|
+
/** Split a bundle into candidate lines. No header means raw entries — see below. */
|
|
2867
|
+
static splitBundleLines(text) {
|
|
2868
|
+
const lines = text.split('\n').filter(Boolean);
|
|
2869
|
+
if (lines.length === 0)
|
|
2870
|
+
return { lines: [] };
|
|
2871
|
+
try {
|
|
2872
|
+
const header = JSON.parse(lines[0]);
|
|
2873
|
+
if (header && header.lemmaBrainExport)
|
|
2874
|
+
return { lines: lines.slice(1) };
|
|
2875
|
+
}
|
|
2876
|
+
catch {
|
|
2877
|
+
// No header — treat the whole file as entries. A raw entries.ndjson copied off another
|
|
2878
|
+
// machine is a perfectly reasonable thing to hand this, and rejecting it would be
|
|
2879
|
+
// pedantry rather than safety.
|
|
2880
|
+
}
|
|
2881
|
+
return { lines };
|
|
2882
|
+
}
|
|
2883
|
+
/**
|
|
2884
|
+
* What importBundle() WOULD do, without writing anything: counts, the first 50 items,
|
|
2885
|
+
* and opposite-outcome conflicts against the live corpus. Run this before importing a
|
|
2886
|
+
* bundle from a teammate or another machine — especially a large one — so a flood of
|
|
2887
|
+
* stale, wrong-project memories is a preview, not a surprise.
|
|
2888
|
+
*/
|
|
2889
|
+
previewImport(text, opts = {}) {
|
|
2890
|
+
this.syncIfChanged();
|
|
2891
|
+
const { lines } = TheBrainV2.splitBundleLines(text);
|
|
2892
|
+
const empty = { ok: false, wouldImport: 0, wouldUpdate: 0, wouldSkip: 0, items: [], truncated: false, conflicts: [], message: 'Bundle is empty.' };
|
|
2893
|
+
if (lines.length === 0)
|
|
2894
|
+
return empty;
|
|
2895
|
+
const markSource = opts.markSource !== false;
|
|
2896
|
+
let wouldImport = 0;
|
|
2897
|
+
let wouldUpdate = 0;
|
|
2898
|
+
let wouldSkip = 0;
|
|
2899
|
+
const items = [];
|
|
2900
|
+
const conflicts = [];
|
|
2901
|
+
const seenConflicts = new Set();
|
|
2902
|
+
for (const line of lines) {
|
|
2903
|
+
const plan = this.planImportLine(line, markSource);
|
|
2904
|
+
if (plan.disposition === 'import')
|
|
2905
|
+
wouldImport++;
|
|
2906
|
+
else if (plan.disposition === 'update')
|
|
2907
|
+
wouldUpdate++;
|
|
2908
|
+
else
|
|
2909
|
+
wouldSkip++;
|
|
2910
|
+
if (items.length < 50) {
|
|
2911
|
+
items.push({
|
|
2912
|
+
id: plan.entry?.id ?? '(unparseable)',
|
|
2913
|
+
query: (plan.entry?.query || '').slice(0, 100),
|
|
2914
|
+
action: plan.disposition,
|
|
2915
|
+
reason: plan.reason,
|
|
2916
|
+
});
|
|
2917
|
+
}
|
|
2918
|
+
if (plan.entry && plan.disposition !== 'skip' && plan.entry.symbolHashes) {
|
|
2919
|
+
const refs = Object.keys(plan.entry.symbolHashes).map((key) => {
|
|
2920
|
+
const sep = key.lastIndexOf('::');
|
|
2921
|
+
return { filePath: key.substring(0, sep), symbolName: key.substring(sep + 2) };
|
|
2922
|
+
});
|
|
2923
|
+
const outcome = plan.entry.outcome === 'failed' ? 'failed' : 'confirmed';
|
|
2924
|
+
for (const c of this.findOutcomeConflicts(refs, outcome, new Set([plan.entry.id]))) {
|
|
2925
|
+
const ck = `${c.id}|${plan.entry.id}`;
|
|
2926
|
+
if (seenConflicts.has(ck))
|
|
2927
|
+
continue;
|
|
2928
|
+
seenConflicts.add(ck);
|
|
2929
|
+
conflicts.push({ ...c, against: plan.entry.id });
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
return {
|
|
2934
|
+
ok: true,
|
|
2935
|
+
wouldImport,
|
|
2936
|
+
wouldUpdate,
|
|
2937
|
+
wouldSkip,
|
|
2938
|
+
items,
|
|
2939
|
+
truncated: lines.length > items.length,
|
|
2940
|
+
conflicts,
|
|
2941
|
+
message: `Dry run: would import ${wouldImport}, update ${wouldUpdate}, skip ${wouldSkip}.` +
|
|
2942
|
+
(conflicts.length > 0 ? ` ${conflicts.length} opposite-outcome conflict(s) with live memories — review before importing.` : '') +
|
|
2943
|
+
` Nothing was written.`,
|
|
2944
|
+
};
|
|
2945
|
+
}
|
|
1997
2946
|
/**
|
|
1998
2947
|
* Merge a bundle produced by exportBundle into this Brain.
|
|
1999
2948
|
*
|
|
@@ -2009,64 +2958,35 @@ export class TheBrainV2 {
|
|
|
2009
2958
|
*/
|
|
2010
2959
|
importBundle(text, opts = {}) {
|
|
2011
2960
|
this.syncIfChanged();
|
|
2012
|
-
const lines =
|
|
2961
|
+
const { lines } = TheBrainV2.splitBundleLines(text);
|
|
2013
2962
|
if (lines.length === 0)
|
|
2014
2963
|
return { ok: false, imported: 0, updated: 0, skipped: 0, message: 'Bundle is empty.' };
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
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
|
-
}
|
|
2964
|
+
// Snapshot before merging foreign entries: an import is the easiest way to flood a
|
|
2965
|
+
// clean corpus with hundreds of stale, wrong-project memories.
|
|
2966
|
+
this.snapshotCorpus('import');
|
|
2026
2967
|
let imported = 0;
|
|
2027
2968
|
let updated = 0;
|
|
2028
2969
|
let skipped = 0;
|
|
2029
2970
|
const markSource = opts.markSource !== false;
|
|
2030
|
-
for (const line of lines
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
continue;
|
|
2042
|
-
}
|
|
2043
|
-
if (this.tombstones.has(entry.id)) {
|
|
2971
|
+
for (const line of lines) {
|
|
2972
|
+
const plan = this.planImportLine(line, markSource);
|
|
2973
|
+
if (plan.disposition === 'skip') {
|
|
2974
|
+
// Tombstoned skips excepted: a deliberately forgotten id gets no counter updates,
|
|
2975
|
+
// exactly as before the planner existed — the deletion stands completely.
|
|
2976
|
+
if (plan.entry && !plan.tombstoned) {
|
|
2977
|
+
const existing = this.entries.get(plan.entry.id);
|
|
2978
|
+
// Older or same age: keep ours, but never lose feedback the other side collected.
|
|
2979
|
+
if (existing)
|
|
2980
|
+
this.absorbImportCounters(existing, plan.entry);
|
|
2981
|
+
}
|
|
2044
2982
|
skipped++;
|
|
2045
2983
|
continue;
|
|
2046
2984
|
}
|
|
2047
|
-
|
|
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';
|
|
2985
|
+
const entry = plan.entry;
|
|
2058
2986
|
const existing = this.entries.get(entry.id);
|
|
2059
2987
|
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
2988
|
this.dropEntry(existing);
|
|
2068
|
-
|
|
2069
|
-
entry.demerits = Math.max(existing.demerits || 0, entry.demerits || 0);
|
|
2989
|
+
this.absorbImportCounters(entry, existing);
|
|
2070
2990
|
updated++;
|
|
2071
2991
|
}
|
|
2072
2992
|
else {
|
|
@@ -2120,6 +3040,8 @@ export class TheBrainV2 {
|
|
|
2120
3040
|
let unscoped = 0;
|
|
2121
3041
|
let untracked = 0;
|
|
2122
3042
|
let oldest = '';
|
|
3043
|
+
let totalTokensSaved = 0;
|
|
3044
|
+
const savers = [];
|
|
2123
3045
|
const bySource = {};
|
|
2124
3046
|
const projects = new Set();
|
|
2125
3047
|
for (const entry of this.entries.values()) {
|
|
@@ -2133,11 +3055,20 @@ export class TheBrainV2 {
|
|
|
2133
3055
|
projects.add(entry.projectId);
|
|
2134
3056
|
if (trackedArtifactCount(entry) === 0)
|
|
2135
3057
|
untracked++;
|
|
3058
|
+
if (entry.tokensSaved) {
|
|
3059
|
+
totalTokensSaved += entry.tokensSaved;
|
|
3060
|
+
savers.push({ id: entry.id, query: entry.query.slice(0, 80), tokensSaved: entry.tokensSaved, hits: entry.hits });
|
|
3061
|
+
}
|
|
2136
3062
|
const src = entrySource(entry);
|
|
2137
3063
|
bySource[src] = (bySource[src] || 0) + 1;
|
|
2138
3064
|
if (entry.timestamp && (!oldest || entry.timestamp < oldest))
|
|
2139
3065
|
oldest = entry.timestamp;
|
|
2140
3066
|
}
|
|
3067
|
+
savers.sort((a, b) => b.tokensSaved - a.tokensSaved);
|
|
3068
|
+
base.totalTokensSaved = totalTokensSaved;
|
|
3069
|
+
if (savers.length > 0)
|
|
3070
|
+
base.topSavers = savers.slice(0, 5);
|
|
3071
|
+
base.integrity = this.health();
|
|
2141
3072
|
base.neverHit = neverHit;
|
|
2142
3073
|
base.downvoted = downvoted;
|
|
2143
3074
|
base.bySource = bySource;
|
|
@@ -2166,7 +3097,26 @@ export class TheBrainV2 {
|
|
|
2166
3097
|
}
|
|
2167
3098
|
return base;
|
|
2168
3099
|
}
|
|
3100
|
+
/**
|
|
3101
|
+
* Integrity + rollback posture in one cheap call (no hashing, no disk reads beyond a
|
|
3102
|
+
* directory listing). `ok` is false while an unverified read, a backup fallback, or an
|
|
3103
|
+
* undecryptable envelope is the reason this process is serving what it serves.
|
|
3104
|
+
*/
|
|
3105
|
+
health() {
|
|
3106
|
+
this.syncIfChanged();
|
|
3107
|
+
return {
|
|
3108
|
+
ok: this.integrityIssue === null,
|
|
3109
|
+
lastIssue: this.integrityIssue,
|
|
3110
|
+
snapshots: listSnapshotNames().length,
|
|
3111
|
+
backups: fs.existsSync(ENTRIES_PREV_FILE),
|
|
3112
|
+
encrypted: encryptionActive(BRAIN_DIR).enabled,
|
|
3113
|
+
};
|
|
3114
|
+
}
|
|
2169
3115
|
clear() {
|
|
3116
|
+
// Even a deliberate wipe gets a restore point: "I didn't mean that clear" is a
|
|
3117
|
+
// support ticket, "I didn't mean that clear and there is no snapshot" is data loss.
|
|
3118
|
+
if (this.entries.size > 0)
|
|
3119
|
+
this.snapshotCorpus('clear');
|
|
2170
3120
|
this.entries.clear();
|
|
2171
3121
|
this.invertedIndex.clear();
|
|
2172
3122
|
this.bloom = new BloomFilter();
|
|
@@ -2176,9 +3126,14 @@ export class TheBrainV2 {
|
|
|
2176
3126
|
this.tombstones.clear();
|
|
2177
3127
|
this.diskStamp = null;
|
|
2178
3128
|
this.sidecar = null;
|
|
3129
|
+
this.integrityIssue = null;
|
|
2179
3130
|
try {
|
|
2180
3131
|
if (fs.existsSync(ENTRIES_FILE))
|
|
2181
3132
|
fs.unlinkSync(ENTRIES_FILE);
|
|
3133
|
+
if (fs.existsSync(ENTRIES_SHA_FILE))
|
|
3134
|
+
fs.unlinkSync(ENTRIES_SHA_FILE);
|
|
3135
|
+
if (fs.existsSync(ENTRIES_PREV_FILE))
|
|
3136
|
+
fs.unlinkSync(ENTRIES_PREV_FILE);
|
|
2182
3137
|
if (fs.existsSync(INDEX_FILE))
|
|
2183
3138
|
fs.unlinkSync(INDEX_FILE);
|
|
2184
3139
|
if (fs.existsSync(META_FILE))
|
|
@@ -2187,6 +3142,8 @@ export class TheBrainV2 {
|
|
|
2187
3142
|
fs.unlinkSync(LOCK_FILE);
|
|
2188
3143
|
if (fs.existsSync(EMBEDDINGS_FILE))
|
|
2189
3144
|
fs.unlinkSync(EMBEDDINGS_FILE);
|
|
3145
|
+
// Snapshots deliberately survive clear(): they are the rollback history, and a wipe
|
|
3146
|
+
// that also destroys every restore point is not a wipe, it is data loss.
|
|
2190
3147
|
}
|
|
2191
3148
|
catch { /* ignore */ }
|
|
2192
3149
|
}
|