memoir-cli 2.5.2 → 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/VISION.md +722 -0
- package/bin/memoir.js +1 -0
- package/package.json +1 -1
- package/src/commands/init.js +13 -0
- package/src/commands/push.js +105 -3
- package/src/commands/restore.js +125 -2
- package/src/context/capture.js +242 -0
- package/src/security/encryption.js +182 -0
- package/src/security/scanner.js +107 -0
package/bin/memoir.js
CHANGED
|
@@ -249,6 +249,7 @@ program
|
|
|
249
249
|
chalk.white(`memoir ${VERSION} → ${chalk.green.bold(latest)}`),
|
|
250
250
|
{ padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
|
|
251
251
|
) + '\n');
|
|
252
|
+
process.exit(0); // Exit immediately — old process still has old VERSION
|
|
252
253
|
} catch (err) {
|
|
253
254
|
console.error(chalk.red('\n✖ Update failed:'), err.message);
|
|
254
255
|
console.log(chalk.gray('Try manually: ') + chalk.cyan('npm install -g memoir-cli'));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memoir-cli",
|
|
3
|
-
"version": "2.
|
|
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",
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
|
package/src/commands/push.js
CHANGED
|
@@ -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,
|
|
145
|
+
await syncToLocal(config, uploadDir, spinner);
|
|
65
146
|
} else if (config.provider === 'git' || config.provider.includes('git')) {
|
|
66
|
-
await syncToGit(config,
|
|
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
|
|
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
|
}
|
package/src/commands/restore.js
CHANGED
|
@@ -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.') +
|
|
51
|
-
|
|
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
|
+
}
|