memoir-cli 3.0.3 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.0.3",
3
+ "version": "3.1.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",
@@ -37,15 +37,22 @@ export const adapters = [
37
37
  if (src === claudeDir) return true;
38
38
  // Only allow these top-level dirs
39
39
  const topDir = rel.split(path.sep)[0];
40
- const allowedDirs = ['projects', 'settings'];
41
40
  const allowedFiles = ['settings.json', 'settings.local.json'];
42
41
  // Allow specific top-level config files
43
42
  if (!rel.includes(path.sep) && allowedFiles.includes(basename)) return true;
44
43
  // Allow projects dir (contains memory .md files)
45
44
  if (topDir === 'projects') {
46
- // Allow directory traversal
47
- try { if (nodeFs.statSync(src).isDirectory()) return true; } catch {}
48
- // Only sync memory markdown files
45
+ // Allow directory traversal but skip dirs that only contain session data
46
+ try {
47
+ if (nodeFs.statSync(src).isDirectory()) {
48
+ // Skip subagents and UUID-named session dirs (contain large .jsonl files, no .md)
49
+ if (basename === 'subagents') return false;
50
+ // UUID pattern: 8-4-4-4-12 hex chars
51
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(basename)) return false;
52
+ return true;
53
+ }
54
+ } catch {}
55
+ // Only sync memory markdown files — skip .jsonl session files
49
56
  return basename.endsWith('.md');
50
57
  }
51
58
  // Allow settings dir
@@ -13,6 +13,7 @@ import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreP
13
13
  import { scanForSecrets, printSecurityReport } from '../security/scanner.js';
14
14
  import { encryptDirectory, createVerifyToken } from '../security/encryption.js';
15
15
  import { getRawConfig, saveConfig, migrateConfigToV2 } from '../config.js';
16
+ import { scanWorkspace } from '../workspace/tracker.js';
16
17
 
17
18
  export async function pushCommand(options = {}) {
18
19
  const config = await getConfig(options.profile);
@@ -101,6 +102,15 @@ export async function pushCommand(options = {}) {
101
102
  // Context capture is best-effort — don't fail the push
102
103
  }
103
104
 
105
+ // Scan workspace for projects (git repos + unbacked projects)
106
+ let workspaceManifest = null;
107
+ spinner.text = chalk.gray('Scanning workspace...');
108
+ try {
109
+ workspaceManifest = await scanWorkspace(stagingDir, spinner);
110
+ } catch {
111
+ // Workspace scan is best-effort
112
+ }
113
+
104
114
  // Count what was found
105
115
  const found = [];
106
116
  for (const adapter of adapters) {
@@ -220,9 +230,18 @@ export async function pushCommand(options = {}) {
220
230
  contextLine += chalk.yellow(` 🔒 ${sessionInfo.secretsRedacted} secret(s) auto-redacted`) + '\n';
221
231
  }
222
232
  }
233
+ let workspaceLine = '';
234
+ if (workspaceManifest && workspaceManifest.projects.length > 0) {
235
+ const gitCount = workspaceManifest.projects.filter(p => p.type === 'git' && p.gitRemote).length;
236
+ const bundleCount = workspaceManifest.projects.filter(p => p.bundleFile).length;
237
+ const parts = [];
238
+ if (gitCount > 0) parts.push(`${gitCount} git`);
239
+ if (bundleCount > 0) parts.push(`${bundleCount} bundled`);
240
+ workspaceLine = '\n' + chalk.green(' ✔ Workspace') + chalk.gray(` (${workspaceManifest.projects.length} projects — ${parts.join(', ')})`) + '\n';
241
+ }
223
242
  console.log('\n' + boxen(
224
243
  gradient.pastel(' Backed up! ') + '\n\n' +
225
- toolList + contextLine + '\n' +
244
+ toolList + contextLine + workspaceLine + '\n' +
226
245
  chalk.white(`${totalFiles} files from ${found.length} tool${found.length !== 1 ? 's' : ''}`) + '\n' +
227
246
  (encrypted ? chalk.green(' 🔒 E2E encrypted') + '\n' : '') +
228
247
  chalk.gray(`→ ${dest}`) + '\n\n' +
@@ -10,6 +10,7 @@ import { getConfig } from '../config.js';
10
10
  import { fetchFromLocal, fetchFromGit } from '../providers/restore.js';
11
11
  import { decryptDirectory, verifyPassphrase } from '../security/encryption.js';
12
12
  import { detectLocalHomeKey } from '../adapters/restore.js';
13
+ import { restoreWorkspace } from '../workspace/tracker.js';
13
14
 
14
15
  const home = os.homedir();
15
16
 
@@ -169,6 +170,39 @@ export async function restoreCommand(options = {}) {
169
170
  }
170
171
  }
171
172
 
173
+ // Restore workspace (clone git projects, unpack bundles)
174
+ let workspaceResults = null;
175
+ try {
176
+ spinner.start(chalk.gray('Checking workspace...'));
177
+ workspaceResults = await restoreWorkspace(stagingDir, spinner, autoYes);
178
+ spinner.stop();
179
+
180
+ if (workspaceResults) {
181
+ const { cloned, unpacked, patched, skipped } = workspaceResults;
182
+ if (cloned.length > 0 || unpacked.length > 0) {
183
+ console.log('\n' + chalk.cyan.bold(' 📁 Workspace restored:'));
184
+ for (const p of cloned) {
185
+ console.log(chalk.green(` ✔ ${p.name}`) + chalk.gray(` → ${p.path}`));
186
+ }
187
+ for (const p of unpacked) {
188
+ console.log(chalk.green(` ✔ ${p.name}`) + chalk.gray(` → ${p.path} (unpacked)`));
189
+ }
190
+ if (patched.length > 0) {
191
+ for (const p of patched) {
192
+ console.log(chalk.yellow(` ↻ ${p.name}`) + chalk.gray(` — uncommitted changes applied`));
193
+ }
194
+ }
195
+ const existingCount = skipped.filter(s => s.reason === 'exists').length;
196
+ if (existingCount > 0) {
197
+ console.log(chalk.gray(` ⏭ ${existingCount} project(s) already on this machine`));
198
+ }
199
+ restored = true;
200
+ }
201
+ }
202
+ } catch {
203
+ // Workspace restore is best-effort
204
+ }
205
+
172
206
  if (restored) {
173
207
  let handoffMsg = '';
174
208
  if (handoffInjected && handoffInfo) {
@@ -178,10 +212,17 @@ export async function restoreCommand(options = {}) {
178
212
  (handoffInfo.duration ? '\n' + chalk.gray(` Duration: ${handoffInfo.duration}`) : '') + '\n' +
179
213
  chalk.gray(' Your AI will pick up where you left off.');
180
214
  }
215
+ let workspaceMsg = '';
216
+ if (workspaceResults) {
217
+ const total = workspaceResults.cloned.length + workspaceResults.unpacked.length;
218
+ if (total > 0) {
219
+ workspaceMsg = '\n' + chalk.cyan(`📁 ${total} project(s) restored to this machine`);
220
+ }
221
+ }
181
222
  console.log(boxen(
182
223
  gradient.pastel(' Done! ') + '\n\n' +
183
224
  chalk.white('Your AI tools have their memories back.') +
184
- handoffMsg + '\n' +
225
+ handoffMsg + workspaceMsg + '\n' +
185
226
  chalk.gray(handoffInjected ? '' : 'Restart your AI tools to pick up the changes.'),
186
227
  { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
187
228
  ) + '\n');
@@ -42,12 +42,18 @@ export function findClaudeSessions() {
42
42
  */
43
43
  export function parseSession(sessionPath, maxSizeMB = 10) {
44
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);
45
+ const TAIL_BYTES = 2 * 1024 * 1024; // Read last 2MB max
46
+
47
+ if (stat.size > TAIL_BYTES) {
48
+ // For large files, only read the tail to avoid loading 20MB+ into memory
49
+ const fd = fs.openSync(sessionPath, 'r');
50
+ const buf = Buffer.alloc(TAIL_BYTES);
51
+ fs.readSync(fd, buf, 0, TAIL_BYTES, stat.size - TAIL_BYTES);
52
+ fs.closeSync(fd);
53
+ const raw = buf.toString('utf8');
54
+ // Skip the first (partial) line since we likely cut mid-line
55
+ const lines = raw.split('\n').slice(1);
56
+ return parseLines(lines);
51
57
  }
52
58
 
53
59
  const raw = fs.readFileSync(sessionPath, 'utf8').trim();
@@ -0,0 +1,350 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { execFileSync } from 'child_process';
5
+
6
+ const home = os.homedir();
7
+
8
+ /**
9
+ * Scan home directory for git projects and build a workspace manifest.
10
+ * Tracks: git remote URL, branch, last commit, uncommitted changes (as patch).
11
+ * For non-git projects with AI configs, bundles them as tar.gz.
12
+ */
13
+ export async function scanWorkspace(stagingDir, spinner, opts = {}) {
14
+ const maxDepth = opts.maxDepth || 3;
15
+ const maxBundleSize = opts.maxBundleSize || 50 * 1024 * 1024; // 50MB
16
+
17
+ const skipDirs = new Set([
18
+ 'node_modules', '.git', '.next', '.vercel', 'dist', 'build',
19
+ '__pycache__', '.venv', 'venv', '.cache', '.npm', '.bun',
20
+ 'Library', '.Trash', 'Applications', 'Pictures', 'Music',
21
+ 'Movies', 'Public', 'Downloads', '.local', '.cargo', '.rustup',
22
+ '.docker', '.ssh', '.config', '.claude', '.gemini'
23
+ ]);
24
+
25
+ // Project markers — files that indicate "this is a project"
26
+ const projectMarkers = [
27
+ 'package.json', 'Cargo.toml', 'go.mod', 'pyproject.toml',
28
+ 'requirements.txt', 'Gemfile', 'pom.xml', 'build.gradle',
29
+ 'Makefile', 'CMakeLists.txt', '.project', 'CLAUDE.md',
30
+ 'GEMINI.md', 'AGENTS.md', 'README.md',
31
+ // Also detect dirs with .git or multiple content files
32
+ '.gitignore', 'index.html', 'main.py', 'app.py', 'index.js',
33
+ ];
34
+
35
+ // Also detect dirs with multiple markdown/code files as potential projects
36
+ const isContentProject = (entries) => {
37
+ const mdFiles = entries.filter(e => !e.isDirectory() && e.name.endsWith('.md'));
38
+ return mdFiles.length >= 2; // 2+ markdown files = likely a writing project
39
+ };
40
+
41
+ const projects = [];
42
+
43
+ const scanDir = async (dir, depth = 0) => {
44
+ if (depth > maxDepth) return;
45
+ let entries;
46
+ try {
47
+ entries = await fs.readdir(dir, { withFileTypes: true });
48
+ } catch { return; }
49
+
50
+ // Check if this dir is a project
51
+ const hasMarker = entries.some(e => !e.isDirectory() && projectMarkers.includes(e.name));
52
+ const hasContent = isContentProject(entries);
53
+
54
+ if ((hasMarker || hasContent) && dir !== home) {
55
+ const info = await getProjectInfo(dir);
56
+ if (info) projects.push(info);
57
+ // Don't recurse into sub-projects deeper than this
58
+ return;
59
+ }
60
+
61
+ // Recurse into subdirectories
62
+ for (const entry of entries) {
63
+ if (!entry.isDirectory()) continue;
64
+ if (entry.name.startsWith('.') && entry.name !== '.github') continue;
65
+ if (skipDirs.has(entry.name)) continue;
66
+ await scanDir(path.join(dir, entry.name), depth + 1);
67
+ }
68
+ };
69
+
70
+ if (spinner) spinner.text = 'Scanning workspace for projects...';
71
+ await scanDir(home);
72
+
73
+ // Build manifest
74
+ const manifest = {
75
+ version: 1,
76
+ machine: os.hostname(),
77
+ platform: process.platform,
78
+ home: home,
79
+ scannedAt: new Date().toISOString(),
80
+ projects: []
81
+ };
82
+
83
+ const bundleDir = path.join(stagingDir, 'workspace-bundles');
84
+
85
+ for (const proj of projects) {
86
+ const entry = {
87
+ name: proj.name,
88
+ relativePath: proj.relativePath,
89
+ originalPath: proj.path,
90
+ type: proj.hasGit ? 'git' : 'bundle',
91
+ };
92
+
93
+ if (proj.hasGit) {
94
+ entry.gitRemote = proj.gitRemote;
95
+ entry.branch = proj.branch;
96
+ entry.lastCommit = proj.lastCommit;
97
+ entry.lastCommitMessage = proj.lastCommitMessage;
98
+
99
+ // Save uncommitted changes as patch
100
+ if (proj.hasDirtyWork && proj.lastCommit) {
101
+ try {
102
+ const patchDir = path.join(stagingDir, 'workspace-patches');
103
+ await fs.ensureDir(patchDir);
104
+ const diff = execFileSync('git', ['diff', 'HEAD'], {
105
+ cwd: proj.path,
106
+ maxBuffer: 10 * 1024 * 1024,
107
+ timeout: 10000
108
+ }).toString();
109
+ if (diff.trim()) {
110
+ const patchFile = `${proj.name}.patch`;
111
+ await fs.writeFile(path.join(patchDir, patchFile), diff);
112
+ entry.patchFile = patchFile;
113
+ }
114
+ } catch {
115
+ // Patch capture is best-effort
116
+ }
117
+ }
118
+ } else {
119
+ // Bundle non-git project (if small enough)
120
+ const size = await getDirSize(proj.path);
121
+ if (size <= maxBundleSize) {
122
+ try {
123
+ await fs.ensureDir(bundleDir);
124
+ const bundleName = `${proj.name}.tar.gz`;
125
+ execFileSync('tar', [
126
+ 'czf', path.join(bundleDir, bundleName),
127
+ '-C', path.dirname(proj.path),
128
+ '--exclude', 'node_modules',
129
+ '--exclude', '.git',
130
+ '--exclude', '__pycache__',
131
+ '--exclude', '.venv',
132
+ '--exclude', 'dist',
133
+ '--exclude', 'build',
134
+ proj.name
135
+ ], { stdio: 'ignore', timeout: 30000 });
136
+ entry.bundleFile = bundleName;
137
+ entry.bundleSize = (await fs.stat(path.join(bundleDir, bundleName))).size;
138
+ } catch {
139
+ // Bundle is best-effort
140
+ entry.bundleFailed = true;
141
+ }
142
+ } else {
143
+ entry.tooLarge = true;
144
+ entry.size = size;
145
+ }
146
+ }
147
+
148
+ manifest.projects.push(entry);
149
+ }
150
+
151
+ // Save manifest
152
+ await fs.writeFile(
153
+ path.join(stagingDir, 'workspace.json'),
154
+ JSON.stringify(manifest, null, 2)
155
+ );
156
+
157
+ return manifest;
158
+ }
159
+
160
+ /**
161
+ * Restore workspace on this machine from a manifest.
162
+ * Clones git projects, unpacks bundles, applies patches.
163
+ */
164
+ export async function restoreWorkspace(sourceDir, spinner, autoYes = false) {
165
+ const manifestPath = path.join(sourceDir, 'workspace.json');
166
+ if (!await fs.pathExists(manifestPath)) return null;
167
+
168
+ const manifest = await fs.readJson(manifestPath);
169
+ if (!manifest.projects || manifest.projects.length === 0) return null;
170
+
171
+ const results = { cloned: [], unpacked: [], patched: [], skipped: [] };
172
+
173
+ for (const proj of manifest.projects) {
174
+ // Determine where to put this project on the local machine
175
+ const localPath = resolveLocalPath(proj);
176
+
177
+ // Skip if project already exists locally
178
+ if (await fs.pathExists(localPath)) {
179
+ // But check if we should apply a patch
180
+ if (proj.patchFile) {
181
+ const patchPath = path.join(sourceDir, 'workspace-patches', proj.patchFile);
182
+ if (await fs.pathExists(patchPath)) {
183
+ try {
184
+ execFileSync('git', ['apply', '--check', patchPath], {
185
+ cwd: localPath, stdio: 'ignore'
186
+ });
187
+ execFileSync('git', ['apply', patchPath], {
188
+ cwd: localPath, stdio: 'ignore'
189
+ });
190
+ results.patched.push({ name: proj.name, path: localPath });
191
+ } catch {
192
+ // Patch didn't apply cleanly — skip
193
+ }
194
+ }
195
+ }
196
+ results.skipped.push({ name: proj.name, path: localPath, reason: 'exists' });
197
+ continue;
198
+ }
199
+
200
+ if (proj.type === 'git' && proj.gitRemote) {
201
+ // Clone the repo
202
+ if (spinner) spinner.text = `Cloning ${proj.name}...`;
203
+ try {
204
+ await fs.ensureDir(path.dirname(localPath));
205
+ execFileSync('git', ['clone', proj.gitRemote, localPath], {
206
+ stdio: 'ignore',
207
+ timeout: 120000
208
+ });
209
+
210
+ // Checkout the right branch
211
+ if (proj.branch && proj.branch !== 'main' && proj.branch !== 'master') {
212
+ try {
213
+ execFileSync('git', ['checkout', proj.branch], {
214
+ cwd: localPath, stdio: 'ignore'
215
+ });
216
+ } catch {}
217
+ }
218
+
219
+ // Apply patch if available
220
+ if (proj.patchFile) {
221
+ const patchPath = path.join(sourceDir, 'workspace-patches', proj.patchFile);
222
+ if (await fs.pathExists(patchPath)) {
223
+ try {
224
+ execFileSync('git', ['apply', patchPath], {
225
+ cwd: localPath, stdio: 'ignore'
226
+ });
227
+ results.patched.push({ name: proj.name, path: localPath });
228
+ } catch {}
229
+ }
230
+ }
231
+
232
+ results.cloned.push({ name: proj.name, path: localPath, remote: proj.gitRemote });
233
+ } catch (err) {
234
+ results.skipped.push({ name: proj.name, reason: `clone failed: ${err.message}` });
235
+ }
236
+ } else if (proj.bundleFile) {
237
+ // Unpack bundle
238
+ const bundlePath = path.join(sourceDir, 'workspace-bundles', proj.bundleFile);
239
+ if (await fs.pathExists(bundlePath)) {
240
+ if (spinner) spinner.text = `Unpacking ${proj.name}...`;
241
+ try {
242
+ await fs.ensureDir(path.dirname(localPath));
243
+ execFileSync('tar', ['xzf', bundlePath, '-C', path.dirname(localPath)], {
244
+ stdio: 'ignore',
245
+ timeout: 60000
246
+ });
247
+ results.unpacked.push({ name: proj.name, path: localPath });
248
+ } catch (err) {
249
+ results.skipped.push({ name: proj.name, reason: `unpack failed: ${err.message}` });
250
+ }
251
+ }
252
+ } else {
253
+ results.skipped.push({ name: proj.name, reason: proj.tooLarge ? 'too large' : 'no source' });
254
+ }
255
+ }
256
+
257
+ return results;
258
+ }
259
+
260
+ /**
261
+ * Figure out where a project should live on this machine.
262
+ */
263
+ function resolveLocalPath(proj) {
264
+ // If the project had a relative path from home, use that
265
+ if (proj.relativePath) {
266
+ return path.join(home, proj.relativePath);
267
+ }
268
+ // Default: put it in home directory
269
+ return path.join(home, proj.name);
270
+ }
271
+
272
+ /**
273
+ * Get info about a project directory.
274
+ */
275
+ async function getProjectInfo(dir) {
276
+ const name = path.basename(dir);
277
+ const relativePath = path.relative(home, dir);
278
+ const info = {
279
+ name,
280
+ path: dir,
281
+ relativePath,
282
+ hasGit: false,
283
+ gitRemote: null,
284
+ branch: null,
285
+ lastCommit: null,
286
+ lastCommitMessage: null,
287
+ hasDirtyWork: false,
288
+ };
289
+
290
+ // Check for git
291
+ const gitDir = path.join(dir, '.git');
292
+ if (await fs.pathExists(gitDir)) {
293
+ info.hasGit = true;
294
+ try {
295
+ const remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
296
+ cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
297
+ }).toString().trim();
298
+ info.gitRemote = remote;
299
+ } catch {}
300
+
301
+ try {
302
+ info.branch = execFileSync('git', ['branch', '--show-current'], {
303
+ cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
304
+ }).toString().trim();
305
+ } catch {}
306
+
307
+ try {
308
+ info.lastCommit = execFileSync('git', ['log', '-1', '--format=%H'], {
309
+ cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
310
+ }).toString().trim();
311
+ info.lastCommitMessage = execFileSync('git', ['log', '-1', '--format=%s'], {
312
+ cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
313
+ }).toString().trim();
314
+ } catch {}
315
+
316
+ try {
317
+ const status = execFileSync('git', ['status', '--porcelain'], {
318
+ cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
319
+ }).toString().trim();
320
+ info.hasDirtyWork = status.length > 0;
321
+ } catch {}
322
+ }
323
+
324
+ return info;
325
+ }
326
+
327
+ /**
328
+ * Get total size of a directory (excluding common heavy dirs).
329
+ */
330
+ async function getDirSize(dir) {
331
+ let size = 0;
332
+ const skip = new Set(['node_modules', '.git', '__pycache__', '.venv', 'dist', 'build']);
333
+
334
+ const walk = async (d) => {
335
+ let entries;
336
+ try { entries = await fs.readdir(d, { withFileTypes: true }); } catch { return; }
337
+ for (const e of entries) {
338
+ if (e.isDirectory()) {
339
+ if (!skip.has(e.name)) await walk(path.join(d, e.name));
340
+ } else {
341
+ try {
342
+ const stat = await fs.stat(path.join(d, e.name));
343
+ size += stat.size;
344
+ } catch {}
345
+ }
346
+ }
347
+ };
348
+ await walk(dir);
349
+ return size;
350
+ }