memoir-cli 3.11.3 → 3.14.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 +129 -124
- package/bin/memoir-work.js +9 -0
- package/bin/memoir.js +72 -8
- package/docs/AUDIT-REMEDIATION.md +55 -0
- package/docs/CASE_TAPE_AMNESIA.md +39 -0
- package/docs/HANDOFF-SECURITY-AUDIT.md +106 -0
- package/docs/LOCAL-HANDOFF-VALIDATION.md +129 -0
- package/docs/MCP-V2-MIGRATION.md +17 -0
- package/docs/PROJECT-HANDOFF.md +255 -0
- package/docs/PROJECT-VIEW-DEBUG.md +66 -0
- package/docs/PROJECT-VIEW-VALIDATION.md +136 -0
- package/docs/RELEASE-3.14-VALIDATION.md +36 -0
- package/docs/RELIABILITY-ROLLOUT.md +57 -0
- package/docs/RETRIEVAL-INDEX.md +45 -0
- package/docs/RETRIEVAL-RESULTS.md +26 -0
- package/docs/SPEC.md +684 -0
- package/evals/CONTINUITY-PROTOCOL.md +45 -0
- package/evals/cases.json +200 -0
- package/evals/results/retrieval-2026-09-05.json +5333 -0
- package/evals/retrieval-performance.mjs +99 -0
- package/evals/run.mjs +87 -0
- package/package.json +13 -5
- package/src/adapters/index.js +13 -6
- package/src/adapters/restore.js +83 -36
- package/src/cloud/auth.js +12 -15
- package/src/cloud/constants.js +6 -2
- package/src/cloud/storage.js +130 -93
- package/src/commands/activate.js +43 -9
- package/src/commands/cloud.js +56 -5
- package/src/commands/consolidate.js +49 -10
- package/src/commands/diff.js +2 -2
- package/src/commands/doctor.js +3 -3
- package/src/commands/forget.js +100 -0
- package/src/commands/push.js +164 -161
- package/src/commands/recall.js +42 -0
- package/src/commands/restore.js +32 -44
- package/src/commands/resume.js +15 -164
- package/src/commands/session.js +51 -9
- package/src/commands/snapshot.js +6 -7
- package/src/commands/status.js +23 -1
- package/src/commands/upgrade.js +13 -11
- package/src/commands/validate.js +16 -0
- package/src/commands/view.js +2 -2
- package/src/commands/why.js +4 -3
- package/src/config.js +9 -40
- package/src/context/capture.js +135 -33
- package/src/context/handoffs.js +72 -0
- package/src/events/summary.js +122 -0
- package/src/integrations/setup.js +88 -0
- package/src/mcp.js +151 -283
- package/src/memory/lexical-index.js +65 -0
- package/src/memory/repository.js +16 -0
- package/src/memory/scope.js +65 -0
- package/src/memory/search.js +598 -0
- package/src/memory/store.js +141 -0
- package/src/providers/index.js +182 -51
- package/src/providers/restore.js +5 -1
- package/src/security/encryption.js +34 -60
- package/src/security/files.js +155 -0
- package/src/session/brief.js +47 -0
- package/src/session/inject.js +12 -6
- package/src/session/lock.js +39 -118
- package/src/session/migrations.js +6 -0
- package/src/session/render.js +34 -4
- package/src/session/state.js +305 -34
- package/src/work/cli.js +64 -0
- package/src/work/errors.js +8 -0
- package/src/work/server.js +28 -0
- package/src/work/setup.js +96 -0
- package/src/work/store.js +340 -0
- package/src/work/ui/app.js +205 -0
- package/src/work/ui/index.html +30 -0
- package/src/work/ui/style.css +3 -0
- package/src/work/view.js +93 -0
- package/src/workspace/tracker.js +84 -332
- package/supabase/migrations/202609050001_backup_versions.sql +50 -0
|
@@ -0,0 +1,598 @@
|
|
|
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
|
+
// • metadata-validated parse/directory caches and incremental postings,
|
|
20
|
+
// while edits, removals, scopes, and source paths are checked per query.
|
|
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 { 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';
|
|
37
|
+
import { parseFrontmatter } from '../commands/validate.js';
|
|
38
|
+
|
|
39
|
+
const home = os.homedir();
|
|
40
|
+
|
|
41
|
+
// ── Tokenizing ───────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const STOPWORDS = new Set([
|
|
44
|
+
'the', 'a', 'an', 'of', 'to', 'in', 'on', 'for', 'and', 'or', 'is', 'it',
|
|
45
|
+
'this', 'that', 'what', 'how', 'do', 'does', 'did', 'we', 'i', 'my', 'our',
|
|
46
|
+
'with', 'about', 'was', 'were', 'be', 'are', 'at', 'by', 'from', 'as',
|
|
47
|
+
'into', 'up', 'out', 'so', 'if', 'me', 'you', 'your', 'us',
|
|
48
|
+
'why', 'when', 'where', 'which', 'who', 'can', 'should', 'would', 'could',
|
|
49
|
+
'have', 'has', 'had', 'been', 'being', 'there', 'here', 'than', 'then',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
// Minimum length at which a token may match another by prefix. Below this
|
|
53
|
+
// "in" would match "index" and "at" would match "attribution".
|
|
54
|
+
const PREFIX_MIN = 4;
|
|
55
|
+
|
|
56
|
+
export function tokenize(str) {
|
|
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
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Fold the commonest English inflections. Conservative on purpose: every
|
|
72
|
+
// rule keeps a stem of at least 4 characters so short words are untouched
|
|
73
|
+
// ("was" -> "was", not "wa"), and -ing/-ed only strip from words long
|
|
74
|
+
// enough that the residue is still a word ("deploying" -> "deploy",
|
|
75
|
+
// "ring" -> "ring").
|
|
76
|
+
export function normalize(token) {
|
|
77
|
+
let t = token;
|
|
78
|
+
if (t.length >= 5 && t.endsWith('ies')) t = t.slice(0, -3) + 'y';
|
|
79
|
+
else if (t.length >= 6 && t.endsWith('sses')) t = t.slice(0, -2);
|
|
80
|
+
else if (t.length >= 4 && t.endsWith('s') && !t.endsWith('ss')) t = t.slice(0, -1);
|
|
81
|
+
if (t.length >= 7 && t.endsWith('ing')) t = t.slice(0, -3);
|
|
82
|
+
else if (t.length >= 6 && t.endsWith('ed')) t = t.slice(0, -2);
|
|
83
|
+
return t;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Does a normalized query term match a normalized document token?
|
|
87
|
+
// Returns 1 for an exact match, PREFIX_WEIGHT for a prefix match, 0 otherwise.
|
|
88
|
+
const PREFIX_WEIGHT = 0.6;
|
|
89
|
+
export function termMatch(term, token) {
|
|
90
|
+
if (term === token) return 1;
|
|
91
|
+
if (term.length >= PREFIX_MIN && token.startsWith(term)) return PREFIX_WEIGHT;
|
|
92
|
+
if (token.length >= PREFIX_MIN && term.startsWith(token)) return PREFIX_WEIGHT;
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function queryTerms(query) {
|
|
97
|
+
const raw = tokenize(query);
|
|
98
|
+
const kept = raw.filter((t) => !STOPWORDS.has(t));
|
|
99
|
+
// If the whole query was stopwords ("what is it"), search on it anyway
|
|
100
|
+
// rather than returning nothing.
|
|
101
|
+
const terms = (kept.length ? kept : raw).map(normalize);
|
|
102
|
+
return Array.from(new Set(terms));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Document model ───────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
// Field weights. Aliases win because they exist for exactly one reason —
|
|
108
|
+
// they are the other names someone might search under. Then the entry's
|
|
109
|
+
// own name, its one-line description, headings, and finally prose.
|
|
110
|
+
const W = { aliases: 6, name: 4, description: 3, headings: 2, body: 1 };
|
|
111
|
+
// Body term-frequency saturates: 1 hit = 1.0, 3 = 1.5, 7 = 2.0. A file that
|
|
112
|
+
// says "deploy" forty times is not forty times more relevant.
|
|
113
|
+
const BODY_TF_CAP = 2;
|
|
114
|
+
|
|
115
|
+
function listField(v) {
|
|
116
|
+
if (Array.isArray(v)) return v.map(String);
|
|
117
|
+
if (typeof v === 'string' && v.trim()) return v.split(',').map((s) => s.trim()).filter(Boolean);
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function fieldTokens(strings) {
|
|
122
|
+
const out = new Map(); // normalized token -> count
|
|
123
|
+
for (const s of strings) {
|
|
124
|
+
for (const t of tokenize(s)) {
|
|
125
|
+
const n = normalize(t);
|
|
126
|
+
out.set(n, (out.get(n) || 0) + 1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Parse a raw memory file into the shape the scorer wants. Cheap enough to
|
|
134
|
+
* run on every file, but cached by (path, mtime, size) in readMemoryFiles.
|
|
135
|
+
*/
|
|
136
|
+
export function buildDoc({ path: relPath, content, tool, absPath, mtimeMs }) {
|
|
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, ''));
|
|
140
|
+
const bodyLines = body.split(/\r?\n/);
|
|
141
|
+
const headings = bodyLines.filter((l) => /^\s{0,3}#{1,6}\s/.test(l));
|
|
142
|
+
|
|
143
|
+
const nameStrings = [path.basename(relPath).replace(/\.[^.]+$/, '')];
|
|
144
|
+
if (fields.name) nameStrings.push(String(fields.name));
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
...fields,
|
|
148
|
+
path: relPath,
|
|
149
|
+
absPath,
|
|
150
|
+
bodyStartLine: content.split(/\r?\n/).length - rawBody.split(/\r?\n/).length + 1,
|
|
151
|
+
tool,
|
|
152
|
+
mtimeMs: mtimeMs || 0,
|
|
153
|
+
isMarkdown,
|
|
154
|
+
type: fields.type || fields.metadata?.type || null,
|
|
155
|
+
description: fields.description ? String(fields.description) : '',
|
|
156
|
+
aliases: listField(fields.aliases),
|
|
157
|
+
tags: listField(fields.tags),
|
|
158
|
+
body,
|
|
159
|
+
bodyLines,
|
|
160
|
+
// Token maps per field
|
|
161
|
+
tf: {
|
|
162
|
+
aliases: fieldTokens([...listField(fields.aliases), ...listField(fields.tags)]),
|
|
163
|
+
name: fieldTokens(nameStrings),
|
|
164
|
+
description: fieldTokens([fields.description || '']),
|
|
165
|
+
headings: fieldTokens(headings),
|
|
166
|
+
body: fieldTokens(bodyLines),
|
|
167
|
+
},
|
|
168
|
+
// Kept for legacy callers (memoir_list sizes, memoir_read) — the raw file.
|
|
169
|
+
content,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Scoring ──────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
function fieldScore(tfMap, term, matches) {
|
|
176
|
+
// Best match across the field's tokens: exact beats prefix; tf saturates.
|
|
177
|
+
let best = 0;
|
|
178
|
+
let count = 0;
|
|
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);
|
|
183
|
+
if (m > best) { best = m; count = n; }
|
|
184
|
+
else if (m === best && m > 0) count += n;
|
|
185
|
+
}
|
|
186
|
+
if (!best) return 0;
|
|
187
|
+
const tf = 0.5 + 0.5 * Math.log2(1 + count);
|
|
188
|
+
return best * Math.min(BODY_TF_CAP, tf);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A single term's evidence in one document is its best field hit plus a
|
|
192
|
+
// quarter of the rest, capped. Without the cap, a term that appears in the
|
|
193
|
+
// alias list AND the name AND the description AND a heading (which is
|
|
194
|
+
// exactly what a well-formed entry looks like) stacked to ~16 and let a
|
|
195
|
+
// one-term match outrank a file that covered every term in the query.
|
|
196
|
+
const TERM_CAP = 8;
|
|
197
|
+
|
|
198
|
+
export function scoreDoc(doc, terms, matches) {
|
|
199
|
+
let sum = 0;
|
|
200
|
+
let matched = 0;
|
|
201
|
+
const perTerm = {};
|
|
202
|
+
for (const term of terms) {
|
|
203
|
+
const fieldScores = Object.keys(W).map((f) => W[f] * fieldScore(doc.tf[f], term, matches?.get(term))).sort((a, b) => b - a);
|
|
204
|
+
const s = Math.min(TERM_CAP, fieldScores[0] + 0.25 * fieldScores.slice(1).reduce((x, y) => x + y, 0));
|
|
205
|
+
if (s > 0) matched++;
|
|
206
|
+
perTerm[term] = s;
|
|
207
|
+
sum += s;
|
|
208
|
+
}
|
|
209
|
+
if (!matched) return { score: 0, matched: 0, perTerm };
|
|
210
|
+
const coverage = matched / terms.length;
|
|
211
|
+
// Coverage-squared: a file that covers all the terms beats a file that
|
|
212
|
+
// covers a third of them unless the partial match is overwhelming
|
|
213
|
+
// (2/3 coverage keeps 44% of its raw score, 1/3 keeps 11%). This is the
|
|
214
|
+
// "over-recall" fix — a common word no longer drags in every file that
|
|
215
|
+
// mentions it once. Non-markdown (settings.json etc.) is de-weighted:
|
|
216
|
+
// it is config, not memory, and matches on it are usually noise.
|
|
217
|
+
const score = sum * coverage * coverage * (doc.isMarkdown ? 1 : 0.5);
|
|
218
|
+
return { score, matched, coverage, perTerm };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Passage extraction ───────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
const PASSAGE_BUDGET = 700; // chars per result
|
|
224
|
+
const CONTEXT_LINES = 1; // lines of context either side of a hit
|
|
225
|
+
const MAX_LINE = 240; // clamp very long lines (pasted JSON, tables)
|
|
226
|
+
|
|
227
|
+
function lineMatches(line, terms) {
|
|
228
|
+
const toks = tokenize(line).map(normalize);
|
|
229
|
+
if (!toks.length) return 0;
|
|
230
|
+
let hits = 0;
|
|
231
|
+
for (const term of terms) {
|
|
232
|
+
if (toks.some((t) => termMatch(term, t) > 0)) hits++;
|
|
233
|
+
}
|
|
234
|
+
return hits;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function clampLine(l) {
|
|
238
|
+
const s = l.replace(/\s+$/, '');
|
|
239
|
+
return s.length > MAX_LINE ? s.slice(0, MAX_LINE - 1) + '…' : s;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The passage(s) of a document that best answer the query: matched lines
|
|
244
|
+
* with a line of context either side, best windows first, within a byte
|
|
245
|
+
* budget. Frontmatter is never included — callers get the description
|
|
246
|
+
* separately for the header line.
|
|
247
|
+
*/
|
|
248
|
+
export function extractPassage(doc, terms, budget = PASSAGE_BUDGET) {
|
|
249
|
+
const lines = doc.bodyLines;
|
|
250
|
+
const hits = [];
|
|
251
|
+
for (let i = 0; i < lines.length; i++) {
|
|
252
|
+
const l = lines[i];
|
|
253
|
+
if (!l.trim()) continue;
|
|
254
|
+
const h = lineMatches(l, terms);
|
|
255
|
+
if (h) hits.push({ i, h });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!hits.length) {
|
|
259
|
+
// Match lived in frontmatter only (name/description/aliases). Show the
|
|
260
|
+
// opening of the body so the reader still gets substance.
|
|
261
|
+
const opening = lines.filter((l) => l.trim()).slice(0, 6).map(clampLine).join('\n');
|
|
262
|
+
return opening.slice(0, budget);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Merge hits into windows [start, end] with context, then rank windows by
|
|
266
|
+
// (distinct terms matched desc, position asc).
|
|
267
|
+
const windows = [];
|
|
268
|
+
for (const { i, h } of hits) {
|
|
269
|
+
const start = Math.max(0, i - CONTEXT_LINES);
|
|
270
|
+
const end = Math.min(lines.length - 1, i + CONTEXT_LINES);
|
|
271
|
+
const last = windows[windows.length - 1];
|
|
272
|
+
if (last && start <= last.end + 1) {
|
|
273
|
+
last.end = Math.max(last.end, end);
|
|
274
|
+
last.h = Math.max(last.h, h);
|
|
275
|
+
last.hits++;
|
|
276
|
+
} else {
|
|
277
|
+
windows.push({ start, end, h, hits: 1 });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
windows.sort((a, b) => b.h - a.h || b.hits - a.hits || a.start - b.start);
|
|
281
|
+
|
|
282
|
+
const chosen = [];
|
|
283
|
+
let used = 0;
|
|
284
|
+
for (const w of windows) {
|
|
285
|
+
const text = lines.slice(w.start, w.end + 1).filter((l) => l.trim()).map(clampLine).join('\n');
|
|
286
|
+
if (!text) continue;
|
|
287
|
+
if (used && used + text.length > budget) continue;
|
|
288
|
+
chosen.push({ ...w, text: used + text.length > budget ? text.slice(0, budget - used - 1) + '…' : text });
|
|
289
|
+
used += text.length + 2;
|
|
290
|
+
if (used >= budget) break;
|
|
291
|
+
}
|
|
292
|
+
// Present in document order so the excerpt reads naturally.
|
|
293
|
+
chosen.sort((a, b) => a.start - b.start);
|
|
294
|
+
return chosen.map((c) => c.text).join('\n⋯\n');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Reading + caching ────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
// (absPath) -> { mtimeMs, size, doc }. The MCP server is a long-lived
|
|
300
|
+
// process; memory files change rarely relative to how often recall runs.
|
|
301
|
+
const docCache = new Map();
|
|
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;
|
|
318
|
+
let content;
|
|
319
|
+
try { content = (await readSafeFile(inventory.root, access.relative)).toString('utf8'); } catch { return null; }
|
|
320
|
+
const doc = buildDoc({ path: relPath, content, tool, absPath, mtimeMs: st.mtimeMs });
|
|
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 });
|
|
325
|
+
return doc;
|
|
326
|
+
}
|
|
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
|
+
|
|
338
|
+
/** Test hook — drop every cached parse. */
|
|
339
|
+
export function clearSearchCache() {
|
|
340
|
+
docCache.clear();
|
|
341
|
+
lexicalIndex.clear();
|
|
342
|
+
projectDirectories.clear();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const MEMORY_EXT = /\.(md|mdc|json|toml|ya?ml)$/i;
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Read every memory file an adapter owns, as parsed docs. Cached by mtime.
|
|
349
|
+
*/
|
|
350
|
+
export async function readMemoryFiles(adapter) {
|
|
351
|
+
const files = [];
|
|
352
|
+
let inventory;
|
|
353
|
+
try { inventory = await createReadInventory(adapter.source); } catch { return files; }
|
|
354
|
+
|
|
355
|
+
if (adapter.customExtract) {
|
|
356
|
+
for (const file of adapter.files) {
|
|
357
|
+
const abs = path.join(adapter.source, file);
|
|
358
|
+
const doc = await readDoc(abs, file, adapter.name, adapter.source, inventory);
|
|
359
|
+
if (doc) files.push(doc);
|
|
360
|
+
}
|
|
361
|
+
return files;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!(await fs.pathExists(adapter.source))) return files;
|
|
365
|
+
|
|
366
|
+
const walk = async (dir, prefix = '') => {
|
|
367
|
+
let entries;
|
|
368
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
369
|
+
for (let offset = 0; offset < entries.length; offset += 24) {
|
|
370
|
+
await Promise.all(entries.slice(offset, offset + 24).map(async entry => {
|
|
371
|
+
const fullPath = path.join(dir, entry.name);
|
|
372
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
373
|
+
if (entry.isDirectory()) {
|
|
374
|
+
if (adapter.filter(fullPath)) await walk(fullPath, relPath);
|
|
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
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}));
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
await walk(adapter.source);
|
|
386
|
+
return files;
|
|
387
|
+
}
|
|
388
|
+
|
|
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.
|
|
391
|
+
const PROJECT_FILES = ['CLAUDE.md', 'GEMINI.md', 'CHATGPT.md', 'AGENTS.md', '.cursorrules', '.windsurfrules', '.clinerules'];
|
|
392
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.next', '.vercel', 'dist', 'build', '__pycache__', '.venv', 'venv', '.cache', 'Library', '.Trash', 'Applications', 'Downloads', 'Movies', 'Music', 'Pictures']);
|
|
393
|
+
const PROJECT_SCAN_DEPTH = 3;
|
|
394
|
+
const projectDirectories = new Map();
|
|
395
|
+
|
|
396
|
+
async function discoverProjectFiles(root) {
|
|
397
|
+
const found = [];
|
|
398
|
+
const visited = new Set();
|
|
399
|
+
const scan = async (dir, depth) => {
|
|
400
|
+
if (depth > PROJECT_SCAN_DEPTH) return;
|
|
401
|
+
let entries;
|
|
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; }
|
|
410
|
+
const names = new Set(entries.filter((e) => e.isFile()).map((e) => e.name));
|
|
411
|
+
for (const f of PROJECT_FILES) {
|
|
412
|
+
if (names.has(f)) found.push({ abs: path.join(dir, f), rel: `${path.basename(dir)}/${f}`, project: dir });
|
|
413
|
+
}
|
|
414
|
+
const children = [];
|
|
415
|
+
for (const entry of entries) {
|
|
416
|
+
if (!entry.isDirectory()) continue;
|
|
417
|
+
if (entry.name.startsWith('.') && entry.name !== '.github') continue;
|
|
418
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
419
|
+
children.push(path.join(dir, entry.name));
|
|
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)));
|
|
422
|
+
};
|
|
423
|
+
await scan(root, 0);
|
|
424
|
+
for (const dir of projectDirectories.keys()) if (!visited.has(dir)) projectDirectories.delete(dir);
|
|
425
|
+
return found;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function projectDocs(root = home) {
|
|
429
|
+
let inventory;
|
|
430
|
+
try { inventory = await createReadInventory(root); } catch { return []; }
|
|
431
|
+
const files = await discoverProjectFiles(inventory.root);
|
|
432
|
+
const docs = [];
|
|
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
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return docs;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── Write-side helper: aliases/tags into frontmatter ─────────────
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Merge `aliases` / `tags` lists into a markdown entry's frontmatter.
|
|
448
|
+
* If the content has frontmatter, lists are added (or extended, deduped)
|
|
449
|
+
* in the SPEC.md 3.1 `- item` shape. If it has none, a minimal block is
|
|
450
|
+
* created — the entry becomes findable without forcing the caller to
|
|
451
|
+
* author YAML. Existing unrelated fields are preserved verbatim.
|
|
452
|
+
*/
|
|
453
|
+
export function withFrontmatterLists(content, lists = {}) {
|
|
454
|
+
const wanted = {};
|
|
455
|
+
for (const [k, v] of Object.entries(lists)) {
|
|
456
|
+
const arr = Array.isArray(v) ? v.map((s) => String(s).trim()).filter(Boolean) : [];
|
|
457
|
+
if (arr.length) wanted[k] = arr;
|
|
458
|
+
}
|
|
459
|
+
if (!Object.keys(wanted).length) return content;
|
|
460
|
+
|
|
461
|
+
const src = String(content || '');
|
|
462
|
+
const lines = src.split(/\r?\n/);
|
|
463
|
+
const hasFm = (lines[0] || '').trim() === '---' && lines.slice(1).some((l) => l.trim() === '---');
|
|
464
|
+
|
|
465
|
+
const renderList = (key, arr) => [`${key}:`, ...arr.map((a) => ` - ${JSON.stringify(a)}`)];
|
|
466
|
+
|
|
467
|
+
if (!hasFm) {
|
|
468
|
+
const fm = ['---'];
|
|
469
|
+
for (const [k, arr] of Object.entries(wanted)) fm.push(...renderList(k, arr));
|
|
470
|
+
fm.push('---', '');
|
|
471
|
+
return fm.join('\n') + src;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
|
|
475
|
+
const head = lines.slice(1, end);
|
|
476
|
+
const { fields } = parseFrontmatter(src);
|
|
477
|
+
const out = [];
|
|
478
|
+
const done = new Set();
|
|
479
|
+
for (let i = 0; i < head.length; i++) {
|
|
480
|
+
const line = head[i];
|
|
481
|
+
const m = line.match(/^([^:\s][^:]*):\s*(.*)$/);
|
|
482
|
+
const key = m ? m[1].trim() : null;
|
|
483
|
+
if (key && wanted[key]) {
|
|
484
|
+
// Replace this key (and any nested/list lines under it) with the merged list.
|
|
485
|
+
const existing = listField(fields[key]);
|
|
486
|
+
const merged = Array.from(new Set([...existing, ...wanted[key]].map((s) => s.trim()).filter(Boolean)));
|
|
487
|
+
out.push(...renderList(key, merged));
|
|
488
|
+
done.add(key);
|
|
489
|
+
while (i + 1 < head.length && /^\s/.test(head[i + 1]) && head[i + 1].trim()) i++;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
out.push(line);
|
|
493
|
+
}
|
|
494
|
+
for (const [k, arr] of Object.entries(wanted)) {
|
|
495
|
+
if (!done.has(k)) out.push(...renderList(k, arr));
|
|
496
|
+
}
|
|
497
|
+
return ['---', ...out, '---', ...lines.slice(end + 1)].join('\n');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ── Search ───────────────────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Search every memory file (all adapters + per-project configs).
|
|
504
|
+
* Returns ranked results with a passage each. Never throws on a bad file.
|
|
505
|
+
*/
|
|
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');
|
|
508
|
+
const terms = queryTerms(query);
|
|
509
|
+
if (!terms.length) return { terms, results: [], total: 0 };
|
|
510
|
+
const docs = [];
|
|
511
|
+
for (const adapter of adapters) {
|
|
512
|
+
try { docs.push(...await readMemoryFiles(adapter)); } catch {}
|
|
513
|
+
}
|
|
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;
|
|
537
|
+
const scored = [];
|
|
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
|
+
});
|
|
573
|
+
}
|
|
574
|
+
return { terms, results: top, total: scored.length };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Plain-text rendering shared by the MCP tool and `memoir recall`.
|
|
579
|
+
*/
|
|
580
|
+
export function formatRecallResults(query, { terms, results, total }) {
|
|
581
|
+
if (!results.length) {
|
|
582
|
+
return `No memories found matching "${query}"${terms.length ? ` (terms: ${terms.join(', ')})` : ''}.`;
|
|
583
|
+
}
|
|
584
|
+
const blocks = results.map((r, i) => {
|
|
585
|
+
const meta = [r.type, r.description].filter(Boolean).join(' · ');
|
|
586
|
+
const cov = r.matched < terms.length ? ` · ${r.matched}/${terms.length} terms` : '';
|
|
587
|
+
return [
|
|
588
|
+
`── ${i + 1}. ${r.tool} / ${r.path}${cov} ──`,
|
|
589
|
+
meta ? ` ${meta}` : null,
|
|
590
|
+
r.passage,
|
|
591
|
+
].filter(Boolean).join('\n');
|
|
592
|
+
});
|
|
593
|
+
const shown = results.length;
|
|
594
|
+
const head = total > shown
|
|
595
|
+
? `Found ${total} memories matching "${query}" — showing the top ${shown}. Use memoir_read for a full file.`
|
|
596
|
+
: `Found ${total} memor${total === 1 ? 'y' : 'ies'} matching "${query}":`;
|
|
597
|
+
return `${head}\n\n${blocks.join('\n\n')}`;
|
|
598
|
+
}
|