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
package/src/commands/restore.js
CHANGED
|
@@ -6,13 +6,14 @@ import ora from 'ora';
|
|
|
6
6
|
import boxen from 'boxen';
|
|
7
7
|
import gradient from 'gradient-string';
|
|
8
8
|
import inquirer from 'inquirer';
|
|
9
|
+
import { readSafeFile, writeSafeFile, relativeFile } from '../security/files.js';
|
|
9
10
|
import { getConfig, autoSetup } from '../config.js';
|
|
10
11
|
import { fetchFromLocal, fetchFromGit } from '../providers/restore.js';
|
|
11
12
|
import { decryptDirectory, verifyPassphrase } from '../security/encryption.js';
|
|
12
13
|
import { detectLocalHomeKey } from '../adapters/restore.js';
|
|
13
14
|
import { restoreWorkspace } from '../workspace/tracker.js';
|
|
14
15
|
import { getSession } from '../cloud/auth.js';
|
|
15
|
-
import { unbundleToDir } from '../cloud/storage.js';
|
|
16
|
+
import { unbundleToDir, readBoundedResponse } from '../cloud/storage.js';
|
|
16
17
|
import { SUPABASE_URL, SUPABASE_ANON_KEY, STORAGE_BUCKET } from '../cloud/constants.js';
|
|
17
18
|
import { readSession, writeSession, mergeSessions, paths as sessionPaths } from '../session/state.js';
|
|
18
19
|
import { withSessionLock } from '../session/lock.js';
|
|
@@ -34,7 +35,7 @@ export async function restoreCommand(options = {}) {
|
|
|
34
35
|
const setupSpinner = ora({ text: chalk.gray('Setting up memoir automatically...'), spinner: 'dots' }).start();
|
|
35
36
|
config = await autoSetup();
|
|
36
37
|
if (config) {
|
|
37
|
-
setupSpinner.succeed(chalk.green('Auto-configured') + chalk.gray(` → ${config.gitRepo}`));
|
|
38
|
+
setupSpinner.succeed(chalk.green('Auto-configured') + chalk.gray(` → ${config.gitRepo || config.localPath}`));
|
|
38
39
|
} else {
|
|
39
40
|
setupSpinner.fail(chalk.red('Could not detect GitHub username'));
|
|
40
41
|
console.log('\n' + boxen(
|
|
@@ -75,8 +76,9 @@ export async function restoreCommand(options = {}) {
|
|
|
75
76
|
|
|
76
77
|
// Verify passphrase first
|
|
77
78
|
const verifyPath = path.join(stagingDir, 'verify.enc');
|
|
78
|
-
let passphrase;
|
|
79
|
-
|
|
79
|
+
let passphrase = process.env.MEMOIR_PASSPHRASE;
|
|
80
|
+
if (!passphrase && !process.stdin.isTTY) throw new Error('Set MEMOIR_PASSPHRASE to restore this encrypted backup.');
|
|
81
|
+
for (let attempt = 0; !passphrase && attempt < 3; attempt++) {
|
|
80
82
|
const { pass } = await inquirer.prompt([{
|
|
81
83
|
type: 'password',
|
|
82
84
|
name: 'pass',
|
|
@@ -97,8 +99,7 @@ export async function restoreCommand(options = {}) {
|
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
if (!passphrase) {
|
|
100
|
-
|
|
101
|
-
return;
|
|
102
|
+
throw new Error('Too many failed passphrase attempts');
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
spinner.start(chalk.gray('Decrypting...'));
|
|
@@ -116,7 +117,7 @@ export async function restoreCommand(options = {}) {
|
|
|
116
117
|
await fs.copy(decryptedDir, stagingDir, { overwrite: true });
|
|
117
118
|
} catch (err) {
|
|
118
119
|
spinner.fail(chalk.red('Decryption failed: ') + err.message);
|
|
119
|
-
|
|
120
|
+
throw err;
|
|
120
121
|
} finally {
|
|
121
122
|
await fs.remove(decryptedDir);
|
|
122
123
|
}
|
|
@@ -135,8 +136,9 @@ export async function restoreCommand(options = {}) {
|
|
|
135
136
|
// raw JSON.parse, so an old-schema file from a lagging machine gets
|
|
136
137
|
// migrated up (or a too-new one safely degraded) BEFORE mergeSessions
|
|
137
138
|
// ever touches it. Symmetric with the push-side fix in push.js.
|
|
138
|
-
const rawRemote = JSON.parse(await
|
|
139
|
-
const { state: remote } = migrateSessionData(rawRemote);
|
|
139
|
+
const rawRemote = JSON.parse((await readSafeFile(stagingDir, 'session.json')).toString('utf8'));
|
|
140
|
+
const { state: remote, future } = migrateSessionData(rawRemote);
|
|
141
|
+
if (future) throw new Error('Backup uses a newer session schema. Upgrade Memoir first.');
|
|
140
142
|
// Read+merge+write inside ONE lock, like every state.js mutator.
|
|
141
143
|
// Reading outside the lock and locking only the write is a
|
|
142
144
|
// check-then-act: a concurrent MCP memoir_note landing in the window
|
|
@@ -159,8 +161,8 @@ export async function restoreCommand(options = {}) {
|
|
|
159
161
|
sessionMerged = true;
|
|
160
162
|
sessionNewMachine = Object.keys(merged.machines || {}).length > beforeMachines;
|
|
161
163
|
}
|
|
162
|
-
} catch {
|
|
163
|
-
|
|
164
|
+
} catch (err) {
|
|
165
|
+
throw new Error('Session restore failed: ' + err.message);
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
if (sessionMerged) {
|
|
@@ -182,7 +184,7 @@ export async function restoreCommand(options = {}) {
|
|
|
182
184
|
if (await fs.pathExists(handoffDir)) {
|
|
183
185
|
const latestPath = path.join(handoffDir, 'latest.md');
|
|
184
186
|
if (await fs.pathExists(latestPath)) {
|
|
185
|
-
handoffContent = await
|
|
187
|
+
handoffContent = (await readSafeFile(handoffDir, 'latest.md')).toString('utf8');
|
|
186
188
|
} else {
|
|
187
189
|
// Find newest handoff
|
|
188
190
|
const files = (await fs.readdir(handoffDir))
|
|
@@ -190,7 +192,7 @@ export async function restoreCommand(options = {}) {
|
|
|
190
192
|
.sort()
|
|
191
193
|
.reverse();
|
|
192
194
|
if (files.length > 0) {
|
|
193
|
-
handoffContent = await
|
|
195
|
+
handoffContent = (await readSafeFile(handoffDir, files[0])).toString('utf8');
|
|
194
196
|
}
|
|
195
197
|
}
|
|
196
198
|
}
|
|
@@ -199,26 +201,10 @@ export async function restoreCommand(options = {}) {
|
|
|
199
201
|
// Save locally
|
|
200
202
|
const localHandoffDir = path.join(home, '.config', 'memoir', 'handoffs');
|
|
201
203
|
await fs.ensureDir(localHandoffDir);
|
|
202
|
-
await
|
|
203
|
-
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
const claudeDir = path.join(home, '.claude');
|
|
207
|
-
if (await fs.pathExists(claudeDir)) {
|
|
208
|
-
let homeKey = detectLocalHomeKey(claudeDir);
|
|
209
|
-
if (!homeKey) {
|
|
210
|
-
// Fallback: compute key matching Claude's actual encoding
|
|
211
|
-
if (process.platform === 'win32') {
|
|
212
|
-
homeKey = home.replace(/\\/g, '-').replace(/:/g, '-');
|
|
213
|
-
} else {
|
|
214
|
-
homeKey = '-' + home.replace(/^\//, '').replace(/\//g, '-');
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
const claudeMemDir = path.join(claudeDir, 'projects', homeKey, 'memory');
|
|
218
|
-
await fs.ensureDir(claudeMemDir);
|
|
219
|
-
await fs.writeFile(path.join(claudeMemDir, 'handoff.md'), handoffContent);
|
|
220
|
-
handoffInjected = true;
|
|
221
|
-
}
|
|
204
|
+
await writeSafeFile(localHandoffDir, 'latest.md', handoffContent);
|
|
205
|
+
|
|
206
|
+
// Historical handoffs stay in the local archive. Project resume uses
|
|
207
|
+
// scoped session records; never inject an unscoped transcript globally.
|
|
222
208
|
|
|
223
209
|
// Extract info for display — handles both old and new handoff formats
|
|
224
210
|
const fromMatch = handoffContent.match(/\*\*From:\*\*\s*(.+)/) || handoffContent.match(/from \*\*(.+?)\*\*/);
|
|
@@ -239,7 +225,7 @@ export async function restoreCommand(options = {}) {
|
|
|
239
225
|
let workspaceResults = null;
|
|
240
226
|
try {
|
|
241
227
|
spinner.start(chalk.gray('Checking workspace...'));
|
|
242
|
-
workspaceResults = await restoreWorkspace(stagingDir, spinner, autoYes);
|
|
228
|
+
if (options.workspace === true) workspaceResults = await restoreWorkspace(stagingDir, spinner, autoYes);
|
|
243
229
|
spinner.stop();
|
|
244
230
|
|
|
245
231
|
if (workspaceResults) {
|
|
@@ -264,8 +250,8 @@ export async function restoreCommand(options = {}) {
|
|
|
264
250
|
restored = true;
|
|
265
251
|
}
|
|
266
252
|
}
|
|
267
|
-
} catch {
|
|
268
|
-
|
|
253
|
+
} catch (err) {
|
|
254
|
+
throw new Error('Workspace restore failed: ' + err.message);
|
|
269
255
|
}
|
|
270
256
|
|
|
271
257
|
if (restored) {
|
|
@@ -305,13 +291,14 @@ export async function restoreCommand(options = {}) {
|
|
|
305
291
|
|
|
306
292
|
} catch (error) {
|
|
307
293
|
spinner.fail(chalk.red('Restore failed: ') + error.message);
|
|
294
|
+
throw error;
|
|
308
295
|
} finally {
|
|
309
296
|
await fs.remove(stagingDir);
|
|
310
297
|
}
|
|
311
298
|
}
|
|
312
299
|
|
|
313
300
|
async function restoreFromShare(options) {
|
|
314
|
-
const shareToken = options.from;
|
|
301
|
+
const shareToken = encodeURIComponent(options.from);
|
|
315
302
|
|
|
316
303
|
console.log();
|
|
317
304
|
const spinner = ora({ text: chalk.gray('Fetching share link...'), spinner: 'dots' }).start();
|
|
@@ -389,7 +376,7 @@ async function restoreFromShare(options) {
|
|
|
389
376
|
? { 'Authorization': `Bearer ${session.access_token}`, 'apikey': SUPABASE_ANON_KEY }
|
|
390
377
|
: { 'apikey': SUPABASE_ANON_KEY };
|
|
391
378
|
|
|
392
|
-
const dlRes = await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}/${shareLink.backup_id}`, {
|
|
379
|
+
const dlRes = await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}/${relativeFile(shareLink.backup_id).split('/').map(encodeURIComponent).join('/')}`, {
|
|
393
380
|
headers: authHeaders,
|
|
394
381
|
});
|
|
395
382
|
|
|
@@ -397,7 +384,7 @@ async function restoreFromShare(options) {
|
|
|
397
384
|
throw new Error(`Download failed: ${await dlRes.text()}`);
|
|
398
385
|
}
|
|
399
386
|
|
|
400
|
-
const gzipped =
|
|
387
|
+
const gzipped = await readBoundedResponse(dlRes);
|
|
401
388
|
await unbundleToDir(gzipped, stagingDir);
|
|
402
389
|
|
|
403
390
|
// Decrypt — backup is always encrypted for shares
|
|
@@ -411,8 +398,9 @@ async function restoreFromShare(options) {
|
|
|
411
398
|
|
|
412
399
|
// Verify passphrase
|
|
413
400
|
const verifyPath = path.join(stagingDir, 'verify.enc');
|
|
414
|
-
let passphrase;
|
|
415
|
-
|
|
401
|
+
let passphrase = process.env.MEMOIR_PASSPHRASE;
|
|
402
|
+
if (!passphrase && !process.stdin.isTTY) throw new Error('Set MEMOIR_PASSPHRASE to restore this share');
|
|
403
|
+
for (let attempt = 0; !passphrase && attempt < 3; attempt++) {
|
|
416
404
|
const { pass } = await inquirer.prompt([{
|
|
417
405
|
type: 'password',
|
|
418
406
|
name: 'pass',
|
|
@@ -433,8 +421,7 @@ async function restoreFromShare(options) {
|
|
|
433
421
|
}
|
|
434
422
|
|
|
435
423
|
if (!passphrase) {
|
|
436
|
-
|
|
437
|
-
return;
|
|
424
|
+
throw new Error('Too many failed passphrase attempts');
|
|
438
425
|
}
|
|
439
426
|
|
|
440
427
|
spinner.start(chalk.gray('Decrypting...'));
|
|
@@ -453,7 +440,7 @@ async function restoreFromShare(options) {
|
|
|
453
440
|
restored = await restoreMemories(decryptedDir, spinner, onlyFilter, autoYes);
|
|
454
441
|
} catch (err) {
|
|
455
442
|
spinner.fail(chalk.red('Decryption failed: ') + err.message);
|
|
456
|
-
|
|
443
|
+
throw err;
|
|
457
444
|
} finally {
|
|
458
445
|
await fs.remove(decryptedDir);
|
|
459
446
|
}
|
|
@@ -491,6 +478,7 @@ async function restoreFromShare(options) {
|
|
|
491
478
|
|
|
492
479
|
} catch (error) {
|
|
493
480
|
spinner.fail(chalk.red('Restore from share failed: ') + error.message);
|
|
481
|
+
throw error;
|
|
494
482
|
} finally {
|
|
495
483
|
await fs.remove(stagingDir);
|
|
496
484
|
}
|
package/src/commands/resume.js
CHANGED
|
@@ -1,172 +1,23 @@
|
|
|
1
|
-
import chalk from 'chalk';
|
|
2
1
|
import fs from 'fs-extra';
|
|
3
2
|
import path from 'path';
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import { getConfig } from '../config.js';
|
|
9
|
-
import { execFileSync } from 'child_process';
|
|
10
|
-
|
|
11
|
-
const home = os.homedir();
|
|
12
|
-
|
|
13
|
-
// Fetch latest handoff from git backup
|
|
14
|
-
async function fetchLatestHandoff(config, spinner) {
|
|
15
|
-
const tmpDir = path.join(os.tmpdir(), `memoir-resume-${Date.now()}`);
|
|
16
|
-
await fs.ensureDir(tmpDir);
|
|
17
|
-
|
|
18
|
-
try {
|
|
19
|
-
if (config.provider === 'git' || config.provider.includes('git')) {
|
|
20
|
-
spinner.text = chalk.gray('Pulling latest handoff from GitHub...');
|
|
21
|
-
execFileSync('git', ['clone', '--depth', '1', config.gitRepo, '.'], { cwd: tmpDir, stdio: 'ignore' });
|
|
22
|
-
} else if (config.provider === 'local' || config.provider.includes('local')) {
|
|
23
|
-
const resolvedSource = config.localPath.replace(/^~/, home);
|
|
24
|
-
spinner.text = chalk.gray('Fetching handoff from local backup...');
|
|
25
|
-
await fs.copy(resolvedSource, tmpDir);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const handoffDir = path.join(tmpDir, 'handoffs');
|
|
29
|
-
if (!await fs.pathExists(handoffDir)) {
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Find the newest handoff file
|
|
34
|
-
const files = (await fs.readdir(handoffDir))
|
|
35
|
-
.filter(f => f.endsWith('.md') && f !== 'latest.md')
|
|
36
|
-
.sort()
|
|
37
|
-
.reverse();
|
|
38
|
-
|
|
39
|
-
if (files.length === 0) return null;
|
|
40
|
-
|
|
41
|
-
const content = await fs.readFile(path.join(handoffDir, files[0]), 'utf8');
|
|
42
|
-
return { filename: files[0], content };
|
|
43
|
-
} finally {
|
|
44
|
-
await fs.remove(tmpDir);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Inject handoff into a tool's context location
|
|
49
|
-
async function injectHandoff(content, tool) {
|
|
50
|
-
const targets = {
|
|
51
|
-
claude: () => {
|
|
52
|
-
// Write to Claude's project memory dir so it's auto-loaded
|
|
53
|
-
const cwd = process.cwd();
|
|
54
|
-
// Match Claude's actual path encoding: each separator → dash
|
|
55
|
-
let cwdKey;
|
|
56
|
-
if (process.platform === 'win32') {
|
|
57
|
-
cwdKey = cwd.replace(/\\/g, '-').replace(/:/g, '-');
|
|
58
|
-
} else {
|
|
59
|
-
cwdKey = '-' + cwd.replace(/^\//, '').replace(/\//g, '-');
|
|
60
|
-
}
|
|
61
|
-
const memDir = path.join(home, '.claude', 'projects', cwdKey, 'memory');
|
|
62
|
-
return path.join(memDir, 'handoff.md');
|
|
63
|
-
},
|
|
64
|
-
gemini: () => {
|
|
65
|
-
return path.join(process.cwd(), 'GEMINI.md');
|
|
66
|
-
},
|
|
67
|
-
cursor: () => {
|
|
68
|
-
return path.join(process.cwd(), '.cursor', 'rules', 'handoff.mdc');
|
|
69
|
-
},
|
|
70
|
-
codex: () => {
|
|
71
|
-
return path.join(process.cwd(), 'AGENTS.md');
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
const getTarget = targets[tool];
|
|
76
|
-
if (!getTarget) {
|
|
77
|
-
throw new Error(`Unknown tool: ${tool}. Supported: claude, gemini, cursor, codex`);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const targetPath = getTarget();
|
|
81
|
-
await fs.ensureDir(path.dirname(targetPath));
|
|
82
|
-
|
|
83
|
-
if (tool === 'gemini' && await fs.pathExists(targetPath)) {
|
|
84
|
-
// Append to existing GEMINI.md
|
|
85
|
-
const existing = await fs.readFile(targetPath, 'utf8');
|
|
86
|
-
if (!existing.includes('# Session Handoff')) {
|
|
87
|
-
await fs.writeFile(targetPath, existing + '\n\n' + content);
|
|
88
|
-
} else {
|
|
89
|
-
// Replace existing handoff section
|
|
90
|
-
const before = existing.split('# Session Handoff')[0];
|
|
91
|
-
await fs.writeFile(targetPath, before + content);
|
|
92
|
-
}
|
|
93
|
-
} else {
|
|
94
|
-
await fs.writeFile(targetPath, content);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return targetPath;
|
|
98
|
-
}
|
|
3
|
+
import { buildResumeBrief, formatResumeBrief } from '../session/brief.js';
|
|
4
|
+
import { BLOCK_START, BLOCK_END } from '../session/render.js';
|
|
5
|
+
import { injectInto } from '../session/inject.js';
|
|
6
|
+
import { safePath, writeSafeFile } from '../security/files.js';
|
|
99
7
|
|
|
100
8
|
export async function resumeCommand(options = {}) {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
chalk.red('Not configured yet\n\n') +
|
|
106
|
-
chalk.white('Run ') + chalk.cyan.bold('memoir init') + chalk.white(' to get started.'),
|
|
107
|
-
{ padding: 1, borderStyle: 'round', borderColor: 'red' }
|
|
108
|
-
) + '\n');
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
console.log();
|
|
113
|
-
const spinner = ora({ text: chalk.gray('Fetching latest handoff...'), spinner: 'dots' }).start();
|
|
114
|
-
|
|
115
|
-
// First check local cache
|
|
116
|
-
const localLatest = path.join(home, '.config', 'memoir', 'handoffs', 'latest.md');
|
|
117
|
-
let handoff;
|
|
118
|
-
|
|
119
|
-
// Try remote first
|
|
120
|
-
try {
|
|
121
|
-
handoff = await fetchLatestHandoff(config, spinner);
|
|
122
|
-
} catch (err) {
|
|
123
|
-
spinner.warn(chalk.yellow(`Remote fetch failed: ${err.message}`));
|
|
124
|
-
spinner.start();
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Fallback to local cache
|
|
128
|
-
if (!handoff && await fs.pathExists(localLatest)) {
|
|
129
|
-
handoff = { filename: 'latest.md', content: await fs.readFile(localLatest, 'utf8') };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
if (!handoff) {
|
|
133
|
-
spinner.fail(chalk.red('No handoffs found.'));
|
|
134
|
-
console.log(chalk.gray('\n Run ') + chalk.cyan('memoir snapshot') + chalk.gray(' on another machine first.\n'));
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Save locally
|
|
139
|
-
const localHandoffDir = path.join(home, '.config', 'memoir', 'handoffs');
|
|
140
|
-
await fs.ensureDir(localHandoffDir);
|
|
141
|
-
await fs.writeFile(path.join(localHandoffDir, 'latest.md'), handoff.content);
|
|
142
|
-
|
|
143
|
-
spinner.stop();
|
|
144
|
-
|
|
145
|
-
// Display the handoff
|
|
146
|
-
console.log(boxen(
|
|
147
|
-
gradient.pastel(' Session Handoff ') + '\n\n' +
|
|
148
|
-
handoff.content
|
|
149
|
-
.replace(/^---[\s\S]*?---\n/, '') // Strip YAML frontmatter for display
|
|
150
|
-
.trim(),
|
|
151
|
-
{ padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
|
|
152
|
-
));
|
|
153
|
-
|
|
154
|
-
// Inject if requested
|
|
9
|
+
const project = options.project || process.env.MEMOIR_PROJECT_ROOT || process.cwd();
|
|
10
|
+
const brief = await buildResumeBrief(project);
|
|
11
|
+
const content = formatResumeBrief(brief);
|
|
12
|
+
console.log(content);
|
|
155
13
|
if (options.inject) {
|
|
14
|
+
const targets = { claude: 'CLAUDE.md', codex: 'AGENTS.md', gemini: 'GEMINI.md', cursor: '.cursor/rules/memoir-resume.mdc' };
|
|
156
15
|
const tool = options.to || 'claude';
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
console.log(chalk.gray(` ${tool.charAt(0).toUpperCase() + tool.slice(1)} will read this on next session.\n`));
|
|
163
|
-
} catch (err) {
|
|
164
|
-
spinner.fail(chalk.red(`Inject failed: ${err.message}`));
|
|
165
|
-
}
|
|
166
|
-
} else {
|
|
167
|
-
console.log('\n' + chalk.gray(' To inject into your AI tool:'));
|
|
168
|
-
console.log(chalk.cyan(' memoir resume --inject') + chalk.gray(' (Claude)'));
|
|
169
|
-
console.log(chalk.cyan(' memoir resume --inject --to gemini'));
|
|
170
|
-
console.log(chalk.cyan(' memoir resume --inject --to cursor') + '\n');
|
|
16
|
+
if (!targets[tool]) throw new Error('Supported injection targets: claude, codex, gemini, cursor');
|
|
17
|
+
const target = await safePath(path.resolve(project), targets[tool], { createParents: true });
|
|
18
|
+
if (tool === 'cursor' && !await fs.pathExists(target)) await writeSafeFile(path.resolve(project), targets[tool], '---\ndescription: Memoir project handoff\nalwaysApply: true\n---\n');
|
|
19
|
+
await injectInto(target, [BLOCK_START, content, BLOCK_END].join('\n'));
|
|
20
|
+
console.log('\nUpdated the managed handoff in ' + target);
|
|
171
21
|
}
|
|
22
|
+
return brief;
|
|
172
23
|
}
|
package/src/commands/session.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
addGoal,
|
|
20
20
|
addNext,
|
|
21
21
|
completeNext,
|
|
22
|
+
completeGoal,
|
|
22
23
|
addNote,
|
|
23
24
|
addQuestion,
|
|
24
25
|
getMachineId,
|
|
@@ -46,25 +47,57 @@ async function refreshPinned() {
|
|
|
46
47
|
return { state, rendered, updated };
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
export async function goalCommand(text) {
|
|
50
|
+
export async function goalCommand(text, options = {}) {
|
|
51
|
+
if (options.done) {
|
|
52
|
+
const state = await completeGoal(String(options.done).trim());
|
|
53
|
+
if (state.completed) {
|
|
54
|
+
await refreshPinned();
|
|
55
|
+
console.log('\n' + chalk.green(' ✓ Goal retired: ') + chalk.white(options.done) + '\n');
|
|
56
|
+
} else {
|
|
57
|
+
console.log('\n' + chalk.yellow(' No matching goal found.\n'));
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
50
61
|
if (!text || !String(text).trim()) {
|
|
51
|
-
console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir goal "your current focus"\n'));
|
|
62
|
+
console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir goal "your current focus"') + chalk.gray(' or ') + chalk.cyan('memoir goal --done "substring"\n'));
|
|
52
63
|
return;
|
|
53
64
|
}
|
|
54
|
-
await addGoal(String(text).trim());
|
|
65
|
+
const state = await addGoal(String(text).trim());
|
|
55
66
|
const { updated } = await refreshPinned();
|
|
56
67
|
console.log('\n' + chalk.green(' ✓ Goal set: ') + chalk.white(text));
|
|
68
|
+
for (const g of state.replacedGoals || []) {
|
|
69
|
+
console.log(chalk.yellow(' ⚠ Goals list is full — replaced: ') + chalk.white(g.text));
|
|
70
|
+
}
|
|
57
71
|
if (updated.length) console.log(chalk.gray(` Pinned to: ${updated.join(', ')}\n`));
|
|
58
72
|
}
|
|
59
73
|
|
|
60
|
-
export async function nextCommand(text) {
|
|
74
|
+
export async function nextCommand(text, options = {}) {
|
|
75
|
+
if (options.parked) {
|
|
76
|
+
const state = await readSession();
|
|
77
|
+
const parked = state.current.parked_actions || [];
|
|
78
|
+
if (!parked.length) {
|
|
79
|
+
console.log('\n' + chalk.gray(' Nothing parked.\n'));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
console.log('\n' + chalk.white.bold(` Parked next-actions (${parked.length}):`));
|
|
83
|
+
for (const p of parked) {
|
|
84
|
+
const when = (p.parked_at || '').slice(0, 10);
|
|
85
|
+
console.log(' ' + chalk.gray(`[ ] ${when ? when + ' ' : ''}`) + chalk.white(p.text));
|
|
86
|
+
}
|
|
87
|
+
console.log(chalk.gray('\n Re-add one with `memoir next "…"` to bring it back; `memoir done "…"` finishes it.\n'));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
61
90
|
if (!text || !String(text).trim()) {
|
|
62
|
-
console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir next "the next action"\n'));
|
|
91
|
+
console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir next "the next action"') + chalk.gray(' or ') + chalk.cyan('memoir next --parked\n'));
|
|
63
92
|
return;
|
|
64
93
|
}
|
|
65
|
-
await addNext(String(text).trim());
|
|
94
|
+
const state = await addNext(String(text).trim());
|
|
66
95
|
await refreshPinned();
|
|
67
|
-
console.log('\n' + chalk.green(' ✓ Added to next: ') + chalk.white(text)
|
|
96
|
+
console.log('\n' + chalk.green(' ✓ Added to next: ') + chalk.white(text));
|
|
97
|
+
for (const p of state.justParked || []) {
|
|
98
|
+
console.log(chalk.yellow(' ⚠ Next list is full — parked (still open): ') + chalk.white(p.text));
|
|
99
|
+
}
|
|
100
|
+
console.log('');
|
|
68
101
|
}
|
|
69
102
|
|
|
70
103
|
export async function doneCommand(text) {
|
|
@@ -72,10 +105,11 @@ export async function doneCommand(text) {
|
|
|
72
105
|
console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir done "substring of the action"\n'));
|
|
73
106
|
return;
|
|
74
107
|
}
|
|
108
|
+
const count = (st) => st.current.next_actions.length + (st.current.parked_actions || []).length;
|
|
75
109
|
const before = await readSession();
|
|
76
110
|
await completeNext(String(text).trim());
|
|
77
111
|
const after = await readSession();
|
|
78
|
-
const removed = before
|
|
112
|
+
const removed = count(before) - count(after);
|
|
79
113
|
if (removed > 0) {
|
|
80
114
|
await refreshPinned();
|
|
81
115
|
console.log('\n' + chalk.green(` ✓ Completed ${removed} action${removed !== 1 ? 's' : ''}\n`));
|
|
@@ -140,6 +174,14 @@ export async function sessionShowCommand() {
|
|
|
140
174
|
body.push('');
|
|
141
175
|
}
|
|
142
176
|
|
|
177
|
+
const parked = state.current.parked_actions || [];
|
|
178
|
+
if (parked.length) {
|
|
179
|
+
body.push(chalk.white.bold(` Parked (${parked.length}):`));
|
|
180
|
+
for (const p of parked.slice(0, 4)) body.push(' ' + chalk.gray('[ ] ') + chalk.gray(p.text.length > 100 ? p.text.slice(0, 100) + '…' : p.text));
|
|
181
|
+
if (parked.length > 4) body.push(chalk.gray(` …and ${parked.length - 4} more — memoir next --parked`));
|
|
182
|
+
body.push('');
|
|
183
|
+
}
|
|
184
|
+
|
|
143
185
|
if (questions.length) {
|
|
144
186
|
body.push(chalk.white.bold(' Open questions:'));
|
|
145
187
|
for (const q of questions) body.push(' ' + chalk.yellow('? ') + chalk.white(q.text));
|
|
@@ -185,7 +227,7 @@ export async function sessionClearCommand() {
|
|
|
185
227
|
// write was silently lost, and worse, could resurrect what was cleared.
|
|
186
228
|
await withSessionLock(paths.sessionLock, async () => {
|
|
187
229
|
const state = await readSession();
|
|
188
|
-
state.current = { goals: [], next_actions: [], open_questions: [], decisions: [] };
|
|
230
|
+
state.current = { goals: [], next_actions: [], parked_actions: [], open_questions: [], decisions: [], completed_actions: [], completed_goals: [] };
|
|
189
231
|
await writeSession(state);
|
|
190
232
|
});
|
|
191
233
|
await refreshPinned();
|
package/src/commands/snapshot.js
CHANGED
|
@@ -7,6 +7,7 @@ import boxen from 'boxen';
|
|
|
7
7
|
import gradient from 'gradient-string';
|
|
8
8
|
import { getConfig, getGeminiApiKey } from '../config.js';
|
|
9
9
|
import { syncToLocal, syncToGit } from '../providers/index.js';
|
|
10
|
+
import { saveHandoff } from '../context/handoffs.js';
|
|
10
11
|
|
|
11
12
|
const home = os.homedir();
|
|
12
13
|
|
|
@@ -337,7 +338,9 @@ export async function snapshotCommand(options = {}) {
|
|
|
337
338
|
if (config.provider === 'local' || config.provider.includes('local')) {
|
|
338
339
|
await syncToLocal(config, stagingDir, spinner);
|
|
339
340
|
} else if (config.provider === 'git' || config.provider.includes('git')) {
|
|
340
|
-
|
|
341
|
+
// additive: the staging dir holds ONE handoff file. A mirror push
|
|
342
|
+
// here would have deleted every other file in the backup.
|
|
343
|
+
await syncToGit(config, stagingDir, spinner, { additive: true });
|
|
341
344
|
}
|
|
342
345
|
} catch (err) {
|
|
343
346
|
spinner.warn(chalk.yellow(`Push failed: ${err.message}. Saved locally.`));
|
|
@@ -346,13 +349,9 @@ export async function snapshotCommand(options = {}) {
|
|
|
346
349
|
await fs.remove(stagingDir);
|
|
347
350
|
}
|
|
348
351
|
|
|
349
|
-
// Also save locally for immediate access
|
|
352
|
+
// Also save locally for immediate access (+ latest.md; dir is pruned)
|
|
350
353
|
const localHandoffDir = path.join(home, '.config', 'memoir', 'handoffs');
|
|
351
|
-
await
|
|
352
|
-
await fs.writeFile(path.join(localHandoffDir, filename), handoff);
|
|
353
|
-
|
|
354
|
-
// Also save as "latest" for easy access
|
|
355
|
-
await fs.writeFile(path.join(localHandoffDir, 'latest.md'), handoff);
|
|
354
|
+
await saveHandoff(handoff, { dirs: [localHandoffDir], filename });
|
|
356
355
|
|
|
357
356
|
spinner.stop();
|
|
358
357
|
|
package/src/commands/status.js
CHANGED
|
@@ -5,6 +5,23 @@ import boxen from 'boxen';
|
|
|
5
5
|
import gradient from 'gradient-string';
|
|
6
6
|
import { getConfig } from '../config.js';
|
|
7
7
|
import { adapters } from '../adapters/index.js';
|
|
8
|
+
import { paths as eventPaths } from '../events/log.js';
|
|
9
|
+
import { summarizeEvents, formatSummaryLines } from '../events/summary.js';
|
|
10
|
+
|
|
11
|
+
const WINDOW_DAYS = 7;
|
|
12
|
+
|
|
13
|
+
// Best-effort usage block from events.jsonl (names and counts only — the
|
|
14
|
+
// log never holds content). Absent or unreadable log → no block.
|
|
15
|
+
async function usageLines() {
|
|
16
|
+
try {
|
|
17
|
+
if (!(await fs.pathExists(eventPaths.events))) return [];
|
|
18
|
+
const raw = await fs.readFile(eventPaths.events, 'utf8');
|
|
19
|
+
const s = summarizeEvents(raw, { sinceMs: WINDOW_DAYS * 24 * 60 * 60 * 1000 });
|
|
20
|
+
return formatSummaryLines(s, { days: WINDOW_DAYS });
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
8
25
|
|
|
9
26
|
export async function statusCommand(options = {}) {
|
|
10
27
|
const config = await getConfig(options.profile);
|
|
@@ -59,11 +76,16 @@ export async function statusCommand(options = {}) {
|
|
|
59
76
|
? '\n' + chalk.gray(` Also supports: ${notFound.join(', ')}`)
|
|
60
77
|
: '';
|
|
61
78
|
|
|
79
|
+
const usage = await usageLines();
|
|
80
|
+
const usageBlock = usage.length
|
|
81
|
+
? '\n\n' + chalk.bold.white(`Last ${WINDOW_DAYS} days`) + '\n' + usage.map((l) => chalk.gray(' ') + chalk.white(l)).join('\n')
|
|
82
|
+
: '';
|
|
83
|
+
|
|
62
84
|
console.log(boxen(
|
|
63
85
|
gradient.pastel(' memoir status ') + '\n\n' +
|
|
64
86
|
configLine + '\n\n' +
|
|
65
87
|
chalk.bold.white('AI Tools') + '\n' +
|
|
66
|
-
lines.join('\n') + notFoundLine + '\n\n' +
|
|
88
|
+
lines.join('\n') + notFoundLine + usageBlock + '\n\n' +
|
|
67
89
|
chalk.gray('─'.repeat(30)) + '\n' +
|
|
68
90
|
summary,
|
|
69
91
|
{ padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
|
package/src/commands/upgrade.js
CHANGED
|
@@ -18,12 +18,18 @@ async function createCheckoutSession(session) {
|
|
|
18
18
|
return data.url;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// No shell. The URL comes from our own API, but `exec(\`open "${url}"\`)`
|
|
22
|
+
// is still a shell string built from data — flagged (correctly) by the
|
|
23
|
+
// agentscores.xyz scan as command injection, and one bad quote away from
|
|
24
|
+
// being one. execFile passes the URL as a single argument; on Windows
|
|
25
|
+
// rundll32's URL handler avoids cmd.exe parsing `&` inside the query string.
|
|
21
26
|
function openUrl(url) {
|
|
22
|
-
const {
|
|
27
|
+
const { execFile } = require('child_process');
|
|
23
28
|
const platform = process.platform;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
const [cmd, args] = platform === 'darwin' ? ['open', [url]]
|
|
30
|
+
: platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
|
|
31
|
+
: ['xdg-open', [url]];
|
|
32
|
+
try { execFile(cmd, args, () => {}); } catch {}
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
export async function upgradeCommand() {
|
|
@@ -63,7 +69,7 @@ export async function upgradeCommand() {
|
|
|
63
69
|
const sep = chalk.gray('─'.repeat(col1 + col2 + 18));
|
|
64
70
|
|
|
65
71
|
const rows = [
|
|
66
|
-
[chalk.gray('
|
|
72
|
+
[chalk.gray('10 cloud backups'), chalk.white('100 backups'), chalk.white('100 backups')],
|
|
67
73
|
[chalk.gray('Local only'), chalk.white('Unlimited machines'), chalk.white('Shared team context')],
|
|
68
74
|
[chalk.gray('Manual snapshots'), chalk.white('Auto snapshots'), chalk.white('Team dashboard')],
|
|
69
75
|
[chalk.gray('Community support'), chalk.white('Priority support'), chalk.white('Audit log')],
|
|
@@ -105,11 +111,7 @@ export async function upgradeCommand() {
|
|
|
105
111
|
const url = await createCheckoutSession(session);
|
|
106
112
|
spinner.succeed(chalk.green(' Opening Stripe checkout...'));
|
|
107
113
|
|
|
108
|
-
|
|
109
|
-
const platform = process.platform;
|
|
110
|
-
if (platform === 'darwin') exec(`open "${url}"`);
|
|
111
|
-
else if (platform === 'win32') exec(`start "" "${url}"`);
|
|
112
|
-
else exec(`xdg-open "${url}"`);
|
|
114
|
+
openUrl(url);
|
|
113
115
|
|
|
114
116
|
console.log(
|
|
115
117
|
'\n' + chalk.gray(' Complete payment in your browser.') + '\n' +
|
|
@@ -119,7 +121,7 @@ export async function upgradeCommand() {
|
|
|
119
121
|
);
|
|
120
122
|
} catch (err) {
|
|
121
123
|
spinner.fail(chalk.red(' ' + err.message));
|
|
122
|
-
console.log(chalk.gray('\n Fallback: visit ') + chalk.cyan('https://memoir.sh
|
|
124
|
+
console.log(chalk.gray('\n Fallback: visit ') + chalk.cyan('https://memoir.sh/#pricing') + '\n');
|
|
123
125
|
}
|
|
124
126
|
} else if (!session) {
|
|
125
127
|
console.log('\n' + chalk.gray(' Run ') + chalk.cyan('memoir login') + chalk.gray(' to create an account, then ') + chalk.cyan('memoir upgrade') + chalk.gray(' to subscribe.') + '\n');
|