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
@@ -1,3 +1,5 @@
1
+ import crypto from 'crypto';
2
+ import { readSafeFile, writeSafeFile, safePath } from '../security/files.js';
1
3
  import chalk from 'chalk';
2
4
  import fs from 'fs-extra';
3
5
  import path from 'path';
@@ -21,7 +23,7 @@ async function readMemoryFiles(adapter) {
21
23
  const filePath = path.join(adapter.source, file);
22
24
  if (await fs.pathExists(filePath)) {
23
25
  try {
24
- const content = await fs.readFile(filePath, 'utf8');
26
+ const content = (await readSafeFile(adapter.source, file)).toString('utf8');
25
27
  const stat = await fs.stat(filePath);
26
28
  files.push({ path: file, fullPath: filePath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
27
29
  } catch {}
@@ -44,10 +46,10 @@ async function readMemoryFiles(adapter) {
44
46
  if (adapter.filter(fullPath)) {
45
47
  await walk(fullPath, relPath);
46
48
  }
47
- } else if (/\.(md|json|yml|yaml)$/.test(entry.name)) {
49
+ } else if (entry.isFile() && /\.(md|json|yml|yaml)$/.test(entry.name)) {
48
50
  if (adapter.filter(fullPath)) {
49
51
  try {
50
- const content = await fs.readFile(fullPath, 'utf8');
52
+ const content = (await readSafeFile(adapter.source, relPath)).toString('utf8');
51
53
  const stat = await fs.stat(fullPath);
52
54
  files.push({ path: relPath, fullPath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
53
55
  } catch {}
@@ -148,7 +150,7 @@ async function llmConsolidate(allFiles, apiKey) {
148
150
  const memoryDigest = allFiles
149
151
  .filter(f => f.content.trim().length > 10)
150
152
  .map(f => `[${f.tool} / ${f.path}] (${daysAgo(f.mtime)}d old, ${f.size}B)\n${f.content.slice(0, 500)}${f.content.length > 500 ? '...' : ''}`)
151
- .join('\n\n---\n\n');
153
+ .join('\n\n---\n\n').slice(0, 64000);
152
154
 
153
155
  const prompt = `You are a memory consolidation engine. Analyze these AI tool memory files and produce a consolidation report.
154
156
 
@@ -176,8 +178,11 @@ Rules:
176
178
  - Be conservative — when in doubt, keep the memory
177
179
  - Return valid JSON only, no markdown fences`;
178
180
 
179
- const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`, {
181
+ const model = process.env.MEMOIR_CONSOLIDATE_MODEL || 'gemini-2.0-flash';
182
+ if (!/^[a-z0-9.-]+$/i.test(model)) throw new Error('Invalid consolidation model');
183
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`, {
180
184
  method: 'POST',
185
+ signal: AbortSignal.timeout(30000),
181
186
  headers: { 'Content-Type': 'application/json' },
182
187
  body: JSON.stringify({
183
188
  contents: [{ parts: [{ text: prompt }] }],
@@ -316,7 +321,7 @@ async function applyPrune(files, allFiles) {
316
321
  const { toDelete } = await inquirer.prompt([{
317
322
  type: 'checkbox',
318
323
  name: 'toDelete',
319
- message: 'Select memories to delete:',
324
+ message: 'Select memories to archive:',
320
325
  choices
321
326
  }]);
322
327
 
@@ -328,7 +333,7 @@ async function applyPrune(files, allFiles) {
328
333
  const { confirm } = await inquirer.prompt([{
329
334
  type: 'confirm',
330
335
  name: 'confirm',
331
- message: `Delete ${toDelete.length} file(s)? This cannot be undone.`,
336
+ message: `Delete ${toDelete.length} file(s)? A recovery copy will be saved locally.`,
332
337
  default: false
333
338
  }]);
334
339
 
@@ -340,7 +345,7 @@ async function applyPrune(files, allFiles) {
340
345
  let deleted = 0;
341
346
  for (const file of toDelete) {
342
347
  try {
343
- await fs.remove(file.fullPath);
348
+ await archiveFile(file);
344
349
  console.log(chalk.red(` ✖ Deleted: ${file.tool}/${file.path}`));
345
350
  deleted++;
346
351
  } catch (err) {
@@ -355,6 +360,10 @@ async function applyMerge(duplicateGroups, allFiles) {
355
360
  let merged = 0;
356
361
 
357
362
  for (const group of duplicateGroups) {
363
+ if (!group.every(file => file.content === group[0].content && file.tool === group[0].tool)) {
364
+ console.log(chalk.gray(' Similar or cross-tool files need a reviewed merge; no files removed.'));
365
+ continue;
366
+ }
358
367
  console.log(chalk.gray('\n ┌ Duplicate group:'));
359
368
  for (const f of group) {
360
369
  console.log(` │ ${f.icon} ${chalk.cyan(f.tool)}/${chalk.white(f.path)} ${chalk.gray(`(${daysAgo(f.mtime)}d old)`)}`);
@@ -375,13 +384,13 @@ async function applyMerge(duplicateGroups, allFiles) {
375
384
  type: 'confirm',
376
385
  name: 'confirm',
377
386
  message: `Remove ${remove.length} duplicate(s), keep the newest?`,
378
- default: true
387
+ default: false
379
388
  }]);
380
389
 
381
390
  if (confirm) {
382
391
  for (const r of remove) {
383
392
  try {
384
- await fs.remove(r.fullPath);
393
+ await archiveFile(r);
385
394
  console.log(chalk.red(` ✖ Removed: ${r.tool}/${r.path}`));
386
395
  merged++;
387
396
  } catch (err) {
@@ -397,6 +406,7 @@ async function applyMerge(duplicateGroups, allFiles) {
397
406
  // ── Main Command ─────────────────────────────────────────────────────────────
398
407
 
399
408
  export async function consolidateCommand(options = {}) {
409
+ if (options.undo) return undoArchive(options.undo);
400
410
  console.log();
401
411
  const spinner = ora({ text: chalk.gray('Scanning memories across all tools...'), spinner: 'dots' }).start();
402
412
 
@@ -475,3 +485,32 @@ export async function consolidateCommand(options = {}) {
475
485
  }
476
486
  }
477
487
  }
488
+
489
+ const archiveRoot = path.join(home, '.config', 'memoir', 'consolidation-history');
490
+
491
+ export async function archiveFile(file) {
492
+ const adapter = adapters.find(a => a.name === file.tool);
493
+ if (!adapter) throw new Error('Unknown adapter');
494
+ const content = await readSafeFile(adapter.source, file.path);
495
+ if (content.toString('utf8') !== file.content) throw new Error('Memory changed since analysis; run analysis again');
496
+ const id = crypto.randomUUID();
497
+ await writeSafeFile(archiveRoot, id + '.json', JSON.stringify({
498
+ tool: adapter.name, path: file.path, content: content.toString('base64'), date: new Date().toISOString(),
499
+ }));
500
+ await fs.unlink(await safePath(adapter.source, file.path));
501
+ console.log(' Undo with: memoir consolidate --undo ' + id);
502
+ return id;
503
+ }
504
+
505
+ export async function undoArchive(id) {
506
+ if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error('Invalid archive ID');
507
+ const entry = JSON.parse((await readSafeFile(archiveRoot, id + '.json')).toString());
508
+ const adapter = adapters.find(a => a.name === entry.tool);
509
+ if (!adapter || (adapter.customExtract ? !adapter.files.includes(entry.path) : !adapter.filter(path.join(adapter.source, entry.path)))) throw new Error('Archive target is outside the adapter allowlist');
510
+ try {
511
+ await readSafeFile(adapter.source, entry.path);
512
+ throw new Error('The target exists; review it before restoring the archive');
513
+ } catch (err) { if (err.code !== 'ENOENT') throw err; }
514
+ await writeSafeFile(adapter.source, entry.path, Buffer.from(entry.content, 'base64'));
515
+ console.log('Restored archived memory ' + id);
516
+ }
@@ -4,7 +4,7 @@ import path from 'path';
4
4
  import os from 'os';
5
5
  import ora from 'ora';
6
6
  import boxen from 'boxen';
7
- import { execSync } from 'child_process';
7
+ import { execSync, execFileSync } from 'child_process';
8
8
  import { getConfig } from '../config.js';
9
9
  import { adapters } from '../adapters/index.js';
10
10
 
@@ -63,7 +63,7 @@ export async function diffCommand(options = {}) {
63
63
 
64
64
  try {
65
65
  if (config.provider === 'git') {
66
- execSync(`git clone --depth 1 ${config.gitRepo} .`, { cwd: stagingDir, stdio: 'ignore' });
66
+ execFileSync('git', ['clone', '--depth', '1', '--', config.gitRepo, '.'], { cwd: stagingDir, stdio: 'ignore' });
67
67
  } else {
68
68
  const resolvedSource = config.localPath.replace(/^~/, os.homedir());
69
69
  if (!(await fs.pathExists(resolvedSource))) {
@@ -5,7 +5,7 @@ import boxen from 'boxen';
5
5
  import ora from 'ora';
6
6
  import gradient from 'gradient-string';
7
7
  import os from 'os';
8
- import { execSync } from 'child_process';
8
+ import { execSync, execFileSync } from 'child_process';
9
9
  import { getConfig } from '../config.js';
10
10
  import { adapters } from '../adapters/index.js';
11
11
  import { scanForSecrets as scanTextForSecrets } from '../security/scanner.js';
@@ -100,7 +100,7 @@ export async function doctorCommand(options = {}) {
100
100
  if (config?.provider === 'git' && gitInstalled && config.gitRepo) {
101
101
  spinner.text = 'Testing remote connectivity...';
102
102
  try {
103
- execSync(`git ls-remote ${config.gitRepo} HEAD`, { stdio: 'pipe', timeout: 10000 });
103
+ execFileSync('git', ['ls-remote', config.gitRepo, 'HEAD'], { stdio: 'pipe', timeout: 10000 });
104
104
  lines.push(pass(`Remote: ${chalk.gray(config.gitRepo)} reachable`));
105
105
  } catch {
106
106
  lines.push(fail(`Remote: cannot reach ${chalk.gray(config.gitRepo)}`));
@@ -185,7 +185,7 @@ export async function doctorCommand(options = {}) {
185
185
  lines.push(chalk.bold.white(' Last Sync'));
186
186
  try {
187
187
  const tmpDir = path.join(os.tmpdir(), 'memoir-doctor-' + Date.now());
188
- execSync(`git clone --depth 1 ${config.gitRepo} ${tmpDir}`, { stdio: 'pipe', timeout: 15000 });
188
+ execFileSync('git', ['clone', '--depth', '1', '--', config.gitRepo, tmpDir], { stdio: 'pipe', timeout: 15000 });
189
189
  const lastCommit = execSync('git log -1 --format=%cr', { cwd: tmpDir, stdio: 'pipe' }).toString().trim();
190
190
  const lastMsg = execSync('git log -1 --format=%s', { cwd: tmpDir, stdio: 'pipe' }).toString().trim();
191
191
  await fs.remove(tmpDir);
@@ -0,0 +1,100 @@
1
+ // `memoir forget <text>` — retract a decision.
2
+ //
3
+ // Sets the SPEC.md 5.3.1 absolute tombstone (hidden + hidden_at). Before
4
+ // 3.12 the ONLY thing that could do this was an unshipped dev script, so a
5
+ // user who auto-captured junk — or a secret — into the pinned block had no
6
+ // way to take it back. Now they do.
7
+ //
8
+ // Two rules that make this safe:
9
+ // • Ambiguity is refused. Substring matching is convenient (same as
10
+ // `memoir done`) but hiding is permanent by spec — hidden is monotonic
11
+ // across every replica — so if more than one visible decision matches
12
+ // we list them and ask for a more specific string, never guess.
13
+ // • Interactive runs confirm before hiding. --yes skips that for scripts.
14
+ //
15
+ // --purge additionally redacts the text in place (keeps a sha256 identity
16
+ // so the tombstone still merges). Use it when the text itself must leave
17
+ // the file — a pasted key, a client name — not just leave the render.
18
+
19
+ import chalk from 'chalk';
20
+ import boxen from 'boxen';
21
+ import readline from 'readline';
22
+ import { readSession, matchDecisions, hideDecision } from '../session/state.js';
23
+ import { renderSession } from '../session/render.js';
24
+ import { injectInto, detectAvailableTargets } from '../session/inject.js';
25
+
26
+ async function refreshPinned() {
27
+ const state = await readSession();
28
+ const rendered = renderSession(state);
29
+ const targets = detectAvailableTargets();
30
+ for (const target of Object.values(targets)) {
31
+ try { await injectInto(target, rendered); } catch {}
32
+ }
33
+ }
34
+
35
+ function describe(d) {
36
+ const lines = [chalk.white.bold(` ${d.text}`)];
37
+ if (d.why) lines.push(chalk.gray(' why: ') + chalk.white(d.why));
38
+ if (d.rejected) lines.push(chalk.gray(' rejected: ') + chalk.white(d.rejected));
39
+ if (d.date) lines.push(chalk.gray(` ${String(d.date).slice(0, 10)}`));
40
+ return lines.join('\n');
41
+ }
42
+
43
+ async function confirm(question) {
44
+ if (!process.stdin.isTTY) return false;
45
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
46
+ const answer = await new Promise((resolve) => rl.question(question, resolve));
47
+ rl.close();
48
+ return /^y(es)?$/i.test(String(answer).trim());
49
+ }
50
+
51
+ export async function forgetCommand(text, options = {}) {
52
+ const query = String(text || '').trim();
53
+ if (!query) {
54
+ console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir forget "substring of the decision" [--purge] [--yes]\n'));
55
+ return;
56
+ }
57
+
58
+ const state = await readSession();
59
+ const matches = matchDecisions(state, query);
60
+
61
+ if (matches.length === 0) {
62
+ console.log('\n' + boxen(
63
+ chalk.yellow(`No visible decision matches "${query}".`) + '\n\n' +
64
+ chalk.gray('See what is recorded with: ') + chalk.cyan('memoir why'),
65
+ { padding: 1, borderStyle: 'round', borderColor: 'yellow' }
66
+ ) + '\n');
67
+ return;
68
+ }
69
+
70
+ if (matches.length > 1) {
71
+ console.log('\n' + chalk.yellow(` "${query}" matches ${matches.length} decisions — be more specific, forgetting is permanent:`) + '\n');
72
+ for (const d of matches) console.log(describe(d) + '\n');
73
+ return;
74
+ }
75
+
76
+ const [target] = matches;
77
+ console.log('\n' + chalk.cyan.bold(options.purge ? ' About to forget AND purge:' : ' About to forget:') + '\n');
78
+ console.log(describe(target) + '\n');
79
+ console.log(chalk.gray(options.purge
80
+ ? ' The text will be redacted in session.json on this machine and, after sync, on every other machine. This cannot be undone.'
81
+ : ' It will be hidden from the pinned block, memoir why, and MCP lookups on every machine after sync. This cannot be undone.'
82
+ ) + '\n');
83
+
84
+ if (!options.yes) {
85
+ const ok = await confirm(chalk.white(' Forget it? [y/N] '));
86
+ if (!ok) {
87
+ console.log(chalk.gray('\n Left as is.\n'));
88
+ return;
89
+ }
90
+ }
91
+
92
+ const res = await hideDecision(target.text, { purge: !!options.purge });
93
+ if (!res.hidden) {
94
+ console.log(chalk.yellow('\n Nothing changed — it may have been forgotten by another process meanwhile.\n'));
95
+ return;
96
+ }
97
+ await refreshPinned();
98
+ console.log('\n' + chalk.green(res.purged ? ' ✓ Forgotten and purged.' : ' ✓ Forgotten.') +
99
+ chalk.gray(' Run ') + chalk.cyan('memoir push') + chalk.gray(' to propagate the tombstone.\n'));
100
+ }