memoir-cli 3.6.1 → 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 +113 -0
- package/package.json +12 -2
- package/src/commands/auto-refresh.js +31 -0
- package/src/commands/autopush.js +57 -0
- package/src/commands/hooks.js +171 -0
- package/src/commands/push.js +62 -0
- package/src/commands/restore.js +36 -0
- package/src/commands/session.js +188 -0
- package/src/commands/why.js +50 -0
- package/src/mcp.js +175 -0
- package/src/session/inject.js +117 -0
- package/src/session/render.js +114 -0
- package/src/session/state.js +296 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -26
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -16
- package/CONTRIBUTING.md +0 -47
- package/demo.svg +0 -201
- package/server.json +0 -20
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.
|
|
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,8 +28,9 @@
|
|
|
19
28
|
},
|
|
20
29
|
"scripts": {
|
|
21
30
|
"start": "node bin/memoir.js",
|
|
22
|
-
"test": "node
|
|
31
|
+
"test": "node run-tests.mjs",
|
|
23
32
|
"test:legacy": "bash test-local.sh",
|
|
33
|
+
"prepublishOnly": "npm test",
|
|
24
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{}\""
|
|
25
35
|
},
|
|
26
36
|
"keywords": [
|
|
@@ -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
|
+
}
|
package/src/commands/push.js
CHANGED
|
@@ -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) {
|
package/src/commands/restore.js
CHANGED
|
@@ -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;
|