memoir-cli 3.12.0 → 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 +128 -137
- package/bin/memoir-work.js +9 -0
- package/bin/memoir.js +50 -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/storage.js +130 -93
- package/src/commands/activate.js +18 -7
- package/src/commands/cloud.js +55 -4
- package/src/commands/consolidate.js +49 -10
- package/src/commands/diff.js +2 -2
- package/src/commands/doctor.js +3 -3
- package/src/commands/push.js +156 -161
- package/src/commands/recall.js +1 -1
- 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 +11 -9
- package/src/commands/validate.js +3 -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 +126 -32
- 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 +105 -152
- 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 +165 -70
- 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 +200 -33
- 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
package/src/memory/search.js
CHANGED
|
@@ -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
|
-
// •
|
|
20
|
-
//
|
|
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', '
|
|
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
|
-
|
|
53
|
-
.
|
|
54
|
-
.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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,
|
|
166
|
-
const
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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 (
|
|
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)
|
|
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
|
-
//
|
|
345
|
-
//
|
|
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
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
383
|
-
const doc = await readDoc(f.abs, f.rel, `Project: ${f.project}
|
|
384
|
-
if (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(...
|
|
512
|
+
try { docs.push(...await readMemoryFiles(adapter)); } catch {}
|
|
458
513
|
}
|
|
459
|
-
|
|
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
|
|
463
|
-
|
|
464
|
-
|
|
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
|
+
}
|