memoir-cli 3.0.3 → 3.1.1

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/README.md CHANGED
@@ -2,43 +2,59 @@
2
2
 
3
3
  # memoir
4
4
 
5
- **Sync your AI memory across every device and every tool.**
5
+ **Your AI remembers everything. On every machine.**
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/memoir-cli.svg?style=flat-square&color=7c6ef0)](https://npmjs.org/package/memoir-cli)
8
8
  [![npm downloads](https://img.shields.io/npm/dm/memoir-cli.svg?style=flat-square&color=7c6ef0)](https://npmjs.org/package/memoir-cli)
9
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
10
10
  [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen?style=flat-square)](https://nodejs.org)
11
11
 
12
- Your AI tools forget you on every new machine. memoir fixes that.
12
+ Close your laptop. Open another one. **Your AI picks up exactly where you left off.**
13
13
 
14
14
  [Website](https://memoir.sh) • [npm](https://npmjs.org/package/memoir-cli) • [Blog](https://memoir.sh/blog)
15
15
 
16
16
  <br />
17
17
 
18
- ![memoir demo](demo.gif)
19
-
20
18
  </div>
21
19
 
22
- ## Why memoir
20
+ ## The Problem
23
21
 
24
- You spend weeks teaching your AI tools how you code. Your CLAUDE.md is dialed in. Your .cursorrules are perfect. Your ChatGPT custom instructions know your stack.
22
+ You spend weeks teaching Claude how you code. Your projects are dialed in. Your AI knows your stack, your decisions, your preferences.
25
23
 
26
- Then you get a new laptop. **Everything is gone.**
24
+ Then you switch machines. **Everything is gone.** Your AI has amnesia. Your projects aren't there. You start from zero.
27
25
 
28
- memoir backs up, restores, and **translates** your AI memory across any machine and any tool. One command to save. One command to restore.
26
+ ## The Fix
29
27
 
30
28
  ```bash
29
+ # On your main machine
30
+ memoir push
31
+
32
+ # On any other machine
31
33
  npm install -g memoir-cli
34
+ memoir restore -y
35
+
36
+ # Done. Everything's back:
37
+ # ✔ AI memory restored (Claude, Gemini, Cursor, 11 tools)
38
+ # ✔ 44 projects cloned & unpacked
39
+ # ✔ Uncommitted changes applied
40
+ # ✔ Session context injected — AI picks up mid-conversation
32
41
  ```
33
42
 
34
- ## Supported Tools (11)
43
+ One command to save. One command to restore. That's it.
44
+
45
+ ## What Gets Synced
46
+
47
+ memoir syncs three layers that no other tool connects:
48
+
49
+ ### Layer 1: AI Memory
50
+ Your AI tool configs, preferences, and project knowledge across 11 tools.
35
51
 
36
52
  | Tool | What gets synced |
37
53
  |------|-----------------|
38
- | **ChatGPT** | CHATGPT.md custom instructions |
39
54
  | **Claude Code** | ~/.claude/ settings, memory, CLAUDE.md files |
40
55
  | **Gemini CLI** | ~/.gemini/ config, GEMINI.md files |
41
- | **OpenAI Codex** | ~/.codex/ config, AGENTS.md, codex.md |
56
+ | **ChatGPT** | CHATGPT.md custom instructions |
57
+ | **OpenAI Codex** | ~/.codex/ config, AGENTS.md |
42
58
  | **Cursor** | Settings, keybindings, .cursorrules |
43
59
  | **GitHub Copilot** | Config, copilot-instructions.md |
44
60
  | **Windsurf** | Settings, keybindings, .windsurfrules |
@@ -47,7 +63,37 @@ npm install -g memoir-cli
47
63
  | **Continue.dev** | Config, .continuerules |
48
64
  | **Aider** | .aider.conf.yml, system prompt |
49
65
 
50
- Plus **per-project configs** memoir scans your filesystem for CLAUDE.md, GEMINI.md, CHATGPT.md, .cursorrules, and AGENTS.md across all your projects.
66
+ ### Layer 2: Session State
67
+ What you were **doing** — not just what your AI knows, but the active context.
68
+
69
+ - Last coding session captured automatically
70
+ - What files you changed, what errors you hit, what decisions you made
71
+ - Injected into your AI on restore so it picks up mid-conversation
72
+ - Secrets auto-redacted (API keys, tokens, passwords stripped before sync)
73
+
74
+ ### Layer 3: Workspace (NEW in v3.1)
75
+ Your actual projects — code, files, everything.
76
+
77
+ - **Git projects:** Remote URLs saved, auto-cloned on restore
78
+ - **Non-git projects:** Bundled as compressed archives, unpacked on restore
79
+ - **Uncommitted work:** Saved as patches, applied after clone
80
+ - **Zero git commands needed** — memoir handles it all
81
+
82
+ ```
83
+ memoir push on Mac:
84
+ ✔ AI memory backed up
85
+ ✔ Session context captured
86
+ ✔ Workspace: 44 projects (17 git, 23 bundled)
87
+ 🔒 E2E encrypted
88
+
89
+ memoir restore on Windows:
90
+ ✔ AI memory restored
91
+ ✔ stock-market-book → C:\Users\You\stock-market-book (cloned)
92
+ ✔ socialslink → C:\Users\You\socialslink (cloned)
93
+ ✔ btc-trader → C:\Users\You\btc-trader (unpacked)
94
+ ✔ 41 more projects restored
95
+ 📋 Session context injected — Claude picks up where you left off
96
+ ```
51
97
 
52
98
  ## Quick Start
53
99
 
@@ -55,41 +101,52 @@ Plus **per-project configs** — memoir scans your filesystem for CLAUDE.md, GEM
55
101
  # Install
56
102
  npm install -g memoir-cli
57
103
 
58
- # First-time setup (GitHub repo or local)
104
+ # First-time setup
59
105
  memoir init
60
106
 
61
- # Back up all your AI configs
107
+ # Back up everything
62
108
  memoir push
63
109
 
64
- # Restore on a new machine
110
+ # Restore on any machine
65
111
  memoir restore
66
112
  ```
67
113
 
68
- That's it. Every AI tool gets its memory back.
69
-
70
114
  ## Key Features
71
115
 
72
- ### Translate between AI tools
116
+ ### Workspace sync
73
117
  ```bash
74
- memoir migrate --from chatgpt --to claude
75
- # AI-powered rewrites conventions, not copy-paste
118
+ memoir push # scans all projects, saves git URLs + bundles non-git projects
119
+ memoir restore # auto-clones repos, unpacks bundles, applies uncommitted patches
76
120
  ```
77
121
 
78
- Works between any combination: ChatGPT, Claude, Gemini, Cursor, Copilot, Codex, Windsurf, Aider, and more. Translate to one tool or all at once:
122
+ No manual git commands. memoir detects your projects, tracks their remotes, and restores them anywhere.
79
123
 
124
+ ### Translate between AI tools
80
125
  ```bash
126
+ memoir migrate --from chatgpt --to claude
127
+ # AI-powered — rewrites conventions, not copy-paste
128
+
81
129
  memoir migrate --from chatgpt --to all
130
+ # Translate to every tool at once
82
131
  ```
83
132
 
84
133
  ### Session handoff
85
134
  ```bash
86
- # Laptop dying? Capture your session
135
+ # Capture your session (automatic on push, or manual)
87
136
  memoir snapshot
88
137
 
89
138
  # Pick up on another machine
90
139
  memoir resume --inject --to claude
91
140
  ```
92
141
 
142
+ ### E2E Encryption
143
+ ```bash
144
+ memoir encrypt # toggle encryption on/off
145
+ memoir push # prompted for passphrase, AES-256-GCM encrypted
146
+ ```
147
+
148
+ Your backup is encrypted before it leaves your machine. Even if your storage is compromised, your data is safe. Secret scanning auto-redacts API keys, tokens, and passwords.
149
+
93
150
  ### Profiles (personal / work)
94
151
  ```bash
95
152
  memoir profile create work
@@ -107,23 +164,24 @@ memoir cloud restore # restore from any version
107
164
  memoir history # view all backup versions
108
165
  ```
109
166
 
110
- Free tier: 3 cloud backups. Pro ($5/mo): unlimited + version history.
111
-
112
- ### Security
167
+ ### Cross-platform (Mac / Windows / Linux)
113
168
  ```bash
114
- memoir doctor
115
- # Scans for secrets, API keys, .env files before pushing
169
+ # Push from Mac
170
+ memoir push
171
+
172
+ # Restore on Windows — paths remap automatically
173
+ memoir restore
116
174
  ```
117
175
 
118
- memoir **never** syncs credentials, API keys, .env files, or auth tokens.
176
+ Claude's memory paths are automatically remapped between platforms. Projects are cloned to the right locations. It just works.
119
177
 
120
178
  ## All Commands
121
179
 
122
180
  | Command | What it does |
123
181
  |---------|-------------|
124
182
  | `memoir init` | Setup wizard — GitHub or local storage |
125
- | `memoir push` | Back up all AI configs |
126
- | `memoir restore` | Restore on a new machine |
183
+ | `memoir push` | Back up AI memory + workspace + session |
184
+ | `memoir restore` | Restore everything on a new machine |
127
185
  | `memoir status` | Show detected AI tools |
128
186
  | `memoir doctor` | Diagnose issues, scan for secrets |
129
187
  | `memoir view` | Preview what's in your backup |
@@ -131,6 +189,7 @@ memoir **never** syncs credentials, API keys, .env files, or auth tokens.
131
189
  | `memoir migrate` | Translate memory between tools via AI |
132
190
  | `memoir snapshot` | Capture current coding session |
133
191
  | `memoir resume` | Pick up where you left off |
192
+ | `memoir encrypt` | Toggle E2E encryption |
134
193
  | `memoir profile` | Manage profiles (personal/work) |
135
194
  | `memoir cloud push` | Back up to memoir cloud |
136
195
  | `memoir cloud restore` | Restore from memoir cloud |
@@ -140,57 +199,65 @@ memoir **never** syncs credentials, API keys, .env files, or auth tokens.
140
199
 
141
200
  ## How memoir compares
142
201
 
143
- | Feature | memoir | skillshare | ai-rulez | memories.sh |
144
- |---------|--------|-----------|----------|-------------|
145
- | Cross-device sync | **Yes** | Yes (git) | No | Yes |
202
+ | Feature | memoir | dotfiles managers | ai-rulez | memories.sh |
203
+ |---------|--------|-------------------|----------|-------------|
204
+ | AI memory sync | **11 tools** | No | 18 tools | 3 tools |
205
+ | Workspace sync | **Yes** | No | No | No |
206
+ | Session handoff | **Yes** | No | No | No |
146
207
  | AI-powered translation | **Yes** | No | No | No |
147
- | Tools supported | **11** | 40+ | 18 | 3 |
208
+ | E2E encryption | **Yes** | No | No | No |
209
+ | Secret scanning | **Yes** | Some | No | No |
210
+ | Cross-platform remap | **Yes** | Some | No | No |
211
+ | Uncommitted work patches | **Yes** | No | No | No |
148
212
  | Cloud backup | **Yes** | No | No | Yes ($15/mo) |
149
- | Version history | **Yes** | No | No | No |
150
- | Session handoff | **Yes** | No | No | No |
151
213
  | Profiles | **Yes** | No | No | No |
152
- | Secret scanning | **Yes** | Yes | No | No |
153
214
  | Free & open source | **Yes** | Yes | Yes | No |
154
215
 
155
- memoir is free and open source. Cloud sync starts at $0 (3 backups free).
156
-
157
216
  ## Common Workflows
158
217
 
159
- ### New laptop setup
218
+ ### New machine setup
160
219
  ```bash
161
220
  # Old machine
162
221
  memoir push
163
222
 
164
- # New machine
165
- npm install -g memoir-cli
166
- memoir init # connect to same repo
167
- memoir restore # all configs restored in seconds
223
+ # New machine — one command, everything's back
224
+ npm install -g memoir-cli && memoir init && memoir restore -y
168
225
  ```
169
226
 
170
- ### Switching from ChatGPT to Claude
227
+ ### Daily sync between machines
171
228
  ```bash
172
- memoir migrate --from chatgpt --to claude
173
- # Your custom instructions become a proper CLAUDE.md
229
+ memoir push # end of day on laptop
230
+ memoir restore # next morning on desktop AI knows what you were doing
174
231
  ```
175
232
 
176
- ### Daily sync between machines
233
+ ### Switching AI tools
177
234
  ```bash
178
- memoir push # end of day on laptop
179
- memoir restore # next morning on desktop
235
+ memoir migrate --from chatgpt --to claude
236
+ # Your custom instructions become a proper CLAUDE.md
180
237
  ```
181
238
 
182
- ### Cross-platform (Mac ↔ Windows ↔ Linux)
239
+ ### Team onboarding
183
240
  ```bash
184
- # Push from Mac
185
- memoir push
241
+ # Senior dev pushes team config
242
+ memoir push --profile team
186
243
 
187
- # Restore on Windows paths remap automatically
188
- memoir restore
244
+ # New hire runs one command
245
+ memoir restore --profile team
246
+ # Every project cloned. Every AI tool configured. Day one productive.
189
247
  ```
190
248
 
249
+ ## Security
250
+
251
+ - **E2E encryption** — AES-256-GCM with scrypt key derivation
252
+ - **Secret scanning** — 20+ patterns detect API keys, tokens, passwords, connection strings
253
+ - **Auto-redaction** — secrets stripped from session handoffs before sync
254
+ - **No credentials synced** — .env files, auth tokens, and API keys are never included
255
+ - **Passphrase verified** — wrong passphrase caught before decrypt attempt
256
+
191
257
  ## Requirements
192
258
 
193
259
  - Node.js >= 18
260
+ - Git (for workspace sync)
194
261
  - Works on macOS, Windows, Linux
195
262
 
196
263
  ## Contributing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.0.3",
3
+ "version": "3.1.1",
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
@@ -9,10 +9,11 @@ import { getConfig } from '../config.js';
9
9
  import { extractMemories, adapters } from '../adapters/index.js';
10
10
  import { syncToLocal, syncToGit } from '../providers/index.js';
11
11
  import inquirer from 'inquirer';
12
- import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject } from '../context/capture.js';
12
+ import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject, persistDecisions } from '../context/capture.js';
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);
@@ -76,10 +77,19 @@ export async function pushCommand(options = {}) {
76
77
  await fs.writeFile(path.join(localHandoffDir, `${timestamp}-claude.md`), clean);
77
78
  await fs.writeFile(path.join(localHandoffDir, 'latest.md'), clean);
78
79
 
80
+ // Persist decisions to Claude's memory so they survive across sessions
81
+ let decisionCount = 0;
82
+ if (parsed.decisions.length > 0) {
83
+ try {
84
+ decisionCount = persistDecisions(parsed.decisions);
85
+ } catch {}
86
+ }
87
+
79
88
  contextCaptured = true;
80
89
  sessionInfo = {
81
90
  slug: parsed.slug,
82
91
  filesModified: parsed.filesWritten.length,
92
+ decisions: decisionCount,
83
93
  duration: parsed.firstTimestamp && parsed.lastTimestamp
84
94
  ? (() => {
85
95
  const ms = new Date(parsed.lastTimestamp) - new Date(parsed.firstTimestamp);
@@ -101,6 +111,15 @@ export async function pushCommand(options = {}) {
101
111
  // Context capture is best-effort — don't fail the push
102
112
  }
103
113
 
114
+ // Scan workspace for projects (git repos + unbacked projects)
115
+ let workspaceManifest = null;
116
+ spinner.text = chalk.gray('Scanning workspace...');
117
+ try {
118
+ workspaceManifest = await scanWorkspace(stagingDir, spinner);
119
+ } catch {
120
+ // Workspace scan is best-effort
121
+ }
122
+
104
123
  // Count what was found
105
124
  const found = [];
106
125
  for (const adapter of adapters) {
@@ -216,13 +235,25 @@ export async function pushCommand(options = {}) {
216
235
  if (sessionInfo.duration) parts.push(sessionInfo.duration);
217
236
  if (sessionInfo.filesModified) parts.push(`${sessionInfo.filesModified} files changed`);
218
237
  contextLine = '\n' + chalk.green(' ✔ Session Context') + chalk.gray(` (${parts.join(', ')})`) + '\n';
238
+ if (sessionInfo.decisions > 0) {
239
+ contextLine += chalk.green(` ✔ ${sessionInfo.decisions} decision(s) saved to persistent memory`) + '\n';
240
+ }
219
241
  if (sessionInfo.secretsRedacted > 0) {
220
242
  contextLine += chalk.yellow(` 🔒 ${sessionInfo.secretsRedacted} secret(s) auto-redacted`) + '\n';
221
243
  }
222
244
  }
245
+ let workspaceLine = '';
246
+ if (workspaceManifest && workspaceManifest.projects.length > 0) {
247
+ const gitCount = workspaceManifest.projects.filter(p => p.type === 'git' && p.gitRemote).length;
248
+ const bundleCount = workspaceManifest.projects.filter(p => p.bundleFile).length;
249
+ const parts = [];
250
+ if (gitCount > 0) parts.push(`${gitCount} git`);
251
+ if (bundleCount > 0) parts.push(`${bundleCount} bundled`);
252
+ workspaceLine = '\n' + chalk.green(' ✔ Workspace') + chalk.gray(` (${workspaceManifest.projects.length} projects — ${parts.join(', ')})`) + '\n';
253
+ }
223
254
  console.log('\n' + boxen(
224
255
  gradient.pastel(' Backed up! ') + '\n\n' +
225
- toolList + contextLine + '\n' +
256
+ toolList + contextLine + workspaceLine + '\n' +
226
257
  chalk.white(`${totalFiles} files from ${found.length} tool${found.length !== 1 ? 's' : ''}`) + '\n' +
227
258
  (encrypted ? chalk.green(' 🔒 E2E encrypted') + '\n' : '') +
228
259
  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();
@@ -55,6 +61,7 @@ export function parseSession(sessionPath, maxSizeMB = 10) {
55
61
  }
56
62
 
57
63
  function parseLines(lines) {
64
+ const assistantTexts = [];
58
65
  const result = {
59
66
  sessionId: null,
60
67
  slug: null,
@@ -89,9 +96,14 @@ function parseLines(lines) {
89
96
  }
90
97
  }
91
98
 
92
- // Tool uses from assistant
99
+ // Tool uses and text from assistant
93
100
  if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
94
101
  for (const block of obj.message.content) {
102
+ if (block.type === 'text' && block.text) {
103
+ // Capture assistant text for decision extraction (limit size)
104
+ if (block.text.length < 2000) assistantTexts.push(block.text);
105
+ continue;
106
+ }
95
107
  if (block.type !== 'tool_use') continue;
96
108
  const name = block.name;
97
109
  const input = block.input || {};
@@ -133,9 +145,148 @@ function parseLines(lines) {
133
145
  result.filesRead = [...result.filesRead];
134
146
  result.errors = [...new Set(result.errors)].slice(0, 10);
135
147
 
148
+ // Extract decisions from user + assistant messages
149
+ result.decisions = extractDecisions(result.userMessages, assistantTexts);
150
+
136
151
  return result;
137
152
  }
138
153
 
154
+ /**
155
+ * Extract durable decisions from session conversation.
156
+ * These are things like renames, tech choices, preferences — stuff that should persist.
157
+ */
158
+ function extractDecisions(userMessages, assistantTexts) {
159
+ const decisions = [];
160
+ const allText = [...userMessages, ...assistantTexts].join('\n');
161
+
162
+ // Patterns that indicate a decision was made
163
+ const patterns = [
164
+ // Renames / naming
165
+ { regex: /(?:rename|call|name)\s+(?:it|this|the (?:project|app|tool|product))\s+(?:to\s+)?["']?([A-Z][a-zA-Z0-9_-]+)["']?/gi, type: 'rename' },
166
+ { regex: /(?:the\s+)?(?:new\s+)?name\s+(?:is|will be|should be)\s+["']?([A-Z][a-zA-Z0-9_-]+)["']?/gi, type: 'rename' },
167
+ { regex: /(?:rebrand|rebranding)\s+(?:to|as)\s+["']?([A-Z][a-zA-Z0-9_-]+)["']?/gi, type: 'rename' },
168
+ // Tech choices
169
+ { regex: /(?:let'?s|we(?:'ll| will| should)?|going to|decided to)\s+use\s+([A-Z][a-zA-Z0-9_./-]+)\s+(?:for|instead|as|to)/gi, type: 'tech' },
170
+ { regex: /(?:switch|migrate|move)\s+(?:from\s+\S+\s+)?to\s+([A-Z][a-zA-Z0-9_./-]+)/gi, type: 'tech' },
171
+ // Architecture / design
172
+ { regex: /(?:let'?s|we(?:'ll| will| should)?)\s+(?:go with|pick|choose)\s+(.{5,60}?)(?:\.|$|,|\n)/gi, type: 'design' },
173
+ // Stack choices
174
+ { regex: /(?:stack|framework|database|backend|frontend)\s+(?:is|will be|should be)\s+(.{5,60}?)(?:\.|$|,|\n)/gi, type: 'stack' },
175
+ ];
176
+
177
+ for (const { regex, type } of patterns) {
178
+ let match;
179
+ while ((match = regex.exec(allText)) !== null) {
180
+ const value = match[1].trim().replace(/["']+$/, '');
181
+ if (value.length > 2 && value.length < 80) {
182
+ // Avoid duplicates
183
+ const existing = decisions.find(d => d.value.toLowerCase() === value.toLowerCase());
184
+ if (!existing) {
185
+ decisions.push({ type, value, context: match[0].trim().slice(0, 120) });
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ // Look for explicit "remember this" instructions from the user
192
+ for (const msg of userMessages) {
193
+ // Only match when user is clearly asking to remember something
194
+ const rememberMatch = msg.match(/(?:remember (?:that|this)|note that|keep in mind that|from now on)[:\s]+(.{10,150})/i);
195
+ if (rememberMatch) {
196
+ decisions.push({ type: 'user-note', value: rememberMatch[1].trim(), context: msg.slice(0, 120) });
197
+ }
198
+ }
199
+
200
+ return decisions.slice(0, 20); // Cap at 20 decisions per session
201
+ }
202
+
203
+ /**
204
+ * Write extracted decisions to Claude's persistent memory.
205
+ * This ensures decisions survive across sessions and machines.
206
+ */
207
+ export function persistDecisions(decisions, claudeSource) {
208
+ if (!decisions || decisions.length === 0) return 0;
209
+
210
+ const claudeDir = claudeSource || path.join(home, '.claude');
211
+ const projectsDir = path.join(claudeDir, 'projects');
212
+ if (!fs.existsSync(projectsDir)) return 0;
213
+
214
+ // Find the HOME-level memory dir (not project-specific)
215
+ // This is the dir that matches the user's home path encoding
216
+ let homeKey;
217
+ if (process.platform === 'win32') {
218
+ homeKey = home.replace(/\\/g, '-').replace(/:/g, '-');
219
+ } else {
220
+ homeKey = '-' + home.replace(/^\//, '').replace(/\//g, '-');
221
+ }
222
+
223
+ // Try exact match first, then detect from existing dirs
224
+ let memDir = path.join(projectsDir, homeKey, 'memory');
225
+ if (!fs.existsSync(memDir)) {
226
+ // Fallback: find dirs with memory/ subfolder, pick shortest name (likely home-level)
227
+ const entries = fs.readdirSync(projectsDir, { withFileTypes: true })
228
+ .filter(e => e.isDirectory() && fs.existsSync(path.join(projectsDir, e.name, 'memory')));
229
+ if (entries.length === 0) return 0;
230
+ // Shortest dir name is most likely the home key (not a sub-project)
231
+ const homeEntry = entries.sort((a, b) => a.name.length - b.name.length)[0];
232
+ memDir = path.join(projectsDir, homeEntry.name, 'memory');
233
+ }
234
+
235
+ fs.mkdirSync(memDir, { recursive: true });
236
+ const decisionsFile = path.join(memDir, 'session-decisions.md');
237
+ const memoryMdPath = path.join(memDir, 'MEMORY.md');
238
+
239
+ // Read existing decisions file or create new
240
+ let existing = '';
241
+ if (fs.existsSync(decisionsFile)) {
242
+ existing = fs.readFileSync(decisionsFile, 'utf8');
243
+ }
244
+
245
+ // Format new decisions
246
+ const date = new Date().toISOString().split('T')[0];
247
+ const newEntries = decisions.map(d => {
248
+ if (d.type === 'rename') return `- **Renamed:** ${d.context}`;
249
+ if (d.type === 'tech') return `- **Tech choice:** ${d.context}`;
250
+ if (d.type === 'design') return `- **Decision:** ${d.context}`;
251
+ if (d.type === 'stack') return `- **Stack:** ${d.context}`;
252
+ if (d.type === 'user-note') return `- **Note:** ${d.value}`;
253
+ return `- ${d.context}`;
254
+ });
255
+
256
+ // Check for duplicates against existing content
257
+ const fresh = newEntries.filter(entry => !existing.includes(entry));
258
+ if (fresh.length === 0) return 0;
259
+
260
+ const section = `\n### ${date}\n${fresh.join('\n')}\n`;
261
+
262
+ if (!existing) {
263
+ // Create new file with frontmatter
264
+ const content = `---
265
+ name: Session Decisions
266
+ description: Project decisions extracted from coding sessions — renames, tech choices, architecture
267
+ type: project
268
+ ---
269
+
270
+ # Decisions from coding sessions
271
+ ${section}`;
272
+ fs.writeFileSync(decisionsFile, content);
273
+ } else {
274
+ // Append to existing
275
+ fs.writeFileSync(decisionsFile, existing.trimEnd() + '\n' + section);
276
+ }
277
+
278
+ // Ensure MEMORY.md references the decisions file
279
+ if (fs.existsSync(memoryMdPath)) {
280
+ const memoryMd = fs.readFileSync(memoryMdPath, 'utf8');
281
+ if (!memoryMd.includes('session-decisions.md')) {
282
+ const addition = `\n- [Session Decisions](session-decisions.md) — project renames, tech choices, architecture decisions from coding sessions\n`;
283
+ fs.writeFileSync(memoryMdPath, memoryMd.trimEnd() + addition);
284
+ }
285
+ }
286
+
287
+ return fresh.length;
288
+ }
289
+
139
290
  /**
140
291
  * Generate a concise handoff markdown from parsed session
141
292
  * This is what gets injected into the AI tool on the other machine
@@ -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
+ }