memoir-cli 2.5.3 → 3.0.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/bin/memoir.js CHANGED
@@ -62,6 +62,7 @@ if (process.argv.length <= 2) {
62
62
  chalk.cyan(' memoir resume ') + chalk.gray('— pick up where you left off') + '\n' +
63
63
  chalk.cyan(' memoir status ') + chalk.gray('— see detected AI tools') + '\n' +
64
64
  chalk.cyan(' memoir profile ') + chalk.gray('— manage profiles (personal/work)') + '\n' +
65
+ chalk.cyan(' memoir encrypt ') + chalk.gray('— toggle E2E encryption') + '\n' +
65
66
  chalk.cyan(' memoir update ') + chalk.gray('— update to latest version') + '\n\n' +
66
67
  chalk.white.bold('Cloud (Pro):') + '\n' +
67
68
  chalk.cyan(' memoir login ') + chalk.gray('— sign in to memoir cloud') + '\n' +
@@ -257,6 +258,44 @@ program
257
258
  }
258
259
  });
259
260
 
261
+ program
262
+ .command('encrypt')
263
+ .description('Toggle E2E encryption for your backups')
264
+ .action(async () => {
265
+ try {
266
+ const { getConfig, getRawConfig, saveConfig, migrateConfigToV2 } = await import('../src/config.js');
267
+ const config = await getConfig();
268
+ if (!config) {
269
+ console.error(chalk.red('\n✖ Not configured. Run memoir init first.'));
270
+ process.exit(1);
271
+ }
272
+ const current = config.encrypt || false;
273
+ console.log(chalk.white(`\n Encryption is currently: ${current ? chalk.green('ON') : chalk.red('OFF')}`));
274
+ const inquirer = (await import('inquirer')).default;
275
+ const { toggle } = await inquirer.prompt([{
276
+ type: 'confirm',
277
+ name: 'toggle',
278
+ message: current ? 'Disable encryption?' : 'Enable encryption?',
279
+ default: !current
280
+ }]);
281
+ if (toggle !== current) {
282
+ let raw = await getRawConfig();
283
+ if (!raw.version || raw.version < 2) raw = migrateConfigToV2(raw);
284
+ const profileName = raw.activeProfile || 'default';
285
+ if (raw.profiles?.[profileName]) {
286
+ raw.profiles[profileName].encrypt = !current;
287
+ } else {
288
+ raw.encrypt = !current;
289
+ }
290
+ await saveConfig(raw);
291
+ console.log(chalk.green(`\n ✔ Encryption ${!current ? 'enabled' : 'disabled'}. Next push will ${!current ? 'encrypt' : 'skip encryption'}.\n`));
292
+ }
293
+ } catch (err) {
294
+ console.error(chalk.red('\n✖ Error:'), err.message);
295
+ process.exit(1);
296
+ }
297
+ });
298
+
260
299
  program
261
300
  .command('migrate')
262
301
  .description('Translate memory between AI tools (Claude, Gemini, Codex, Cursor, etc.)')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "2.5.3",
3
+ "version": "3.0.0",
4
4
  "description": "Sync AI memory across devices. Back up and restore Claude, Gemini, Codex, Cursor, Copilot, Windsurf configs. Snapshot coding sessions and resume on another machine. Migrate instructions between AI assistants.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -3,6 +3,7 @@ import nodeFs from 'node:fs';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
5
  import chalk from 'chalk';
6
+ import { shouldIgnoreProject } from '../context/capture.js';
6
7
 
7
8
  const home = os.homedir();
8
9
 
@@ -332,7 +333,7 @@ export async function extractMemories(stagingDir, spinner, onlyFilter = null) {
332
333
  }
333
334
  }
334
335
 
335
- if (foundFiles.length > 0 && dir !== home) {
336
+ if (foundFiles.length > 0 && dir !== home && !shouldIgnoreProject(dir)) {
336
337
  // This is a project with AI configs
337
338
  const projectName = path.basename(dir);
338
339
  const projectDestDir = path.join(projectsDest, projectName);
@@ -108,6 +108,19 @@ export async function initCommand() {
108
108
  }
109
109
  }
110
110
 
111
+ // Ask about encryption
112
+ const { encrypt } = await inquirer.prompt([{
113
+ type: 'confirm',
114
+ name: 'encrypt',
115
+ message: 'Enable E2E encryption? (protects your data even if backup is compromised)',
116
+ default: true
117
+ }]);
118
+ config.encrypt = encrypt;
119
+
120
+ if (encrypt) {
121
+ console.log(chalk.gray(' You\'ll set a passphrase on first push. Same passphrase on all machines.'));
122
+ }
123
+
111
124
  await saveConfig(config);
112
125
  console.log(chalk.green('✔ Saved!\n'));
113
126
 
@@ -8,6 +8,11 @@ import gradient from 'gradient-string';
8
8
  import { getConfig } from '../config.js';
9
9
  import { extractMemories, adapters } from '../adapters/index.js';
10
10
  import { syncToLocal, syncToGit } from '../providers/index.js';
11
+ import inquirer from 'inquirer';
12
+ import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject } from '../context/capture.js';
13
+ import { scanForSecrets, printSecurityReport } from '../security/scanner.js';
14
+ import { encryptDirectory, createVerifyToken } from '../security/encryption.js';
15
+ import { getRawConfig, saveConfig, migrateConfigToV2 } from '../config.js';
11
16
 
12
17
  export async function pushCommand(options = {}) {
13
18
  const config = await getConfig(options.profile);
@@ -43,6 +48,57 @@ export async function pushCommand(options = {}) {
43
48
  return;
44
49
  }
45
50
 
51
+ // Capture session context from latest Claude session
52
+ let contextCaptured = false;
53
+ let sessionInfo = null;
54
+ spinner.text = chalk.gray('Capturing session context...');
55
+ try {
56
+ const sessions = findClaudeSessions();
57
+ if (sessions.length > 0) {
58
+ const parsed = parseSession(sessions[0].path);
59
+ if (parsed.userMessages.length > 0) {
60
+ // Scan the generated handoff for any remaining secrets
61
+ const handoff = generateContextHandoff(parsed);
62
+ const { found, clean } = scanForSecrets(handoff);
63
+
64
+ // Save handoff to staging dir
65
+ const handoffDir = path.join(stagingDir, 'handoffs');
66
+ await fs.ensureDir(handoffDir);
67
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
68
+ await fs.writeFile(path.join(handoffDir, `${timestamp}-claude.md`), clean);
69
+ await fs.writeFile(path.join(handoffDir, 'latest.md'), clean);
70
+
71
+ // Also save locally for memoir resume
72
+ const localHandoffDir = path.join(os.homedir(), '.config', 'memoir', 'handoffs');
73
+ await fs.ensureDir(localHandoffDir);
74
+ await fs.writeFile(path.join(localHandoffDir, `${timestamp}-claude.md`), clean);
75
+ await fs.writeFile(path.join(localHandoffDir, 'latest.md'), clean);
76
+
77
+ contextCaptured = true;
78
+ sessionInfo = {
79
+ slug: parsed.slug,
80
+ filesModified: parsed.filesWritten.length,
81
+ duration: parsed.firstTimestamp && parsed.lastTimestamp
82
+ ? (() => {
83
+ const ms = new Date(parsed.lastTimestamp) - new Date(parsed.firstTimestamp);
84
+ const mins = Math.floor(ms / 60000);
85
+ return mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h ${mins % 60}m`;
86
+ })()
87
+ : null,
88
+ secretsRedacted: found.length
89
+ };
90
+
91
+ spinner.stop();
92
+ if (found.length > 0) {
93
+ printSecurityReport(found);
94
+ }
95
+ spinner.start();
96
+ }
97
+ }
98
+ } catch {
99
+ // Context capture is best-effort — don't fail the push
100
+ }
101
+
46
102
  // Count what was found
47
103
  const found = [];
48
104
  for (const adapter of adapters) {
@@ -58,12 +114,70 @@ export async function pushCommand(options = {}) {
58
114
  }
59
115
  }
60
116
 
117
+ // Encrypt if enabled (or ask on first push if not configured)
118
+ let uploadDir = stagingDir;
119
+ let encrypted = false;
120
+ let shouldEncrypt = config.encrypt;
121
+
122
+ if (shouldEncrypt === undefined) {
123
+ // First push since encryption was added — ask once and save preference
124
+ spinner.stop();
125
+ const { wantEncrypt } = await inquirer.prompt([{
126
+ type: 'confirm',
127
+ name: 'wantEncrypt',
128
+ message: 'Enable E2E encryption? (protects your backup even if compromised)',
129
+ default: true
130
+ }]);
131
+ shouldEncrypt = wantEncrypt;
132
+
133
+ // Save to config so we don't ask again
134
+ try {
135
+ let raw = await getRawConfig();
136
+ if (raw) {
137
+ if (!raw.version || raw.version < 2) {
138
+ raw = migrateConfigToV2(raw);
139
+ }
140
+ const profileName = options.profile || raw.activeProfile || 'default';
141
+ if (raw.profiles?.[profileName]) {
142
+ raw.profiles[profileName].encrypt = shouldEncrypt;
143
+ } else {
144
+ raw.encrypt = shouldEncrypt;
145
+ }
146
+ await saveConfig(raw);
147
+ }
148
+ } catch {}
149
+ spinner.start();
150
+ }
151
+
152
+ if (shouldEncrypt) {
153
+ spinner.stop();
154
+ const { passphrase } = await inquirer.prompt([{
155
+ type: 'password',
156
+ name: 'passphrase',
157
+ message: '🔒 Encryption passphrase:',
158
+ mask: '*',
159
+ validate: (input) => input.length >= 6 ? true : 'Passphrase must be at least 6 characters'
160
+ }]);
161
+ spinner.start(chalk.gray('Encrypting...'));
162
+
163
+ const encryptedDir = path.join(os.tmpdir(), `memoir-encrypted-${Date.now()}`);
164
+ await fs.ensureDir(encryptedDir);
165
+ await encryptDirectory(stagingDir, encryptedDir, passphrase);
166
+
167
+ // Save verify token so restore can check passphrase before decrypting
168
+ const token = createVerifyToken(passphrase);
169
+ await fs.writeFile(path.join(encryptedDir, 'verify.enc'), token);
170
+
171
+ uploadDir = encryptedDir;
172
+ encrypted = true;
173
+ }
174
+
61
175
  spinner.text = chalk.gray('Uploading to ' + (config.provider === 'git' ? 'GitHub' : 'local storage') + '...');
62
176
 
63
177
  if (config.provider === 'local' || config.provider.includes('local')) {
64
- await syncToLocal(config, stagingDir, spinner);
178
+ await syncToLocal(config, uploadDir, spinner);
65
179
  } else if (config.provider === 'git' || config.provider.includes('git')) {
66
- await syncToGit(config, stagingDir, spinner);
180
+ await syncToGit(config, uploadDir, spinner);
67
181
  } else {
68
182
  spinner.fail(chalk.red(`Unknown provider: ${config.provider}`));
69
183
  return;
@@ -93,10 +207,22 @@ export async function pushCommand(options = {}) {
93
207
 
94
208
  // Success output
95
209
  const toolList = found.map(t => chalk.cyan(' ✔ ' + t)).join('\n');
210
+ let contextLine = '';
211
+ if (contextCaptured && sessionInfo) {
212
+ const parts = [];
213
+ if (sessionInfo.slug) parts.push(sessionInfo.slug);
214
+ if (sessionInfo.duration) parts.push(sessionInfo.duration);
215
+ if (sessionInfo.filesModified) parts.push(`${sessionInfo.filesModified} files changed`);
216
+ contextLine = '\n' + chalk.green(' ✔ Session Context') + chalk.gray(` (${parts.join(', ')})`) + '\n';
217
+ if (sessionInfo.secretsRedacted > 0) {
218
+ contextLine += chalk.yellow(` 🔒 ${sessionInfo.secretsRedacted} secret(s) auto-redacted`) + '\n';
219
+ }
220
+ }
96
221
  console.log('\n' + boxen(
97
222
  gradient.pastel(' Backed up! ') + '\n\n' +
98
- toolList + '\n\n' +
223
+ toolList + contextLine + '\n' +
99
224
  chalk.white(`${totalFiles} files from ${found.length} tool${found.length !== 1 ? 's' : ''}`) + '\n' +
225
+ (encrypted ? chalk.green(' 🔒 E2E encrypted') + '\n' : '') +
100
226
  chalk.gray(`→ ${dest}`) + '\n\n' +
101
227
  chalk.gray('Restore on another machine with: ') + chalk.cyan('memoir restore'),
102
228
  { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
@@ -105,5 +231,14 @@ export async function pushCommand(options = {}) {
105
231
  spinner.fail(chalk.red('Sync failed: ') + error.message);
106
232
  } finally {
107
233
  await fs.remove(stagingDir);
234
+ // Clean up encrypted dir if it was created
235
+ if (true) {
236
+ const encDirs = await fs.readdir(os.tmpdir());
237
+ for (const d of encDirs) {
238
+ if (d.startsWith('memoir-encrypted-')) {
239
+ await fs.remove(path.join(os.tmpdir(), d)).catch(() => {});
240
+ }
241
+ }
242
+ }
108
243
  }
109
244
  }
@@ -5,8 +5,12 @@ import os from 'os';
5
5
  import ora from 'ora';
6
6
  import boxen from 'boxen';
7
7
  import gradient from 'gradient-string';
8
+ import inquirer from 'inquirer';
8
9
  import { getConfig } from '../config.js';
9
10
  import { fetchFromLocal, fetchFromGit } from '../providers/restore.js';
11
+ import { decryptDirectory, verifyPassphrase } from '../security/encryption.js';
12
+
13
+ const home = os.homedir();
10
14
 
11
15
  export async function restoreCommand(options = {}) {
12
16
  const config = await getConfig(options.profile);
@@ -42,13 +46,132 @@ export async function restoreCommand(options = {}) {
42
46
  return;
43
47
  }
44
48
 
49
+ // If backup is encrypted, decrypt it first then re-restore
50
+ const manifestPath = path.join(stagingDir, 'manifest.enc');
51
+ if (!restored && await fs.pathExists(manifestPath)) {
52
+ spinner.stop();
53
+ console.log(chalk.cyan('\n 🔒 Backup is encrypted'));
54
+
55
+ // Verify passphrase first
56
+ const verifyPath = path.join(stagingDir, 'verify.enc');
57
+ let passphrase;
58
+ for (let attempt = 0; attempt < 3; attempt++) {
59
+ const { pass } = await inquirer.prompt([{
60
+ type: 'password',
61
+ name: 'pass',
62
+ message: 'Decryption passphrase:',
63
+ mask: '*',
64
+ }]);
65
+ passphrase = pass;
66
+
67
+ if (await fs.pathExists(verifyPath)) {
68
+ const token = await fs.readFile(verifyPath);
69
+ if (!verifyPassphrase(token, passphrase)) {
70
+ console.log(chalk.red(' Wrong passphrase. Try again.'));
71
+ passphrase = null;
72
+ continue;
73
+ }
74
+ }
75
+ break;
76
+ }
77
+
78
+ if (!passphrase) {
79
+ console.log(chalk.red('\n Too many failed attempts.'));
80
+ return;
81
+ }
82
+
83
+ spinner.start(chalk.gray('Decrypting...'));
84
+ const decryptedDir = path.join(os.tmpdir(), `memoir-decrypted-${Date.now()}`);
85
+ try {
86
+ const count = await decryptDirectory(stagingDir, decryptedDir, passphrase);
87
+ spinner.succeed(chalk.green(`Decrypted ${count} files`));
88
+
89
+ // Now restore from decrypted dir
90
+ spinner.start(chalk.gray('Restoring...'));
91
+ const { restoreMemories } = await import('../adapters/restore.js');
92
+ restored = await restoreMemories(decryptedDir, spinner, onlyFilter, autoYes);
93
+
94
+ // Copy staging dir contents for handoff injection below
95
+ await fs.copy(decryptedDir, stagingDir, { overwrite: true });
96
+ } catch (err) {
97
+ spinner.fail(chalk.red('Decryption failed: ') + err.message);
98
+ return;
99
+ } finally {
100
+ await fs.remove(decryptedDir);
101
+ }
102
+ }
103
+
45
104
  spinner.stop();
46
105
 
106
+ // Auto-inject session handoff if available
107
+ let handoffInjected = false;
108
+ let handoffInfo = null;
109
+ if (restored) {
110
+ try {
111
+ // Check staging dir for handoffs (came from backup)
112
+ const handoffDir = path.join(stagingDir, 'handoffs');
113
+ let handoffContent = null;
114
+
115
+ if (await fs.pathExists(handoffDir)) {
116
+ const latestPath = path.join(handoffDir, 'latest.md');
117
+ if (await fs.pathExists(latestPath)) {
118
+ handoffContent = await fs.readFile(latestPath, 'utf8');
119
+ } else {
120
+ // Find newest handoff
121
+ const files = (await fs.readdir(handoffDir))
122
+ .filter(f => f.endsWith('.md'))
123
+ .sort()
124
+ .reverse();
125
+ if (files.length > 0) {
126
+ handoffContent = await fs.readFile(path.join(handoffDir, files[0]), 'utf8');
127
+ }
128
+ }
129
+ }
130
+
131
+ if (handoffContent) {
132
+ // Save locally
133
+ const localHandoffDir = path.join(home, '.config', 'memoir', 'handoffs');
134
+ await fs.ensureDir(localHandoffDir);
135
+ await fs.writeFile(path.join(localHandoffDir, 'latest.md'), handoffContent);
136
+
137
+ // Inject into Claude's home-level memory so it's always loaded
138
+ const cwdKey = '-' + home.replace(/^\//, '').replace(/\\/g, '-').replace(/\//g, '-').replace(/:/g, '');
139
+ const claudeMemDir = path.join(home, '.claude', 'projects', cwdKey, 'memory');
140
+ if (await fs.pathExists(path.join(home, '.claude'))) {
141
+ await fs.ensureDir(claudeMemDir);
142
+ await fs.writeFile(path.join(claudeMemDir, 'handoff.md'), handoffContent);
143
+ handoffInjected = true;
144
+ }
145
+
146
+ // Extract info for display — handles both old and new handoff formats
147
+ const fromMatch = handoffContent.match(/\*\*From:\*\*\s*(.+)/) || handoffContent.match(/from \*\*(.+?)\*\*/);
148
+ const whenMatch = handoffContent.match(/\*\*When:\*\*\s*(.+)/) || handoffContent.match(/on (\d{4}-\d{2}-\d{2}) at (.+)/);
149
+ const durationMatch = handoffContent.match(/\*\*Duration:\*\*\s*(.+)/) || handoffContent.match(/Session: (\w+)/);
150
+ handoffInfo = {
151
+ from: fromMatch ? fromMatch[1] : 'another machine',
152
+ when: whenMatch ? (whenMatch[2] ? `${whenMatch[1]} ${whenMatch[2]}` : whenMatch[1]) : 'recently',
153
+ duration: durationMatch ? durationMatch[1] : null,
154
+ };
155
+ }
156
+ } catch {
157
+ // Handoff injection is best-effort
158
+ }
159
+ }
160
+
47
161
  if (restored) {
162
+ let handoffMsg = '';
163
+ if (handoffInjected && handoffInfo) {
164
+ handoffMsg = '\n\n' + chalk.cyan('📋 Session context injected') + '\n' +
165
+ chalk.gray(` From: ${handoffInfo.from}`) + '\n' +
166
+ chalk.gray(` When: ${handoffInfo.when}`) +
167
+ (handoffInfo.duration ? '\n' + chalk.gray(` Duration: ${handoffInfo.duration}`) : '') + '\n' +
168
+ chalk.gray(' Your AI will pick up where you left off.');
169
+ }
48
170
  console.log(boxen(
49
171
  gradient.pastel(' Done! ') + '\n\n' +
50
- chalk.white('Your AI tools have their memories back.') + '\n' +
51
- chalk.gray('Restart your AI tools to pick up the changes.'),
172
+ chalk.white('Your AI tools have their memories back.') +
173
+ handoffMsg + '\n' +
174
+ chalk.gray(handoffInjected ? '' : 'Restart your AI tools to pick up the changes.'),
52
175
  { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
53
176
  ) + '\n');
54
177
  } else {
@@ -0,0 +1,236 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { scanForSecrets, redactSecrets } from '../security/scanner.js';
5
+
6
+ const home = os.homedir();
7
+
8
+ /**
9
+ * Find all Claude session files, sorted newest first
10
+ */
11
+ export function findClaudeSessions() {
12
+ const projectsDir = path.join(home, '.claude', 'projects');
13
+ if (!fs.existsSync(projectsDir)) return [];
14
+
15
+ const sessions = [];
16
+ const scanDir = (dir) => {
17
+ let entries;
18
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
19
+ for (const entry of entries) {
20
+ const full = path.join(dir, entry.name);
21
+ if (entry.isDirectory()) {
22
+ scanDir(full);
23
+ } else if (entry.name.endsWith('.jsonl') && !entry.name.includes('subagent')) {
24
+ try {
25
+ const stat = fs.statSync(full);
26
+ // Skip files older than 7 days for performance
27
+ if (Date.now() - stat.mtimeMs < 7 * 24 * 60 * 60 * 1000) {
28
+ sessions.push({ path: full, mtime: stat.mtimeMs, size: stat.size });
29
+ }
30
+ } catch {}
31
+ }
32
+ }
33
+ };
34
+ scanDir(projectsDir);
35
+ sessions.sort((a, b) => b.mtime - a.mtime);
36
+ return sessions;
37
+ }
38
+
39
+ /**
40
+ * Parse a Claude session file and extract context (with secret redaction)
41
+ * Streams large files instead of loading entirely into memory
42
+ */
43
+ export function parseSession(sessionPath, maxSizeMB = 10) {
44
+ const stat = fs.statSync(sessionPath);
45
+ if (stat.size > maxSizeMB * 1024 * 1024) {
46
+ // For large files, only parse the last portion
47
+ const raw = fs.readFileSync(sessionPath, 'utf8');
48
+ const lines = raw.split('\n');
49
+ const lastLines = lines.slice(-500); // Last 500 lines
50
+ return parseLines(lastLines);
51
+ }
52
+
53
+ const raw = fs.readFileSync(sessionPath, 'utf8').trim();
54
+ return parseLines(raw.split('\n'));
55
+ }
56
+
57
+ function parseLines(lines) {
58
+ const result = {
59
+ sessionId: null,
60
+ slug: null,
61
+ gitBranch: null,
62
+ cwd: null,
63
+ firstTimestamp: null,
64
+ lastTimestamp: null,
65
+ userMessages: [],
66
+ filesWritten: new Set(),
67
+ filesRead: new Set(),
68
+ bashCommands: [],
69
+ errors: [],
70
+ decisions: [],
71
+ };
72
+
73
+ for (const line of lines) {
74
+ let obj;
75
+ try { obj = JSON.parse(line); } catch { continue; }
76
+
77
+ if (!result.sessionId && obj.sessionId) result.sessionId = obj.sessionId;
78
+ if (!result.slug && obj.slug) result.slug = obj.slug;
79
+ if (!result.gitBranch && obj.gitBranch) result.gitBranch = obj.gitBranch;
80
+ if (!result.cwd && obj.cwd) result.cwd = obj.cwd;
81
+ if (!result.firstTimestamp && obj.timestamp) result.firstTimestamp = obj.timestamp;
82
+ if (obj.timestamp) result.lastTimestamp = obj.timestamp;
83
+
84
+ // User messages — redact secrets
85
+ if (obj.type === 'user' && obj.message?.content) {
86
+ const content = typeof obj.message.content === 'string' ? obj.message.content : '';
87
+ if (content.length > 3 && !content.startsWith('<task-notification>')) {
88
+ result.userMessages.push(redactSecrets(content));
89
+ }
90
+ }
91
+
92
+ // Tool uses from assistant
93
+ if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
94
+ for (const block of obj.message.content) {
95
+ if (block.type !== 'tool_use') continue;
96
+ const name = block.name;
97
+ const input = block.input || {};
98
+
99
+ if (name === 'Write' || name === 'Edit') {
100
+ const fp = input.file_path || '';
101
+ if (fp && !fp.startsWith('/tmp/') && !fp.startsWith('/private/tmp/')) {
102
+ result.filesWritten.add(fp);
103
+ }
104
+ } else if (name === 'Read') {
105
+ const fp = input.file_path || '';
106
+ if (fp && !fp.startsWith('/tmp/') && !fp.startsWith('/private/tmp/')) {
107
+ result.filesRead.add(fp);
108
+ }
109
+ } else if (name === 'Bash') {
110
+ const cmd = (input.command || '').trim();
111
+ if (cmd && !cmd.startsWith('sleep') && !cmd.startsWith('cat /private/tmp')) {
112
+ // Redact secrets from commands
113
+ const clean = redactSecrets(cmd.length > 120 ? cmd.slice(0, 120) + '...' : cmd);
114
+ result.bashCommands.push(clean);
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ // Errors from tool results
121
+ if (obj.type === 'tool_result' && obj.message?.content) {
122
+ const content = typeof obj.message.content === 'string' ? obj.message.content : '';
123
+ if (content.includes('Error') || content.includes('error') || content.includes('FAIL')) {
124
+ const errorLine = content.split('\n').find(l => /error|fail/i.test(l));
125
+ if (errorLine && errorLine.length < 200) {
126
+ result.errors.push(redactSecrets(errorLine.trim()));
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ result.filesWritten = [...result.filesWritten];
133
+ result.filesRead = [...result.filesRead];
134
+ result.errors = [...new Set(result.errors)].slice(0, 10);
135
+
136
+ return result;
137
+ }
138
+
139
+ /**
140
+ * Generate a concise handoff markdown from parsed session
141
+ * This is what gets injected into the AI tool on the other machine
142
+ */
143
+ export function generateContextHandoff(parsed) {
144
+ const now = new Date();
145
+ const hostname = os.hostname();
146
+ const platform = process.platform === 'darwin' ? 'macOS' : process.platform === 'win32' ? 'Windows' : 'Linux';
147
+ const cwd = parsed.cwd || home;
148
+
149
+ // Duration
150
+ let duration = 'unknown';
151
+ if (parsed.firstTimestamp && parsed.lastTimestamp) {
152
+ const ms = new Date(parsed.lastTimestamp) - new Date(parsed.firstTimestamp);
153
+ const mins = Math.floor(ms / 60000);
154
+ duration = mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h ${mins % 60}m`;
155
+ }
156
+
157
+ // Shorten paths
158
+ const shorten = (fp) => {
159
+ if (fp.startsWith(cwd + '/')) return fp.slice(cwd.length + 1);
160
+ if (fp.startsWith(cwd + '\\')) return fp.slice(cwd.length + 1);
161
+ if (fp.startsWith(home + '/')) return '~/' + fp.slice(home.length + 1);
162
+ if (fp.startsWith(home + '\\')) return '~\\' + fp.slice(home.length + 1);
163
+ return fp;
164
+ };
165
+
166
+ // Filter meaningful user messages
167
+ const meaningful = parsed.userMessages
168
+ .filter(m => m.length > 10 && !/^(ok|yes|no|sure|yea|yeah|yep|nah|nope|thanks|ty|thx|good|great|nice|cool|done|hmm)$/i.test(m.trim()))
169
+ .map(m => m.length > 150 ? m.slice(0, 150) + '...' : m);
170
+
171
+ // Build a concise, actionable handoff
172
+ let md = `---
173
+ name: Session Handoff
174
+ description: Coding session context — resume on any machine, any AI tool
175
+ type: project
176
+ ---
177
+
178
+ # Continue where I left off
179
+
180
+ > Handed off from **${hostname}** (${platform}) on ${now.toISOString().split('T')[0]} at ${now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
181
+ > Session: ${duration} | Branch: \`${parsed.gitBranch || 'unknown'}\` | Project: \`${cwd}\`
182
+
183
+ ## What I was working on
184
+ ${meaningful.length > 0 ? meaningful.slice(0, 8).map(m => `- ${m}`).join('\n') : '_No significant messages captured_'}
185
+
186
+ ## Files I changed
187
+ ${parsed.filesWritten.length > 0
188
+ ? parsed.filesWritten.slice(0, 15).map(f => `- \`${shorten(f)}\``).join('\n')
189
+ : '_None_'}
190
+ `;
191
+
192
+ // Only show referenced files that weren't also modified
193
+ const readOnly = parsed.filesRead.filter(f => !parsed.filesWritten.includes(f));
194
+ if (readOnly.length > 0) {
195
+ md += `\n## Files I was looking at\n${readOnly.slice(0, 10).map(f => `- \`${shorten(f)}\``).join('\n')}\n`;
196
+ }
197
+
198
+ if (parsed.errors.length > 0) {
199
+ md += `\n## Issues I ran into\n${parsed.errors.slice(0, 5).map(e => `- ${e}`).join('\n')}\n`;
200
+ }
201
+
202
+ if (parsed.filesWritten.length > 0) {
203
+ md += `\n## Pick up here\nStart by reviewing: ${parsed.filesWritten.slice(0, 3).map(f => '`' + shorten(f) + '`').join(', ')}. ${parsed.filesWritten.length} files were modified in total.\n`;
204
+ }
205
+
206
+ return md;
207
+ }
208
+
209
+ /**
210
+ * Check if a project path should be ignored based on .memoirignore
211
+ */
212
+ export function shouldIgnoreProject(projectPath) {
213
+ // Check for .memoirignore in home dir
214
+ const ignoreFile = path.join(home, '.memoirignore');
215
+ if (!fs.existsSync(ignoreFile)) return false;
216
+
217
+ const patterns = fs.readFileSync(ignoreFile, 'utf8')
218
+ .split('\n')
219
+ .map(l => l.trim())
220
+ .filter(l => l && !l.startsWith('#'));
221
+
222
+ const projectName = path.basename(projectPath);
223
+ const projectFull = projectPath.toLowerCase();
224
+
225
+ for (const pattern of patterns) {
226
+ const p = pattern.toLowerCase();
227
+ // Exact match on project name
228
+ if (projectName.toLowerCase() === p) return true;
229
+ // Path contains pattern
230
+ if (projectFull.includes(p)) return true;
231
+ // Glob-like: pattern ends with *
232
+ if (p.endsWith('*') && projectFull.startsWith(p.slice(0, -1))) return true;
233
+ }
234
+
235
+ return false;
236
+ }