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