memoir-cli 3.12.0 → 3.15.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 (74) hide show
  1. package/README.md +128 -137
  2. package/bin/memoir-work.js +9 -0
  3. package/bin/memoir.js +50 -8
  4. package/docs/AUDIT-REMEDIATION.md +55 -0
  5. package/docs/CASE_TAPE_AMNESIA.md +39 -0
  6. package/docs/HANDOFF-SECURITY-AUDIT.md +106 -0
  7. package/docs/LOCAL-HANDOFF-VALIDATION.md +129 -0
  8. package/docs/MCP-V2-MIGRATION.md +17 -0
  9. package/docs/PROJECT-HANDOFF.md +301 -0
  10. package/docs/PROJECT-MAP-TRIAL.md +149 -0
  11. package/docs/PROJECT-VIEW-DEBUG.md +66 -0
  12. package/docs/PROJECT-VIEW-VALIDATION.md +136 -0
  13. package/docs/RELEASE-3.14-VALIDATION.md +36 -0
  14. package/docs/RELIABILITY-ROLLOUT.md +57 -0
  15. package/docs/RETRIEVAL-INDEX.md +45 -0
  16. package/docs/RETRIEVAL-RESULTS.md +26 -0
  17. package/docs/SPEC.md +684 -0
  18. package/evals/CONTINUITY-PROTOCOL.md +45 -0
  19. package/evals/cases.json +200 -0
  20. package/evals/results/retrieval-2026-09-05.json +5333 -0
  21. package/evals/retrieval-performance.mjs +99 -0
  22. package/evals/run.mjs +87 -0
  23. package/package.json +13 -5
  24. package/src/adapters/index.js +13 -6
  25. package/src/adapters/restore.js +83 -36
  26. package/src/cloud/storage.js +130 -93
  27. package/src/commands/activate.js +18 -7
  28. package/src/commands/cloud.js +55 -4
  29. package/src/commands/consolidate.js +49 -10
  30. package/src/commands/diff.js +2 -2
  31. package/src/commands/doctor.js +3 -3
  32. package/src/commands/push.js +156 -161
  33. package/src/commands/recall.js +1 -1
  34. package/src/commands/restore.js +32 -44
  35. package/src/commands/resume.js +15 -164
  36. package/src/commands/session.js +51 -9
  37. package/src/commands/snapshot.js +6 -7
  38. package/src/commands/status.js +23 -1
  39. package/src/commands/upgrade.js +11 -9
  40. package/src/commands/validate.js +3 -0
  41. package/src/commands/view.js +2 -2
  42. package/src/commands/why.js +4 -3
  43. package/src/config.js +9 -40
  44. package/src/context/capture.js +126 -32
  45. package/src/context/handoffs.js +72 -0
  46. package/src/events/summary.js +122 -0
  47. package/src/integrations/setup.js +88 -0
  48. package/src/mcp.js +105 -152
  49. package/src/memory/lexical-index.js +65 -0
  50. package/src/memory/repository.js +16 -0
  51. package/src/memory/scope.js +65 -0
  52. package/src/memory/search.js +165 -70
  53. package/src/memory/store.js +141 -0
  54. package/src/providers/index.js +182 -51
  55. package/src/providers/restore.js +5 -1
  56. package/src/security/encryption.js +34 -60
  57. package/src/security/files.js +155 -0
  58. package/src/session/brief.js +47 -0
  59. package/src/session/inject.js +12 -6
  60. package/src/session/lock.js +39 -118
  61. package/src/session/migrations.js +6 -0
  62. package/src/session/render.js +34 -4
  63. package/src/session/state.js +200 -33
  64. package/src/work/cli.js +64 -0
  65. package/src/work/errors.js +8 -0
  66. package/src/work/server.js +28 -0
  67. package/src/work/setup.js +96 -0
  68. package/src/work/store.js +340 -0
  69. package/src/work/ui/app.js +398 -0
  70. package/src/work/ui/index.html +45 -0
  71. package/src/work/ui/style.css +248 -0
  72. package/src/work/view.js +93 -0
  73. package/src/workspace/tracker.js +84 -332
  74. package/supabase/migrations/202609050001_backup_versions.sql +50 -0
@@ -16,8 +16,8 @@
16
16
  // • light morphology: plural/-ing/-ed folding plus prefix matching from
17
17
  // 4 chars, so "auth" finds "authentication" and "deploys" finds
18
18
  // "deploy" (a real stemmer over-merges; this is deliberately timid);
19
- // • an mtime-keyed parse cache and a TTL'd project-file index, so a
20
- // long-lived MCP process stops re-reading and re-walking the disk.
19
+ // • metadata-validated parse/directory caches and incremental postings,
20
+ // while edits, removals, scopes, and source paths are checked per query.
21
21
  //
22
22
  // What it does NOT do: semantic/concept matching. "tiktok" still won't
23
23
  // find a file that only ever says "vertical swipe feed". The honest,
@@ -29,6 +29,11 @@ import fs from 'fs-extra';
29
29
  import path from 'path';
30
30
  import os from 'os';
31
31
  import { adapters } from '../adapters/index.js';
32
+ import { memoryRoot } from './store.js';
33
+ import { memoryVisibility, projectIdentity } from './scope.js';
34
+ import { LexicalIndex } from './lexical-index.js';
35
+ import { readSession, allDecisions } from '../session/state.js';
36
+ import { readSafeFile, safePath, createReadInventory } from '../security/files.js';
32
37
  import { parseFrontmatter } from '../commands/validate.js';
33
38
 
34
39
  const home = os.homedir();
@@ -39,7 +44,7 @@ const STOPWORDS = new Set([
39
44
  'the', 'a', 'an', 'of', 'to', 'in', 'on', 'for', 'and', 'or', 'is', 'it',
40
45
  'this', 'that', 'what', 'how', 'do', 'does', 'did', 'we', 'i', 'my', 'our',
41
46
  'with', 'about', 'was', 'were', 'be', 'are', 'at', 'by', 'from', 'as',
42
- 'into', 'up', 'out', 'so', 'if', 'not', 'no', 'me', 'you', 'your', 'us',
47
+ 'into', 'up', 'out', 'so', 'if', 'me', 'you', 'your', 'us',
43
48
  'why', 'when', 'where', 'which', 'who', 'can', 'should', 'would', 'could',
44
49
  'have', 'has', 'had', 'been', 'being', 'there', 'here', 'than', 'then',
45
50
  ]);
@@ -49,12 +54,18 @@ const STOPWORDS = new Set([
49
54
  const PREFIX_MIN = 4;
50
55
 
51
56
  export function tokenize(str) {
52
- return String(str || '')
53
- .toLowerCase()
54
- .split(/[^a-z0-9_$./-]+/)
55
- .flatMap((t) => t.split(/[./-]+/))
56
- .map((t) => t.replace(/^[_$]+|[_$]+$/g, ''))
57
- .filter((t) => t.length >= 2);
57
+ const chunks = String(str || '').normalize('NFKC').toLowerCase()
58
+ .split(/[^\p{L}\p{N}_$./-]+/u).flatMap(t => t.split(/[./-]+/))
59
+ .map(t => t.replace(/^[_$]+|[_$]+$/g, '')).filter(Boolean);
60
+ return chunks.flatMap(t => {
61
+ // CJK has no mandatory word separators. Index overlapping character
62
+ // bigrams as well as the complete token; retain single-character queries.
63
+ if (/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(t)) {
64
+ const chars = Array.from(t);
65
+ return [t, ...chars.slice(0, -1).map((c, i) => c + chars[i + 1])];
66
+ }
67
+ return t.length >= 2 || /^\p{N}$/u.test(t) ? [t] : [];
68
+ });
58
69
  }
59
70
 
60
71
  // Fold the commonest English inflections. Conservative on purpose: every
@@ -123,8 +134,9 @@ function fieldTokens(strings) {
123
134
  * run on every file, but cached by (path, mtime, size) in readMemoryFiles.
124
135
  */
125
136
  export function buildDoc({ path: relPath, content, tool, absPath, mtimeMs }) {
126
- const isMarkdown = /\.(md|markdown)$/i.test(relPath);
127
- const { fields, body } = isMarkdown ? parseFrontmatter(content) : { fields: {}, body: content };
137
+ const isMarkdown = /\.(md|mdc|markdown)$/i.test(relPath);
138
+ const { fields, body: rawBody } = isMarkdown ? parseFrontmatter(content) : { fields: {}, body: content };
139
+ const body = rawBody.replace(/<!--\s*memoir:session-block[^>]*-->[\s\S]*?<!--\s*\/memoir:session-block\s*-->/g, block => block.replace(/[^\r\n]/g, ''));
128
140
  const bodyLines = body.split(/\r?\n/);
129
141
  const headings = bodyLines.filter((l) => /^\s{0,3}#{1,6}\s/.test(l));
130
142
 
@@ -132,8 +144,10 @@ export function buildDoc({ path: relPath, content, tool, absPath, mtimeMs }) {
132
144
  if (fields.name) nameStrings.push(String(fields.name));
133
145
 
134
146
  return {
147
+ ...fields,
135
148
  path: relPath,
136
149
  absPath,
150
+ bodyStartLine: content.split(/\r?\n/).length - rawBody.split(/\r?\n/).length + 1,
137
151
  tool,
138
152
  mtimeMs: mtimeMs || 0,
139
153
  isMarkdown,
@@ -158,12 +172,14 @@ export function buildDoc({ path: relPath, content, tool, absPath, mtimeMs }) {
158
172
 
159
173
  // ── Scoring ──────────────────────────────────────────────────────
160
174
 
161
- function fieldScore(tfMap, term) {
175
+ function fieldScore(tfMap, term, matches) {
162
176
  // Best match across the field's tokens: exact beats prefix; tf saturates.
163
177
  let best = 0;
164
178
  let count = 0;
165
- for (const [token, n] of tfMap) {
166
- const m = termMatch(term, token);
179
+ for (const [token, value] of matches || tfMap) {
180
+ const n = matches ? tfMap.get(token) : value;
181
+ if (!n) continue;
182
+ const m = matches ? value : termMatch(term, token);
167
183
  if (m > best) { best = m; count = n; }
168
184
  else if (m === best && m > 0) count += n;
169
185
  }
@@ -179,12 +195,12 @@ function fieldScore(tfMap, term) {
179
195
  // one-term match outrank a file that covered every term in the query.
180
196
  const TERM_CAP = 8;
181
197
 
182
- export function scoreDoc(doc, terms) {
198
+ export function scoreDoc(doc, terms, matches) {
183
199
  let sum = 0;
184
200
  let matched = 0;
185
201
  const perTerm = {};
186
202
  for (const term of terms) {
187
- const fieldScores = Object.keys(W).map((f) => W[f] * fieldScore(doc.tf[f], term)).sort((a, b) => b - a);
203
+ const fieldScores = Object.keys(W).map((f) => W[f] * fieldScore(doc.tf[f], term, matches?.get(term))).sort((a, b) => b - a);
188
204
  const s = Math.min(TERM_CAP, fieldScores[0] + 0.25 * fieldScores.slice(1).reduce((x, y) => x + y, 0));
189
205
  if (s > 0) matched++;
190
206
  perTerm[term] = s;
@@ -283,38 +299,63 @@ export function extractPassage(doc, terms, budget = PASSAGE_BUDGET) {
283
299
  // (absPath) -> { mtimeMs, size, doc }. The MCP server is a long-lived
284
300
  // process; memory files change rarely relative to how often recall runs.
285
301
  const docCache = new Map();
286
-
287
- async function readDoc(absPath, relPath, tool) {
288
- let st;
289
- try { st = await fs.stat(absPath); } catch { return null; }
290
- const hit = docCache.get(absPath);
291
- if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.doc;
302
+ const lexicalIndex = new LexicalIndex();
303
+ const sourceChecks = new WeakMap();
304
+ const sameFile = (a, b) => a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs && a.ino === b.ino && a.dev === b.dev && a.size === b.size;
305
+
306
+ async function readDoc(absPath, relPath, tool, root = path.dirname(absPath), inventory) {
307
+ let st, access;
308
+ try {
309
+ inventory ||= await createReadInventory(root);
310
+ access = await inventory.stat(path.relative(root, absPath));
311
+ st = access.stat;
312
+ absPath = access.full;
313
+ } catch { return null; }
314
+ // A file can be exposed under more than one adapter/project projection.
315
+ const key = JSON.stringify([absPath, relPath, tool]);
316
+ const hit = docCache.get(key);
317
+ if (hit && sameFile(hit.stat, st)) return hit.doc;
292
318
  let content;
293
- try { content = await fs.readFile(absPath, 'utf8'); } catch { return null; }
319
+ try { content = (await readSafeFile(inventory.root, access.relative)).toString('utf8'); } catch { return null; }
294
320
  const doc = buildDoc({ path: relPath, content, tool, absPath, mtimeMs: st.mtimeMs });
295
- docCache.set(absPath, { mtimeMs: st.mtimeMs, size: st.size, doc });
321
+ const match = tool === 'Claude CLI' ? relPath.match(/^projects\/([^/]+)\//) : null;
322
+ if (match) doc.claudeProjectKey = match[1];
323
+ docCache.set(key, { stat: st, doc });
324
+ sourceChecks.set(doc, { root: inventory.root, relative: access.relative, stat: st, key });
296
325
  return doc;
297
326
  }
298
327
 
328
+ async function sourceUnchanged(doc) {
329
+ const source = sourceChecks.get(doc);
330
+ if (!source) return true; // Session documents have their own fresh read.
331
+ try {
332
+ const full = await safePath(source.root, source.relative);
333
+ const st = await fs.lstat(full);
334
+ return st.isFile() && !st.isSymbolicLink() && sameFile(source.stat, st);
335
+ } catch { return false; }
336
+ }
337
+
299
338
  /** Test hook — drop every cached parse. */
300
339
  export function clearSearchCache() {
301
340
  docCache.clear();
302
- projectIndex.at = 0;
303
- projectIndex.files = [];
341
+ lexicalIndex.clear();
342
+ projectDirectories.clear();
304
343
  }
305
344
 
306
- const MEMORY_EXT = /\.(md|json|ya?ml)$/i;
345
+ const MEMORY_EXT = /\.(md|mdc|json|toml|ya?ml)$/i;
307
346
 
308
347
  /**
309
348
  * Read every memory file an adapter owns, as parsed docs. Cached by mtime.
310
349
  */
311
350
  export async function readMemoryFiles(adapter) {
312
351
  const files = [];
352
+ let inventory;
353
+ try { inventory = await createReadInventory(adapter.source); } catch { return files; }
313
354
 
314
355
  if (adapter.customExtract) {
315
356
  for (const file of adapter.files) {
316
357
  const abs = path.join(adapter.source, file);
317
- const doc = await readDoc(abs, file, adapter.name);
358
+ const doc = await readDoc(abs, file, adapter.name, adapter.source, inventory);
318
359
  if (doc) files.push(doc);
319
360
  }
320
361
  return files;
@@ -325,15 +366,19 @@ export async function readMemoryFiles(adapter) {
325
366
  const walk = async (dir, prefix = '') => {
326
367
  let entries;
327
368
  try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
328
- for (const entry of entries) {
369
+ for (let offset = 0; offset < entries.length; offset += 24) {
370
+ await Promise.all(entries.slice(offset, offset + 24).map(async entry => {
329
371
  const fullPath = path.join(dir, entry.name);
330
372
  const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
331
373
  if (entry.isDirectory()) {
332
374
  if (adapter.filter(fullPath)) await walk(fullPath, relPath);
333
- } else if (MEMORY_EXT.test(entry.name) && adapter.filter(fullPath)) {
334
- const doc = await readDoc(fullPath, relPath, adapter.name);
335
- if (doc) files.push(doc);
375
+ } else if (entry.isFile() && MEMORY_EXT.test(entry.name) && adapter.filter(fullPath)) {
376
+ const doc = await readDoc(fullPath, relPath, adapter.name, adapter.source, inventory);
377
+ if (doc) {
378
+ files.push(doc);
379
+ }
336
380
  }
381
+ }));
337
382
  }
338
383
  };
339
384
 
@@ -341,47 +386,57 @@ export async function readMemoryFiles(adapter) {
341
386
  return files;
342
387
  }
343
388
 
344
- // Per-project AI config files. Discovery (the directory walk) is the
345
- // expensive part and changes rarely, so the found-path list is cached for
346
- // PROJECT_INDEX_TTL_MS; the files themselves go through readDoc's mtime cache.
389
+ // Cache directory entries by metadata, not time: a newly created project
390
+ // instruction file must be visible on the next recall without a minute's wait.
347
391
  const PROJECT_FILES = ['CLAUDE.md', 'GEMINI.md', 'CHATGPT.md', 'AGENTS.md', '.cursorrules', '.windsurfrules', '.clinerules'];
348
392
  const SKIP_DIRS = new Set(['node_modules', '.git', '.next', '.vercel', 'dist', 'build', '__pycache__', '.venv', 'venv', '.cache', 'Library', '.Trash', 'Applications', 'Downloads', 'Movies', 'Music', 'Pictures']);
349
- const PROJECT_INDEX_TTL_MS = 60_000;
350
393
  const PROJECT_SCAN_DEPTH = 3;
351
- const projectIndex = { at: 0, files: [] };
394
+ const projectDirectories = new Map();
352
395
 
353
396
  async function discoverProjectFiles(root) {
354
397
  const found = [];
398
+ const visited = new Set();
355
399
  const scan = async (dir, depth) => {
356
400
  if (depth > PROJECT_SCAN_DEPTH) return;
357
401
  let entries;
358
- try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
402
+ try {
403
+ const stat = await fs.lstat(dir);
404
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return;
405
+ visited.add(dir);
406
+ const previous = projectDirectories.get(dir);
407
+ entries = previous && sameFile(previous.stat, stat) ? previous.entries : await fs.readdir(dir, { withFileTypes: true });
408
+ projectDirectories.set(dir, { stat, entries });
409
+ } catch { return; }
359
410
  const names = new Set(entries.filter((e) => e.isFile()).map((e) => e.name));
360
411
  for (const f of PROJECT_FILES) {
361
- if (names.has(f)) found.push({ abs: path.join(dir, f), rel: `${path.basename(dir)}/${f}`, project: path.basename(dir) });
412
+ if (names.has(f)) found.push({ abs: path.join(dir, f), rel: `${path.basename(dir)}/${f}`, project: dir });
362
413
  }
414
+ const children = [];
363
415
  for (const entry of entries) {
364
416
  if (!entry.isDirectory()) continue;
365
417
  if (entry.name.startsWith('.') && entry.name !== '.github') continue;
366
418
  if (SKIP_DIRS.has(entry.name)) continue;
367
- await scan(path.join(dir, entry.name), depth + 1);
419
+ children.push(path.join(dir, entry.name));
368
420
  }
421
+ for (let i = 0; i < children.length; i += 16) await Promise.all(children.slice(i, i + 16).map(child => scan(child, depth + 1)));
369
422
  };
370
423
  await scan(root, 0);
424
+ for (const dir of projectDirectories.keys()) if (!visited.has(dir)) projectDirectories.delete(dir);
371
425
  return found;
372
426
  }
373
427
 
374
428
  async function projectDocs(root = home) {
375
- const now = Date.now();
376
- if (now - projectIndex.at > PROJECT_INDEX_TTL_MS || projectIndex.root !== root) {
377
- projectIndex.files = await discoverProjectFiles(root);
378
- projectIndex.at = now;
379
- projectIndex.root = root;
380
- }
429
+ let inventory;
430
+ try { inventory = await createReadInventory(root); } catch { return []; }
431
+ const files = await discoverProjectFiles(inventory.root);
381
432
  const docs = [];
382
- for (const f of projectIndex.files) {
383
- const doc = await readDoc(f.abs, f.rel, `Project: ${f.project}`);
384
- if (doc) docs.push(doc);
433
+ for (const f of files) {
434
+ const doc = await readDoc(f.abs, f.rel, `Project: ${f.project}`, inventory.root, inventory);
435
+ if (doc) {
436
+ const projected = { ...doc, project: projectIdentity(f.project) };
437
+ sourceChecks.set(projected, sourceChecks.get(doc));
438
+ docs.push(projected);
439
+ }
385
440
  }
386
441
  return docs;
387
442
  }
@@ -448,34 +503,74 @@ export function withFrontmatterLists(content, lists = {}) {
448
503
  * Search every memory file (all adapters + per-project configs).
449
504
  * Returns ranked results with a passage each. Never throws on a bad file.
450
505
  */
451
- export async function searchMemories(query, { limit = 10, root = home } = {}) {
506
+ export async function searchMemories(query, { limit = 10, root = home, project, allProjects = false, budget = 6000, engine = 'indexed' } = {}) {
507
+ if (!['indexed', 'scan'].includes(engine)) throw new Error('Unknown retrieval engine');
452
508
  const terms = queryTerms(query);
453
509
  if (!terms.length) return { terms, results: [], total: 0 };
454
-
455
510
  const docs = [];
456
511
  for (const adapter of adapters) {
457
- try { docs.push(...(await readMemoryFiles(adapter))); } catch {}
512
+ try { docs.push(...await readMemoryFiles(adapter)); } catch {}
458
513
  }
459
- try { docs.push(...(await projectDocs(root))); } catch {}
460
-
514
+ // Normal recall needs the active project's instructions, even when its
515
+ // checkout is deeper than the old three-level home-directory crawl.
516
+ const activePath = project || process.env.MEMOIR_PROJECT_ROOT || process.cwd();
517
+ const discoveryRoot = root === home && !allProjects && !/^(git|local):[a-f0-9]{32}$/.test(activePath)
518
+ ? path.resolve(activePath.replace(/^~/, home)) : root;
519
+ try { docs.push(...await projectDocs(discoveryRoot)); } catch {}
520
+ docs.push(...await readMemoryFiles({
521
+ name: 'Memoir', source: memoryRoot,
522
+ filter: full => path.dirname(full) === memoryRoot && /^[a-f0-9]{64}\.md$/.test(path.basename(full)),
523
+ }));
524
+ const visible = memoryVisibility({ project, allProjects });
525
+ const state = await readSession();
526
+ for (const d of allDecisions(state)) {
527
+ if (!visible(d)) continue;
528
+ const doc = buildDoc({ path: 'decisions/' + (d.id || d.date || 'legacy') + '.md', tool: 'Memoir decisions', content: [d.text, d.why, d.rejected ? 'Rejected: ' + d.rejected : ''].filter(Boolean).join('\n') });
529
+ docs.push({ ...doc, id: d.id, project: d.project, type: 'decision', mtimeMs: Date.parse(d.date) || 0, source: 'session.json', evidence: { date: d.date, machine_id: d.machine_id } });
530
+ }
531
+ // Drop removed/unreadable source parses; an index never resurrects them.
532
+ const liveKeys = new Set(docs.map(doc => sourceChecks.get(doc)?.key).filter(Boolean));
533
+ for (const key of docCache.keys()) if (!liveKeys.has(key)) docCache.delete(key);
534
+ const candidates = docs.filter(visible);
535
+ lexicalIndex.sync(candidates);
536
+ const lookup = engine === 'indexed' ? lexicalIndex.lookup(terms) : null;
461
537
  const scored = [];
462
- for (const doc of docs) {
463
- const s = scoreDoc(doc, terms);
464
- if (s.score > 0) scored.push({ doc, ...s });
538
+ for (const doc of candidates) {
539
+ if (lookup && !lookup.documents.has(doc)) continue;
540
+ const score = scoreDoc(doc, terms, lookup?.matches);
541
+ if (score.score > 0) scored.push({ doc, ...score });
542
+ }
543
+ // IDF improves rare-term discrimination while preserving field/coverage
544
+ // behavior. Document frequencies use the same matching rules as retrieval.
545
+ const df = new Map(terms.map(term => [term, scored.filter(r => r.perTerm[term] > 0).length]));
546
+ for (const r of scored) {
547
+ const idf = terms.reduce((sum, term) => sum + (r.perTerm[term] > 0 ? Math.log(1 + (candidates.length - df.get(term) + 0.5) / (df.get(term) + 0.5)) : 0), 0);
548
+ r.score *= idf / Math.max(1, r.matched);
549
+ }
550
+ scored.sort((a, b) => b.score - a.score || b.doc.mtimeMs - a.doc.mtimeMs || a.doc.path.localeCompare(b.doc.path));
551
+ let remaining = Math.max(256, Math.min(16000, Number(budget) || 6000));
552
+ const top = [];
553
+ const seen = new Set();
554
+ for (const r of scored) {
555
+ if (top.length >= limit || remaining < 80) break;
556
+ const key = r.doc.id || r.doc.absPath || r.doc.path;
557
+ if (seen.has(key)) continue;
558
+ // Parent proofs used during inventory are query-local. Validate the full
559
+ // source path again before returning any cached passage to the caller.
560
+ if (!await sourceUnchanged(r.doc)) continue;
561
+ seen.add(key);
562
+ const passage = extractPassage(r.doc, terms, Math.min(PASSAGE_BUDGET, remaining));
563
+ if (!passage.trim()) continue;
564
+ remaining -= passage.length;
565
+ top.push({
566
+ id: r.doc.id, project: r.doc.project || 'shared', tool: r.doc.tool,
567
+ path: r.doc.path, type: r.doc.type, description: r.doc.description,
568
+ score: r.score, coverage: r.coverage, matched: r.matched, passage,
569
+ source: r.doc.source || r.doc.path,
570
+ matchedLines: r.doc.bodyLines.flatMap((line, i) => lineMatches(line, terms) ? [r.doc.bodyStartLine + i] : []),
571
+ updated: r.doc.updated || null,
572
+ });
465
573
  }
466
- scored.sort((a, b) => b.score - a.score || b.doc.mtimeMs - a.doc.mtimeMs);
467
-
468
- const top = scored.slice(0, limit).map((r) => ({
469
- tool: r.doc.tool,
470
- path: r.doc.path,
471
- type: r.doc.type,
472
- description: r.doc.description,
473
- score: r.score,
474
- coverage: r.coverage,
475
- matched: r.matched,
476
- passage: extractPassage(r.doc, terms),
477
- }));
478
-
479
574
  return { terms, results: top, total: scored.length };
480
575
  }
481
576
 
@@ -0,0 +1,141 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import crypto from 'crypto';
5
+ import { parseFrontmatter } from '../commands/validate.js';
6
+ import { memoryFilename, readSafeFile, writeSafeFile, listSafeFiles, safePath, MAX_FILE_BYTES } from '../security/files.js';
7
+ import { withSessionLock } from '../session/lock.js';
8
+ import { projectIdentity } from './scope.js';
9
+
10
+ export const memoryRoot = path.join(os.homedir(), '.config', 'memoir', 'memories');
11
+
12
+ export async function rememberMemory({ filename, content, project, scope = 'project', tool = 'memoir', aliases = [], tags = [] }) {
13
+ const name = memoryFilename(filename);
14
+ if (Buffer.byteLength(content) > MAX_FILE_BYTES) throw new Error('Memory exceeds the size limit');
15
+ const projectId = scope === 'shared' ? 'shared' : projectIdentity(project);
16
+ const id = crypto.createHash('sha256').update(projectId + '\0' + name).digest('hex');
17
+ return withSessionLock(path.join(memoryRoot, '.write.lock'), async () => {
18
+ const rel = id + '.md';
19
+ let prior = null;
20
+ try { prior = parseFrontmatter((await readSafeFile(memoryRoot, rel)).toString('utf8')); }
21
+ catch (err) { if (err.code !== 'ENOENT') throw err; }
22
+ if (prior?.fields.hidden === true) throw new Error('This memory identity was forgotten. Choose a new filename for a new memory.');
23
+ const parsed = parseFrontmatter(content);
24
+ if (parsed.error) throw new Error(parsed.error);
25
+ const now = new Date().toISOString();
26
+ const fields = {
27
+ ...parsed.fields,
28
+ id, name: parsed.fields.name || name.replace(/\.md$/, ''),
29
+ type: parsed.fields.type || 'fact',
30
+ project: projectId, source_tool: tool, updated: now,
31
+ created: prior?.fields.created || now,
32
+ revision: Number(prior?.fields.revision || 0) + 1,
33
+ verification: 'unverified',
34
+ aliases: [...new Set([...(Array.isArray(parsed.fields.aliases) ? parsed.fields.aliases : []), ...aliases])],
35
+ tags: [...new Set([...(Array.isArray(parsed.fields.tags) ? parsed.fields.tags : []), ...tags])],
36
+ };
37
+ const lines = ['---'];
38
+ for (const [key, value] of Object.entries(fields)) {
39
+ if (!/^[a-z][a-z0-9_]*$/i.test(key)) continue;
40
+ if (Array.isArray(value)) lines.push(key + ':', ...value.map(v => ' - ' + JSON.stringify(String(v))));
41
+ else if (value != null && typeof value !== 'object') lines.push(key + ': ' + JSON.stringify(value));
42
+ }
43
+ const rendered = lines.concat(['---', parsed.body]).join('\n');
44
+ // Keep previous revisions separate from the searchable current record.
45
+ if (prior) await writeSafeFile(memoryRoot, 'history/' + id + '/' + crypto.randomUUID() + '.md', await readSafeFile(memoryRoot, rel));
46
+ await writeSafeFile(memoryRoot, rel, rendered);
47
+ return { id, revision: fields.revision, project: projectId, path: rel };
48
+ });
49
+ }
50
+
51
+ export async function readStoredMemories() {
52
+ if (!await fs.pathExists(memoryRoot)) return [];
53
+ const docs = [];
54
+ for (const entry of await fs.readdir(memoryRoot, { withFileTypes: true })) {
55
+ if (!entry.isFile() || !/^[a-f0-9]{64}\.md$/.test(entry.name)) continue;
56
+ docs.push({ path: entry.name, absPath: path.join(memoryRoot, entry.name), content: (await readSafeFile(memoryRoot, entry.name)).toString('utf8'), tool: 'Memoir' });
57
+ }
58
+ return docs;
59
+ }
60
+
61
+ export async function stageMemories(dest) {
62
+ if (!await fs.pathExists(memoryRoot)) return 0;
63
+ let count = 0;
64
+ for (const rel of await listSafeFiles(memoryRoot)) {
65
+ if (rel.endsWith('.lock') || rel.includes('.memoir-write-')) continue;
66
+ await writeSafeFile(dest, 'memoir-memories/' + rel, await readSafeFile(memoryRoot, rel));
67
+ count++;
68
+ }
69
+ return count;
70
+ }
71
+
72
+ export async function restoreStoredMemories(source) {
73
+ const dir = path.join(source, 'memoir-memories');
74
+ if (!await fs.pathExists(dir)) return 0;
75
+ return withSessionLock(path.join(memoryRoot, '.write.lock'), async () => {
76
+ const entries = [], purged = new Set();
77
+ // Parse and validate the entire incoming set before modifying records.
78
+ for (const rel of await listSafeFiles(dir)) {
79
+ if (!/^(?:[a-f0-9]{64}\.md|history\/[a-f0-9]{64}\/[a-f0-9-]+\.md)$/.test(rel)) throw new Error('Invalid canonical memory path');
80
+ const id = rel.startsWith('history/') ? rel.split('/')[1] : rel.slice(0, -3);
81
+ const incoming = await readSafeFile(dir, rel);
82
+ const parsed = parseFrontmatter(incoming.toString());
83
+ if (parsed.error || parsed.fields.id !== id || !/^(?:shared|(?:git|local):[a-f0-9]{32})$/.test(parsed.fields.project || '')) throw new Error('Invalid canonical memory record');
84
+ await safePath(memoryRoot, rel);
85
+ let local;
86
+ try { local = await readSafeFile(memoryRoot, rel); } catch (err) { if (err.code !== 'ENOENT') throw err; }
87
+ if (!rel.startsWith('history/') && (parsed.fields.purged === true || (local && parseFrontmatter(local.toString()).fields.purged === true))) purged.add(id);
88
+ entries.push({ rel, id, incoming, parsed, local });
89
+ }
90
+ // A stale backup might contain history without its current record.
91
+ for (const entry of await readStoredMemories()) {
92
+ const fields = parseFrontmatter(entry.content).fields;
93
+ if (fields.purged === true) purged.add(fields.id);
94
+ }
95
+ let count = 0;
96
+ for (const { rel, id, incoming, parsed, local } of entries) {
97
+ if (rel.startsWith('history/') && purged.has(id)) continue;
98
+ if (local && !local.equals(incoming) && !rel.startsWith('history/')) {
99
+ const a = parseFrontmatter(local.toString()).fields, b = parsed.fields;
100
+ const rank = fields => fields.purged === true ? 2 : fields.hidden === true ? 1 : 0;
101
+ const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
102
+ const incomingWins = rank(b) > rank(a) || (rank(b) === rank(a) &&
103
+ (String(b.updated || '') > String(a.updated || '') ||
104
+ (String(b.updated || '') === String(a.updated || '') && digest(incoming) > digest(local))));
105
+ if (!purged.has(id)) await writeSafeFile(memoryRoot, 'history/' + id + '/' + crypto.randomUUID() + '.md', incomingWins ? local : incoming);
106
+ if (!incomingWins) continue;
107
+ }
108
+ await writeSafeFile(memoryRoot, rel, incoming);
109
+ count++;
110
+ }
111
+ for (const id of purged) await fs.remove(path.join(memoryRoot, 'history', id));
112
+ return count;
113
+ });
114
+ }
115
+
116
+ // Hiding is durable and syncable. Purge affects this device's current record
117
+ // and its local revision history, not older remote snapshots or Git history.
118
+ export async function forgetStoredMemory(id, { purge = false, project } = {}) {
119
+ if (!/^[a-f0-9]{64}$/.test(id)) throw new Error('Invalid memory ID');
120
+ return withSessionLock(path.join(memoryRoot, '.write.lock'), async () => {
121
+ const rel = id + '.md';
122
+ const raw = await readSafeFile(memoryRoot, rel);
123
+ const { fields, body } = parseFrontmatter(raw.toString());
124
+ const { visibleMemory } = await import('./scope.js');
125
+ if (!visibleMemory(fields, { project })) throw new Error('Memory is hidden or outside this project');
126
+ const metadata = purge ? { id, project: fields.project, type: fields.type || 'fact' } : fields;
127
+ metadata.hidden = true;
128
+ if (purge) metadata.purged = true;
129
+ metadata.hidden_at = new Date().toISOString();
130
+ metadata.updated = metadata.hidden_at;
131
+ metadata.revision = Number(fields.revision || 0) + 1;
132
+ const lines = ['---'];
133
+ for (const [key, value] of Object.entries(metadata)) {
134
+ if (Array.isArray(value)) lines.push(key + ':', ...value.map(v => ' - ' + JSON.stringify(v)));
135
+ else if (value != null && typeof value !== 'object') lines.push(key + ': ' + JSON.stringify(value));
136
+ }
137
+ await writeSafeFile(memoryRoot, rel, lines.concat(['---', purge ? '[purged]' : body]).join('\n'));
138
+ if (purge) await fs.remove(path.join(memoryRoot, 'history', id));
139
+ return { id, hidden: true, purged: purge };
140
+ });
141
+ }