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.
Files changed (76) hide show
  1. package/README.md +129 -124
  2. package/bin/memoir-work.js +9 -0
  3. package/bin/memoir.js +72 -8
  4. package/docs/AUDIT-REMEDIATION.md +55 -0
  5. package/docs/CASE_TAPE_AMNESIA.md +39 -0
  6. package/docs/HANDOFF-SECURITY-AUDIT.md +106 -0
  7. package/docs/LOCAL-HANDOFF-VALIDATION.md +129 -0
  8. package/docs/MCP-V2-MIGRATION.md +17 -0
  9. package/docs/PROJECT-HANDOFF.md +255 -0
  10. package/docs/PROJECT-VIEW-DEBUG.md +66 -0
  11. package/docs/PROJECT-VIEW-VALIDATION.md +136 -0
  12. package/docs/RELEASE-3.14-VALIDATION.md +36 -0
  13. package/docs/RELIABILITY-ROLLOUT.md +57 -0
  14. package/docs/RETRIEVAL-INDEX.md +45 -0
  15. package/docs/RETRIEVAL-RESULTS.md +26 -0
  16. package/docs/SPEC.md +684 -0
  17. package/evals/CONTINUITY-PROTOCOL.md +45 -0
  18. package/evals/cases.json +200 -0
  19. package/evals/results/retrieval-2026-09-05.json +5333 -0
  20. package/evals/retrieval-performance.mjs +99 -0
  21. package/evals/run.mjs +87 -0
  22. package/package.json +13 -5
  23. package/src/adapters/index.js +13 -6
  24. package/src/adapters/restore.js +83 -36
  25. package/src/cloud/auth.js +12 -15
  26. package/src/cloud/constants.js +6 -2
  27. package/src/cloud/storage.js +130 -93
  28. package/src/commands/activate.js +43 -9
  29. package/src/commands/cloud.js +56 -5
  30. package/src/commands/consolidate.js +49 -10
  31. package/src/commands/diff.js +2 -2
  32. package/src/commands/doctor.js +3 -3
  33. package/src/commands/forget.js +100 -0
  34. package/src/commands/push.js +164 -161
  35. package/src/commands/recall.js +42 -0
  36. package/src/commands/restore.js +32 -44
  37. package/src/commands/resume.js +15 -164
  38. package/src/commands/session.js +51 -9
  39. package/src/commands/snapshot.js +6 -7
  40. package/src/commands/status.js +23 -1
  41. package/src/commands/upgrade.js +13 -11
  42. package/src/commands/validate.js +16 -0
  43. package/src/commands/view.js +2 -2
  44. package/src/commands/why.js +4 -3
  45. package/src/config.js +9 -40
  46. package/src/context/capture.js +135 -33
  47. package/src/context/handoffs.js +72 -0
  48. package/src/events/summary.js +122 -0
  49. package/src/integrations/setup.js +88 -0
  50. package/src/mcp.js +151 -283
  51. package/src/memory/lexical-index.js +65 -0
  52. package/src/memory/repository.js +16 -0
  53. package/src/memory/scope.js +65 -0
  54. package/src/memory/search.js +598 -0
  55. package/src/memory/store.js +141 -0
  56. package/src/providers/index.js +182 -51
  57. package/src/providers/restore.js +5 -1
  58. package/src/security/encryption.js +34 -60
  59. package/src/security/files.js +155 -0
  60. package/src/session/brief.js +47 -0
  61. package/src/session/inject.js +12 -6
  62. package/src/session/lock.js +39 -118
  63. package/src/session/migrations.js +6 -0
  64. package/src/session/render.js +34 -4
  65. package/src/session/state.js +305 -34
  66. package/src/work/cli.js +64 -0
  67. package/src/work/errors.js +8 -0
  68. package/src/work/server.js +28 -0
  69. package/src/work/setup.js +96 -0
  70. package/src/work/store.js +340 -0
  71. package/src/work/ui/app.js +205 -0
  72. package/src/work/ui/index.html +30 -0
  73. package/src/work/ui/style.css +3 -0
  74. package/src/work/view.js +93 -0
  75. package/src/workspace/tracker.js +84 -332
  76. package/supabase/migrations/202609050001_backup_versions.sql +50 -0
@@ -0,0 +1,99 @@
1
+ // Synthetic filesystem benchmark. No models, network, or real user memories.
2
+ // Run unchanged against a prior checkout with --repo to compare implementations.
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+ import { pathToFileURL, fileURLToPath } from 'node:url';
8
+ import { performance } from 'node:perf_hooks';
9
+ import { execFileSync } from 'node:child_process';
10
+
11
+ const args = process.argv.slice(2);
12
+ const option = (key, fallback) => { const i = args.indexOf('--' + key); return i < 0 ? fallback : args[i + 1]; };
13
+ const repo = path.resolve(option('repo', fileURLToPath(new URL('..', import.meta.url))));
14
+ const sizes = option('sizes', '1000,10000').split(',').map(Number);
15
+ const samples = Number(option('samples', '120'));
16
+ const kinds = option('kinds', 'adapter,canonical').split(',');
17
+ if (!sizes.every(n => Number.isInteger(n) && n > 0 && n <= 50000) || !Number.isInteger(samples) || samples < 2 || samples > 1000 || !kinds.every(k => ['adapter', 'canonical'].includes(k))) throw new Error('Invalid benchmark options');
18
+ const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'memoir-retrieval-bench-'));
19
+ const realHomedir = os.homedir;
20
+ // Only this process sees the fixture home; no system environment is repurposed.
21
+ os.homedir = () => scratch;
22
+ process.env.DO_NOT_TRACK = '1';
23
+ globalThis.fetch = async () => { throw new Error('Network prohibited in evaluation'); };
24
+ const fixtureVersion = 'coding-retrieval-v1';
25
+ const prose = [
26
+ 'The migration must create the new table before updating the reader. Keep the old reader available until the verification passes.',
27
+ 'The rejected approach used a timer to hide the race. A transaction with an idempotency key prevents duplicate work.',
28
+ 'The last session tested rollback against a disposable local database. That result describes the saved checkout, not every later commit.',
29
+ 'An unsuccessful retry reused the old session token. Fetch the current token and confirm the request belongs to this project.',
30
+ 'Record evidence from the test output and keep the pending check separate from a verified result.',
31
+ 'Résumé: vérifier la migration et conserver les preuves. 数据库迁移需要验证。認証の更新を確認する。',
32
+ ].join('\n');
33
+ const queries = [
34
+ 'amberkingfisher recovery', 'migration reader', 'transaction idempotency', 'rejected timer',
35
+ 'rollback disposable', 'session token', 'verification evidence', 'migration vérifi',
36
+ '数据库迁移', '認証', 'zzunknownnothing', 'component42',
37
+ ];
38
+ const results = [];
39
+ try {
40
+ const { adapters } = await import(pathToFileURL(path.join(repo, 'src/adapters/index.js')));
41
+ const { searchMemories, clearSearchCache } = await import(pathToFileURL(path.join(repo, 'src/memory/search.js')));
42
+ const { projectIdentity } = await import(pathToFileURL(path.join(repo, 'src/memory/scope.js')));
43
+ const { memoryRoot } = await import(pathToFileURL(path.join(repo, 'src/memory/store.js')));
44
+ const project = path.join(scratch, 'project');
45
+ await fs.mkdir(project);
46
+ const projectId = projectIdentity(project);
47
+ const source = path.join(scratch, 'adapter');
48
+ for (const kind of kinds) for (const count of sizes) {
49
+ await fs.rm(source, { recursive: true, force: true });
50
+ await fs.rm(memoryRoot, { recursive: true, force: true });
51
+ const dest = kind === 'canonical' ? memoryRoot : source;
52
+ await fs.mkdir(dest, { recursive: true });
53
+ adapters.splice(0, adapters.length, ...(kind === 'adapter' ? [{ name: 'Benchmark', source, filter: () => true }] : []));
54
+ const digest = crypto.createHash('sha256');
55
+ let bytes = 0;
56
+ for (let offset = 0; offset < count; offset += 32) {
57
+ const writes = [];
58
+ for (let i = offset; i < Math.min(count, offset + 32); i++) {
59
+ const id = crypto.createHash('sha256').update('record-' + i).digest('hex');
60
+ const filename = kind === 'canonical' ? id + '.md' : 'record-' + i + '.md';
61
+ const content = ['---', 'id: ' + id, 'name: component' + i, 'project: ' + projectId, 'type: decision', 'description: Component migration and recovery constraints', '---', '# Component ' + i, prose, i === count - 1 ? 'amberkingfisher recovery uses the verified snapshot.' : 'Routine component operations require a current smoke check.'].join('\n');
62
+ // Exclude machine-specific project IDs from the portable fixture hash.
63
+ digest.update(filename + '\0' + content.replace(projectId, '<project>'));
64
+ bytes += Buffer.byteLength(content);
65
+ writes.push(fs.writeFile(path.join(dest, filename), content));
66
+ }
67
+ await Promise.all(writes);
68
+ }
69
+ clearSearchCache();
70
+ const all = [];
71
+ for (let i = 0; i <= samples; i++) {
72
+ const query = i === 0 ? queries[0] : queries[(i - 1) % queries.length];
73
+ const start = performance.now();
74
+ const r = await searchMemories(query, { root: project, project, limit: 5, budget: 1800 });
75
+ const ms = performance.now() - start;
76
+ if (query === queries[0] && !r.results.some(x => x.passage.includes('verified snapshot'))) throw new Error('Required result missing');
77
+ if (query === 'zzunknownnothing' && r.results.length) throw new Error('No-evidence query returned a result');
78
+ all.push({ query, ms, total: r.total, results: r.results.length });
79
+ }
80
+ const warm = all.slice(1).map(r => r.ms).sort((a, b) => a - b);
81
+ const row = { kind, records: count, bytes, fixture_sha256: digest.digest('hex'), cold_ms: all[0].ms, warm_samples: samples, warm_median_ms: warm[Math.floor(warm.length / 2)], warm_p95_ms: warm[Math.ceil(warm.length * .95) - 1], rss_bytes: process.memoryUsage().rss, queries: all };
82
+ results.push(row);
83
+ console.error(JSON.stringify({ kind, count, cold_ms: row.cold_ms, median_ms: row.warm_median_ms, p95_ms: row.warm_p95_ms }));
84
+ }
85
+ let commit = null;
86
+ try { commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim(); } catch {}
87
+ const sourceFiles = {};
88
+ for (const relative of ['src/memory/search.js', 'src/memory/scope.js', 'src/memory/lexical-index.js', 'src/security/files.js']) {
89
+ try { sourceFiles[relative] = crypto.createHash('sha256').update(await fs.readFile(path.join(repo, relative))).digest('hex'); }
90
+ catch (err) { if (err.code !== 'ENOENT') throw err; sourceFiles[relative] = null; }
91
+ }
92
+ const report = { label: 'Synthetic retrieval latency; not a coding utility or SOTA evaluation', fixture_version: fixtureVersion, harness_sha256: crypto.createHash('sha256').update(await fs.readFile(fileURLToPath(import.meta.url))).digest('hex'), source_commit: commit, source_file_sha256: sourceFiles, measured_at: new Date().toISOString(), node: process.version, platform: process.platform, arch: process.arch, cpu: os.cpus()[0]?.model, protocol: 'In-process public search API, including filesystem discovery/validation, parsing, ranking, and passage generation. One cold query then a fixed mixed query cycle. OS file cache is not flushed. RSS is process-wide. Sources are synthetic. File hashes identify the measured implementation even if the checkout has uncommitted edits.', results };
93
+ const json = JSON.stringify(report, null, 2) + '\n';
94
+ if (option('output', null)) await fs.writeFile(path.resolve(option('output')), json);
95
+ else console.log(json);
96
+ } finally {
97
+ os.homedir = realHomedir;
98
+ await fs.rm(scratch, { recursive: true, force: true });
99
+ }
package/evals/run.mjs ADDED
@@ -0,0 +1,87 @@
1
+ // Development regression evaluation, not a held-out SOTA benchmark.
2
+ // All storage is synthetic; no model/network calls or user memory are used.
3
+ import fs from 'fs-extra';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+ import { performance } from 'node:perf_hooks';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const fixturePath = fileURLToPath(new URL('./cases.json', import.meta.url));
11
+ const fixtureRaw = await fs.readFile(fixturePath);
12
+ const fixture = JSON.parse(fixtureRaw);
13
+ const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'memoir-evaluation-'));
14
+ process.env.HOME = scratch;
15
+ process.env.USERPROFILE = scratch;
16
+ process.env.APPDATA = path.join(scratch, 'AppData', 'Roaming');
17
+ process.env.DO_NOT_TRACK = '1';
18
+ process.env.CI = '1';
19
+ process.env.GIT_CONFIG_NOSYSTEM = '1';
20
+ process.env.GIT_CONFIG_GLOBAL = path.join(scratch, 'gitconfig');
21
+ globalThis.fetch = async () => { throw new Error('Network prohibited during evaluation'); };
22
+ try {
23
+ const projects = { alpha: path.join(scratch, 'alpha'), beta: path.join(scratch, 'beta') };
24
+ await Promise.all(Object.values(projects).map(dir => fs.ensureDir(dir)));
25
+ process.env.MEMOIR_PROJECT_ROOT = projects.alpha;
26
+ const { rememberMemory, readStoredMemories } = await import('../src/memory/store.js');
27
+ const { searchMemories } = await import('../src/memory/search.js');
28
+ const ids = new Map();
29
+ for (const record of fixture.records) {
30
+ const metadata = { ...(record.metadata || {}), name: record.key };
31
+ const content = ['---', ...Object.entries(metadata).map(([key, value]) => key + ': ' + JSON.stringify(value)), '---', record.text].join('\n');
32
+ const saved = await rememberMemory({
33
+ filename: record.key + '.md', content,
34
+ project: projects[record.project || 'alpha'],
35
+ scope: record.project === 'shared' ? 'shared' : 'project',
36
+ aliases: record.aliases || [],
37
+ });
38
+ ids.set(saved.id, record.key);
39
+ }
40
+ const raw = await readStoredMemories();
41
+ const rows = [];
42
+ for (const test of fixture.cases) {
43
+ const started = performance.now();
44
+ const output = await searchMemories(test.query, { project: projects[test.project || 'alpha'], limit: 5, budget: 1800 });
45
+ const milliseconds = performance.now() - started;
46
+ const reference = await searchMemories(test.query, { project: projects[test.project || 'alpha'], limit: 5, budget: 1800, engine: 'scan' });
47
+ const retrieved = output.results.map(r => ids.get(r.id)).filter(Boolean);
48
+ const substring = raw.filter(doc => test.query.trim() && doc.content.toLowerCase().includes(test.query.toLowerCase()))
49
+ .slice(0, 5).map(doc => ids.get(path.basename(doc.path, '.md')));
50
+ rows.push({ name: test.name, expected: test.expected, forbidden: test.forbidden || [], retrieved, substring, milliseconds,
51
+ scan_reference: reference.results.map(r => ids.get(r.id)).filter(Boolean),
52
+ matches_scan_output: JSON.stringify(output) === JSON.stringify(reference),
53
+ passage_characters: output.results.reduce((sum, r) => sum + r.passage.length, 0) });
54
+ }
55
+ const metrics = field => {
56
+ const positive = rows.filter(r => r.expected.length);
57
+ const hits = positive.map(r => r.expected.filter(id => r[field].includes(id)).length / r.expected.length);
58
+ const rr = positive.map(r => { const rank = r[field].findIndex(id => r.expected.includes(id)); return rank < 0 ? 0 : 1 / (rank + 1); });
59
+ const negatives = rows.filter(r => !r.expected.length);
60
+ return {
61
+ recall_at_5: hits.reduce((a,b) => a+b, 0) / positive.length,
62
+ mean_reciprocal_rank: rr.reduce((a,b) => a+b, 0) / positive.length,
63
+ correct_abstentions: negatives.filter(r => r[field].length === 0).length,
64
+ abstention_cases: negatives.length,
65
+ forbidden_results: rows.reduce((sum, r) => sum + r[field].filter(id => r.forbidden.includes(id)).length, 0),
66
+ };
67
+ };
68
+ const latencies = rows.map(r => r.milliseconds).sort((a,b) => a-b);
69
+ const report = {
70
+ label: 'Development fixture evaluation; created after implementation, not held out',
71
+ fixture_sha256: crypto.createHash('sha256').update(fixtureRaw).digest('hex'),
72
+ runtime: process.version, platform: process.platform, architecture: process.arch,
73
+ records: fixture.records.length, cases: rows.length,
74
+ memoir: metrics('retrieved'),
75
+ scoped_scan_reference: metrics('scan_reference'),
76
+ indexed_scan_agreement_cases: rows.filter(r => r.matches_scan_output).length,
77
+ scan_reference_limit: 'Exhaustive scoring using the same source reader, visibility rules, and ranking formula. Measures index equivalence, not competitive quality. Use retrieval-performance.mjs for latency.',
78
+ unscoped_substring_control: metrics('substring'),
79
+ control_limit: 'Simple substring control, not the previous released ranking engine or a competing product',
80
+ latency_ms: { median: latencies[Math.floor(latencies.length / 2)], p95: latencies[Math.ceil(latencies.length * .95)-1] },
81
+ rows,
82
+ };
83
+ const json = JSON.stringify(report, null, 2) + '\n';
84
+ if (process.argv[2]) await fs.outputFile(path.resolve(process.argv[2]), json);
85
+ console.log(json);
86
+ if (report.memoir.forbidden_results || report.memoir.recall_at_5 < 1 || report.memoir.correct_abstentions !== report.memoir.abstention_cases || report.indexed_scan_agreement_cases !== rows.length) process.exitCode = 1;
87
+ } finally { await fs.remove(scratch); }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.11.3",
3
+ "version": "3.14.0",
4
4
  "mcpName": "io.github.camgitt/memoir",
5
- "description": "Private, portable AI memory: synced across every coding tool and machine, end-to-end encrypted, free. One memory for Claude Code, Cursor, Copilot, Gemini + more — MCP-native, zero-knowledge, open source.",
5
+ "description": "Portable, project-scoped memory and session handoffs for coding agents. Readable files, MCP recall, and optional user-passphrase encrypted backups.",
6
6
  "main": "src/index.js",
7
7
  "type": "module",
8
8
  "bin": {
@@ -14,7 +14,10 @@
14
14
  "bin/",
15
15
  "src/",
16
16
  "README.md",
17
- "LICENSE"
17
+ "LICENSE",
18
+ "docs/",
19
+ "supabase/migrations/",
20
+ "evals/"
18
21
  ],
19
22
  "engines": {
20
23
  "node": ">=18"
@@ -32,7 +35,10 @@
32
35
  "test": "node run-tests.mjs",
33
36
  "test:legacy": "bash test-local.sh",
34
37
  "prepublishOnly": "node scripts/check-clean-for-publish.mjs && npm test",
35
- "postinstall": "node -e \"try{const c='\\x1b[36m',r='\\x1b[0m',g='\\x1b[90m';console.log('\\n '+c+'memoir'+r+' installed.\\n Run '+c+'memoir activate'+r+' in any project to give your AI long-term memory.\\n '+g+'https://memoir.sh'+r+'\\n')}catch{}\""
38
+ "version": "node -e \"const f=require('fs');const p=require('./package.json');const s=JSON.parse(f.readFileSync('server.json','utf8'));s.version=p.version;s.packages.forEach(x=>x.version=p.version);f.writeFileSync('server.json',JSON.stringify(s,null,2)+'\\n')\" && git add server.json",
39
+ "eval": "node evals/run.mjs",
40
+ "bench:retrieval": "node evals/retrieval-performance.mjs",
41
+ "test:packed": "node scripts/test-packed.mjs"
36
42
  },
37
43
  "keywords": [
38
44
  "mcp",
@@ -81,6 +87,8 @@
81
87
  "fs-extra": "^11.2.0",
82
88
  "gradient-string": "^3.0.0",
83
89
  "inquirer": "^9.2.15",
84
- "ora": "^7.0.1"
90
+ "ora": "^7.0.1",
91
+ "smol-toml": "^1.8.0",
92
+ "zod": "4.5.4"
85
93
  }
86
94
  }
@@ -1,3 +1,6 @@
1
+ import { projectIdentity } from '../memory/scope.js';
2
+ import { readSafeFile, writeSafeFile } from '../security/files.js';
3
+ import { stageMemories } from '../memory/store.js';
1
4
  import fs from 'fs-extra';
2
5
  import nodeFs from 'node:fs';
3
6
  import path from 'path';
@@ -80,7 +83,7 @@ export const adapters = [
80
83
  if (src === codexDir) return true;
81
84
  const basename = path.basename(src);
82
85
  // Only sync config files
83
- const allowed = ['config.json', 'settings.json', 'instructions.md'];
86
+ const allowed = ['config.toml', 'config.json', 'settings.json', 'instructions.md', 'AGENTS.md'];
84
87
  return allowed.includes(basename) && !rel.includes(path.sep);
85
88
  }
86
89
  },
@@ -241,7 +244,7 @@ function formatSize(bytes) {
241
244
  }
242
245
 
243
246
  export async function extractMemories(stagingDir, spinner, onlyFilter = null) {
244
- let foundAny = false;
247
+ let foundAny = (await stageMemories(stagingDir)) > 0;
245
248
  const results = [];
246
249
 
247
250
  for (const adapter of adapters) {
@@ -264,7 +267,7 @@ export async function extractMemories(stagingDir, spinner, onlyFilter = null) {
264
267
  await fs.ensureDir(dest);
265
268
  foundFile = true;
266
269
  }
267
- await fs.copy(filePath, path.join(dest, file));
270
+ await writeSafeFile(dest, file, await readSafeFile(adapter.source, file));
268
271
  fileCount++;
269
272
  }
270
273
  }
@@ -278,7 +281,7 @@ export async function extractMemories(stagingDir, spinner, onlyFilter = null) {
278
281
  spinner.text = `${adapter.icon} Scanning ${chalk.cyan(adapter.name)}...`;
279
282
  const dest = path.join(stagingDir, adapter.name.toLowerCase().replace(/ /g, '-'));
280
283
  await fs.ensureDir(dest);
281
- await fs.copy(adapter.source, dest, { filter: adapter.filter });
284
+ await fs.copy(adapter.source, dest, { filter: (src, dest) => !fs.lstatSync(src).isSymbolicLink() && adapter.filter(src, dest) });
282
285
 
283
286
  const fileCount = await countFiles(dest);
284
287
  const size = await dirSize(dest);
@@ -309,6 +312,7 @@ export async function extractMemories(stagingDir, spinner, onlyFilter = null) {
309
312
  let projectCount = 0;
310
313
  let projectFileCount = 0;
311
314
  const projectNames = [];
315
+ const projectManifest = {};
312
316
 
313
317
  // Walk home dir up to 3 levels deep looking for project markers
314
318
  const scanDir = async (dir, depth = 0) => {
@@ -329,7 +333,9 @@ export async function extractMemories(stagingDir, spinner, onlyFilter = null) {
329
333
 
330
334
  if (foundFiles.length > 0 && dir !== home && !shouldIgnoreProject(dir)) {
331
335
  // This is a project with AI configs
332
- const projectName = path.basename(dir);
336
+ const identity = projectIdentity(dir);
337
+ const projectName = path.basename(dir) + '-' + identity.split(':')[1].slice(0, 12);
338
+ projectManifest[projectName] = { identity, name: path.basename(dir), relative_path: path.relative(home, dir).replace(/\\/g, '/') };
333
339
  const projectDestDir = path.join(projectsDest, projectName);
334
340
  await fs.ensureDir(projectDestDir);
335
341
 
@@ -337,7 +343,7 @@ export async function extractMemories(stagingDir, spinner, onlyFilter = null) {
337
343
  const src = path.join(dir, file);
338
344
  const dest = path.join(projectDestDir, file);
339
345
  await fs.ensureDir(path.dirname(dest));
340
- await fs.copy(src, dest);
346
+ await writeSafeFile(projectDestDir, file, await readSafeFile(dir, file));
341
347
  projectFileCount++;
342
348
  }
343
349
 
@@ -357,6 +363,7 @@ export async function extractMemories(stagingDir, spinner, onlyFilter = null) {
357
363
  await scanDir(home);
358
364
 
359
365
  if (projectCount > 0) {
366
+ await writeSafeFile(stagingDir, 'projects.json', JSON.stringify(projectManifest, null, 2));
360
367
  const size = await dirSize(projectsDest);
361
368
  foundAny = true;
362
369
  results.push({
@@ -1,3 +1,9 @@
1
+ import { parseFrontmatter } from '../commands/validate.js';
2
+ import { visibleMemory } from '../memory/scope.js';
3
+ import { migrateSessionData } from '../session/migrations.js';
4
+ import { readSafeFile, writeSafeFile, safePath, relativeFile, listSafeFiles } from '../security/files.js';
5
+ import { projectIdentity } from '../memory/scope.js';
6
+ import { restoreStoredMemories } from '../memory/store.js';
1
7
  import chalk from 'chalk';
2
8
  import fs from 'fs-extra';
3
9
  import path from 'path';
@@ -15,7 +21,7 @@ export function detectLocalHomeKey(adapterSource) {
15
21
 
16
22
  const entries = fs.readdirSync(localProjectsDir)
17
23
  .filter(e => !e.startsWith('.'))
18
- .filter(e => fs.statSync(path.join(localProjectsDir, e)).isDirectory());
24
+ .filter(e => fs.lstatSync(path.join(localProjectsDir, e)).isDirectory());
19
25
  if (entries.length === 0) return null;
20
26
 
21
27
  // Prefer the key that matches this machine's homedir encoding.
@@ -65,7 +71,7 @@ function remapProjectPaths(backupDir, adapterSource) {
65
71
  if (!fs.existsSync(projectsDir)) return [];
66
72
 
67
73
  const backupEntries = fs.readdirSync(projectsDir)
68
- .filter(e => fs.statSync(path.join(projectsDir, e)).isDirectory());
74
+ .filter(e => fs.lstatSync(path.join(projectsDir, e)).isDirectory());
69
75
  if (backupEntries.length === 0) return [];
70
76
 
71
77
  // Step 1: Detect the local home key from existing Claude dirs
@@ -221,11 +227,11 @@ async function mergeMemoryDirs(src, dest) {
221
227
  const srcStat = await fs.stat(srcPath);
222
228
  const destStat = await fs.stat(destPath);
223
229
  if (srcStat.mtimeMs > destStat.mtimeMs) {
224
- await fs.copy(srcPath, destPath);
230
+ await writeSafeFile(dest, entry.name, await readSafeFile(src, entry.name));
225
231
  }
226
232
  } else {
227
233
  // File only exists on foreign machine — always copy it
228
- await fs.copy(srcPath, destPath);
234
+ await writeSafeFile(dest, entry.name, await readSafeFile(src, entry.name));
229
235
  }
230
236
  }
231
237
  }
@@ -246,7 +252,7 @@ async function reconcileMemoryIndexes(claudeSource) {
246
252
  const memoryMdPath = path.join(memDir, 'MEMORY.md');
247
253
  let memoryMd = '';
248
254
  if (fs.existsSync(memoryMdPath)) {
249
- memoryMd = fs.readFileSync(memoryMdPath, 'utf8');
255
+ memoryMd = (await readSafeFile(claudeSource, path.relative(claudeSource, memoryMdPath))).toString('utf8');
250
256
  }
251
257
 
252
258
  // Find all .md files in this memory dir
@@ -265,7 +271,8 @@ async function reconcileMemoryIndexes(claudeSource) {
265
271
  // Read each unreferenced file to get its name/description from frontmatter
266
272
  let additions = '\n\n## Synced from another machine\n';
267
273
  for (const file of unreferenced) {
268
- const content = fs.readFileSync(path.join(memDir, file), 'utf8');
274
+ const content = (await readSafeFile(claudeSource, path.relative(claudeSource, path.join(memDir, file)))).toString('utf8');
275
+ if (!visibleMemory(parseFrontmatter(content).fields, { allProjects: true })) continue;
269
276
  // Try to extract name from frontmatter
270
277
  const nameMatch = content.match(/^name:\s*(.+)/m);
271
278
  const descMatch = content.match(/^description:\s*(.+)/m);
@@ -281,32 +288,36 @@ async function reconcileMemoryIndexes(claudeSource) {
281
288
  // Remove old "Synced from another machine" section if it exists, then re-add
282
289
  memoryMd = memoryMd.replace(/\n\n## Synced from another machine\n[\s\S]*$/, '');
283
290
  memoryMd = memoryMd.trimEnd() + additions;
284
- fs.writeFileSync(memoryMdPath, memoryMd);
291
+ await writeSafeFile(claudeSource, path.relative(claudeSource, memoryMdPath), memoryMd);
285
292
  }
286
293
  }
287
294
 
288
- async function syncFiles(src, dest, changes) {
295
+ async function syncFiles(src, dest, changes, adapter = null, base = dest) {
289
296
  const entries = await fs.readdir(src, { withFileTypes: true });
290
297
  for (const entry of entries) {
291
298
  const srcPath = path.join(src, entry.name);
292
299
  const destPath = path.join(dest, entry.name);
293
300
 
301
+ if (entry.isSymbolicLink()) throw new Error('Backup contains a symlink');
302
+ if (adapter && !adapter.filter(destPath)) throw new Error('Backup contains a file excluded by the adapter');
294
303
  if (entry.isDirectory()) {
295
- await fs.ensureDir(destPath);
296
- await syncFiles(srcPath, destPath, changes);
304
+ // Validate an eventual child before creating directories to reject
305
+ // symlink parents as well as symlink files.
306
+ await safePath(base, path.relative(base, destPath) + '/.memoir-check', { createParents: true });
307
+ await syncFiles(srcPath, destPath, changes, adapter, base);
297
308
  } else {
298
309
  if (await fs.pathExists(destPath)) {
299
310
  // Compare modification times — update if backup is newer
300
311
  const srcStat = await fs.stat(srcPath);
301
312
  const destStat = await fs.stat(destPath);
302
313
  if (srcStat.mtimeMs > destStat.mtimeMs) {
303
- await fs.copy(srcPath, destPath);
314
+ await writeSafeFile(dest, entry.name, await readSafeFile(src, entry.name));
304
315
  changes.updated.push(destPath);
305
316
  } else {
306
317
  changes.skipped.push(destPath);
307
318
  }
308
319
  } else {
309
- await fs.copy(srcPath, destPath);
320
+ await writeSafeFile(dest, entry.name, await readSafeFile(src, entry.name));
310
321
  changes.added.push(destPath);
311
322
  }
312
323
  }
@@ -314,7 +325,32 @@ async function syncFiles(src, dest, changes) {
314
325
  }
315
326
 
316
327
  export async function restoreMemories(sourceDir, spinner, onlyFilter = null, autoYes = false) {
317
- let restoredAny = false;
328
+ if (await fs.pathExists(path.join(sourceDir, 'session.json'))) {
329
+ const raw = JSON.parse((await readSafeFile(sourceDir, 'session.json')).toString('utf8'));
330
+ if (migrateSessionData(raw).future) throw new Error('Backup uses a newer session schema. Upgrade Memoir first.');
331
+ }
332
+ for (const adapter of adapters) {
333
+ if (onlyFilter && !onlyFilter.some(f => adapter.name.toLowerCase().includes(f))) continue;
334
+ const dir = path.join(sourceDir, adapter.name.toLowerCase().replace(/ /g, '-'));
335
+ if (!await fs.pathExists(dir)) continue;
336
+ for (const rel of await listSafeFiles(dir)) {
337
+ const allowed = adapter.customExtract ? adapter.files.includes(rel) : adapter.filter(path.join(adapter.source, rel));
338
+ if (!allowed) throw new Error('Backup contains an excluded file for ' + adapter.name);
339
+ if (await fs.pathExists(adapter.source)) await safePath(adapter.source, rel);
340
+ }
341
+ }
342
+ const importedProjects = path.join(sourceDir, 'projects');
343
+ if (await fs.pathExists(importedProjects)) {
344
+ const allowed = new Set(['CLAUDE.md','GEMINI.md','CHATGPT.md','AGENTS.md','.cursorrules','.windsurfrules','.aider.conf.yml','.clinerules','.github/copilot-instructions.md']);
345
+ for (const rel of await listSafeFiles(importedProjects)) {
346
+ if (!allowed.has(rel.split('/').slice(1).join('/'))) throw new Error('Backup contains an unsupported project file');
347
+ }
348
+ if (await fs.pathExists(path.join(sourceDir, 'projects.json'))) {
349
+ const manifest = JSON.parse((await readSafeFile(sourceDir, 'projects.json')).toString());
350
+ for (const metadata of Object.values(manifest)) relativeFile(metadata.relative_path);
351
+ }
352
+ }
353
+ let restoredAny = (await restoreStoredMemories(sourceDir)) > 0;
318
354
  const allResults = [];
319
355
 
320
356
  for (const adapter of adapters) {
@@ -393,25 +429,26 @@ export async function restoreMemories(sourceDir, spinner, onlyFilter = null, aut
393
429
  if (adapter.customExtract) {
394
430
  const files = await fs.readdir(backupDir);
395
431
  for (const file of files) {
432
+ if (!adapter.files.includes(file)) throw new Error('Backup contains an excluded file');
396
433
  const destFile = path.join(adapter.source, file);
397
434
  if (await fs.pathExists(destFile)) {
398
435
  const srcStat = await fs.stat(path.join(backupDir, file));
399
436
  const destStat = await fs.stat(destFile);
400
437
  if (srcStat.mtimeMs > destStat.mtimeMs) {
401
- await fs.copy(path.join(backupDir, file), destFile);
438
+ await writeSafeFile(adapter.source, file, await readSafeFile(backupDir, file));
402
439
  changes.updated.push(destFile);
403
440
  } else {
404
441
  changes.skipped.push(destFile);
405
442
  }
406
443
  } else {
407
- await fs.copy(path.join(backupDir, file), destFile);
444
+ await writeSafeFile(adapter.source, file, await readSafeFile(backupDir, file));
408
445
  changes.added.push(destFile);
409
446
  }
410
447
  }
411
448
  } else {
412
449
  spinner.text = `Restoring ${chalk.cyan(adapter.name)} to ${adapter.source}...`;
413
450
  await fs.ensureDir(adapter.source);
414
- await syncFiles(backupDir, adapter.source, changes);
451
+ await syncFiles(backupDir, adapter.source, changes, adapter);
415
452
  }
416
453
 
417
454
  // After syncing, reconcile MEMORY.md files
@@ -458,6 +495,8 @@ export async function restoreMemories(sourceDir, spinner, onlyFilter = null, aut
458
495
  const projectsDir = path.join(sourceDir, 'projects');
459
496
  if (await fs.pathExists(projectsDir)) {
460
497
  const projectEntries = await fs.readdir(projectsDir, { withFileTypes: true });
498
+ const manifestPath = path.join(sourceDir, 'projects.json');
499
+ const projectManifest = await fs.pathExists(manifestPath) ? JSON.parse((await readSafeFile(sourceDir, 'projects.json')).toString()) : {};
461
500
  const projectDirs = projectEntries.filter(e => e.isDirectory() && e.name !== '.git');
462
501
 
463
502
  if (projectDirs.length > 0) {
@@ -474,26 +513,29 @@ export async function restoreMemories(sourceDir, spinner, onlyFilter = null, aut
474
513
 
475
514
  // Search for project on local machine (up to 3 levels deep)
476
515
  let localProjDir = null;
477
- const searchDirs = [home];
478
- for (const searchDir of searchDirs) {
479
- const candidate = path.join(searchDir, proj.name);
480
- if (await fs.pathExists(candidate)) {
516
+ const metadata = projectManifest[proj.name];
517
+ if (metadata) {
518
+ const relative = relativeFile(metadata.relative_path);
519
+ const candidate = path.join(home, relative);
520
+ if (await fs.pathExists(candidate) && projectIdentity(candidate) === metadata.identity) {
521
+ await safePath(home, relative + '/.memoir-check');
481
522
  localProjDir = candidate;
482
- break;
483
523
  }
484
- // Check one level deeper
485
- try {
486
- const entries = await fs.readdir(searchDir, { withFileTypes: true });
487
- for (const e of entries) {
488
- if (!e.isDirectory() || e.name.startsWith('.')) continue;
489
- const deeper = path.join(searchDir, e.name, proj.name);
490
- if (await fs.pathExists(deeper)) {
491
- localProjDir = deeper;
492
- break;
493
- }
494
- }
495
- } catch {}
496
- if (localProjDir) break;
524
+ }
525
+ if (!metadata) {
526
+ const candidates = [];
527
+ const direct = path.join(home, proj.name);
528
+ if (await fs.pathExists(direct)) candidates.push(direct);
529
+ for (const e of await fs.readdir(home, { withFileTypes: true })) {
530
+ if (!e.isDirectory() || e.name.startsWith('.')) continue;
531
+ const deeper = path.join(home, e.name, proj.name);
532
+ if (await fs.pathExists(deeper)) candidates.push(deeper);
533
+ }
534
+ if (candidates.length === 1) {
535
+ await safePath(home, path.relative(home, candidates[0]) + '/.memoir-check');
536
+ localProjDir = candidates[0];
537
+ }
538
+ if (candidates.length > 1) console.log(chalk.yellow(' Ambiguous legacy project name; skipped ' + proj.name));
497
539
  }
498
540
 
499
541
  if (!localProjDir) {
@@ -517,6 +559,11 @@ export async function restoreMemories(sourceDir, spinner, onlyFilter = null, aut
517
559
  }
518
560
 
519
561
  if (confirm) {
562
+ const allowedProjectFiles = new Set(['CLAUDE.md','GEMINI.md','CHATGPT.md','AGENTS.md','.cursorrules','.windsurfrules','.aider.conf.yml','.clinerules','.github/copilot-instructions.md']);
563
+ for (const rel of await listSafeFiles(backupProjDir)) {
564
+ if (!allowedProjectFiles.has(rel)) throw new Error('Backup contains an unsupported project file');
565
+ await safePath(localProjDir, rel);
566
+ }
520
567
  for (const file of files) {
521
568
  const src = path.join(backupProjDir, file);
522
569
  const dest = path.join(localProjDir, file);
@@ -530,14 +577,14 @@ export async function restoreMemories(sourceDir, spinner, onlyFilter = null, aut
530
577
  const srcStat = await fs.stat(src);
531
578
  const destStat = await fs.stat(dest);
532
579
  if (srcStat.mtimeMs > destStat.mtimeMs) {
533
- await fs.copy(src, dest);
580
+ await writeSafeFile(localProjDir, file, await readSafeFile(backupProjDir, file));
534
581
  console.log(chalk.yellow(` ↻ ${file}`) + chalk.gray(` (updated)`));
535
582
  } else {
536
583
  console.log(chalk.gray(` = ${file} (up to date)`));
537
584
  }
538
585
  } else {
539
586
  await fs.ensureDir(path.dirname(dest));
540
- await fs.copy(src, dest);
587
+ await writeSafeFile(localProjDir, file, await readSafeFile(backupProjDir, file));
541
588
  console.log(chalk.green(` + ${file}`) + chalk.gray(` (new)`));
542
589
  }
543
590
  totalRestored++;
package/src/cloud/auth.js CHANGED
@@ -22,16 +22,18 @@ async function supaFetch(endpoint, options = {}) {
22
22
  return res;
23
23
  }
24
24
 
25
+ // GoTrue's REST API takes the post-email landing URL as a `redirect_to`
26
+ // QUERY PARAM. `options.emailRedirectTo` / `options.redirectTo` in the JSON
27
+ // body is supabase-js's client shape — sent raw it is silently ignored and
28
+ // the email links fall back to the project's Site URL. Both URLs below must
29
+ // also be on the Auth → URL Configuration → Redirect URLs allow-list.
30
+ const CONFIRMED_URL = 'https://memoir.sh/confirmed';
31
+ const RESET_URL = 'https://memoir.sh/reset-password';
32
+
25
33
  export async function signUp(email, password) {
26
- const res = await supaFetch('/auth/v1/signup', {
34
+ const res = await supaFetch(`/auth/v1/signup?redirect_to=${encodeURIComponent(CONFIRMED_URL)}`, {
27
35
  method: 'POST',
28
- body: JSON.stringify({
29
- email,
30
- password,
31
- options: {
32
- emailRedirectTo: 'https://memoir.sh/confirmed',
33
- },
34
- }),
36
+ body: JSON.stringify({ email, password }),
35
37
  });
36
38
  const data = await res.json();
37
39
  if (!res.ok) throw new Error(data.error_description || data.msg || 'Sign up failed');
@@ -116,14 +118,9 @@ export async function getSubscription(session) {
116
118
  }
117
119
 
118
120
  export async function resetPassword(email) {
119
- const res = await supaFetch('/auth/v1/recover', {
121
+ const res = await supaFetch(`/auth/v1/recover?redirect_to=${encodeURIComponent(RESET_URL)}`, {
120
122
  method: 'POST',
121
- body: JSON.stringify({
122
- email,
123
- options: {
124
- redirectTo: 'https://memoir.sh/reset-password',
125
- },
126
- }),
123
+ body: JSON.stringify({ email }),
127
124
  });
128
125
  if (!res.ok) {
129
126
  const data = await res.json();
@@ -1,5 +1,9 @@
1
1
  export const SUPABASE_URL = process.env.MEMOIR_SUPABASE_URL || 'https://oqrkxytbahfwjhcbyzrx.supabase.co';
2
2
  export const SUPABASE_ANON_KEY = process.env.MEMOIR_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xcmt4eXRiYWhmd2poY2J5enJ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMyMTQ4MzMsImV4cCI6MjA4ODc5MDgzM30.jOKOi73OJgIgi1zj0VOIQkGp0xqS3ee4gfCjpdqCnvM';
3
3
  export const STORAGE_BUCKET = 'memoir-backups';
4
- export const MAX_BACKUPS_FREE = 100;
5
- export const MAX_BACKUPS_PRO = 50;
4
+ // Cloud backup retention (cleanupOldBackups prunes beyond these, oldest first).
5
+ // Through 3.11 these were FREE=100 / PRO=50 — Pro pruned twice as aggressively
6
+ // as free on a destructive path, while the upsell copy promised Pro more.
7
+ // Free is a safety net; Pro is "full version history" (memoir.sh pricing).
8
+ export const MAX_BACKUPS_FREE = 10;
9
+ export const MAX_BACKUPS_PRO = 100;