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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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)?
|
|
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
|
|
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:
|
|
387
|
+
default: false
|
|
379
388
|
}]);
|
|
380
389
|
|
|
381
390
|
if (confirm) {
|
|
382
391
|
for (const r of remove) {
|
|
383
392
|
try {
|
|
384
|
-
await
|
|
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
|
+
}
|
package/src/commands/diff.js
CHANGED
|
@@ -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
|
-
|
|
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))) {
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|