memoir-cli 2.5.3 → 2.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "2.5.3",
3
+ "version": "2.6.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",
@@ -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,10 @@ 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 } from '../context/capture.js';
13
+ import { scanForSecrets, printSecurityReport } from '../security/scanner.js';
14
+ import { encryptDirectory, createVerifyToken } from '../security/encryption.js';
11
15
 
12
16
  export async function pushCommand(options = {}) {
13
17
  const config = await getConfig(options.profile);
@@ -43,6 +47,57 @@ export async function pushCommand(options = {}) {
43
47
  return;
44
48
  }
45
49
 
50
+ // Capture session context from latest Claude session
51
+ let contextCaptured = false;
52
+ let sessionInfo = null;
53
+ spinner.text = chalk.gray('Capturing session context...');
54
+ try {
55
+ const sessions = findClaudeSessions();
56
+ if (sessions.length > 0) {
57
+ const parsed = parseSession(sessions[0].path);
58
+ if (parsed.userMessages.length > 0) {
59
+ // Scan the generated handoff for any remaining secrets
60
+ const handoff = generateContextHandoff(parsed);
61
+ const { found, clean } = scanForSecrets(handoff);
62
+
63
+ // Save handoff to staging dir
64
+ const handoffDir = path.join(stagingDir, 'handoffs');
65
+ await fs.ensureDir(handoffDir);
66
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
67
+ await fs.writeFile(path.join(handoffDir, `${timestamp}-claude.md`), clean);
68
+ await fs.writeFile(path.join(handoffDir, 'latest.md'), clean);
69
+
70
+ // Also save locally for memoir resume
71
+ const localHandoffDir = path.join(os.homedir(), '.config', 'memoir', 'handoffs');
72
+ await fs.ensureDir(localHandoffDir);
73
+ await fs.writeFile(path.join(localHandoffDir, `${timestamp}-claude.md`), clean);
74
+ await fs.writeFile(path.join(localHandoffDir, 'latest.md'), clean);
75
+
76
+ contextCaptured = true;
77
+ sessionInfo = {
78
+ slug: parsed.slug,
79
+ filesModified: parsed.filesWritten.length,
80
+ duration: parsed.firstTimestamp && parsed.lastTimestamp
81
+ ? (() => {
82
+ const ms = new Date(parsed.lastTimestamp) - new Date(parsed.firstTimestamp);
83
+ const mins = Math.floor(ms / 60000);
84
+ return mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h ${mins % 60}m`;
85
+ })()
86
+ : null,
87
+ secretsRedacted: found.length
88
+ };
89
+
90
+ spinner.stop();
91
+ if (found.length > 0) {
92
+ printSecurityReport(found);
93
+ }
94
+ spinner.start();
95
+ }
96
+ }
97
+ } catch {
98
+ // Context capture is best-effort — don't fail the push
99
+ }
100
+
46
101
  // Count what was found
47
102
  const found = [];
48
103
  for (const adapter of adapters) {
@@ -58,12 +113,38 @@ export async function pushCommand(options = {}) {
58
113
  }
59
114
  }
60
115
 
116
+ // Encrypt if enabled
117
+ let uploadDir = stagingDir;
118
+ let encrypted = false;
119
+ if (config.encrypt) {
120
+ spinner.stop();
121
+ const { passphrase } = await inquirer.prompt([{
122
+ type: 'password',
123
+ name: 'passphrase',
124
+ message: '🔒 Encryption passphrase:',
125
+ mask: '*',
126
+ validate: (input) => input.length >= 6 ? true : 'Passphrase must be at least 6 characters'
127
+ }]);
128
+ spinner.start(chalk.gray('Encrypting...'));
129
+
130
+ const encryptedDir = path.join(os.tmpdir(), `memoir-encrypted-${Date.now()}`);
131
+ await fs.ensureDir(encryptedDir);
132
+ await encryptDirectory(stagingDir, encryptedDir, passphrase);
133
+
134
+ // Save verify token so restore can check passphrase before decrypting
135
+ const token = createVerifyToken(passphrase);
136
+ await fs.writeFile(path.join(encryptedDir, 'verify.enc'), token);
137
+
138
+ uploadDir = encryptedDir;
139
+ encrypted = true;
140
+ }
141
+
61
142
  spinner.text = chalk.gray('Uploading to ' + (config.provider === 'git' ? 'GitHub' : 'local storage') + '...');
62
143
 
63
144
  if (config.provider === 'local' || config.provider.includes('local')) {
64
- await syncToLocal(config, stagingDir, spinner);
145
+ await syncToLocal(config, uploadDir, spinner);
65
146
  } else if (config.provider === 'git' || config.provider.includes('git')) {
66
- await syncToGit(config, stagingDir, spinner);
147
+ await syncToGit(config, uploadDir, spinner);
67
148
  } else {
68
149
  spinner.fail(chalk.red(`Unknown provider: ${config.provider}`));
69
150
  return;
@@ -93,10 +174,22 @@ export async function pushCommand(options = {}) {
93
174
 
94
175
  // Success output
95
176
  const toolList = found.map(t => chalk.cyan(' ✔ ' + t)).join('\n');
177
+ let contextLine = '';
178
+ if (contextCaptured && sessionInfo) {
179
+ const parts = [];
180
+ if (sessionInfo.slug) parts.push(sessionInfo.slug);
181
+ if (sessionInfo.duration) parts.push(sessionInfo.duration);
182
+ if (sessionInfo.filesModified) parts.push(`${sessionInfo.filesModified} files changed`);
183
+ contextLine = '\n' + chalk.green(' ✔ Session Context') + chalk.gray(` (${parts.join(', ')})`) + '\n';
184
+ if (sessionInfo.secretsRedacted > 0) {
185
+ contextLine += chalk.yellow(` 🔒 ${sessionInfo.secretsRedacted} secret(s) auto-redacted`) + '\n';
186
+ }
187
+ }
96
188
  console.log('\n' + boxen(
97
189
  gradient.pastel(' Backed up! ') + '\n\n' +
98
- toolList + '\n\n' +
190
+ toolList + contextLine + '\n' +
99
191
  chalk.white(`${totalFiles} files from ${found.length} tool${found.length !== 1 ? 's' : ''}`) + '\n' +
192
+ (encrypted ? chalk.green(' 🔒 E2E encrypted') + '\n' : '') +
100
193
  chalk.gray(`→ ${dest}`) + '\n\n' +
101
194
  chalk.gray('Restore on another machine with: ') + chalk.cyan('memoir restore'),
102
195
  { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
@@ -105,5 +198,14 @@ export async function pushCommand(options = {}) {
105
198
  spinner.fail(chalk.red('Sync failed: ') + error.message);
106
199
  } finally {
107
200
  await fs.remove(stagingDir);
201
+ // Clean up encrypted dir if it was created
202
+ if (config.encrypt) {
203
+ const encDirs = await fs.readdir(os.tmpdir());
204
+ for (const d of encDirs) {
205
+ if (d.startsWith('memoir-encrypted-')) {
206
+ await fs.remove(path.join(os.tmpdir(), d)).catch(() => {});
207
+ }
208
+ }
209
+ }
108
210
  }
109
211
  }
@@ -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
147
+ const fromMatch = handoffContent.match(/\*\*From:\*\*\s*(.+)/);
148
+ const whenMatch = handoffContent.match(/\*\*When:\*\*\s*(.+)/);
149
+ const durationMatch = handoffContent.match(/\*\*Duration:\*\*\s*(.+)/);
150
+ handoffInfo = {
151
+ from: fromMatch ? fromMatch[1] : 'another machine',
152
+ when: whenMatch ? 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,242 @@
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
+ let md = `---
172
+ name: Session Handoff
173
+ description: Auto-generated context from last coding session for seamless cross-device continuity
174
+ type: project
175
+ ---
176
+
177
+ # Session Handoff
178
+
179
+ **From:** ${hostname} (${platform})
180
+ **When:** ${now.toISOString().split('T')[0]} ${now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
181
+ **Duration:** ${duration}
182
+ **Project:** ${cwd}
183
+ **Branch:** ${parsed.gitBranch || 'unknown'}
184
+
185
+ ## What was being worked on
186
+ ${meaningful.length > 0 ? meaningful.slice(0, 8).map(m => `- ${m}`).join('\n') : '_No significant messages captured_'}
187
+
188
+ ## Files modified
189
+ ${parsed.filesWritten.length > 0
190
+ ? parsed.filesWritten.slice(0, 15).map(f => `- \`${shorten(f)}\``).join('\n')
191
+ : '_None_'}
192
+
193
+ ## Key files referenced
194
+ ${parsed.filesRead.length > 0
195
+ ? parsed.filesRead.filter(f => !parsed.filesWritten.includes(f)).slice(0, 10).map(f => `- \`${shorten(f)}\``).join('\n')
196
+ : '_None_'}
197
+ `;
198
+
199
+ if (parsed.errors.length > 0) {
200
+ md += `\n## Errors hit\n${parsed.errors.slice(0, 5).map(e => `- ${e}`).join('\n')}\n`;
201
+ }
202
+
203
+ md += `\n## Context for continuing
204
+ This session ran for ${duration} on ${platform}. ${parsed.filesWritten.length} files were modified and ${parsed.bashCommands.length} commands were run.`;
205
+
206
+ if (parsed.filesWritten.length > 0) {
207
+ md += ` Start by reviewing: ${parsed.filesWritten.slice(0, 3).map(f => '`' + shorten(f) + '`').join(', ')}.`;
208
+ }
209
+
210
+ md += '\n';
211
+
212
+ return md;
213
+ }
214
+
215
+ /**
216
+ * Check if a project path should be ignored based on .memoirignore
217
+ */
218
+ export function shouldIgnoreProject(projectPath) {
219
+ // Check for .memoirignore in home dir
220
+ const ignoreFile = path.join(home, '.memoirignore');
221
+ if (!fs.existsSync(ignoreFile)) return false;
222
+
223
+ const patterns = fs.readFileSync(ignoreFile, 'utf8')
224
+ .split('\n')
225
+ .map(l => l.trim())
226
+ .filter(l => l && !l.startsWith('#'));
227
+
228
+ const projectName = path.basename(projectPath);
229
+ const projectFull = projectPath.toLowerCase();
230
+
231
+ for (const pattern of patterns) {
232
+ const p = pattern.toLowerCase();
233
+ // Exact match on project name
234
+ if (projectName.toLowerCase() === p) return true;
235
+ // Path contains pattern
236
+ if (projectFull.includes(p)) return true;
237
+ // Glob-like: pattern ends with *
238
+ if (p.endsWith('*') && projectFull.startsWith(p.slice(0, -1))) return true;
239
+ }
240
+
241
+ return false;
242
+ }