memoir-cli 1.5.2 → 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 +37 -4
- package/package.json +6 -3
- package/src/adapters/restore.js +9 -5
- package/src/commands/resume.js +166 -0
- package/src/commands/snapshot.js +382 -0
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,12 @@ 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
|
|
24
|
-
chalk.cyan(' memoir push
|
|
25
|
-
chalk.cyan(' memoir restore
|
|
26
|
-
chalk.cyan(' memoir
|
|
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' +
|
|
27
31
|
chalk.gray(' Tip: use --only claude,gemini to sync specific tools') + '\n\n' +
|
|
28
32
|
chalk.gray(`v${VERSION}`),
|
|
29
33
|
{ padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
|
|
@@ -108,6 +112,35 @@ program
|
|
|
108
112
|
}
|
|
109
113
|
});
|
|
110
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
|
+
|
|
111
144
|
program
|
|
112
145
|
.command('migrate')
|
|
113
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": "
|
|
4
|
-
"description": "Sync
|
|
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",
|
package/src/adapters/restore.js
CHANGED
|
@@ -131,20 +131,24 @@ export async function restoreMemories(sourceDir, spinner, onlyFilter = null) {
|
|
|
131
131
|
// Show summary of changes
|
|
132
132
|
spinner.stop();
|
|
133
133
|
const totalChanged = changes.added.length + changes.updated.length;
|
|
134
|
+
const relPath = (f) => path.relative(adapter.source, f);
|
|
134
135
|
if (totalChanged > 0) {
|
|
135
136
|
console.log(chalk.green.bold(`\n ${adapter.icon} ${adapter.name} — ${totalChanged} file(s) restored to ${chalk.underline(adapter.source)}`));
|
|
136
137
|
for (const f of changes.added) {
|
|
137
|
-
console.log(chalk.green(` + ${
|
|
138
|
+
console.log(chalk.green(` + ${relPath(f)}`) + chalk.gray(` (new)`));
|
|
138
139
|
}
|
|
139
140
|
for (const f of changes.updated) {
|
|
140
|
-
console.log(chalk.yellow(` ↻ ${
|
|
141
|
+
console.log(chalk.yellow(` ↻ ${relPath(f)}`) + chalk.gray(` (updated)`));
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
144
|
if (changes.skipped.length > 0) {
|
|
144
|
-
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
|
+
}
|
|
145
149
|
}
|
|
146
|
-
if (totalChanged === 0) {
|
|
147
|
-
console.log(chalk.gray(` ✔ ${adapter.name} —
|
|
150
|
+
if (totalChanged === 0 && changes.skipped.length === 0) {
|
|
151
|
+
console.log(chalk.gray(` ✔ ${adapter.name} — nothing to restore`));
|
|
148
152
|
}
|
|
149
153
|
spinner.start();
|
|
150
154
|
|
|
@@ -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
|
+
}
|