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 +113 -0
- package/package.json +13 -2
- package/src/adapters/restore.js +76 -0
- 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
|
@@ -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
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Inject / update the pinned session block in target files.
|
|
2
|
+
//
|
|
3
|
+
// Primary target: ~/.claude/CLAUDE.md (user-global, always loaded by Claude Code
|
|
4
|
+
// regardless of MEMORY.md truncation).
|
|
5
|
+
//
|
|
6
|
+
// Rules:
|
|
7
|
+
// - If markers are present, replace the block in place. Nothing else is touched.
|
|
8
|
+
// - If markers are absent, prepend the block at the top of the file (after any
|
|
9
|
+
// leading frontmatter or title line).
|
|
10
|
+
// - If the file doesn't exist, create it containing only the block.
|
|
11
|
+
// - Never touch content outside the markers.
|
|
12
|
+
|
|
13
|
+
import fs from 'fs-extra';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import os from 'os';
|
|
16
|
+
import { BLOCK_START, BLOCK_END } from './render.js';
|
|
17
|
+
|
|
18
|
+
const home = os.homedir();
|
|
19
|
+
const isWin = process.platform === 'win32';
|
|
20
|
+
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
21
|
+
|
|
22
|
+
// Every target that memoir knows how to inject the pinned block into.
|
|
23
|
+
// Added when we extend cross-tool support. Each target is a single file path.
|
|
24
|
+
//
|
|
25
|
+
// Claude: ~/.claude/CLAUDE.md (always loaded by Claude Code)
|
|
26
|
+
// Cursor: ~/.cursor/rules/memoir-session.mdc (global user rules — auto-loaded)
|
|
27
|
+
// Windsurf: {AppSupport}/Windsurf/User/memoir-session.md (user-global instructions)
|
|
28
|
+
// Gemini: ~/.gemini/GEMINI.md (user-global)
|
|
29
|
+
export const INJECTION_TARGETS = {
|
|
30
|
+
claude: path.join(home, '.claude', 'CLAUDE.md'),
|
|
31
|
+
cursor: path.join(home, '.cursor', 'rules', 'memoir-session.mdc'),
|
|
32
|
+
windsurf: isWin
|
|
33
|
+
? path.join(appData, 'Windsurf', 'User', 'memoir-session.md')
|
|
34
|
+
: path.join(home, 'Library', 'Application Support', 'Windsurf', 'User', 'memoir-session.md'),
|
|
35
|
+
gemini: path.join(home, '.gemini', 'GEMINI.md'),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Returns the target paths whose parent infrastructure exists — i.e. the tool
|
|
39
|
+
// is actually installed. Avoids creating empty tool dirs for tools the user
|
|
40
|
+
// doesn't use.
|
|
41
|
+
export function detectAvailableTargets() {
|
|
42
|
+
const detectors = {
|
|
43
|
+
claude: path.join(home, '.claude'),
|
|
44
|
+
cursor: path.join(home, '.cursor'),
|
|
45
|
+
windsurf: isWin
|
|
46
|
+
? path.join(appData, 'Windsurf', 'User')
|
|
47
|
+
: path.join(home, 'Library', 'Application Support', 'Windsurf', 'User'),
|
|
48
|
+
gemini: path.join(home, '.gemini'),
|
|
49
|
+
};
|
|
50
|
+
const available = {};
|
|
51
|
+
for (const [name, dir] of Object.entries(detectors)) {
|
|
52
|
+
try {
|
|
53
|
+
if (fs.existsSync(dir)) available[name] = INJECTION_TARGETS[name];
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
return available;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Pattern that matches the whole block including markers. Tolerant of the
|
|
60
|
+
// marker text evolving across versions — anchors on `memoir:session-block`.
|
|
61
|
+
const BLOCK_RE = /<!--\s*memoir:session-block[^>]*-->[\s\S]*?<!--\s*\/memoir:session-block\s*-->/;
|
|
62
|
+
|
|
63
|
+
export async function injectInto(targetPath, renderedBlock) {
|
|
64
|
+
await fs.ensureDir(path.dirname(targetPath));
|
|
65
|
+
|
|
66
|
+
let content = '';
|
|
67
|
+
let existed = false;
|
|
68
|
+
try {
|
|
69
|
+
content = await fs.readFile(targetPath, 'utf8');
|
|
70
|
+
existed = true;
|
|
71
|
+
} catch {
|
|
72
|
+
// Doesn't exist yet — will create.
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const updated = applyBlock(content, renderedBlock, existed);
|
|
76
|
+
|
|
77
|
+
// Atomic write
|
|
78
|
+
const tmp = `${targetPath}.tmp-${process.pid}`;
|
|
79
|
+
await fs.writeFile(tmp, updated);
|
|
80
|
+
await fs.move(tmp, targetPath, { overwrite: true });
|
|
81
|
+
|
|
82
|
+
return { path: targetPath, created: !existed, replaced: existed && BLOCK_RE.test(content) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Apply the block to existing content. Exported for tests.
|
|
86
|
+
export function applyBlock(content, renderedBlock, existed = true) {
|
|
87
|
+
if (!existed || content.trim() === '') {
|
|
88
|
+
// Fresh file — block only, plus a trailing newline for poetry.
|
|
89
|
+
return renderedBlock + '\n';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (BLOCK_RE.test(content)) {
|
|
93
|
+
// Replace in place.
|
|
94
|
+
return content.replace(BLOCK_RE, renderedBlock);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// No existing block — prepend. Preserve any H1 title at the top by placing
|
|
98
|
+
// the block immediately after it. Otherwise put it at the very top.
|
|
99
|
+
const h1Match = content.match(/^(#\s.+\n+)/);
|
|
100
|
+
if (h1Match) {
|
|
101
|
+
return h1Match[1] + renderedBlock + '\n\n' + content.slice(h1Match[1].length);
|
|
102
|
+
}
|
|
103
|
+
return renderedBlock + '\n\n' + content;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Remove the block, if present. Used when user wants memoir to stop managing CLAUDE.md.
|
|
107
|
+
export async function uninjectFrom(targetPath) {
|
|
108
|
+
if (!await fs.pathExists(targetPath)) return { removed: false };
|
|
109
|
+
const content = await fs.readFile(targetPath, 'utf8');
|
|
110
|
+
if (!BLOCK_RE.test(content)) return { removed: false };
|
|
111
|
+
// Strip the block and any trailing blank lines that follow it
|
|
112
|
+
const cleaned = content.replace(BLOCK_RE, '').replace(/\n{3,}/g, '\n\n').trimStart();
|
|
113
|
+
const tmp = `${targetPath}.tmp-${process.pid}`;
|
|
114
|
+
await fs.writeFile(tmp, cleaned);
|
|
115
|
+
await fs.move(tmp, targetPath, { overwrite: true });
|
|
116
|
+
return { removed: true };
|
|
117
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Render session state → pinned markdown block.
|
|
2
|
+
// The block is wrapped in <!-- memoir:session-block v1 --> markers so inject.js
|
|
3
|
+
// can find and replace it without touching anything else in CLAUDE.md.
|
|
4
|
+
|
|
5
|
+
export const BLOCK_START = '<!-- memoir:session-block v1 — managed by memoir, edit via `memoir goal/next/note` -->';
|
|
6
|
+
export const BLOCK_END = '<!-- /memoir:session-block -->';
|
|
7
|
+
|
|
8
|
+
const MAX_RENDERED_GOALS = 2;
|
|
9
|
+
const MAX_RENDERED_NEXT = 6;
|
|
10
|
+
const MAX_RENDERED_QUESTIONS = 4;
|
|
11
|
+
const MAX_RENDERED_DECISIONS = 5;
|
|
12
|
+
const MAX_RENDERED_HISTORY = 5;
|
|
13
|
+
|
|
14
|
+
export function renderSession(state) {
|
|
15
|
+
if (!state) return renderEmpty();
|
|
16
|
+
|
|
17
|
+
const lines = [BLOCK_START, '## 🎯 Continuing from where we left off', ''];
|
|
18
|
+
|
|
19
|
+
const goals = (state.current?.goals || []).slice(0, MAX_RENDERED_GOALS);
|
|
20
|
+
const nexts = (state.current?.next_actions || []).slice(-MAX_RENDERED_NEXT).reverse();
|
|
21
|
+
const questions = (state.current?.open_questions || []).slice(-MAX_RENDERED_QUESTIONS).reverse();
|
|
22
|
+
const decisions = (state.current?.decisions || []).slice(0, MAX_RENDERED_DECISIONS);
|
|
23
|
+
const history = (state.history || []).slice(0, MAX_RENDERED_HISTORY);
|
|
24
|
+
|
|
25
|
+
const everythingEmpty = !goals.length && !nexts.length && !questions.length && !decisions.length && !history.length;
|
|
26
|
+
if (everythingEmpty) return renderEmpty();
|
|
27
|
+
|
|
28
|
+
// Goals — show current goal prominently
|
|
29
|
+
if (goals.length === 1) {
|
|
30
|
+
lines.push(`**Current goal:** ${goals[0].text}${machineTag(goals[0], state)}`);
|
|
31
|
+
lines.push('');
|
|
32
|
+
} else if (goals.length > 1) {
|
|
33
|
+
lines.push('**Goals:**');
|
|
34
|
+
for (const g of goals) lines.push(`- ${g.text}${machineTag(g, state)}`);
|
|
35
|
+
lines.push('');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Next actions — checkbox format so they read as actionable
|
|
39
|
+
if (nexts.length) {
|
|
40
|
+
lines.push('**Next:**');
|
|
41
|
+
for (const n of nexts) {
|
|
42
|
+
lines.push(`- [ ] ${n.text}${machineTag(n, state)}`);
|
|
43
|
+
}
|
|
44
|
+
lines.push('');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Open questions
|
|
48
|
+
if (questions.length) {
|
|
49
|
+
lines.push('**Open questions:**');
|
|
50
|
+
for (const q of questions) lines.push(`- ${q.text}${machineTag(q, state)}`);
|
|
51
|
+
lines.push('');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Recent decisions
|
|
55
|
+
if (decisions.length) {
|
|
56
|
+
lines.push('**Recent decisions:**');
|
|
57
|
+
for (const d of decisions) {
|
|
58
|
+
let line = `- ${d.text}`;
|
|
59
|
+
if (d.why) line += ` — *${d.why}*`;
|
|
60
|
+
line += machineTag(d, state);
|
|
61
|
+
lines.push(line);
|
|
62
|
+
}
|
|
63
|
+
lines.push('');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Recent session history — machine-tagged so user sees cross-machine trail
|
|
67
|
+
if (history.length) {
|
|
68
|
+
lines.push('**Recent sessions:**');
|
|
69
|
+
for (const h of history) {
|
|
70
|
+
const date = (h.date || '').slice(0, 10);
|
|
71
|
+
const machineLabel = labelFor(h.machine_id, state) || 'unknown';
|
|
72
|
+
const dur = h.duration_min ? ` (${formatDuration(h.duration_min)})` : '';
|
|
73
|
+
const summary = h.summary || '—';
|
|
74
|
+
lines.push(`- ${date} ${machineLabel}${dur}: ${summary}`);
|
|
75
|
+
}
|
|
76
|
+
lines.push('');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
lines.push(BLOCK_END);
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function machineTag(item, state) {
|
|
84
|
+
if (!item?.machine_id) return '';
|
|
85
|
+
const label = labelFor(item.machine_id, state);
|
|
86
|
+
// Only show machine tag if we have more than one machine — otherwise it's noise
|
|
87
|
+
const machineCount = Object.keys(state?.machines || {}).length;
|
|
88
|
+
if (machineCount <= 1 || !label) return '';
|
|
89
|
+
return ` _(${label})_`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function labelFor(machineId, state) {
|
|
93
|
+
if (!machineId) return null;
|
|
94
|
+
return state?.machines?.[machineId]?.label || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatDuration(min) {
|
|
98
|
+
if (!min || min <= 0) return '';
|
|
99
|
+
if (min < 60) return `${min}m`;
|
|
100
|
+
const h = Math.floor(min / 60);
|
|
101
|
+
const m = min % 60;
|
|
102
|
+
return m ? `${h}h ${m}m` : `${h}h`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function renderEmpty() {
|
|
106
|
+
return [
|
|
107
|
+
BLOCK_START,
|
|
108
|
+
'## 🎯 Continuing from where we left off',
|
|
109
|
+
'',
|
|
110
|
+
'_No session context yet. Set one with:_ `memoir goal "your current focus"`',
|
|
111
|
+
'',
|
|
112
|
+
BLOCK_END,
|
|
113
|
+
].join('\n');
|
|
114
|
+
}
|