memoir-cli 3.6.0 → 3.7.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/bin/memoir.js CHANGED
@@ -22,6 +22,19 @@ import { projectsListCommand, projectsTodoCommand } from '../src/commands/projec
22
22
  import { upgradeCommand } from '../src/commands/upgrade.js';
23
23
  import { activateCommand, deactivateCommand } from '../src/commands/activate.js';
24
24
  import { consolidateCommand } from '../src/commands/consolidate.js';
25
+ import {
26
+ goalCommand,
27
+ nextCommand,
28
+ doneCommand,
29
+ noteCommand,
30
+ askCommand,
31
+ sessionShowCommand,
32
+ sessionClearCommand,
33
+ } from '../src/commands/session.js';
34
+ import { autopushCommand } from '../src/commands/autopush.js';
35
+ import { whyCommand } from '../src/commands/why.js';
36
+ import { autoRefreshCommand } from '../src/commands/auto-refresh.js';
37
+ import { hooksInstallCommand, hooksUninstallCommand, hooksStatusCommand } from '../src/commands/hooks.js';
25
38
  import { createRequire } from 'module';
26
39
 
27
40
  const require = createRequire(import.meta.url);
@@ -153,6 +166,106 @@ program
153
166
  }
154
167
  });
155
168
 
169
+ // ── Session continuity ──────────────────────────────────────────
170
+ program
171
+ .command('goal <text...>')
172
+ .description('Set your current goal (pinned into CLAUDE.md, syncs across machines)')
173
+ .action(async (text) => {
174
+ try { await goalCommand(text.join(' ')); }
175
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
176
+ });
177
+
178
+ program
179
+ .command('next <text...>')
180
+ .description('Add a next action')
181
+ .action(async (text) => {
182
+ try { await nextCommand(text.join(' ')); }
183
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
184
+ });
185
+
186
+ program
187
+ .command('done <text...>')
188
+ .description('Mark a next action complete (substring match)')
189
+ .action(async (text) => {
190
+ try { await doneCommand(text.join(' ')); }
191
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
192
+ });
193
+
194
+ program
195
+ .command('note <text...>')
196
+ .description('Record a decision with rationale (--why) and rejected alternative (--rejected)')
197
+ .option('--why <rationale>', 'Why this decision was made')
198
+ .option('--rejected <alternative>', 'The alternative you considered and rejected')
199
+ .action(async (text, options) => {
200
+ try { await noteCommand(text.join(' '), options); }
201
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
202
+ });
203
+
204
+ program
205
+ .command('ask <text...>')
206
+ .description('Capture an open question for later')
207
+ .action(async (text) => {
208
+ try { await askCommand(text.join(' ')); }
209
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
210
+ });
211
+
212
+ program
213
+ .command('why [query...]')
214
+ .description('Look up decisions by keyword — returns what was decided + why + what was rejected')
215
+ .action(async (query) => {
216
+ try { await whyCommand((query || []).join(' ')); }
217
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
218
+ });
219
+
220
+ program
221
+ .command('session')
222
+ .description('Show the current session state')
223
+ .action(async () => {
224
+ try { await sessionShowCommand(); }
225
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
226
+ });
227
+
228
+ program
229
+ .command('session-clear')
230
+ .description('Clear the current session (history retained)')
231
+ .action(async () => {
232
+ try { await sessionClearCommand(); }
233
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
234
+ });
235
+
236
+ // ── Hooks + auto-sync (called by Claude Code hooks or the user) ─────
237
+ program
238
+ .command('autopush')
239
+ .description('Debounced auto-push — called by the Claude Code Stop hook')
240
+ .option('--debounce <seconds>', 'Minimum seconds between auto-pushes', '30')
241
+ .option('-v, --verbose', 'Print debounce state')
242
+ .action(async (options) => {
243
+ try { await autopushCommand(options); }
244
+ catch (err) { if (options.verbose) console.error(chalk.red(err.message)); /* silent by default */ }
245
+ });
246
+
247
+ program
248
+ .command('auto-refresh')
249
+ .description('Re-render the pinned session block — called by the SessionStart hook')
250
+ .option('-v, --verbose', 'Print what changed')
251
+ .action(async (options) => {
252
+ try { await autoRefreshCommand(options); }
253
+ catch (err) { if (options.verbose) console.error(chalk.red(err.message)); }
254
+ });
255
+
256
+ program
257
+ .command('hooks')
258
+ .description('Manage Claude Code hooks (install | uninstall | status)')
259
+ .argument('[subcommand]', 'install, uninstall, or status', 'status')
260
+ .option('-y, --yes', 'Skip confirmation prompts')
261
+ .action(async (subcommand, options) => {
262
+ try {
263
+ if (subcommand === 'install') await hooksInstallCommand(options);
264
+ else if (subcommand === 'uninstall') await hooksUninstallCommand(options);
265
+ else await hooksStatusCommand();
266
+ } catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
267
+ });
268
+
156
269
  program
157
270
  .command('doctor')
158
271
  .alias('diagnose')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.6.0",
3
+ "version": "3.7.1",
4
4
  "mcpName": "io.github.camgitt/memoir",
5
5
  "description": "MCP server that gives Claude, Cursor, and Gemini long-term memory across sessions. Your AI remembers your codebase, decisions, and preferences — across tools and machines.",
6
6
  "main": "src/index.js",
@@ -9,6 +9,15 @@
9
9
  "memoir": "bin/memoir.js",
10
10
  "memoir-mcp": "src/mcp.js"
11
11
  },
12
+ "files": [
13
+ "bin/",
14
+ "src/",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
12
21
  "repository": {
13
22
  "type": "git",
14
23
  "url": "https://github.com/camgitt/memoir.git"
@@ -19,7 +28,9 @@
19
28
  },
20
29
  "scripts": {
21
30
  "start": "node bin/memoir.js",
22
- "test": "bash test-local.sh",
31
+ "test": "node run-tests.mjs",
32
+ "test:legacy": "bash test-local.sh",
33
+ "prepublishOnly": "npm test",
23
34
  "postinstall": "node -e \"try{const c='\\x1b[36m',r='\\x1b[0m',g='\\x1b[90m';console.log('\\n '+c+'memoir'+r+' installed.\\n Run '+c+'memoir activate'+r+' in any project to give your AI long-term memory.\\n '+g+'https://memoir.sh'+r+'\\n')}catch{}\""
24
35
  },
25
36
  "keywords": [
@@ -14,9 +14,19 @@ export function detectLocalHomeKey(adapterSource) {
14
14
  if (!fs.existsSync(localProjectsDir)) return null;
15
15
 
16
16
  const entries = fs.readdirSync(localProjectsDir)
17
+ .filter(e => !e.startsWith('.'))
17
18
  .filter(e => fs.statSync(path.join(localProjectsDir, e)).isDirectory());
18
19
  if (entries.length === 0) return null;
19
20
 
21
+ // Prefer the key that matches this machine's homedir encoding.
22
+ // Stale foreign dirs (from older memoir versions) can have newer mtimes,
23
+ // so mtime alone is unreliable — the encoded homedir is the ground truth.
24
+ const home = os.homedir();
25
+ const expectedKey = process.platform === 'win32'
26
+ ? home.replace(/\\/g, '-').replace(/:/g, '-')
27
+ : '-' + home.replace(/^\//, '').replace(/\//g, '-');
28
+ if (entries.includes(expectedKey)) return expectedKey;
29
+
20
30
  // Find dirs with a memory/ subfolder that aren't sub-projects of another dir
21
31
  const candidates = entries.filter(entry => {
22
32
  const hasMemory = fs.existsSync(path.join(localProjectsDir, entry, 'memory'));
@@ -143,6 +153,57 @@ function remapProjectPaths(backupDir, adapterSource) {
143
153
  return remaps;
144
154
  }
145
155
 
156
+ // Scan local ~/.claude/projects/ for foreign home-key dirs left behind by older
157
+ // memoir versions (when remap was unreliable). Merge their memory/ into the local
158
+ // home key and move the stale dir into .memoir-archived-{ts}/ so nothing is lost.
159
+ export async function cleanupLocalForeignKeys(adapterSource) {
160
+ const projectsDir = path.join(adapterSource, 'projects');
161
+ if (!await fs.pathExists(projectsDir)) return { archived: [], merged: 0 };
162
+
163
+ const localHomeKey = detectLocalHomeKey(adapterSource);
164
+ if (!localHomeKey) return { archived: [], merged: 0 };
165
+
166
+ const home = os.homedir();
167
+ const localUsername = path.basename(home).toLowerCase();
168
+
169
+ const entries = (await fs.readdir(projectsDir, { withFileTypes: true }))
170
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
171
+ .map(e => e.name);
172
+
173
+ const foreignKeys = [];
174
+ for (const entry of entries) {
175
+ if (entry === localHomeKey) continue;
176
+ if (entry.startsWith(localHomeKey + '-')) continue;
177
+ // Leave alt encodings of THIS machine alone (contain local username)
178
+ if (entry.toLowerCase().includes(localUsername)) continue;
179
+ // Must have memory/ — dirs without it are project dirs that Claude won't read anyway
180
+ if (!await fs.pathExists(path.join(projectsDir, entry, 'memory'))) continue;
181
+ foreignKeys.push(entry);
182
+ }
183
+
184
+ if (foreignKeys.length === 0) return { archived: [], merged: 0 };
185
+
186
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
187
+ const archiveDir = path.join(projectsDir, `.memoir-archived-${ts}`);
188
+ await fs.ensureDir(archiveDir);
189
+
190
+ const localMemDir = path.join(projectsDir, localHomeKey, 'memory');
191
+ await fs.ensureDir(localMemDir);
192
+
193
+ let merged = 0;
194
+ for (const key of foreignKeys) {
195
+ const foreignMemDir = path.join(projectsDir, key, 'memory');
196
+ if (await fs.pathExists(foreignMemDir)) {
197
+ const before = (await fs.readdir(localMemDir)).length;
198
+ await mergeMemoryDirs(foreignMemDir, localMemDir);
199
+ merged += (await fs.readdir(localMemDir)).length - before;
200
+ }
201
+ await fs.move(path.join(projectsDir, key), path.join(archiveDir, key));
202
+ }
203
+
204
+ return { archived: foreignKeys, merged };
205
+ }
206
+
146
207
  // Merge memory dirs from a foreign machine — copies files that don't exist locally,
147
208
  // and for files that exist on both, keeps the newer version.
148
209
  async function mergeMemoryDirs(src, dest) {
@@ -291,6 +352,21 @@ export async function restoreMemories(sourceDir, spinner, onlyFilter = null, aut
291
352
  if (confirm) {
292
353
  const changes = { added: [], updated: [], skipped: [] };
293
354
 
355
+ // Clean up stale foreign home-key dirs left on this machine by older
356
+ // memoir versions. Must run before remap so detectLocalHomeKey sees a
357
+ // clean local state.
358
+ if (adapter.name === 'Claude CLI') {
359
+ try {
360
+ const { archived, merged } = await cleanupLocalForeignKeys(adapter.source);
361
+ if (archived.length > 0) {
362
+ spinner.stop();
363
+ console.log(chalk.gray(` Cleaned up ${archived.length} stale foreign dir(s) on this machine${merged > 0 ? ` (merged ${merged} new file(s))` : ''}`));
364
+ for (const k of archived) console.log(chalk.gray(` archived: ${k}`));
365
+ spinner.start();
366
+ }
367
+ } catch {}
368
+ }
369
+
294
370
  // Remap Claude project paths from source machine to this machine
295
371
  if (adapter.name === 'Claude CLI') {
296
372
  const remaps = remapProjectPaths(backupDir, adapter.source);
@@ -0,0 +1,31 @@
1
+ // Auto-refresh — called by the Claude Code SessionStart hook.
2
+ // Reads current session.json (local) and re-injects the pinned block into
3
+ // CLAUDE.md. Instant, no network, no side effects beyond that file.
4
+ //
5
+ // For cross-machine pull-on-start, that's a separate concern — handled by
6
+ // periodic auto-restore or explicit `memoir restore`. This hook only ensures
7
+ // the pinned block matches the current local session state.
8
+
9
+ import { readSession } from '../session/state.js';
10
+ import { renderSession } from '../session/render.js';
11
+ import { injectInto, detectAvailableTargets } from '../session/inject.js';
12
+
13
+ export async function autoRefreshCommand(options = {}) {
14
+ const verbose = !!options.verbose;
15
+ try {
16
+ const state = await readSession();
17
+ const rendered = renderSession(state);
18
+ const targets = detectAvailableTargets();
19
+ for (const [tool, target] of Object.entries(targets)) {
20
+ try {
21
+ const res = await injectInto(target, rendered);
22
+ if (verbose) console.log(`memoir auto-refresh: ${res.replaced ? 'updated' : 'created'} ${tool} → ${res.path}`);
23
+ } catch (err) {
24
+ if (verbose) console.error(`memoir auto-refresh: ${tool} failed: ${err.message}`);
25
+ }
26
+ }
27
+ } catch (err) {
28
+ if (verbose) console.error(`memoir auto-refresh: ${err.message}`);
29
+ // Never fail the hook — session start must proceed.
30
+ }
31
+ }
@@ -0,0 +1,57 @@
1
+ // Auto-push command — called by the Claude Code Stop hook after every response.
2
+ //
3
+ // Rules of engagement:
4
+ // - Debounced: won't run more than once per DEBOUNCE_SECONDS.
5
+ // - Non-blocking: detaches a background subprocess and exits immediately
6
+ // so Claude's response pipeline is never held up.
7
+ // - Silent: no stdout unless --verbose is passed.
8
+ //
9
+ // The actual push work happens in a fully detached child — the hook process
10
+ // itself just records a timestamp and returns.
11
+
12
+ import fs from 'fs-extra';
13
+ import path from 'path';
14
+ import os from 'os';
15
+ import { spawn } from 'child_process';
16
+
17
+ const home = os.homedir();
18
+ const STAMP_FILE = path.join(home, '.config', 'memoir', 'last-autopush.timestamp');
19
+ const DEBOUNCE_SECONDS_DEFAULT = 30;
20
+
21
+ export async function autopushCommand(options = {}) {
22
+ const debounce = parseInt(options.debounce || DEBOUNCE_SECONDS_DEFAULT, 10);
23
+ const verbose = !!options.verbose;
24
+
25
+ try {
26
+ await fs.ensureDir(path.dirname(STAMP_FILE));
27
+ } catch {}
28
+
29
+ const now = Date.now();
30
+ let last = 0;
31
+ try {
32
+ const raw = await fs.readFile(STAMP_FILE, 'utf8');
33
+ last = parseInt(raw.trim(), 10) || 0;
34
+ } catch {}
35
+
36
+ const elapsed = (now - last) / 1000;
37
+ if (last && elapsed < debounce) {
38
+ if (verbose) console.log(`memoir autopush: skipped (${Math.floor(elapsed)}s since last, debounce=${debounce}s)`);
39
+ return;
40
+ }
41
+
42
+ // Stamp BEFORE spawning so rapid repeat calls don't all race through.
43
+ try {
44
+ await fs.writeFile(STAMP_FILE, String(now));
45
+ } catch {}
46
+
47
+ // Detach a background push. Parent exits immediately so Claude isn't blocked.
48
+ const memoirBin = process.argv[1]; // path to this same memoir CLI
49
+ const child = spawn(process.execPath, [memoirBin, 'push'], {
50
+ detached: true,
51
+ stdio: verbose ? 'inherit' : 'ignore',
52
+ env: { ...process.env, MEMOIR_AUTOPUSH: '1' },
53
+ });
54
+ child.unref();
55
+
56
+ if (verbose) console.log('memoir autopush: triggered (background)');
57
+ }
@@ -0,0 +1,171 @@
1
+ // Install / uninstall / status for Claude Code hooks.
2
+ //
3
+ // Hooks are configured in ~/.claude/settings.json under the `hooks` object:
4
+ // {
5
+ // "hooks": {
6
+ // "Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": "..." }] }],
7
+ // "SessionStart": [{ "matcher": "", "hooks": [{ "type": "command", "command": "..." }] }]
8
+ // }
9
+ // }
10
+ //
11
+ // We merge into any existing hooks the user has — never clobber. Our entries
12
+ // are identified by a marker in the command string so we can find and remove
13
+ // them on uninstall.
14
+
15
+ import fs from 'fs-extra';
16
+ import path from 'path';
17
+ import os from 'os';
18
+ import chalk from 'chalk';
19
+ import boxen from 'boxen';
20
+ import inquirer from 'inquirer';
21
+
22
+ const home = os.homedir();
23
+ const CLAUDE_SETTINGS = path.join(home, '.claude', 'settings.json');
24
+ const MARKER = 'memoir'; // any command containing this is ours
25
+
26
+ const OUR_HOOKS = {
27
+ Stop: {
28
+ type: 'command',
29
+ command: 'memoir autopush --debounce 30',
30
+ },
31
+ SessionStart: {
32
+ type: 'command',
33
+ command: 'memoir auto-refresh',
34
+ },
35
+ };
36
+
37
+ async function readSettings() {
38
+ if (!await fs.pathExists(CLAUDE_SETTINGS)) return {};
39
+ try {
40
+ return JSON.parse(await fs.readFile(CLAUDE_SETTINGS, 'utf8'));
41
+ } catch {
42
+ return {};
43
+ }
44
+ }
45
+
46
+ async function writeSettings(settings) {
47
+ await fs.ensureDir(path.dirname(CLAUDE_SETTINGS));
48
+ const tmp = `${CLAUDE_SETTINGS}.tmp-${process.pid}`;
49
+ await fs.writeFile(tmp, JSON.stringify(settings, null, 2));
50
+ await fs.move(tmp, CLAUDE_SETTINGS, { overwrite: true });
51
+ }
52
+
53
+ function findOurEntry(hookList) {
54
+ // hookList is array of { matcher, hooks: [{type, command}] }
55
+ return (hookList || []).findIndex(entry =>
56
+ (entry.hooks || []).some(h => h.type === 'command' && (h.command || '').includes(MARKER))
57
+ );
58
+ }
59
+
60
+ function ensureOurHook(settings, eventName, hook) {
61
+ settings.hooks = settings.hooks || {};
62
+ settings.hooks[eventName] = settings.hooks[eventName] || [];
63
+ const list = settings.hooks[eventName];
64
+
65
+ const existing = findOurEntry(list);
66
+ if (existing >= 0) {
67
+ // Replace (idempotent — same shape every time)
68
+ list[existing] = { matcher: '', hooks: [hook] };
69
+ } else {
70
+ list.push({ matcher: '', hooks: [hook] });
71
+ }
72
+ }
73
+
74
+ function removeOurHook(settings, eventName) {
75
+ if (!settings?.hooks?.[eventName]) return false;
76
+ const list = settings.hooks[eventName];
77
+ const idx = findOurEntry(list);
78
+ if (idx < 0) return false;
79
+ list.splice(idx, 1);
80
+ if (list.length === 0) delete settings.hooks[eventName];
81
+ return true;
82
+ }
83
+
84
+ export async function hooksInstallCommand(options = {}) {
85
+ const settings = await readSettings();
86
+ const fresh = JSON.parse(JSON.stringify(settings)); // snapshot for diff
87
+
88
+ ensureOurHook(fresh, 'Stop', OUR_HOOKS.Stop);
89
+ ensureOurHook(fresh, 'SessionStart', OUR_HOOKS.SessionStart);
90
+
91
+ const isNoop = JSON.stringify(settings) === JSON.stringify(fresh);
92
+ if (isNoop) {
93
+ console.log('\n' + chalk.green(' ✓ Hooks already installed — nothing to do.\n'));
94
+ return;
95
+ }
96
+
97
+ // Show what will change
98
+ console.log('\n' + boxen(
99
+ chalk.cyan('memoir will add these hooks to ') + chalk.white.bold('~/.claude/settings.json') + chalk.cyan(':') + '\n\n' +
100
+ chalk.gray(' Stop: ') + chalk.white(OUR_HOOKS.Stop.command) + '\n' +
101
+ chalk.gray(' fires after every response; auto-pushes (debounced 30s)') + '\n\n' +
102
+ chalk.gray(' SessionStart: ') + chalk.white(OUR_HOOKS.SessionStart.command) + '\n' +
103
+ chalk.gray(' fires at session open; refreshes pinned block from session.json') + '\n\n' +
104
+ chalk.gray(' Your existing settings (including other hooks) will be preserved.'),
105
+ { padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
106
+ ) + '\n');
107
+
108
+ if (!options.yes) {
109
+ const { confirm } = await inquirer.prompt([{
110
+ type: 'confirm',
111
+ name: 'confirm',
112
+ message: 'Install hooks?',
113
+ default: true,
114
+ }]);
115
+ if (!confirm) {
116
+ console.log(chalk.yellow('\n Cancelled.\n'));
117
+ return;
118
+ }
119
+ }
120
+
121
+ await writeSettings(fresh);
122
+ console.log('\n' + chalk.green(' ✓ Hooks installed. Restart Claude Code to activate.\n'));
123
+ }
124
+
125
+ export async function hooksUninstallCommand(options = {}) {
126
+ const settings = await readSettings();
127
+ const fresh = JSON.parse(JSON.stringify(settings));
128
+ const removedStop = removeOurHook(fresh, 'Stop');
129
+ const removedStart = removeOurHook(fresh, 'SessionStart');
130
+
131
+ if (!removedStop && !removedStart) {
132
+ console.log('\n' + chalk.gray(' No memoir hooks installed.\n'));
133
+ return;
134
+ }
135
+
136
+ if (!options.yes) {
137
+ const { confirm } = await inquirer.prompt([{
138
+ type: 'confirm',
139
+ name: 'confirm',
140
+ message: 'Remove memoir hooks from ~/.claude/settings.json?',
141
+ default: true,
142
+ }]);
143
+ if (!confirm) {
144
+ console.log(chalk.yellow('\n Cancelled.\n'));
145
+ return;
146
+ }
147
+ }
148
+
149
+ await writeSettings(fresh);
150
+ console.log('\n' + chalk.green(' ✓ memoir hooks removed.\n'));
151
+ }
152
+
153
+ export async function hooksStatusCommand() {
154
+ const settings = await readSettings();
155
+ const hasStop = (settings.hooks?.Stop || []).some(entry =>
156
+ (entry.hooks || []).some(h => (h.command || '').includes(MARKER))
157
+ );
158
+ const hasStart = (settings.hooks?.SessionStart || []).some(entry =>
159
+ (entry.hooks || []).some(h => (h.command || '').includes(MARKER))
160
+ );
161
+
162
+ const mark = (b) => b ? chalk.green(' ✓ ') : chalk.gray(' ✗ ');
163
+ console.log('\n' + boxen(
164
+ chalk.cyan.bold('memoir hooks status') + '\n\n' +
165
+ mark(hasStop) + chalk.white('Stop hook') + chalk.gray(' (auto-push)') + '\n' +
166
+ mark(hasStart) + chalk.white('SessionStart hook') + chalk.gray(' (auto-refresh)') + '\n\n' +
167
+ chalk.gray(' Settings: ') + chalk.white(CLAUDE_SETTINGS) + '\n' +
168
+ chalk.gray(' Run ') + chalk.cyan('memoir hooks install') + chalk.gray(' to add missing hooks.'),
169
+ { padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
170
+ ) + '\n');
171
+ }
@@ -15,6 +15,9 @@ import { encryptDirectory, createVerifyToken } from '../security/encryption.js';
15
15
  import { getRawConfig, saveConfig, migrateConfigToV2 } from '../config.js';
16
16
  import { scanWorkspace } from '../workspace/tracker.js';
17
17
  import { promptActivate } from './activate.js';
18
+ import { paths as sessionPaths, readSession, addNote, recordSessionEnd } from '../session/state.js';
19
+ import { renderSession } from '../session/render.js';
20
+ import { injectInto, detectAvailableTargets } from '../session/inject.js';
18
21
 
19
22
  export async function pushCommand(options = {}) {
20
23
  let config = await getConfig(options.profile);
@@ -93,6 +96,54 @@ export async function pushCommand(options = {}) {
93
96
  } catch {}
94
97
  }
95
98
 
99
+ // Also feed structured decisions into session.json so they appear in
100
+ // the pinned block and sync cross-machine. Dedupe against anything
101
+ // the AI already captured via MCP tools or the user via `memoir note`.
102
+ try {
103
+ const current = await readSession();
104
+ const existingTexts = new Set(
105
+ current.current.decisions.map(d => (d.text || '').trim().toLowerCase())
106
+ );
107
+ // Quality filter: auto-extracted decisions come from regex patterns
108
+ // that sometimes catch table cells or prose fragments. Keep only
109
+ // substantive-looking entries.
110
+ const isQuality = (text) => {
111
+ if (!text) return false;
112
+ if (text.length < 15) return false; // too short to be a real decision
113
+ if (text.length > 200) return false; // probably a snippet, not a decision
114
+ if (/\|/.test(text)) return false; // markdown table fragment
115
+ if (/[_*`]{3,}/.test(text)) return false; // markdown formatting leaked in
116
+ if (!/[a-zA-Z]/.test(text)) return false; // no actual words
117
+ const words = text.split(/\s+/).length;
118
+ if (words < 3) return false; // less than 3 words isn't a decision
119
+ return true;
120
+ };
121
+ for (const d of parsed.decisions.slice(0, 10)) {
122
+ const text = String(d.value || '').trim();
123
+ if (!isQuality(text)) continue;
124
+ if (existingTexts.has(text.toLowerCase())) continue;
125
+ await addNote(text, { why: d.context ? `auto-captured: ${d.context.slice(0, 80)}` : undefined });
126
+ }
127
+ // Record a session summary in history for "recent sessions" section
128
+ const filesList = Array.from(parsed.filesWritten || []).slice(0, 10);
129
+ const durationMin = (parsed.firstTimestamp && parsed.lastTimestamp)
130
+ ? Math.floor((new Date(parsed.lastTimestamp) - new Date(parsed.firstTimestamp)) / 60000)
131
+ : null;
132
+ const summary = parsed.slug ? `Worked on ${parsed.slug}` : `${filesList.length} file(s) touched`;
133
+ await recordSessionEnd({ summary, filesTouched: filesList, durationMin });
134
+ // Re-render into every detected tool so the pinned block reflects
135
+ // what was just auto-captured from the .jsonl
136
+ try {
137
+ const state = await readSession();
138
+ const rendered = renderSession(state);
139
+ for (const target of Object.values(detectAvailableTargets())) {
140
+ try { await injectInto(target, rendered); } catch {}
141
+ }
142
+ } catch {}
143
+ } catch {
144
+ // Session.json capture is best-effort
145
+ }
146
+
96
147
  contextCaptured = true;
97
148
  sessionInfo = {
98
149
  slug: parsed.slug,
@@ -128,6 +179,17 @@ export async function pushCommand(options = {}) {
128
179
  // Workspace scan is best-effort
129
180
  }
130
181
 
182
+ // Include session.json (continuity state) so it syncs across machines
183
+ let sessionIncluded = false;
184
+ try {
185
+ if (await fs.pathExists(sessionPaths.session)) {
186
+ await fs.copy(sessionPaths.session, path.join(stagingDir, 'session.json'));
187
+ sessionIncluded = true;
188
+ }
189
+ } catch {
190
+ // Best-effort — don't fail the push over this
191
+ }
192
+
131
193
  // Count what was found
132
194
  const found = [];
133
195
  for (const adapter of adapters) {
@@ -14,6 +14,9 @@ import { restoreWorkspace } from '../workspace/tracker.js';
14
14
  import { getSession } from '../cloud/auth.js';
15
15
  import { unbundleToDir } from '../cloud/storage.js';
16
16
  import { SUPABASE_URL, SUPABASE_ANON_KEY, STORAGE_BUCKET } from '../cloud/constants.js';
17
+ import { readSession, writeSession, mergeSessions, paths as sessionPaths } from '../session/state.js';
18
+ import { renderSession } from '../session/render.js';
19
+ import { injectInto, detectAvailableTargets } from '../session/inject.js';
17
20
 
18
21
  const home = os.homedir();
19
22
 
@@ -119,6 +122,39 @@ export async function restoreCommand(options = {}) {
119
122
 
120
123
  spinner.stop();
121
124
 
125
+ // Merge session.json (continuity state) from backup into local
126
+ let sessionMerged = false;
127
+ let sessionNewMachine = false;
128
+ try {
129
+ const remoteSessionPath = path.join(stagingDir, 'session.json');
130
+ if (await fs.pathExists(remoteSessionPath)) {
131
+ const remote = JSON.parse(await fs.readFile(remoteSessionPath, 'utf8'));
132
+ const local = await readSession();
133
+ const beforeMachines = Object.keys(local.machines || {}).length;
134
+ const merged = mergeSessions(local, remote);
135
+ await writeSession(merged);
136
+ // Re-render + inject into every detected tool so the pinned block
137
+ // reflects the merged state right away across Claude/Cursor/Windsurf/Gemini
138
+ try {
139
+ const rendered = renderSession(merged);
140
+ for (const target of Object.values(detectAvailableTargets())) {
141
+ try { await injectInto(target, rendered); } catch {}
142
+ }
143
+ } catch {}
144
+ sessionMerged = true;
145
+ sessionNewMachine = Object.keys(merged.machines || {}).length > beforeMachines;
146
+ }
147
+ } catch {
148
+ // Best-effort — don't fail the restore over this
149
+ }
150
+
151
+ if (sessionMerged) {
152
+ const msg = sessionNewMachine
153
+ ? chalk.cyan(' 🔄 Session state merged from another machine')
154
+ : chalk.gray(' ✔ Session state up to date');
155
+ console.log(msg);
156
+ }
157
+
122
158
  // Auto-inject session handoff if available
123
159
  let handoffInjected = false;
124
160
  let handoffInfo = null;