memoir-cli 3.11.3 → 3.12.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.
@@ -0,0 +1,503 @@
1
+ // Memory retrieval — the read side of memoir.
2
+ //
3
+ // Before 3.12 `memoir_recall` was `content.toLowerCase().includes(term)`
4
+ // per term, ranked by how many terms hit, and it returned the FIRST 500
5
+ // characters of each file. For a spec-shaped entry that is ~440 chars of
6
+ // YAML frontmatter and one truncated line of prose: the model asked a
7
+ // question, got ten headers back, and learned nothing. It also re-crawled
8
+ // ~1,000 directories under $HOME on every call to find project CLAUDE.md
9
+ // files (measured: 6,462 stat probes / call on a real machine).
10
+ //
11
+ // This module fixes the read path without adding a dependency:
12
+ // • matched PASSAGES, frontmatter stripped, with a line of context;
13
+ // • field-weighted scoring — aliases > name > description > headings >
14
+ // body — with per-term saturation and a coverage multiplier so a file
15
+ // that mentions all three query terms outranks one that hammers one;
16
+ // • light morphology: plural/-ing/-ed folding plus prefix matching from
17
+ // 4 chars, so "auth" finds "authentication" and "deploys" finds
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.
21
+ //
22
+ // What it does NOT do: semantic/concept matching. "tiktok" still won't
23
+ // find a file that only ever says "vertical swipe feed". The honest,
24
+ // dependency-free answer to that is the `aliases:` frontmatter field
25
+ // (SPEC.md 3.2) written at save time and weighted heaviest here — the
26
+ // model that saves a memory knows what else it might be called.
27
+
28
+ import fs from 'fs-extra';
29
+ import path from 'path';
30
+ import os from 'os';
31
+ import { adapters } from '../adapters/index.js';
32
+ import { parseFrontmatter } from '../commands/validate.js';
33
+
34
+ const home = os.homedir();
35
+
36
+ // ── Tokenizing ───────────────────────────────────────────────────
37
+
38
+ const STOPWORDS = new Set([
39
+ 'the', 'a', 'an', 'of', 'to', 'in', 'on', 'for', 'and', 'or', 'is', 'it',
40
+ 'this', 'that', 'what', 'how', 'do', 'does', 'did', 'we', 'i', 'my', 'our',
41
+ 'with', 'about', 'was', 'were', 'be', 'are', 'at', 'by', 'from', 'as',
42
+ 'into', 'up', 'out', 'so', 'if', 'not', 'no', 'me', 'you', 'your', 'us',
43
+ 'why', 'when', 'where', 'which', 'who', 'can', 'should', 'would', 'could',
44
+ 'have', 'has', 'had', 'been', 'being', 'there', 'here', 'than', 'then',
45
+ ]);
46
+
47
+ // Minimum length at which a token may match another by prefix. Below this
48
+ // "in" would match "index" and "at" would match "attribution".
49
+ const PREFIX_MIN = 4;
50
+
51
+ 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);
58
+ }
59
+
60
+ // Fold the commonest English inflections. Conservative on purpose: every
61
+ // rule keeps a stem of at least 4 characters so short words are untouched
62
+ // ("was" -> "was", not "wa"), and -ing/-ed only strip from words long
63
+ // enough that the residue is still a word ("deploying" -> "deploy",
64
+ // "ring" -> "ring").
65
+ export function normalize(token) {
66
+ let t = token;
67
+ if (t.length >= 5 && t.endsWith('ies')) t = t.slice(0, -3) + 'y';
68
+ else if (t.length >= 6 && t.endsWith('sses')) t = t.slice(0, -2);
69
+ else if (t.length >= 4 && t.endsWith('s') && !t.endsWith('ss')) t = t.slice(0, -1);
70
+ if (t.length >= 7 && t.endsWith('ing')) t = t.slice(0, -3);
71
+ else if (t.length >= 6 && t.endsWith('ed')) t = t.slice(0, -2);
72
+ return t;
73
+ }
74
+
75
+ // Does a normalized query term match a normalized document token?
76
+ // Returns 1 for an exact match, PREFIX_WEIGHT for a prefix match, 0 otherwise.
77
+ const PREFIX_WEIGHT = 0.6;
78
+ export function termMatch(term, token) {
79
+ if (term === token) return 1;
80
+ if (term.length >= PREFIX_MIN && token.startsWith(term)) return PREFIX_WEIGHT;
81
+ if (token.length >= PREFIX_MIN && term.startsWith(token)) return PREFIX_WEIGHT;
82
+ return 0;
83
+ }
84
+
85
+ export function queryTerms(query) {
86
+ const raw = tokenize(query);
87
+ const kept = raw.filter((t) => !STOPWORDS.has(t));
88
+ // If the whole query was stopwords ("what is it"), search on it anyway
89
+ // rather than returning nothing.
90
+ const terms = (kept.length ? kept : raw).map(normalize);
91
+ return Array.from(new Set(terms));
92
+ }
93
+
94
+ // ── Document model ───────────────────────────────────────────────
95
+
96
+ // Field weights. Aliases win because they exist for exactly one reason —
97
+ // they are the other names someone might search under. Then the entry's
98
+ // own name, its one-line description, headings, and finally prose.
99
+ const W = { aliases: 6, name: 4, description: 3, headings: 2, body: 1 };
100
+ // Body term-frequency saturates: 1 hit = 1.0, 3 = 1.5, 7 = 2.0. A file that
101
+ // says "deploy" forty times is not forty times more relevant.
102
+ const BODY_TF_CAP = 2;
103
+
104
+ function listField(v) {
105
+ if (Array.isArray(v)) return v.map(String);
106
+ if (typeof v === 'string' && v.trim()) return v.split(',').map((s) => s.trim()).filter(Boolean);
107
+ return [];
108
+ }
109
+
110
+ function fieldTokens(strings) {
111
+ const out = new Map(); // normalized token -> count
112
+ for (const s of strings) {
113
+ for (const t of tokenize(s)) {
114
+ const n = normalize(t);
115
+ out.set(n, (out.get(n) || 0) + 1);
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * Parse a raw memory file into the shape the scorer wants. Cheap enough to
123
+ * run on every file, but cached by (path, mtime, size) in readMemoryFiles.
124
+ */
125
+ 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 };
128
+ const bodyLines = body.split(/\r?\n/);
129
+ const headings = bodyLines.filter((l) => /^\s{0,3}#{1,6}\s/.test(l));
130
+
131
+ const nameStrings = [path.basename(relPath).replace(/\.[^.]+$/, '')];
132
+ if (fields.name) nameStrings.push(String(fields.name));
133
+
134
+ return {
135
+ path: relPath,
136
+ absPath,
137
+ tool,
138
+ mtimeMs: mtimeMs || 0,
139
+ isMarkdown,
140
+ type: fields.type || fields.metadata?.type || null,
141
+ description: fields.description ? String(fields.description) : '',
142
+ aliases: listField(fields.aliases),
143
+ tags: listField(fields.tags),
144
+ body,
145
+ bodyLines,
146
+ // Token maps per field
147
+ tf: {
148
+ aliases: fieldTokens([...listField(fields.aliases), ...listField(fields.tags)]),
149
+ name: fieldTokens(nameStrings),
150
+ description: fieldTokens([fields.description || '']),
151
+ headings: fieldTokens(headings),
152
+ body: fieldTokens(bodyLines),
153
+ },
154
+ // Kept for legacy callers (memoir_list sizes, memoir_read) — the raw file.
155
+ content,
156
+ };
157
+ }
158
+
159
+ // ── Scoring ──────────────────────────────────────────────────────
160
+
161
+ function fieldScore(tfMap, term) {
162
+ // Best match across the field's tokens: exact beats prefix; tf saturates.
163
+ let best = 0;
164
+ let count = 0;
165
+ for (const [token, n] of tfMap) {
166
+ const m = termMatch(term, token);
167
+ if (m > best) { best = m; count = n; }
168
+ else if (m === best && m > 0) count += n;
169
+ }
170
+ if (!best) return 0;
171
+ const tf = 0.5 + 0.5 * Math.log2(1 + count);
172
+ return best * Math.min(BODY_TF_CAP, tf);
173
+ }
174
+
175
+ // A single term's evidence in one document is its best field hit plus a
176
+ // quarter of the rest, capped. Without the cap, a term that appears in the
177
+ // alias list AND the name AND the description AND a heading (which is
178
+ // exactly what a well-formed entry looks like) stacked to ~16 and let a
179
+ // one-term match outrank a file that covered every term in the query.
180
+ const TERM_CAP = 8;
181
+
182
+ export function scoreDoc(doc, terms) {
183
+ let sum = 0;
184
+ let matched = 0;
185
+ const perTerm = {};
186
+ 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);
188
+ const s = Math.min(TERM_CAP, fieldScores[0] + 0.25 * fieldScores.slice(1).reduce((x, y) => x + y, 0));
189
+ if (s > 0) matched++;
190
+ perTerm[term] = s;
191
+ sum += s;
192
+ }
193
+ if (!matched) return { score: 0, matched: 0, perTerm };
194
+ const coverage = matched / terms.length;
195
+ // Coverage-squared: a file that covers all the terms beats a file that
196
+ // covers a third of them unless the partial match is overwhelming
197
+ // (2/3 coverage keeps 44% of its raw score, 1/3 keeps 11%). This is the
198
+ // "over-recall" fix — a common word no longer drags in every file that
199
+ // mentions it once. Non-markdown (settings.json etc.) is de-weighted:
200
+ // it is config, not memory, and matches on it are usually noise.
201
+ const score = sum * coverage * coverage * (doc.isMarkdown ? 1 : 0.5);
202
+ return { score, matched, coverage, perTerm };
203
+ }
204
+
205
+ // ── Passage extraction ───────────────────────────────────────────
206
+
207
+ const PASSAGE_BUDGET = 700; // chars per result
208
+ const CONTEXT_LINES = 1; // lines of context either side of a hit
209
+ const MAX_LINE = 240; // clamp very long lines (pasted JSON, tables)
210
+
211
+ function lineMatches(line, terms) {
212
+ const toks = tokenize(line).map(normalize);
213
+ if (!toks.length) return 0;
214
+ let hits = 0;
215
+ for (const term of terms) {
216
+ if (toks.some((t) => termMatch(term, t) > 0)) hits++;
217
+ }
218
+ return hits;
219
+ }
220
+
221
+ function clampLine(l) {
222
+ const s = l.replace(/\s+$/, '');
223
+ return s.length > MAX_LINE ? s.slice(0, MAX_LINE - 1) + '…' : s;
224
+ }
225
+
226
+ /**
227
+ * The passage(s) of a document that best answer the query: matched lines
228
+ * with a line of context either side, best windows first, within a byte
229
+ * budget. Frontmatter is never included — callers get the description
230
+ * separately for the header line.
231
+ */
232
+ export function extractPassage(doc, terms, budget = PASSAGE_BUDGET) {
233
+ const lines = doc.bodyLines;
234
+ const hits = [];
235
+ for (let i = 0; i < lines.length; i++) {
236
+ const l = lines[i];
237
+ if (!l.trim()) continue;
238
+ const h = lineMatches(l, terms);
239
+ if (h) hits.push({ i, h });
240
+ }
241
+
242
+ if (!hits.length) {
243
+ // Match lived in frontmatter only (name/description/aliases). Show the
244
+ // opening of the body so the reader still gets substance.
245
+ const opening = lines.filter((l) => l.trim()).slice(0, 6).map(clampLine).join('\n');
246
+ return opening.slice(0, budget);
247
+ }
248
+
249
+ // Merge hits into windows [start, end] with context, then rank windows by
250
+ // (distinct terms matched desc, position asc).
251
+ const windows = [];
252
+ for (const { i, h } of hits) {
253
+ const start = Math.max(0, i - CONTEXT_LINES);
254
+ const end = Math.min(lines.length - 1, i + CONTEXT_LINES);
255
+ const last = windows[windows.length - 1];
256
+ if (last && start <= last.end + 1) {
257
+ last.end = Math.max(last.end, end);
258
+ last.h = Math.max(last.h, h);
259
+ last.hits++;
260
+ } else {
261
+ windows.push({ start, end, h, hits: 1 });
262
+ }
263
+ }
264
+ windows.sort((a, b) => b.h - a.h || b.hits - a.hits || a.start - b.start);
265
+
266
+ const chosen = [];
267
+ let used = 0;
268
+ for (const w of windows) {
269
+ const text = lines.slice(w.start, w.end + 1).filter((l) => l.trim()).map(clampLine).join('\n');
270
+ if (!text) continue;
271
+ if (used && used + text.length > budget) continue;
272
+ chosen.push({ ...w, text: used + text.length > budget ? text.slice(0, budget - used - 1) + '…' : text });
273
+ used += text.length + 2;
274
+ if (used >= budget) break;
275
+ }
276
+ // Present in document order so the excerpt reads naturally.
277
+ chosen.sort((a, b) => a.start - b.start);
278
+ return chosen.map((c) => c.text).join('\n⋯\n');
279
+ }
280
+
281
+ // ── Reading + caching ────────────────────────────────────────────
282
+
283
+ // (absPath) -> { mtimeMs, size, doc }. The MCP server is a long-lived
284
+ // process; memory files change rarely relative to how often recall runs.
285
+ 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;
292
+ let content;
293
+ try { content = await fs.readFile(absPath, 'utf8'); } catch { return null; }
294
+ const doc = buildDoc({ path: relPath, content, tool, absPath, mtimeMs: st.mtimeMs });
295
+ docCache.set(absPath, { mtimeMs: st.mtimeMs, size: st.size, doc });
296
+ return doc;
297
+ }
298
+
299
+ /** Test hook — drop every cached parse. */
300
+ export function clearSearchCache() {
301
+ docCache.clear();
302
+ projectIndex.at = 0;
303
+ projectIndex.files = [];
304
+ }
305
+
306
+ const MEMORY_EXT = /\.(md|json|ya?ml)$/i;
307
+
308
+ /**
309
+ * Read every memory file an adapter owns, as parsed docs. Cached by mtime.
310
+ */
311
+ export async function readMemoryFiles(adapter) {
312
+ const files = [];
313
+
314
+ if (adapter.customExtract) {
315
+ for (const file of adapter.files) {
316
+ const abs = path.join(adapter.source, file);
317
+ const doc = await readDoc(abs, file, adapter.name);
318
+ if (doc) files.push(doc);
319
+ }
320
+ return files;
321
+ }
322
+
323
+ if (!(await fs.pathExists(adapter.source))) return files;
324
+
325
+ const walk = async (dir, prefix = '') => {
326
+ let entries;
327
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
328
+ for (const entry of entries) {
329
+ const fullPath = path.join(dir, entry.name);
330
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
331
+ if (entry.isDirectory()) {
332
+ 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);
336
+ }
337
+ }
338
+ };
339
+
340
+ await walk(adapter.source);
341
+ return files;
342
+ }
343
+
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.
347
+ const PROJECT_FILES = ['CLAUDE.md', 'GEMINI.md', 'CHATGPT.md', 'AGENTS.md', '.cursorrules', '.windsurfrules', '.clinerules'];
348
+ 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
+ const PROJECT_SCAN_DEPTH = 3;
351
+ const projectIndex = { at: 0, files: [] };
352
+
353
+ async function discoverProjectFiles(root) {
354
+ const found = [];
355
+ const scan = async (dir, depth) => {
356
+ if (depth > PROJECT_SCAN_DEPTH) return;
357
+ let entries;
358
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
359
+ const names = new Set(entries.filter((e) => e.isFile()).map((e) => e.name));
360
+ 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) });
362
+ }
363
+ for (const entry of entries) {
364
+ if (!entry.isDirectory()) continue;
365
+ if (entry.name.startsWith('.') && entry.name !== '.github') continue;
366
+ if (SKIP_DIRS.has(entry.name)) continue;
367
+ await scan(path.join(dir, entry.name), depth + 1);
368
+ }
369
+ };
370
+ await scan(root, 0);
371
+ return found;
372
+ }
373
+
374
+ 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
+ }
381
+ 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);
385
+ }
386
+ return docs;
387
+ }
388
+
389
+ // ── Write-side helper: aliases/tags into frontmatter ─────────────
390
+
391
+ /**
392
+ * Merge `aliases` / `tags` lists into a markdown entry's frontmatter.
393
+ * If the content has frontmatter, lists are added (or extended, deduped)
394
+ * in the SPEC.md 3.1 `- item` shape. If it has none, a minimal block is
395
+ * created — the entry becomes findable without forcing the caller to
396
+ * author YAML. Existing unrelated fields are preserved verbatim.
397
+ */
398
+ export function withFrontmatterLists(content, lists = {}) {
399
+ const wanted = {};
400
+ for (const [k, v] of Object.entries(lists)) {
401
+ const arr = Array.isArray(v) ? v.map((s) => String(s).trim()).filter(Boolean) : [];
402
+ if (arr.length) wanted[k] = arr;
403
+ }
404
+ if (!Object.keys(wanted).length) return content;
405
+
406
+ const src = String(content || '');
407
+ const lines = src.split(/\r?\n/);
408
+ const hasFm = (lines[0] || '').trim() === '---' && lines.slice(1).some((l) => l.trim() === '---');
409
+
410
+ const renderList = (key, arr) => [`${key}:`, ...arr.map((a) => ` - ${JSON.stringify(a)}`)];
411
+
412
+ if (!hasFm) {
413
+ const fm = ['---'];
414
+ for (const [k, arr] of Object.entries(wanted)) fm.push(...renderList(k, arr));
415
+ fm.push('---', '');
416
+ return fm.join('\n') + src;
417
+ }
418
+
419
+ const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
420
+ const head = lines.slice(1, end);
421
+ const { fields } = parseFrontmatter(src);
422
+ const out = [];
423
+ const done = new Set();
424
+ for (let i = 0; i < head.length; i++) {
425
+ const line = head[i];
426
+ const m = line.match(/^([^:\s][^:]*):\s*(.*)$/);
427
+ const key = m ? m[1].trim() : null;
428
+ if (key && wanted[key]) {
429
+ // Replace this key (and any nested/list lines under it) with the merged list.
430
+ const existing = listField(fields[key]);
431
+ const merged = Array.from(new Set([...existing, ...wanted[key]].map((s) => s.trim()).filter(Boolean)));
432
+ out.push(...renderList(key, merged));
433
+ done.add(key);
434
+ while (i + 1 < head.length && /^\s/.test(head[i + 1]) && head[i + 1].trim()) i++;
435
+ continue;
436
+ }
437
+ out.push(line);
438
+ }
439
+ for (const [k, arr] of Object.entries(wanted)) {
440
+ if (!done.has(k)) out.push(...renderList(k, arr));
441
+ }
442
+ return ['---', ...out, '---', ...lines.slice(end + 1)].join('\n');
443
+ }
444
+
445
+ // ── Search ───────────────────────────────────────────────────────
446
+
447
+ /**
448
+ * Search every memory file (all adapters + per-project configs).
449
+ * Returns ranked results with a passage each. Never throws on a bad file.
450
+ */
451
+ export async function searchMemories(query, { limit = 10, root = home } = {}) {
452
+ const terms = queryTerms(query);
453
+ if (!terms.length) return { terms, results: [], total: 0 };
454
+
455
+ const docs = [];
456
+ for (const adapter of adapters) {
457
+ try { docs.push(...(await readMemoryFiles(adapter))); } catch {}
458
+ }
459
+ try { docs.push(...(await projectDocs(root))); } catch {}
460
+
461
+ const scored = [];
462
+ for (const doc of docs) {
463
+ const s = scoreDoc(doc, terms);
464
+ if (s.score > 0) scored.push({ doc, ...s });
465
+ }
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
+ return { terms, results: top, total: scored.length };
480
+ }
481
+
482
+ /**
483
+ * Plain-text rendering shared by the MCP tool and `memoir recall`.
484
+ */
485
+ export function formatRecallResults(query, { terms, results, total }) {
486
+ if (!results.length) {
487
+ return `No memories found matching "${query}"${terms.length ? ` (terms: ${terms.join(', ')})` : ''}.`;
488
+ }
489
+ const blocks = results.map((r, i) => {
490
+ const meta = [r.type, r.description].filter(Boolean).join(' · ');
491
+ const cov = r.matched < terms.length ? ` · ${r.matched}/${terms.length} terms` : '';
492
+ return [
493
+ `── ${i + 1}. ${r.tool} / ${r.path}${cov} ──`,
494
+ meta ? ` ${meta}` : null,
495
+ r.passage,
496
+ ].filter(Boolean).join('\n');
497
+ });
498
+ const shown = results.length;
499
+ const head = total > shown
500
+ ? `Found ${total} memories matching "${query}" — showing the top ${shown}. Use memoir_read for a full file.`
501
+ : `Found ${total} memor${total === 1 ? 'y' : 'ies'} matching "${query}":`;
502
+ return `${head}\n\n${blocks.join('\n\n')}`;
503
+ }
@@ -40,6 +40,43 @@ const MAX_QUESTIONS = 5;
40
40
  const MAX_DECISIONS_RECENT = 10;
41
41
  const MAX_HISTORY = 30;
42
42
 
43
+ // ── Decision identity ────────────────────────────────────────────
44
+ //
45
+ // SPEC.md 5.1: a decision's identity is its normalized text. A PURGED
46
+ // tombstone (memoir forget --purge) has had that text redacted, so it
47
+ // carries `text_hash` = sha256(identity) instead and matches by hash.
48
+ // Both forms resolve to the same key here so unionByText/capDecisions
49
+ // treat "the original" and "the purged tombstone of the original" as one
50
+ // identity — that is what lets the tombstone keep suppressing copies of
51
+ // the un-purged text on replicas that never saw the purge.
52
+ export const PURGED_TEXT = '[purged]';
53
+
54
+ export function decisionIdentity(text) {
55
+ return String(text || '').trim().toLowerCase();
56
+ }
57
+
58
+ export function decisionHash(text) {
59
+ return crypto.createHash('sha256').update(decisionIdentity(text)).digest('hex');
60
+ }
61
+
62
+ function decisionKey(item) {
63
+ if (!item) return null;
64
+ if (item.text_hash) return `sha256:${item.text_hash}`;
65
+ if (!item.text) return null;
66
+ return `sha256:${decisionHash(item.text)}`;
67
+ }
68
+
69
+ // Cap decisions WITHOUT evicting tombstones. A plain `slice(0, cap)` after
70
+ // an unshift meant the 11th note pushed the oldest hidden decision off the
71
+ // list — and a dropped tombstone is a resurrection waiting for the next
72
+ // merge with any replica still holding the un-hidden copy. Tombstones and
73
+ // visible entries get separate budgets, same as unionByText below.
74
+ function capDecisions(list = [], cap = MAX_DECISIONS_RECENT) {
75
+ const visible = list.filter((d) => d && !d.hidden).slice(0, cap);
76
+ const tombstones = list.filter((d) => d && d.hidden).slice(0, cap);
77
+ return [...visible, ...tombstones];
78
+ }
79
+
43
80
  // ── Machine identity ─────────────────────────────────────────────
44
81
 
45
82
  // Stable per-machine identifier. Persisted once, reused forever.
@@ -263,7 +300,7 @@ export async function addNote(text, opts = {}) {
263
300
  if (opts.why) decision.why = opts.why;
264
301
  if (opts.rejected) decision.rejected = opts.rejected;
265
302
  state.current.decisions.unshift(decision);
266
- state.current.decisions = state.current.decisions.slice(0, MAX_DECISIONS_RECENT);
303
+ state.current.decisions = capDecisions(state.current.decisions);
267
304
  await writeSession(state);
268
305
  // Count/booleans only — never the decision text itself.
269
306
  await appendEvent('decision_captured', { has_why: !!opts.why, has_rejected: !!opts.rejected });
@@ -271,6 +308,63 @@ export async function addNote(text, opts = {}) {
271
308
  });
272
309
  }
273
310
 
311
+ /**
312
+ * Find visible decisions matching a query — substring on text/why/rejected,
313
+ * or an exact identity match. Pure; shared by `memoir forget` and the
314
+ * memoir_forget MCP tool so both agree on what "matches" means.
315
+ */
316
+ export function matchDecisions(state, query) {
317
+ const q = decisionIdentity(query);
318
+ if (!q) return [];
319
+ const decisions = (state.current?.decisions || []).filter((d) => d && d.text && !d.hidden);
320
+ const exact = decisions.filter((d) => decisionIdentity(d.text) === q);
321
+ if (exact.length) return exact;
322
+ return decisions.filter((d) =>
323
+ [d.text, d.why, d.rejected].filter(Boolean).join(' ').toLowerCase().includes(q)
324
+ );
325
+ }
326
+
327
+ /**
328
+ * Forget a decision: set the SPEC.md 5.3.1 absolute tombstone
329
+ * (`hidden: true` + `hidden_at`) on the decision whose identity is `text`.
330
+ *
331
+ * With `purge`, the text/why/rejected are also redacted in place and the
332
+ * entry keeps only `text_hash` as its identity — for when the thing to
333
+ * forget is a leaked secret and hiding it from render is not enough. The
334
+ * hash still lets the tombstone suppress un-purged copies on other
335
+ * replicas at merge time (see unionByText).
336
+ *
337
+ * Deliberately NOT a delete: removal does not survive union-merge (the
338
+ * exact bug 3.10.2 fixed for next_actions). And there is no un-forget —
339
+ * `hidden` is monotonic by spec, which is why the CLI confirms first.
340
+ */
341
+ export async function hideDecision(text, { purge = false } = {}) {
342
+ return withSessionLock(SESSION_LOCK_PATH, async () => {
343
+ const state = await readSession();
344
+ await touchMachine(state);
345
+ const key = decisionIdentity(text);
346
+ const idx = (state.current.decisions || []).findIndex(
347
+ (d) => d && d.text && !d.hidden && decisionIdentity(d.text) === key
348
+ );
349
+ if (idx < 0) return { state, hidden: false };
350
+
351
+ const now = new Date().toISOString();
352
+ const d = state.current.decisions[idx];
353
+ const tomb = { ...d, hidden: true, hidden_at: now };
354
+ if (purge) {
355
+ tomb.text_hash = decisionHash(d.text);
356
+ tomb.text = PURGED_TEXT;
357
+ delete tomb.why;
358
+ delete tomb.rejected;
359
+ }
360
+ state.current.decisions[idx] = tomb;
361
+ state.current.decisions = capDecisions(state.current.decisions);
362
+ await writeSession(state);
363
+ await appendEvent('decision_hidden', { purged: !!purge });
364
+ return { state, hidden: true, purged: !!purge };
365
+ });
366
+ }
367
+
274
368
  export async function addQuestion(text) {
275
369
  return withSessionLock(SESSION_LOCK_PATH, async () => {
276
370
  const state = await readSession();
@@ -360,9 +454,14 @@ export function mergeSessions(local, remote) {
360
454
 
361
455
  function unionByText(a = [], b = [], dateField, cap) {
362
456
  const byText = new Map();
457
+ // Identity is normalized text (SPEC 5.1). Keyed through decisionKey so a
458
+ // PURGED decision tombstone — text redacted, `text_hash` kept — lands on
459
+ // the same key as the un-purged copies it must keep suppressing. For
460
+ // goals/next_actions/questions (no purge concept) this is just a hash of
461
+ // the same normalized text and behaves exactly as before.
363
462
  for (const item of [...a, ...b]) {
364
- if (!item || !item.text) continue;
365
- const key = item.text.trim().toLowerCase();
463
+ const key = decisionKey(item);
464
+ if (!key) continue;
366
465
  const existing = byText.get(key);
367
466
  if (!existing || new Date(item[dateField] || 0) > new Date(existing[dateField] || 0)) {
368
467
  byText.set(key, item);
@@ -379,12 +478,17 @@ function unionByText(a = [], b = [], dateField, cap) {
379
478
  // the tombstoned copy doesn't even win the date comparison.) Suppression has
380
479
  // to be monotonic or it isn't suppression — you'd be re-hiding the same junk
381
480
  // on every machine forever.
481
+ //
482
+ // A PURGED tombstone wins outright — never let a date-winning un-purged
483
+ // copy carry the redacted text back into the merged result. Purge is
484
+ // "this text must leave the file"; the merged entry must be the purged one.
382
485
  for (const [key, winner] of byText) {
383
- if (winner.hidden) continue;
384
- const tombstone = [...a, ...b].find(
385
- (i) => i && i.text && i.text.trim().toLowerCase() === key && i.hidden
386
- );
387
- if (tombstone) {
486
+ const stones = [...a, ...b].filter((i) => i && i.hidden && decisionKey(i) === key);
487
+ if (!stones.length) continue;
488
+ const tombstone = stones.find((i) => i.text_hash) || stones[0];
489
+ if (tombstone.text_hash) {
490
+ byText.set(key, tombstone);
491
+ } else if (!winner.hidden) {
388
492
  byText.set(key, { ...winner, hidden: true, hidden_at: tombstone.hidden_at });
389
493
  }
390
494
  }