memoir-cli 3.6.1 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,55 @@ 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';
21
+
22
+ // Recursively scan every staged file (the REAL tool memory/config files about
23
+ // to be uploaded — CLAUDE.md, .cursorrules, settings.json, project configs,
24
+ // etc.) for secrets. When `redact` is true, rewrite each offending file in
25
+ // place so the cleaned version is what gets uploaded (and encrypted, if on).
26
+ // Returns { findings, scanned } where findings is a flat list of detections
27
+ // keyed by file. Best-effort: unreadable/binary files are skipped.
28
+ export async function scanStagedFiles(dir, { redact = false } = {}) {
29
+ const findings = [];
30
+ let scanned = 0;
31
+
32
+ const walk = async (d) => {
33
+ let entries;
34
+ try {
35
+ entries = await fs.readdir(d, { withFileTypes: true });
36
+ } catch { return; }
37
+ for (const entry of entries) {
38
+ const fullPath = path.join(d, entry.name);
39
+ if (entry.isDirectory()) {
40
+ await walk(fullPath);
41
+ continue;
42
+ }
43
+ try {
44
+ const stat = await fs.stat(fullPath);
45
+ // Skip files larger than 1MB — same threshold as doctor's scan
46
+ if (stat.size > 1024 * 1024) continue;
47
+ const content = await fs.readFile(fullPath, 'utf-8');
48
+ scanned++;
49
+ const { found, clean } = scanForSecrets(content);
50
+ if (found.length > 0) {
51
+ for (const f of found) {
52
+ findings.push({ file: fullPath, label: f.label, redacted: f.redacted });
53
+ }
54
+ if (redact && clean !== content) {
55
+ await fs.writeFile(fullPath, clean);
56
+ }
57
+ }
58
+ } catch {
59
+ // Skip unreadable / non-text files
60
+ }
61
+ }
62
+ };
63
+
64
+ await walk(dir);
65
+ return { findings, scanned };
66
+ }
18
67
 
19
68
  export async function pushCommand(options = {}) {
20
69
  let config = await getConfig(options.profile);
@@ -93,6 +142,54 @@ export async function pushCommand(options = {}) {
93
142
  } catch {}
94
143
  }
95
144
 
145
+ // Also feed structured decisions into session.json so they appear in
146
+ // the pinned block and sync cross-machine. Dedupe against anything
147
+ // the AI already captured via MCP tools or the user via `memoir note`.
148
+ try {
149
+ const current = await readSession();
150
+ const existingTexts = new Set(
151
+ current.current.decisions.map(d => (d.text || '').trim().toLowerCase())
152
+ );
153
+ // Quality filter: auto-extracted decisions come from regex patterns
154
+ // that sometimes catch table cells or prose fragments. Keep only
155
+ // substantive-looking entries.
156
+ const isQuality = (text) => {
157
+ if (!text) return false;
158
+ if (text.length < 15) return false; // too short to be a real decision
159
+ if (text.length > 200) return false; // probably a snippet, not a decision
160
+ if (/\|/.test(text)) return false; // markdown table fragment
161
+ if (/[_*`]{3,}/.test(text)) return false; // markdown formatting leaked in
162
+ if (!/[a-zA-Z]/.test(text)) return false; // no actual words
163
+ const words = text.split(/\s+/).length;
164
+ if (words < 3) return false; // less than 3 words isn't a decision
165
+ return true;
166
+ };
167
+ for (const d of parsed.decisions.slice(0, 10)) {
168
+ const text = String(d.value || '').trim();
169
+ if (!isQuality(text)) continue;
170
+ if (existingTexts.has(text.toLowerCase())) continue;
171
+ await addNote(text, { why: d.context ? `auto-captured: ${d.context.slice(0, 80)}` : undefined });
172
+ }
173
+ // Record a session summary in history for "recent sessions" section
174
+ const filesList = Array.from(parsed.filesWritten || []).slice(0, 10);
175
+ const durationMin = (parsed.firstTimestamp && parsed.lastTimestamp)
176
+ ? Math.floor((new Date(parsed.lastTimestamp) - new Date(parsed.firstTimestamp)) / 60000)
177
+ : null;
178
+ const summary = parsed.slug ? `Worked on ${parsed.slug}` : `${filesList.length} file(s) touched`;
179
+ await recordSessionEnd({ summary, filesTouched: filesList, durationMin });
180
+ // Re-render into every detected tool so the pinned block reflects
181
+ // what was just auto-captured from the .jsonl
182
+ try {
183
+ const state = await readSession();
184
+ const rendered = renderSession(state);
185
+ for (const target of Object.values(detectAvailableTargets())) {
186
+ try { await injectInto(target, rendered); } catch {}
187
+ }
188
+ } catch {}
189
+ } catch {
190
+ // Session.json capture is best-effort
191
+ }
192
+
96
193
  contextCaptured = true;
97
194
  sessionInfo = {
98
195
  slug: parsed.slug,
@@ -128,6 +225,17 @@ export async function pushCommand(options = {}) {
128
225
  // Workspace scan is best-effort
129
226
  }
130
227
 
228
+ // Include session.json (continuity state) so it syncs across machines
229
+ let sessionIncluded = false;
230
+ try {
231
+ if (await fs.pathExists(sessionPaths.session)) {
232
+ await fs.copy(sessionPaths.session, path.join(stagingDir, 'session.json'));
233
+ sessionIncluded = true;
234
+ }
235
+ } catch {
236
+ // Best-effort — don't fail the push over this
237
+ }
238
+
131
239
  // Count what was found
132
240
  const found = [];
133
241
  for (const adapter of adapters) {
@@ -143,6 +251,47 @@ export async function pushCommand(options = {}) {
143
251
  }
144
252
  }
145
253
 
254
+ // Scan the REAL files being synced (the staged tool memory/config files,
255
+ // not just the handoff blob) for secrets before they leave the machine.
256
+ // • --redact → strip secrets in place, then upload (sanitized)
257
+ // • otherwise → WARN and continue
258
+ // • background autopush → stay silent and continue
259
+ // We deliberately do NOT hard-block. This is a zero-knowledge encrypted
260
+ // backup of the user's OWN files; silently refusing to back up — which the
261
+ // detached `autopush` Stop-hook path (stdio:'ignore', MEMOIR_AUTOPUSH=1, no
262
+ // TTY) would hit on any false-positive match — is a worse failure than
263
+ // backing up. A future `--strict` flag could fail-closed for the
264
+ // encrypt-off / shared-destination case. Wrapped so a scanner error can
265
+ // never break the push.
266
+ const background = process.env.MEMOIR_AUTOPUSH === '1';
267
+ try {
268
+ const { findings } = await scanStagedFiles(stagingDir, { redact: options.redact === true });
269
+ if (findings.length > 0) {
270
+ if (options.redact === true) {
271
+ spinner.stop();
272
+ console.log(chalk.yellow(`\n 🔒 Redacted ${findings.length} secret(s) from synced files before upload:`));
273
+ for (const f of findings.slice(0, 5)) {
274
+ console.log(chalk.gray(` ${path.basename(f.file)}: ${f.label} (${f.redacted})`));
275
+ }
276
+ if (findings.length > 5) console.log(chalk.gray(` ...and ${findings.length - 5} more`));
277
+ spinner.start();
278
+ } else if (!background) {
279
+ // Warn (interactive or piped) but never block — the backup proceeds.
280
+ spinner.stop();
281
+ console.log(chalk.yellow(`\n ⚠️ ${findings.length} potential secret(s) in synced files (backed up as-is):`));
282
+ for (const f of findings.slice(0, 5)) {
283
+ console.log(chalk.gray(` ${path.basename(f.file)}: ${f.label} (${f.redacted})`));
284
+ }
285
+ if (findings.length > 5) console.log(chalk.gray(` ...and ${findings.length - 5} more`));
286
+ console.log(chalk.gray(' Re-run with ') + chalk.cyan('--redact') + chalk.gray(' to strip them from the backup.'));
287
+ spinner.start();
288
+ }
289
+ // background autopush: silent, continue — never block the auto-backup
290
+ }
291
+ } catch {
292
+ // Secret scan is best-effort — never let it break the push.
293
+ }
294
+
146
295
  // Encrypt if enabled (or ask on first push if not configured)
147
296
  let uploadDir = stagingDir;
148
297
  let encrypted = false;
@@ -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;
@@ -0,0 +1,188 @@
1
+ // CLI commands for the session continuity feature.
2
+ // memoir goal "..." — set current goal
3
+ // memoir next "..." — add a next action
4
+ // memoir done "..." — mark a next action complete (removes it)
5
+ // memoir note "..." — record a decision (supports --why and --rejected)
6
+ // memoir ask "..." — capture an open question
7
+ // memoir session — show current session state
8
+ // memoir session clear — wipe current (history retained)
9
+ //
10
+ // Every mutator re-renders the pinned block into ~/.claude/CLAUDE.md so Claude
11
+ // picks it up at the next session start.
12
+
13
+ import chalk from 'chalk';
14
+ import boxen from 'boxen';
15
+ import gradient from 'gradient-string';
16
+ import {
17
+ readSession,
18
+ writeSession,
19
+ addGoal,
20
+ addNext,
21
+ completeNext,
22
+ addNote,
23
+ addQuestion,
24
+ getMachineId,
25
+ paths,
26
+ } from '../session/state.js';
27
+ import { renderSession } from '../session/render.js';
28
+ import { injectInto, detectAvailableTargets } from '../session/inject.js';
29
+
30
+ // Render + inject into every detected tool. Best-effort; if a tool isn't
31
+ // installed, we just skip it silently (detectAvailableTargets filters).
32
+ async function refreshPinned() {
33
+ const state = await readSession();
34
+ const rendered = renderSession(state);
35
+ const targets = detectAvailableTargets();
36
+ const updated = [];
37
+ for (const target of Object.values(targets)) {
38
+ try {
39
+ const res = await injectInto(target, rendered);
40
+ updated.push(res.path);
41
+ } catch {
42
+ // Target not writable — skip silently.
43
+ }
44
+ }
45
+ return { state, rendered, updated };
46
+ }
47
+
48
+ export async function goalCommand(text) {
49
+ if (!text || !String(text).trim()) {
50
+ console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir goal "your current focus"\n'));
51
+ return;
52
+ }
53
+ await addGoal(String(text).trim());
54
+ const { updated } = await refreshPinned();
55
+ console.log('\n' + chalk.green(' ✓ Goal set: ') + chalk.white(text));
56
+ if (updated.length) console.log(chalk.gray(` Pinned to: ${updated.join(', ')}\n`));
57
+ }
58
+
59
+ export async function nextCommand(text) {
60
+ if (!text || !String(text).trim()) {
61
+ console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir next "the next action"\n'));
62
+ return;
63
+ }
64
+ await addNext(String(text).trim());
65
+ await refreshPinned();
66
+ console.log('\n' + chalk.green(' ✓ Added to next: ') + chalk.white(text) + '\n');
67
+ }
68
+
69
+ export async function doneCommand(text) {
70
+ if (!text || !String(text).trim()) {
71
+ console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir done "substring of the action"\n'));
72
+ return;
73
+ }
74
+ const before = await readSession();
75
+ await completeNext(String(text).trim());
76
+ const after = await readSession();
77
+ const removed = before.current.next_actions.length - after.current.next_actions.length;
78
+ if (removed > 0) {
79
+ await refreshPinned();
80
+ console.log('\n' + chalk.green(` ✓ Completed ${removed} action${removed !== 1 ? 's' : ''}\n`));
81
+ } else {
82
+ console.log('\n' + chalk.yellow(' No matching action found.\n'));
83
+ }
84
+ }
85
+
86
+ export async function noteCommand(text, options = {}) {
87
+ if (!text || !String(text).trim()) {
88
+ console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir note "the decision" [--why "rationale"] [--rejected "alternative"]\n'));
89
+ return;
90
+ }
91
+ await addNote(String(text).trim(), {
92
+ why: options.why ? String(options.why).trim() : undefined,
93
+ rejected: options.rejected ? String(options.rejected).trim() : undefined,
94
+ });
95
+ await refreshPinned();
96
+ console.log('\n' + chalk.green(' ✓ Decision recorded: ') + chalk.white(text));
97
+ if (options.why) console.log(chalk.gray(' Why: ') + chalk.white(options.why));
98
+ if (options.rejected) console.log(chalk.gray(' Rejected: ') + chalk.white(options.rejected));
99
+ console.log('');
100
+ }
101
+
102
+ export async function askCommand(text) {
103
+ if (!text || !String(text).trim()) {
104
+ console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir ask "the open question"\n'));
105
+ return;
106
+ }
107
+ await addQuestion(String(text).trim());
108
+ await refreshPinned();
109
+ console.log('\n' + chalk.green(' ✓ Question captured: ') + chalk.white(text) + '\n');
110
+ }
111
+
112
+ export async function sessionShowCommand() {
113
+ const state = await readSession();
114
+ const machine = await getMachineId();
115
+ const goals = state.current.goals;
116
+ const nexts = state.current.next_actions;
117
+ const questions = state.current.open_questions;
118
+ const decisions = state.current.decisions;
119
+ const history = state.history;
120
+
121
+ const body = [];
122
+ body.push(gradient.pastel(' Session '));
123
+ body.push('');
124
+ body.push(chalk.gray(` This machine: ${machine.label} (${machine.id.slice(0, 8)})`));
125
+ body.push(chalk.gray(` Storage: ${paths.session}`));
126
+ body.push('');
127
+
128
+ if (goals.length === 0) {
129
+ body.push(chalk.yellow(' No goal set.') + chalk.gray(' Run ') + chalk.cyan('memoir goal "..."'));
130
+ } else {
131
+ body.push(chalk.white.bold(' Current goal:'));
132
+ for (const g of goals) body.push(' ' + chalk.cyan('→ ') + chalk.white(g.text));
133
+ }
134
+ body.push('');
135
+
136
+ if (nexts.length) {
137
+ body.push(chalk.white.bold(' Next:'));
138
+ for (const n of nexts) body.push(' ' + chalk.gray('[ ] ') + chalk.white(n.text));
139
+ body.push('');
140
+ }
141
+
142
+ if (questions.length) {
143
+ body.push(chalk.white.bold(' Open questions:'));
144
+ for (const q of questions) body.push(' ' + chalk.yellow('? ') + chalk.white(q.text));
145
+ body.push('');
146
+ }
147
+
148
+ if (decisions.length) {
149
+ body.push(chalk.white.bold(' Recent decisions:'));
150
+ for (const d of decisions.slice(0, 5)) {
151
+ let line = ' ' + chalk.green('✓ ') + chalk.white(d.text);
152
+ if (d.why) line += chalk.gray(` — ${d.why}`);
153
+ body.push(line);
154
+ }
155
+ body.push('');
156
+ }
157
+
158
+ if (history.length) {
159
+ body.push(chalk.white.bold(' Recent sessions:'));
160
+ for (const h of history.slice(0, 5)) {
161
+ const date = (h.date || '').slice(0, 10);
162
+ const label = state.machines?.[h.machine_id]?.label || '?';
163
+ const dur = h.duration_min ? chalk.gray(` (${h.duration_min}m)`) : '';
164
+ body.push(' ' + chalk.gray(`${date} ${label}`) + dur + chalk.gray(': ') + chalk.white(h.summary || '—'));
165
+ }
166
+ body.push('');
167
+ }
168
+
169
+ const machineCount = Object.keys(state.machines || {}).length;
170
+ if (machineCount > 1) {
171
+ body.push(chalk.gray(` ${machineCount} machines sync this session.`));
172
+ }
173
+
174
+ console.log('\n' + boxen(body.join('\n'), {
175
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
176
+ borderStyle: 'round',
177
+ borderColor: 'cyan',
178
+ dimBorder: true,
179
+ }) + '\n');
180
+ }
181
+
182
+ export async function sessionClearCommand() {
183
+ const state = await readSession();
184
+ state.current = { goals: [], next_actions: [], open_questions: [], decisions: [] };
185
+ await writeSession(state);
186
+ await refreshPinned();
187
+ console.log('\n' + chalk.green(' ✓ Current session cleared.') + chalk.gray(' History retained.\n'));
188
+ }
@@ -0,0 +1,50 @@
1
+ // Decision registry lookup — `memoir why <query>`
2
+ // Search session.json decisions[] by text / why / rejected substring.
3
+ // Returns matching decisions sorted by recency.
4
+
5
+ import chalk from 'chalk';
6
+ import boxen from 'boxen';
7
+ import { readSession } from '../session/state.js';
8
+
9
+ function searchDecisions(decisions, query) {
10
+ if (!query) return decisions;
11
+ const q = String(query).toLowerCase();
12
+ return decisions.filter(d => {
13
+ const haystack = [d.text, d.why, d.rejected].filter(Boolean).join(' ').toLowerCase();
14
+ return haystack.includes(q);
15
+ });
16
+ }
17
+
18
+ export async function whyCommand(query) {
19
+ const state = await readSession();
20
+ const decisions = state.current?.decisions || [];
21
+ const matches = searchDecisions(decisions, query);
22
+
23
+ if (matches.length === 0) {
24
+ const msg = query
25
+ ? chalk.yellow(`No decisions match "${query}".`) + '\n\n' +
26
+ chalk.gray('Record one with: ') + chalk.cyan('memoir note "the decision" --why "rationale"')
27
+ : chalk.yellow('No decisions recorded yet.') + '\n\n' +
28
+ chalk.gray('Record one with: ') + chalk.cyan('memoir note "the decision" --why "rationale"');
29
+ console.log('\n' + boxen(msg, { padding: 1, borderStyle: 'round', borderColor: 'yellow' }) + '\n');
30
+ return;
31
+ }
32
+
33
+ const lines = [chalk.cyan.bold(`${matches.length} decision${matches.length !== 1 ? 's' : ''} matching "${query || '*'}":`)];
34
+ lines.push('');
35
+ for (const d of matches) {
36
+ const date = (d.date || '').slice(0, 10);
37
+ const label = state.machines?.[d.machine_id]?.label || '';
38
+ lines.push(chalk.green(' ● ') + chalk.white.bold(d.text));
39
+ if (d.why) lines.push(chalk.gray(' why: ') + chalk.white(d.why));
40
+ if (d.rejected) lines.push(chalk.gray(' rejected: ') + chalk.white(d.rejected));
41
+ if (date) lines.push(chalk.gray(` ${date}${label ? ` on ${label}` : ''}`));
42
+ lines.push('');
43
+ }
44
+ console.log('\n' + lines.join('\n'));
45
+ }
46
+
47
+ // Exported for MCP tool
48
+ export function findDecisions(state, query) {
49
+ return searchDecisions(state.current?.decisions || [], query);
50
+ }
package/src/mcp.js CHANGED
@@ -14,6 +14,19 @@ import os from 'os';
14
14
  import { z } from 'zod';
15
15
  import { getConfig, listProfiles, getActiveProfileName } from './config.js';
16
16
  import { adapters } from './adapters/index.js';
17
+ import {
18
+ readSession,
19
+ writeSession,
20
+ addGoal,
21
+ addNext,
22
+ completeNext,
23
+ addNote,
24
+ addQuestion,
25
+ getMachineId,
26
+ } from './session/state.js';
27
+ import { renderSession } from './session/render.js';
28
+ import { injectInto, detectAvailableTargets } from './session/inject.js';
29
+ import { findDecisions } from './commands/why.js';
17
30
 
18
31
  const home = os.homedir();
19
32
 
@@ -518,6 +531,168 @@ server.tool(
518
531
  }
519
532
  );
520
533
 
534
+ // ── Session continuity tools ─────────────────────────────────────────────────
535
+ // These let the AI record its own goals, decisions, and next-actions into
536
+ // session.json — which is auto-rendered into ~/.claude/CLAUDE.md (and other
537
+ // tools in the future) so the next session picks up where this one ended.
538
+
539
+ async function refreshPinnedBlock() {
540
+ try {
541
+ const state = await readSession();
542
+ const rendered = renderSession(state);
543
+ for (const target of Object.values(detectAvailableTargets())) {
544
+ try { await injectInto(target, rendered); } catch {}
545
+ }
546
+ } catch {
547
+ // Best-effort; don't fail the MCP call
548
+ }
549
+ }
550
+
551
+ server.tool(
552
+ 'memoir_set_goal',
553
+ 'Set the current goal for this session. Use when the user states what they want to work on, or when a clear focus emerges. Pinned into CLAUDE.md so future sessions see it.',
554
+ { text: z.string().describe('The goal, one short sentence') },
555
+ async ({ text }) => {
556
+ await addGoal(text);
557
+ await refreshPinnedBlock();
558
+ return { content: [{ type: 'text', text: `Goal set: ${text}` }] };
559
+ }
560
+ );
561
+
562
+ server.tool(
563
+ 'memoir_add_next',
564
+ 'Add a next action to the current session. Use when the user decides on a concrete next step, or when you finish something and the logical next move is clear.',
565
+ { text: z.string().describe('The action, one short imperative sentence') },
566
+ async ({ text }) => {
567
+ await addNext(text);
568
+ await refreshPinnedBlock();
569
+ return { content: [{ type: 'text', text: `Next: ${text}` }] };
570
+ }
571
+ );
572
+
573
+ server.tool(
574
+ 'memoir_complete_next',
575
+ 'Mark a next action as complete (removes it from the pinned list). Match by substring — pass the relevant keywords, not the whole text.',
576
+ { match: z.string().describe('Substring to match against existing next actions') },
577
+ async ({ match }) => {
578
+ const before = await readSession();
579
+ const beforeCount = before.current.next_actions.length;
580
+ await completeNext(match);
581
+ const after = await readSession();
582
+ const removed = beforeCount - after.current.next_actions.length;
583
+ await refreshPinnedBlock();
584
+ return {
585
+ content: [{
586
+ type: 'text',
587
+ text: removed > 0 ? `Completed ${removed} action(s) matching "${match}"` : `No action matched "${match}"`,
588
+ }],
589
+ };
590
+ }
591
+ );
592
+
593
+ server.tool(
594
+ 'memoir_note',
595
+ 'Record a decision with optional rationale and rejected alternative. Use when a non-obvious technical or product choice is made — the kind of thing a future session would want to know "why did we do this."',
596
+ {
597
+ text: z.string().describe('The decision, one short sentence (what was decided)'),
598
+ why: z.string().optional().describe('Rationale — why this choice over others'),
599
+ rejected: z.string().optional().describe('The alternative that was considered and rejected'),
600
+ },
601
+ async ({ text, why, rejected }) => {
602
+ await addNote(text, { why, rejected });
603
+ await refreshPinnedBlock();
604
+ const extras = [];
605
+ if (why) extras.push(`why: ${why}`);
606
+ if (rejected) extras.push(`rejected: ${rejected}`);
607
+ return {
608
+ content: [{ type: 'text', text: `Decision recorded: ${text}${extras.length ? ` (${extras.join('; ')})` : ''}` }],
609
+ };
610
+ }
611
+ );
612
+
613
+ server.tool(
614
+ 'memoir_ask',
615
+ 'Capture an open question for later. Use when the user poses a question you cannot fully answer now, or when an ambiguity surfaces that needs resolution in a future session.',
616
+ { text: z.string().describe('The open question') },
617
+ async ({ text }) => {
618
+ await addQuestion(text);
619
+ await refreshPinnedBlock();
620
+ return { content: [{ type: 'text', text: `Question captured: ${text}` }] };
621
+ }
622
+ );
623
+
624
+ server.tool(
625
+ 'memoir_session',
626
+ 'Show the current session state — goals, next actions, open questions, recent decisions, recent sessions across machines. Use this to catch up at the start of a session, or when you need to orient yourself on what was decided.',
627
+ {},
628
+ async () => {
629
+ const state = await readSession();
630
+ const machine = await getMachineId();
631
+ const goals = state.current.goals.map(g => `- ${g.text}`).join('\n') || '(none)';
632
+ const nexts = state.current.next_actions.map(n => `- [ ] ${n.text}`).join('\n') || '(none)';
633
+ const questions = state.current.open_questions.map(q => `- ${q.text}`).join('\n') || '(none)';
634
+ const decisions = state.current.decisions.slice(0, 5).map(d => {
635
+ let line = `- ${d.text}`;
636
+ if (d.why) line += ` — *${d.why}*`;
637
+ return line;
638
+ }).join('\n') || '(none)';
639
+ const history = state.history.slice(0, 5).map(h => {
640
+ const date = (h.date || '').slice(0, 10);
641
+ const label = state.machines?.[h.machine_id]?.label || '?';
642
+ return `- ${date} ${label}: ${h.summary || '—'}`;
643
+ }).join('\n') || '(none)';
644
+ const machineList = Object.entries(state.machines || {})
645
+ .map(([id, m]) => `- ${m.label} (last seen: ${(m.last_seen || '').slice(0, 10)})`)
646
+ .join('\n') || '(just this one)';
647
+
648
+ const text = [
649
+ `# Memoir session`,
650
+ `This machine: ${machine.label}`,
651
+ '',
652
+ '## Current goal',
653
+ goals,
654
+ '',
655
+ '## Next',
656
+ nexts,
657
+ '',
658
+ '## Open questions',
659
+ questions,
660
+ '',
661
+ '## Recent decisions',
662
+ decisions,
663
+ '',
664
+ '## Recent sessions',
665
+ history,
666
+ '',
667
+ '## Machines syncing this session',
668
+ machineList,
669
+ ].join('\n');
670
+
671
+ return { content: [{ type: 'text', text }] };
672
+ }
673
+ );
674
+
675
+ server.tool(
676
+ 'memoir_why',
677
+ 'Look up past decisions by keyword. Returns the decision text, why it was made, and what alternative was rejected. Use when the user asks "why did we do X" or when you need to avoid re-opening a settled question.',
678
+ { query: z.string().describe('Keyword or phrase to search in decision text, rationale, or rejected alternative') },
679
+ async ({ query }) => {
680
+ const state = await readSession();
681
+ const matches = findDecisions(state, query);
682
+ if (matches.length === 0) {
683
+ return { content: [{ type: 'text', text: `No decisions match "${query}".` }] };
684
+ }
685
+ const out = matches.map(d => {
686
+ const parts = [`● ${d.text}`];
687
+ if (d.why) parts.push(` why: ${d.why}`);
688
+ if (d.rejected) parts.push(` rejected: ${d.rejected}`);
689
+ if (d.date) parts.push(` (${d.date.slice(0, 10)})`);
690
+ return parts.join('\n');
691
+ }).join('\n\n');
692
+ return { content: [{ type: 'text', text: `${matches.length} decision(s) matching "${query}":\n\n${out}` }] };
693
+ }
694
+ );
695
+
521
696
  // ── Resources ────────────────────────────────────────────────────────────────
522
697
 
523
698
  // Expose detected tools as browsable resources