memoir-cli 1.5.1 → 2.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
@@ -9,6 +9,8 @@ import { restoreCommand } from '../src/commands/restore.js';
9
9
  import { statusCommand } from '../src/commands/status.js';
10
10
  import { viewCommand } from '../src/commands/view.js';
11
11
  import { migrateCommand } from '../src/commands/migrate.js';
12
+ import { snapshotCommand } from '../src/commands/snapshot.js';
13
+ import { resumeCommand } from '../src/commands/resume.js';
12
14
  import { createRequire } from 'module';
13
15
 
14
16
  const require = createRequire(import.meta.url);
@@ -20,10 +22,13 @@ if (process.argv.length <= 2) {
20
22
  gradient.pastel.multiline(' memoir ') + '\n' +
21
23
  chalk.gray(' Your AI remembers everything.') + '\n\n' +
22
24
  chalk.white.bold('Quick Start:') + '\n' +
23
- chalk.cyan(' memoir init ') + chalk.gray('— first-time setup') + '\n' +
24
- chalk.cyan(' memoir push ') + chalk.gray('— back up your AI memory') + '\n' +
25
- chalk.cyan(' memoir restore ') + chalk.gray('— restore on a new machine') + '\n' +
26
- chalk.cyan(' memoir status ') + chalk.gray('— see detected AI tools') + '\n\n' +
25
+ chalk.cyan(' memoir init ') + chalk.gray('— first-time setup') + '\n' +
26
+ chalk.cyan(' memoir push ') + chalk.gray('— back up your AI memory') + '\n' +
27
+ chalk.cyan(' memoir restore ') + chalk.gray('— restore on a new machine') + '\n' +
28
+ chalk.cyan(' memoir snapshot ') + chalk.gray('— capture your current session') + '\n' +
29
+ chalk.cyan(' memoir resume ') + chalk.gray('— pick up where you left off') + '\n' +
30
+ chalk.cyan(' memoir status ') + chalk.gray('— see detected AI tools') + '\n\n' +
31
+ chalk.gray(' Tip: use --only claude,gemini to sync specific tools') + '\n\n' +
27
32
  chalk.gray(`v${VERSION}`),
28
33
  { padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
29
34
  ) + '\n');
@@ -58,9 +63,10 @@ program
58
63
  .command('push')
59
64
  .alias('remember')
60
65
  .description('Back up your AI memory to the cloud')
61
- .action(async () => {
66
+ .option('--only <tools>', 'Only sync specific tools (comma-separated: claude,gemini,codex,cursor,copilot,windsurf,aider)')
67
+ .action(async (options) => {
62
68
  try {
63
- await pushCommand();
69
+ await pushCommand(options);
64
70
  } catch (err) {
65
71
  console.error(chalk.red('\n✖ Error during sync:'), err.message);
66
72
  process.exit(1);
@@ -71,9 +77,10 @@ program
71
77
  .command('restore')
72
78
  .alias('pull')
73
79
  .description('Restore your AI memory on this machine')
74
- .action(async () => {
80
+ .option('--only <tools>', 'Only restore specific tools (comma-separated: claude,gemini,codex,cursor,copilot,windsurf,aider)')
81
+ .action(async (options) => {
75
82
  try {
76
- await restoreCommand();
83
+ await restoreCommand(options);
77
84
  } catch (err) {
78
85
  console.error(chalk.red('\n✖ Error during restore:'), err.message);
79
86
  process.exit(1);
@@ -105,6 +112,35 @@ program
105
112
  }
106
113
  });
107
114
 
115
+ program
116
+ .command('snapshot')
117
+ .alias('handoff')
118
+ .description('Capture your current coding session for handoff')
119
+ .option('--smart', 'Use AI to generate a better summary (requires Gemini API key)')
120
+ .option('--goal <goal>', 'What you want to do next (goal-directed handoff)')
121
+ .action(async (options) => {
122
+ try {
123
+ await snapshotCommand(options);
124
+ } catch (err) {
125
+ console.error(chalk.red('\n✖ Error during snapshot:'), err.message);
126
+ process.exit(1);
127
+ }
128
+ });
129
+
130
+ program
131
+ .command('resume')
132
+ .description('Pick up where you left off on another machine')
133
+ .option('--inject', 'Write the handoff where your AI tool will read it')
134
+ .option('--to <tool>', 'Target tool for injection (claude, gemini, cursor, codex)')
135
+ .action(async (options) => {
136
+ try {
137
+ await resumeCommand(options);
138
+ } catch (err) {
139
+ console.error(chalk.red('\n✖ Error during resume:'), err.message);
140
+ process.exit(1);
141
+ }
142
+ });
143
+
108
144
  program
109
145
  .command('migrate')
110
146
  .description('Translate memory between AI tools (Claude, Gemini, Codex, Cursor, etc.)')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "1.5.1",
4
- "description": "Sync and translate AI memory across devices and tools. Back up Claude, Gemini, Codex, Cursor, Copilot, Windsurf, and Aider configs. Migrate instructions between AI coding assistants with one command.",
3
+ "version": "2.0.0",
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",
7
7
  "bin": {
@@ -44,7 +44,10 @@
44
44
  "openai",
45
45
  "ai-assistant",
46
46
  "coding-assistant",
47
- "context-sync"
47
+ "context-sync",
48
+ "session-handoff",
49
+ "snapshot",
50
+ "resume"
48
51
  ],
49
52
  "author": "camgitt",
50
53
  "license": "MIT",
@@ -55,7 +58,6 @@
55
58
  "fs-extra": "^11.2.0",
56
59
  "gradient-string": "^3.0.0",
57
60
  "inquirer": "^9.2.15",
58
-
59
61
  "open": "^11.0.0",
60
62
  "ora": "^7.0.1"
61
63
  }
@@ -171,11 +171,17 @@ function formatSize(bytes) {
171
171
  return `${(bytes / (1024 * 1024)).toFixed(1)}mb`;
172
172
  }
173
173
 
174
- export async function extractMemories(stagingDir, spinner) {
174
+ export async function extractMemories(stagingDir, spinner, onlyFilter = null) {
175
175
  let foundAny = false;
176
176
  const results = [];
177
177
 
178
178
  for (const adapter of adapters) {
179
+ // Skip if --only filter is set and this adapter doesn't match
180
+ if (onlyFilter) {
181
+ const adapterKey = adapter.name.toLowerCase().replace(/ /g, '-').replace('cli', '').replace('openai-', '').trim().replace(/-$/, '');
182
+ const matches = onlyFilter.some(f => adapter.name.toLowerCase().includes(f) || adapterKey.includes(f));
183
+ if (!matches) continue;
184
+ }
179
185
  if (adapter.customExtract) {
180
186
  const dest = path.join(stagingDir, adapter.name.toLowerCase().replace(/ /g, '-'));
181
187
  let foundFile = false;
@@ -56,11 +56,17 @@ async function syncFiles(src, dest, changes) {
56
56
  }
57
57
  }
58
58
 
59
- export async function restoreMemories(sourceDir, spinner) {
59
+ export async function restoreMemories(sourceDir, spinner, onlyFilter = null) {
60
60
  let restoredAny = false;
61
61
  const allResults = [];
62
62
 
63
63
  for (const adapter of adapters) {
64
+ // Skip if --only filter is set and this adapter doesn't match
65
+ if (onlyFilter) {
66
+ const matches = onlyFilter.some(f => adapter.name.toLowerCase().includes(f));
67
+ if (!matches) continue;
68
+ }
69
+
64
70
  const backupDir = path.join(sourceDir, adapter.name.toLowerCase().replace(/ /g, '-'));
65
71
 
66
72
  if (await fs.pathExists(backupDir)) {
@@ -125,20 +131,24 @@ export async function restoreMemories(sourceDir, spinner) {
125
131
  // Show summary of changes
126
132
  spinner.stop();
127
133
  const totalChanged = changes.added.length + changes.updated.length;
134
+ const relPath = (f) => path.relative(adapter.source, f);
128
135
  if (totalChanged > 0) {
129
136
  console.log(chalk.green.bold(`\n ${adapter.icon} ${adapter.name} — ${totalChanged} file(s) restored to ${chalk.underline(adapter.source)}`));
130
137
  for (const f of changes.added) {
131
- console.log(chalk.green(` + ${path.basename(f)}`) + chalk.gray(` (new)`));
138
+ console.log(chalk.green(` + ${relPath(f)}`) + chalk.gray(` (new)`));
132
139
  }
133
140
  for (const f of changes.updated) {
134
- console.log(chalk.yellow(` ↻ ${path.basename(f)}`) + chalk.gray(` (updated)`));
141
+ console.log(chalk.yellow(` ↻ ${relPath(f)}`) + chalk.gray(` (updated)`));
135
142
  }
136
143
  }
137
144
  if (changes.skipped.length > 0) {
138
- console.log(chalk.gray(` ⏭ ${changes.skipped.length} file(s) already up to date`));
145
+ console.log(chalk.gray(` ⏭ ${changes.skipped.length} file(s) already up to date:`));
146
+ for (const f of changes.skipped) {
147
+ console.log(chalk.gray(` = ${relPath(f)}`));
148
+ }
139
149
  }
140
- if (totalChanged === 0) {
141
- console.log(chalk.gray(` ✔ ${adapter.name} — already up to date`));
150
+ if (totalChanged === 0 && changes.skipped.length === 0) {
151
+ console.log(chalk.gray(` ✔ ${adapter.name} — nothing to restore`));
142
152
  }
143
153
  spinner.start();
144
154
 
@@ -9,7 +9,7 @@ import { getConfig } from '../config.js';
9
9
  import { extractMemories, adapters } from '../adapters/index.js';
10
10
  import { syncToLocal, syncToGit } from '../providers/index.js';
11
11
 
12
- export async function pushCommand() {
12
+ export async function pushCommand(options = {}) {
13
13
  const config = await getConfig();
14
14
 
15
15
  if (!config) {
@@ -28,7 +28,8 @@ export async function pushCommand() {
28
28
  await fs.ensureDir(stagingDir);
29
29
 
30
30
  try {
31
- const foundAny = await extractMemories(stagingDir, spinner);
31
+ const onlyFilter = options.only ? options.only.split(',').map(t => t.trim().toLowerCase()) : null;
32
+ const foundAny = await extractMemories(stagingDir, spinner, onlyFilter);
32
33
 
33
34
  if (!foundAny) {
34
35
  spinner.stop();
@@ -8,7 +8,7 @@ import gradient from 'gradient-string';
8
8
  import { getConfig } from '../config.js';
9
9
  import { fetchFromLocal, fetchFromGit } from '../providers/restore.js';
10
10
 
11
- export async function restoreCommand() {
11
+ export async function restoreCommand(options = {}) {
12
12
  const config = await getConfig();
13
13
 
14
14
  if (!config) {
@@ -29,10 +29,12 @@ export async function restoreCommand() {
29
29
  try {
30
30
  let restored = false;
31
31
 
32
+ const onlyFilter = options.only ? options.only.split(',').map(t => t.trim().toLowerCase()) : null;
33
+
32
34
  if (config.provider === 'local' || config.provider.includes('local')) {
33
- restored = await fetchFromLocal(config, stagingDir, spinner);
35
+ restored = await fetchFromLocal(config, stagingDir, spinner, onlyFilter);
34
36
  } else if (config.provider === 'git' || config.provider.includes('git')) {
35
- restored = await fetchFromGit(config, stagingDir, spinner);
37
+ restored = await fetchFromGit(config, stagingDir, spinner, onlyFilter);
36
38
  } else {
37
39
  spinner.fail(chalk.red(`Unknown provider: ${config.provider}`));
38
40
  return;
@@ -0,0 +1,166 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import ora from 'ora';
6
+ import boxen from 'boxen';
7
+ import gradient from 'gradient-string';
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
+ const cwdKey = '-' + cwd.replace(/^\//, '').replace(/\\/g, '-').replace(/\//g, '-').replace(/:/g, '');
55
+ const memDir = path.join(home, '.claude', 'projects', cwdKey, 'memory');
56
+ return path.join(memDir, 'handoff.md');
57
+ },
58
+ gemini: () => {
59
+ return path.join(process.cwd(), 'GEMINI.md');
60
+ },
61
+ cursor: () => {
62
+ return path.join(process.cwd(), '.cursor', 'rules', 'handoff.mdc');
63
+ },
64
+ codex: () => {
65
+ return path.join(process.cwd(), 'AGENTS.md');
66
+ }
67
+ };
68
+
69
+ const getTarget = targets[tool];
70
+ if (!getTarget) {
71
+ throw new Error(`Unknown tool: ${tool}. Supported: claude, gemini, cursor, codex`);
72
+ }
73
+
74
+ const targetPath = getTarget();
75
+ await fs.ensureDir(path.dirname(targetPath));
76
+
77
+ if (tool === 'gemini' && await fs.pathExists(targetPath)) {
78
+ // Append to existing GEMINI.md
79
+ const existing = await fs.readFile(targetPath, 'utf8');
80
+ if (!existing.includes('# Session Handoff')) {
81
+ await fs.writeFile(targetPath, existing + '\n\n' + content);
82
+ } else {
83
+ // Replace existing handoff section
84
+ const before = existing.split('# Session Handoff')[0];
85
+ await fs.writeFile(targetPath, before + content);
86
+ }
87
+ } else {
88
+ await fs.writeFile(targetPath, content);
89
+ }
90
+
91
+ return targetPath;
92
+ }
93
+
94
+ export async function resumeCommand(options = {}) {
95
+ const config = await getConfig();
96
+
97
+ if (!config) {
98
+ console.log('\n' + boxen(
99
+ chalk.red('Not configured yet\n\n') +
100
+ chalk.white('Run ') + chalk.cyan.bold('memoir init') + chalk.white(' to get started.'),
101
+ { padding: 1, borderStyle: 'round', borderColor: 'red' }
102
+ ) + '\n');
103
+ return;
104
+ }
105
+
106
+ console.log();
107
+ const spinner = ora({ text: chalk.gray('Fetching latest handoff...'), spinner: 'dots' }).start();
108
+
109
+ // First check local cache
110
+ const localLatest = path.join(home, '.config', 'memoir', 'handoffs', 'latest.md');
111
+ let handoff;
112
+
113
+ // Try remote first
114
+ try {
115
+ handoff = await fetchLatestHandoff(config, spinner);
116
+ } catch (err) {
117
+ spinner.warn(chalk.yellow(`Remote fetch failed: ${err.message}`));
118
+ spinner.start();
119
+ }
120
+
121
+ // Fallback to local cache
122
+ if (!handoff && await fs.pathExists(localLatest)) {
123
+ handoff = { filename: 'latest.md', content: await fs.readFile(localLatest, 'utf8') };
124
+ }
125
+
126
+ if (!handoff) {
127
+ spinner.fail(chalk.red('No handoffs found.'));
128
+ console.log(chalk.gray('\n Run ') + chalk.cyan('memoir snapshot') + chalk.gray(' on another machine first.\n'));
129
+ return;
130
+ }
131
+
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'), handoff.content);
136
+
137
+ spinner.stop();
138
+
139
+ // Display the handoff
140
+ console.log(boxen(
141
+ gradient.pastel(' Session Handoff ') + '\n\n' +
142
+ handoff.content
143
+ .replace(/^---[\s\S]*?---\n/, '') // Strip YAML frontmatter for display
144
+ .trim(),
145
+ { padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
146
+ ));
147
+
148
+ // Inject if requested
149
+ if (options.inject) {
150
+ const tool = options.to || 'claude';
151
+ spinner.start(chalk.gray(`Injecting handoff for ${tool}...`));
152
+ try {
153
+ const targetPath = await injectHandoff(handoff.content, tool);
154
+ spinner.stop();
155
+ console.log('\n' + chalk.green(` Injected handoff → ${targetPath}`));
156
+ console.log(chalk.gray(` ${tool.charAt(0).toUpperCase() + tool.slice(1)} will read this on next session.\n`));
157
+ } catch (err) {
158
+ spinner.fail(chalk.red(`Inject failed: ${err.message}`));
159
+ }
160
+ } else {
161
+ console.log('\n' + chalk.gray(' To inject into your AI tool:'));
162
+ console.log(chalk.cyan(' memoir resume --inject') + chalk.gray(' (Claude)'));
163
+ console.log(chalk.cyan(' memoir resume --inject --to gemini'));
164
+ console.log(chalk.cyan(' memoir resume --inject --to cursor') + '\n');
165
+ }
166
+ }
@@ -0,0 +1,382 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import ora from 'ora';
6
+ import boxen from 'boxen';
7
+ import gradient from 'gradient-string';
8
+ import { getConfig, getGeminiApiKey } from '../config.js';
9
+ import { syncToLocal, syncToGit } from '../providers/index.js';
10
+
11
+ const home = os.homedir();
12
+
13
+ // Find all Claude session files, sorted newest first
14
+ function findClaudeSessions() {
15
+ const projectsDir = path.join(home, '.claude', 'projects');
16
+ if (!fs.existsSync(projectsDir)) return [];
17
+
18
+ const sessions = [];
19
+ const scanDir = (dir) => {
20
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
21
+ for (const entry of entries) {
22
+ const full = path.join(dir, entry.name);
23
+ if (entry.isDirectory()) {
24
+ scanDir(full);
25
+ } else if (entry.name.endsWith('.jsonl') && !entry.name.includes('subagent')) {
26
+ const stat = fs.statSync(full);
27
+ sessions.push({ path: full, mtime: stat.mtimeMs, size: stat.size });
28
+ }
29
+ }
30
+ };
31
+ scanDir(projectsDir);
32
+ sessions.sort((a, b) => b.mtime - a.mtime);
33
+ return sessions;
34
+ }
35
+
36
+ // Parse a Claude .jsonl session file
37
+ function parseClaudeSession(sessionPath) {
38
+ const raw = fs.readFileSync(sessionPath, 'utf8').trim();
39
+ const lines = raw.split('\n');
40
+
41
+ const result = {
42
+ sessionId: null,
43
+ slug: null,
44
+ gitBranch: null,
45
+ cwd: null,
46
+ firstTimestamp: null,
47
+ lastTimestamp: null,
48
+ model: null,
49
+ userMessages: [],
50
+ filesWritten: new Set(),
51
+ filesRead: new Set(),
52
+ bashCommands: [],
53
+ errors: [],
54
+ };
55
+
56
+ for (const line of lines) {
57
+ let obj;
58
+ try { obj = JSON.parse(line); } catch { continue; }
59
+
60
+ if (!result.sessionId && obj.sessionId) result.sessionId = obj.sessionId;
61
+ if (!result.slug && obj.slug) result.slug = obj.slug;
62
+ if (!result.gitBranch && obj.gitBranch) result.gitBranch = obj.gitBranch;
63
+ if (!result.cwd && obj.cwd) result.cwd = obj.cwd;
64
+ if (!result.firstTimestamp && obj.timestamp) result.firstTimestamp = obj.timestamp;
65
+ if (obj.timestamp) result.lastTimestamp = obj.timestamp;
66
+
67
+ // User messages (skip system/task notifications)
68
+ if (obj.type === 'user' && obj.message?.content) {
69
+ const content = typeof obj.message.content === 'string' ? obj.message.content : '';
70
+ if (content.length > 3 && !content.startsWith('<task-notification>')) {
71
+ result.userMessages.push(content);
72
+ }
73
+ }
74
+
75
+ // Tool uses from assistant messages
76
+ if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
77
+ for (const block of obj.message.content) {
78
+ if (block.type !== 'tool_use') continue;
79
+ const name = block.name;
80
+ const input = block.input || {};
81
+
82
+ if (name === 'Write' || name === 'Edit') {
83
+ const fp = input.file_path || '';
84
+ if (fp && !fp.startsWith('/tmp/') && !fp.startsWith('/private/tmp/')) {
85
+ result.filesWritten.add(fp);
86
+ }
87
+ } else if (name === 'Read') {
88
+ const fp = input.file_path || '';
89
+ if (fp && !fp.startsWith('/tmp/') && !fp.startsWith('/private/tmp/')) {
90
+ result.filesRead.add(fp);
91
+ }
92
+ } else if (name === 'Bash') {
93
+ const cmd = (input.command || '').trim();
94
+ if (cmd && !cmd.startsWith('sleep') && !cmd.startsWith('cat /private/tmp')) {
95
+ result.bashCommands.push(cmd.length > 120 ? cmd.slice(0, 120) + '...' : cmd);
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ // Capture errors from tool results
102
+ if (obj.type === 'tool_result' && obj.message?.content) {
103
+ const content = typeof obj.message.content === 'string' ? obj.message.content : '';
104
+ if (content.includes('Error') || content.includes('error') || content.includes('FAIL')) {
105
+ const errorLine = content.split('\n').find(l => /error|fail/i.test(l));
106
+ if (errorLine && errorLine.length < 200) {
107
+ result.errors.push(errorLine.trim());
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ // Convert sets to arrays
114
+ result.filesWritten = [...result.filesWritten];
115
+ result.filesRead = [...result.filesRead];
116
+ // Deduplicate errors
117
+ result.errors = [...new Set(result.errors)].slice(0, 10);
118
+
119
+ return result;
120
+ }
121
+
122
+ // Shorten file paths relative to cwd for readability
123
+ function shortenPath(filePath, cwd) {
124
+ if (filePath.startsWith(cwd + '/')) {
125
+ return filePath.slice(cwd.length + 1);
126
+ }
127
+ if (filePath.startsWith(home + '/')) {
128
+ return '~/' + filePath.slice(home.length + 1);
129
+ }
130
+ return filePath;
131
+ }
132
+
133
+ // Format duration between two ISO timestamps
134
+ function formatDuration(start, end) {
135
+ const ms = new Date(end) - new Date(start);
136
+ const mins = Math.floor(ms / 60000);
137
+ if (mins < 60) return `${mins}m`;
138
+ const hours = Math.floor(mins / 60);
139
+ const remaining = mins % 60;
140
+ return `${hours}h ${remaining}m`;
141
+ }
142
+
143
+ // Generate handoff markdown from parsed session
144
+ function generateHandoff(parsed, options = {}) {
145
+ const now = new Date();
146
+ const dateStr = now.toISOString().split('T')[0];
147
+ const timeStr = now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
148
+ const hostname = os.hostname();
149
+ const platform = process.platform === 'darwin' ? 'macOS' : process.platform === 'win32' ? 'Windows' : 'Linux';
150
+ const duration = formatDuration(parsed.firstTimestamp, parsed.lastTimestamp);
151
+ const cwd = parsed.cwd || home;
152
+
153
+ // Build user message summary (filter noise, keep substance)
154
+ const meaningfulMessages = parsed.userMessages
155
+ .filter(m => m.length > 10 && !m.startsWith('ok') && !m.startsWith('yes'))
156
+ .map(m => m.length > 200 ? m.slice(0, 200) + '...' : m);
157
+
158
+ // YAML frontmatter
159
+ let md = `---
160
+ memoir_version: "2.0"
161
+ source_tool: claude
162
+ session_id: ${parsed.sessionId || 'unknown'}
163
+ session_name: ${parsed.slug || 'unknown'}
164
+ timestamp: ${now.toISOString()}
165
+ machine: ${hostname} (${platform})
166
+ project: ${cwd}
167
+ branch: ${parsed.gitBranch || 'unknown'}
168
+ duration: ${duration}
169
+ files_modified: ${parsed.filesWritten.length}
170
+ files_read: ${parsed.filesRead.length}
171
+ ---
172
+
173
+ # Session Handoff
174
+
175
+ **From:** ${hostname} (${platform})
176
+ **When:** ${dateStr} ${timeStr}
177
+ **Tool:** Claude Code
178
+ **Branch:** ${parsed.gitBranch || 'unknown'}
179
+ **Duration:** ${duration}
180
+ **Project:** ${cwd}
181
+
182
+ ## What was discussed
183
+ ${meaningfulMessages.map(m => `- ${m}`).join('\n')}
184
+
185
+ ## Files modified
186
+ ${parsed.filesWritten.length > 0
187
+ ? parsed.filesWritten.map(f => `- \`${shortenPath(f, cwd)}\``).join('\n')
188
+ : '_None_'}
189
+
190
+ ## Files referenced
191
+ ${parsed.filesRead.length > 0
192
+ ? parsed.filesRead.map(f => `- \`${shortenPath(f, cwd)}\``).join('\n')
193
+ : '_None_'}
194
+
195
+ ## Commands run
196
+ ${parsed.bashCommands.length > 0
197
+ ? parsed.bashCommands.slice(0, 20).map(c => `- \`${c}\``).join('\n')
198
+ : '_None_'}
199
+ `;
200
+
201
+ if (parsed.errors.length > 0) {
202
+ md += `\n## Errors encountered\n${parsed.errors.map(e => `- ${e}`).join('\n')}\n`;
203
+ }
204
+
205
+ if (options.goal) {
206
+ md += `\n## Goal for next session\n${options.goal}\n`;
207
+ }
208
+
209
+ md += `\n## Context for next session\nThis handoff was captured from a Claude Code session on ${platform}. `;
210
+ md += `The session touched ${parsed.filesWritten.length} files and ran ${parsed.bashCommands.length} commands. `;
211
+ if (parsed.filesWritten.length > 0) {
212
+ md += `Key files to review: ${parsed.filesWritten.slice(0, 5).map(f => '`' + shortenPath(f, cwd) + '`').join(', ')}.`;
213
+ }
214
+ md += '\n';
215
+
216
+ return md;
217
+ }
218
+
219
+ // Use Gemini API to create a smart summary
220
+ async function smartSummarize(parsed, apiKey) {
221
+ const prompt = `You are summarizing a coding session for handoff to another machine. Be concise and actionable.
222
+
223
+ Session info:
224
+ - Duration: ${formatDuration(parsed.firstTimestamp, parsed.lastTimestamp)}
225
+ - Branch: ${parsed.gitBranch || 'unknown'}
226
+ - Project dir: ${parsed.cwd || 'unknown'}
227
+
228
+ User messages (what they asked for):
229
+ ${parsed.userMessages.filter(m => m.length > 10).map(m => `- ${m.slice(0, 200)}`).join('\n')}
230
+
231
+ Files modified:
232
+ ${parsed.filesWritten.map(f => `- ${f}`).join('\n')}
233
+
234
+ Write a structured summary with these exact sections:
235
+ ## Summary
236
+ (2-3 sentences of what was accomplished)
237
+
238
+ ## Key decisions
239
+ (Bullet list of important decisions made)
240
+
241
+ ## Current state
242
+ (What's done, what's in progress, what's left)
243
+
244
+ ## Next steps
245
+ (What should be done next to continue this work)
246
+
247
+ Keep it under 300 words total. Be specific about file names and features.`;
248
+
249
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`, {
250
+ method: 'POST',
251
+ headers: { 'Content-Type': 'application/json' },
252
+ body: JSON.stringify({
253
+ contents: [{ parts: [{ text: prompt }] }],
254
+ generationConfig: { maxOutputTokens: 1000, temperature: 0.3 }
255
+ })
256
+ });
257
+
258
+ if (!response.ok) {
259
+ throw new Error(`Gemini API error: ${response.status}`);
260
+ }
261
+
262
+ const data = await response.json();
263
+ return data.candidates?.[0]?.content?.parts?.[0]?.text || null;
264
+ }
265
+
266
+ export async function snapshotCommand(options = {}) {
267
+ const config = await getConfig();
268
+
269
+ console.log();
270
+ const spinner = ora({ text: chalk.gray('Finding latest session...'), spinner: 'dots' }).start();
271
+
272
+ // Find sessions
273
+ const sessions = findClaudeSessions();
274
+ if (sessions.length === 0) {
275
+ spinner.fail(chalk.red('No Claude Code sessions found.'));
276
+ return;
277
+ }
278
+
279
+ const latest = sessions[0];
280
+ spinner.text = chalk.gray('Parsing session...');
281
+
282
+ // Parse the session
283
+ const parsed = parseClaudeSession(latest.path);
284
+
285
+ if (parsed.userMessages.length === 0) {
286
+ spinner.fail(chalk.red('Session has no user messages.'));
287
+ return;
288
+ }
289
+
290
+ spinner.text = chalk.gray('Generating handoff...');
291
+
292
+ // Generate handoff markdown
293
+ let handoff;
294
+
295
+ if (options.smart) {
296
+ const apiKey = await getGeminiApiKey();
297
+ if (!apiKey) {
298
+ spinner.warn(chalk.yellow('No Gemini API key found. Using local extraction.'));
299
+ spinner.start();
300
+ handoff = generateHandoff(parsed, options);
301
+ } else {
302
+ spinner.text = chalk.gray('Generating AI-powered summary...');
303
+ try {
304
+ const smartSummary = await smartSummarize(parsed, apiKey);
305
+ // Generate base handoff then inject smart summary
306
+ handoff = generateHandoff(parsed, options);
307
+ if (smartSummary) {
308
+ // Insert smart summary after the frontmatter header
309
+ const insertPoint = handoff.indexOf('## What was discussed');
310
+ handoff = handoff.slice(0, insertPoint) +
311
+ '## AI Summary\n' + smartSummary + '\n\n' +
312
+ handoff.slice(insertPoint);
313
+ }
314
+ } catch (err) {
315
+ spinner.warn(chalk.yellow(`AI summary failed: ${err.message}. Using local extraction.`));
316
+ spinner.start();
317
+ handoff = generateHandoff(parsed, options);
318
+ }
319
+ }
320
+ } else {
321
+ handoff = generateHandoff(parsed, options);
322
+ }
323
+
324
+ // Save handoff
325
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
326
+ const filename = `${timestamp}-claude.md`;
327
+
328
+ // If config exists, push to backup
329
+ if (config) {
330
+ const stagingDir = path.join(os.tmpdir(), `memoir-handoff-${Date.now()}`);
331
+ await fs.ensureDir(path.join(stagingDir, 'handoffs'));
332
+ await fs.writeFile(path.join(stagingDir, 'handoffs', filename), handoff);
333
+
334
+ spinner.text = chalk.gray('Pushing handoff to backup...');
335
+
336
+ try {
337
+ if (config.provider === 'local' || config.provider.includes('local')) {
338
+ await syncToLocal(config, stagingDir, spinner);
339
+ } else if (config.provider === 'git' || config.provider.includes('git')) {
340
+ await syncToGit(config, stagingDir, spinner);
341
+ }
342
+ } catch (err) {
343
+ spinner.warn(chalk.yellow(`Push failed: ${err.message}. Saved locally.`));
344
+ }
345
+
346
+ await fs.remove(stagingDir);
347
+ }
348
+
349
+ // Also save locally for immediate access
350
+ const localHandoffDir = path.join(home, '.config', 'memoir', 'handoffs');
351
+ await fs.ensureDir(localHandoffDir);
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);
356
+
357
+ spinner.stop();
358
+
359
+ // Display summary
360
+ const duration = formatDuration(parsed.firstTimestamp, parsed.lastTimestamp);
361
+ console.log('\n' + boxen(
362
+ gradient.pastel(' Snapshot captured ') + '\n\n' +
363
+ chalk.white(`Session: ${parsed.slug || 'unnamed'}`) + '\n' +
364
+ chalk.gray(`Duration: ${duration} | Branch: ${parsed.gitBranch || '?'} | ${parsed.filesWritten.length} files changed`) + '\n\n' +
365
+ chalk.white.bold('Files modified:') + '\n' +
366
+ (parsed.filesWritten.length > 0
367
+ ? parsed.filesWritten.slice(0, 8).map(f => chalk.cyan(` ${shortenPath(f, parsed.cwd || home)}`)).join('\n')
368
+ : chalk.gray(' None')) +
369
+ (parsed.filesWritten.length > 8 ? chalk.gray(`\n ...and ${parsed.filesWritten.length - 8} more`) : '') + '\n\n' +
370
+ chalk.white.bold('User requests:') + '\n' +
371
+ parsed.userMessages
372
+ .filter(m => m.length > 10 && !m.startsWith('ok') && !m.startsWith('yes'))
373
+ .slice(0, 5)
374
+ .map(m => chalk.gray(` "${m.slice(0, 80)}${m.length > 80 ? '...' : ''}"`))
375
+ .join('\n') + '\n\n' +
376
+ chalk.gray(`Saved to: ${localHandoffDir}/${filename}`) +
377
+ (config ? '\n' + chalk.gray(`Pushed to: ${config.provider === 'git' ? config.gitRepo : config.localPath}`) : ''),
378
+ { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
379
+ ) + '\n');
380
+
381
+ console.log(chalk.gray(' Restore on another machine with: ') + chalk.cyan('memoir resume') + '\n');
382
+ }
@@ -5,7 +5,7 @@ import os from 'os';
5
5
  import { execFileSync } from 'child_process';
6
6
  import { restoreMemories } from '../adapters/restore.js';
7
7
 
8
- export async function fetchFromLocal(config, stagingDir, spinner) {
8
+ export async function fetchFromLocal(config, stagingDir, spinner, onlyFilter = null) {
9
9
  const sourceDir = config.localPath;
10
10
  if (!sourceDir) throw new Error('Local path is not configured.');
11
11
 
@@ -18,10 +18,10 @@ export async function fetchFromLocal(config, stagingDir, spinner) {
18
18
  spinner.text = `Fetching data from local directory: ${chalk.cyan(resolvedSource)}`;
19
19
  await fs.copy(resolvedSource, stagingDir);
20
20
 
21
- return await restoreMemories(stagingDir, spinner);
21
+ return await restoreMemories(stagingDir, spinner, onlyFilter);
22
22
  }
23
23
 
24
- export async function fetchFromGit(config, stagingDir, spinner) {
24
+ export async function fetchFromGit(config, stagingDir, spinner, onlyFilter = null) {
25
25
  const repoUrl = config.gitRepo;
26
26
  if (!repoUrl) throw new Error('Git repository is not configured.');
27
27
 
@@ -33,5 +33,5 @@ export async function fetchFromGit(config, stagingDir, spinner) {
33
33
  throw new Error('Failed to pull from git repository. Ensure your SSH keys are configured and the repository is accessible.');
34
34
  }
35
35
 
36
- return await restoreMemories(stagingDir, spinner);
36
+ return await restoreMemories(stagingDir, spinner, onlyFilter);
37
37
  }