Recently Written · git

ideamine

An idea inbox for Claude Code: /idea saves ideas at zero tokens; Claude triages them and routes each to the cheapest model that can build it.

git clone https://github.com/equwal/ideamine

Log | Files | Refs


src/mcp.js (19328 bytes)

1 // Minimal MCP server over stdio (newline-delimited JSON-RPC 2.0). No dependencies, so the plugin
2 // runs straight from a git checkout with nothing to install.
3 
4 import fs from 'node:fs';
5 import path from 'node:path';
6 import readline from 'node:readline';
7 import { fileURLToPath } from 'node:url';
8 import * as archive from './archive.js';
9 import { headlessTriage, triageFirst } from './claude.js';
10 import * as embed from './embed.js';
11 import { knownProjects } from './projects.js';
12 import { renderAdded, renderBoard, renderFound, renderGroups, renderIdea } from './render.js';
13 import { BATCH_SCHEMA, MODELS, pendingIdeas, triagePrompt } from './rubric.js';
14 import { counts, FILTERS, findIdea, lane, listIdeas, load, pickNext, STATUSES } from './store.js';
15 import { clip, splitIdeas } from './text.js';
16 
17 const ROOT = new URL('..', import.meta.url);
18 const VERSION = JSON.parse(fs.readFileSync(new URL('package.json', ROOT), 'utf8')).version;
19 const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
20 
21 const INSTRUCTIONS =
22   'ideamine is the user\'s idea archive, shared by every session, project, and model. When the user tosses out an ' +
23   'idea for later ("idea: ...", "save this idea", "someday we should ..."), save it with idea_add and continue the ' +
24   'current task. Do not start building a saved idea unless the user asks.';
25 
26 const currentProject = () => process.env.CLAUDE_PROJECT_DIR || process.cwd();
27 
28 const MODEL_ENUM = MODELS.map((m) => m.alias);
29 
30 const TOOLS = [
31   {
32     name: 'idea_add',
33     description:
34       'Save an idea to the ideamine archive. Use when the user shares an idea to keep for later. Only save it: ' +
35       'do not plan or build it, and continue what you were doing. A bulleted list becomes one idea per bullet.',
36     inputSchema: {
37       type: 'object',
38       properties: {
39         text: { type: 'string', description: 'The idea, verbatim.' },
40         tags: { type: 'array', items: { type: 'string' } },
41         project: { type: 'string', description: 'Project directory the idea belongs to. Default: current project.' },
42       },
43       required: ['text'],
44       additionalProperties: false,
45     },
46     annotations: { title: 'Save idea', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
47   },
48   {
49     name: 'idea_list',
50     description:
51       'Show the idea board, or one idea in full when id is given. full=true shows every listed idea in full. ' +
52       'semantic=true ranks ideas by meaning for the query. groups=true groups the listed ideas by meaning.',
53     inputSchema: {
54       type: 'object',
55       properties: {
56         id: { type: 'integer', description: 'Show this idea in full.' },
57         filter: { type: 'string', enum: FILTERS, description: 'Default: open (doing, do, maybe, inbox).' },
58         query: { type: 'string', description: 'Only ideas containing all of these words (with semantic: ideas like it).' },
59         semantic: { type: 'boolean', description: 'Rank by meaning (nomic-embed-text), best first. Falls back to words.' },
60         groups: { type: 'boolean', description: 'Group the listed ideas by meaning.' },
61         here: { type: 'boolean', description: 'Only ideas from the current project.' },
62         limit: { type: 'integer' },
63         full: { type: 'boolean', description: 'Every listed idea in full (brief, text, notes), not one line each.' },
64       },
65       additionalProperties: false,
66     },
67     annotations: { title: 'List ideas', readOnlyHint: true, openWorldHint: false },
68   },
69   {
70     name: 'idea_triage',
71     description:
72       'Triage ideas: decide which are worth doing and the cheapest model that can build each. headless=true ' +
73       'does it in a separate minimal Claude Code call (cheapest; your context is not used). Otherwise call with ' +
74       'no verdicts to get the rubric and the untriaged ideas, then call once more with all verdicts.',
75     inputSchema: {
76       type: 'object',
77       properties: {
78         headless: { type: 'boolean', description: 'Triage the inbox in a separate low-cost `claude -p` call.' },
79         model: { type: 'string', enum: MODEL_ENUM, description: 'Model for headless triage (default sonnet).' },
80         verdicts: BATCH_SCHEMA.properties.verdicts,
81         ids: { type: 'array', items: { type: 'integer' }, description: 'Re-triage these ideas instead of the inbox.' },
82         limit: { type: 'integer', description: 'Max ideas (default 20 headless, 30 otherwise).' },
83         by: { type: 'string', description: 'Your model name, recorded with the verdicts.' },
84       },
85       additionalProperties: false,
86     },
87     annotations: { title: 'Triage ideas', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
88   },
89   {
90     name: 'idea_update',
91     description: 'Change an idea: status, text, title, tags, model override, project, or append a note.',
92     inputSchema: {
93       type: 'object',
94       properties: {
95         id: { type: 'integer' },
96         status: { type: 'string', enum: [...STATUSES, 'reopen'] },
97         note: { type: 'string', description: 'Appended to the idea\'s notes, e.g. the outcome.' },
98         title: { type: 'string' },
99         text: { type: 'string' },
100         tags: { type: 'array', items: { type: 'string' } },
101         model: { type: 'string', enum: MODEL_ENUM },
102         project: { type: 'string' },
103       },
104       required: ['id'],
105       additionalProperties: false,
106     },
107     annotations: { title: 'Update idea', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
108   },
109   {
110     name: 'idea_next',
111     description:
112       'Pick the idea to build next (verdict "do" before "maybe", current project first, then best value per ' +
113       'effort), or fetch one by id. Returns its brief, project, and the model recommended to build it. Without ' +
114       'id, it also returns the whole queue. triage=true first triages the untriaged candidates (the inbox, or ' +
115       'the given id) in one headless call.',
116     inputSchema: {
117       type: 'object',
118       properties: {
119         id: { type: 'integer' },
120         here: { type: 'boolean', description: 'Only consider ideas from the current project.' },
121         triage: { type: 'boolean', description: 'Triage untriaged candidates first, so the pick and the model are current.' },
122       },
123       additionalProperties: false,
124     },
125     annotations: { title: 'Next idea', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
126   },
127   {
128     name: 'idea_remove',
129     description:
130       'Take ideas out of the queue: delete them from the archive for good, and return each one in full. ' +
131       'An unknown id is an error, and then nothing is deleted.',
132     inputSchema: {
133       type: 'object',
134       properties: {
135         ids: { type: 'array', items: { type: 'integer' }, description: 'The ideas to delete.' },
136       },
137       required: ['ids'],
138       additionalProperties: false,
139     },
140     annotations: { title: 'Remove ideas', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
141   },
142 ];
143 
144 // Slash commands for clients that load this server without the plugin (the plugin ships skills instead).
145 // The prompt text is the matching SKILL.md body, so both stay in sync.
146 const PROMPTS = [
147   { name: 'idea', description: 'Save an idea for later', arguments: [{ name: 'text', required: true }] },
148   { name: 'ideas', description: 'Show the queue, or ask about your ideas', arguments: [{ name: 'question', required: false }] },
149   { name: 'ideas-ls', description: 'List the queue, one lane, or every lane (-a)', arguments: [{ name: 'lane', required: false }] },
150   { name: 'ideas-cat', description: 'Show ideas in full', arguments: [{ name: 'ids', required: true }] },
151   { name: 'ideas-rm', description: 'Delete ideas for good', arguments: [{ name: 'ids', required: true }] },
152   { name: 'ideas-find', description: 'Search ideas by meaning', arguments: [{ name: 'words', required: true }] },
153   { name: 'ideas-groups', description: 'Show ideas grouped by meaning', arguments: [{ name: 'lane', required: false }] },
154   { name: 'ideas-done', description: 'Mark an idea done', arguments: [{ name: 'id', required: true }, { name: 'note', required: false }] },
155   { name: 'ideas-reopen', description: 'Put an idea back in the queue', arguments: [{ name: 'id', required: true }] },
156   { name: 'ideas-go', description: 'Build the next idea on its recommended model', arguments: [{ name: 'id', required: false }] },
157   { name: 'ideas-pipeline', description: 'Build the next idea through the agent pipeline', arguments: [{ name: 'id', required: false }] },
158   { name: 'ideas-all', description: 'Do every idea that fits this chat', arguments: [] },
159   { name: 'ideas-sort', description: 'Triage the inbox now and show the queue', arguments: [] },
160   { name: 'ideas-watch', description: 'Turn the background watcher on or off', arguments: [{ name: 'off', required: false }] },
161   { name: 'ideas-web', description: 'Start or stop the dashboard with buttons on this PC', arguments: [{ name: 'off', required: false }] },
162   { name: 'ideas-sync', description: 'Share one archive between your machines through an ideamine server', arguments: [{ name: 'url', required: false }] },
163   { name: 'pipeline', description: 'Run the agent pipeline for a feature request: research, storyboard, plan, engineers, test, validate', arguments: [{ name: 'request', required: true }] },
164 ];
165 
166 function skillBody(name, args) {
167   const file = new URL(`skills/${name}/SKILL.md`, ROOT);
168   const text = fs.readFileSync(fileURLToPath(file), 'utf8').replace(/^---[\s\S]*?\n---\s*\n/, '');
169   return text.replaceAll('$ARGUMENTS', args || '').trim();
170 }
171 
172 function summarizeTriage(results, how = '') {
173   const ok = results.filter((r) => !r.error);
174   const tally = ['do', 'maybe', 'skip'].map((v) => `${ok.filter((r) => r.verdict === v).length} ${v}`).join(' · ');
175   const lines = [`Saved ${ok.length} verdict${ok.length === 1 ? '' : 's'}: ${tally}${how ? ` (${how})` : ''}`];
176   for (const r of ok) {
177     const meta = r.verdict === 'skip' ? '' : ` · ${r.model} · ${r.size.toUpperCase()}`;
178     lines.push(`  #${r.id} ${r.verdict}${meta}  ${clip(r.title, 60)}${r.moved ? `  → ${path.basename(r.moved)}` : ''}`);
179   }
180   for (const r of results.filter((x) => x.error)) lines.push(`  #${r.id} not saved: ${r.error}`);
181   const left = counts(load()).inbox;
182   if (left) lines.push(`${left} still in inbox.`);
183   return lines.join('\n');
184 }
185 
186 export function headlessSummary(out) {
187   return out.message || summarizeTriage(out.results, `${out.model}, ${out.tokens.input} in / ${out.tokens.output} out tokens`);
188 }
189 
190 /** The board after a triage, so the user sees every idea in its new place. */
191 function withBoard(text) {
192   return `${text}\n\n${renderBoard(load(), { cwd: currentProject(), hints: true })}`;
193 }
194 
195 // ---------------------------------------------------------------------------------------------
196 // Tool handlers return plain text for the model.
197 
198 const handlers = {
199   async idea_add({ text, tags, project }) {
200     const results = await archive.add(splitIdeas(text || ''), { source: 'mcp', project: project || currentProject(), tags });
201     return results.queued ? `Saved on this machine. ${archive.queuedText(results)}` : renderAdded(results, load());
202   },
203 
204   async idea_list({ id, filter = 'open', query = '', here = false, limit = 0, full = false, semantic = false, groups = false }) {
205     const db = await archive.fresh();
206     if (id != null) {
207       const idea = findIdea(db, id);
208       if (!idea) throw new Error(`no idea #${id}`);
209       return renderIdea(idea);
210     }
211     const project = here ? currentProject() : null;
212     if (semantic && query) {
213       const found = await embed.find(db, query, { filter, project, limit: limit || 10 });
214       if (!full) return renderFound(found, query, { cwd: currentProject() });
215       return found.results.length ? found.results.map((r) => renderIdea(r.idea)).join('\n\n') : 'No ideas match.';
216     }
217     if (groups) {
218       const ideas = listIdeas(db, { filter, query, limit, project });
219       return renderGroups(await embed.groupIdeas(ideas), ideas, { cwd: currentProject(), scope: filter });
220     }
221     if (full) {
222       const ideas = listIdeas(db, { filter, query, limit, project });
223       return ideas.length ? ideas.map(renderIdea).join('\n\n') : 'No ideas match.';
224     }
225     return renderBoard(db, { filter, query, limit, project, cwd: currentProject() });
226   },
227 
228   async idea_triage({ verdicts, ids, limit, by, headless = false, model }) {
229     if (headless) {
230       return withBoard(headlessSummary(await headlessTriage({ model, limit: limit || 20 })));
231     }
232     if (Array.isArray(verdicts) && verdicts.length) {
233       const projects = knownProjects(await archive.fresh());
234       const results = await archive.triage(verdicts, { by: by ? clip(by, 40) : 'claude', projects });
235       return results.queued ? archive.queuedText(results) : withBoard(summarizeTriage(results));
236     }
237     limit ||= 30;
238     const db = await archive.fresh();
239     const pending = pendingIdeas(db, { ids, limit });
240     if (!pending.length) return 'Nothing to triage: the inbox is empty.';
241     return (
242       triagePrompt(db, pending, knownProjects(db)) +
243       '\n\nSave every verdict in ONE idea_triage call: ' +
244       '{"verdicts":[{"id":1,"verdict":"do","impact":3,"size":"s","model":"sonnet","title":"...","why":"...","brief":"...","project":"..."}]}'
245     );
246   },
247 
248   async idea_update({ id, ...patch }) {
249     const idea = await archive.update(id, patch);
250     if (idea.queued) return archive.queuedText(idea);
251     const bits = [`#${idea.id} ${clip(idea.title, 60)} · ${lane(idea)}`];
252     if (patch.model) bits.push(`model ${idea.triage.model}`);
253     if (patch.note) bits.push('note added');
254     return `Updated ${bits.join(' · ')}`;
255   },
256 
257   async idea_next({ id, here = false, triage = false }) {
258     const out = [];
259     if (triage) {
260       try {
261         const res = await triageFirst({ id });
262         if (res) out.push(headlessSummary(res));
263       } catch (e) {
264         out.push(`Triage failed: ${e.message}`);
265       }
266     }
267     const db = await archive.fresh();
268     let idea;
269     if (id != null) {
270       idea = findIdea(db, id);
271       if (!idea) throw new Error(`no idea #${id}`);
272     } else {
273       idea = pickNext(db, { project: currentProject(), only: here });
274     }
275     if (!idea) {
276       const c = counts(db);
277       out.push(
278         c.inbox
279           ? `No idea is ready: ${c.inbox} untriaged in the inbox.${triage ? '' : ' Pass triage: true to triage them first.'}`
280           : `The queue is empty${here ? ' for this project' : ''}.`,
281       );
282       return out.join('\n\n');
283     }
284     const t = idea.triage;
285     const lines = [renderIdea(idea), ''];
286     if (!t) lines.push('recommended model: none yet (untriaged)');
287     else lines.push(`recommended model: ${t.model}`);
288     // Ideas are saved from any session, so the recorded folder can be a scratch folder that is gone.
289     const dir = idea.project;
290     lines.push(`project dir: ${dir ? `${dir} (${fs.existsSync(dir) ? 'exists' : 'does not exist'})` : '(none recorded)'}`);
291     out.push(lines.join('\n'));
292     // The whole queue, so the caller can choose an idea that fits its chat better than the first one.
293     if (id == null) out.push(`The queue:\n${renderBoard(db, { cwd: currentProject() })}`);
294     return out.join('\n\n');
295   },
296 
297   async idea_remove({ ids = [] }) {
298     if (!ids.length) throw new Error('pass the ids to delete');
299     const gone = await archive.remove(ids);
300     if (gone.queued) return archive.queuedText(gone);
301     return [`Removed ${gone.length} idea${gone.length === 1 ? '' : 's'}.`, ...gone.map(renderIdea)].join('\n\n');
302   },
303 };
304 
305 // ---------------------------------------------------------------------------------------------
306 // JSON-RPC plumbing
307 
308 function send(msg) {
309   process.stdout.write(JSON.stringify(msg) + '\n');
310 }
311 
312 function result(id, value) {
313   return { jsonrpc: '2.0', id, result: value };
314 }
315 
316 function error(id, code, message) {
317   return { jsonrpc: '2.0', id, error: { code, message } };
318 }
319 
320 async function handleRequest(msg, { prompts }) {
321   const { id, method, params = {} } = msg;
322   switch (method) {
323     case 'initialize': {
324       const asked = params.protocolVersion;
325       const protocolVersion = PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0];
326       const capabilities = { tools: { listChanged: false } };
327       if (prompts) capabilities.prompts = { listChanged: false };
328       return result(id, { protocolVersion, capabilities, serverInfo: { name: 'ideamine', version: VERSION }, instructions: INSTRUCTIONS });
329     }
330     case 'ping':
331       return result(id, {});
332     case 'tools/list':
333       return result(id, { tools: TOOLS });
334     case 'tools/call': {
335       const handler = handlers[params.name];
336       if (!handler) return error(id, -32602, `unknown tool: ${params.name}`);
337       try {
338         const text = await handler(params.arguments || {});
339         return result(id, { content: [{ type: 'text', text }] });
340       } catch (e) {
341         return result(id, { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
342       }
343     }
344     case 'prompts/list':
345       return result(id, { prompts: prompts ? PROMPTS : [] });
346     case 'prompts/get': {
347       const p = prompts && PROMPTS.find((x) => x.name === params.name);
348       if (!p) return error(id, -32602, `unknown prompt: ${params.name}`);
349       const args = Object.values(params.arguments || {}).filter(Boolean).join(' ');
350       return result(id, {
351         description: p.description,
352         messages: [{ role: 'user', content: { type: 'text', text: skillBody(p.name, args) } }],
353       });
354     }
355     case 'resources/list':
356       return result(id, { resources: [] });
357     case 'resources/templates/list':
358       return result(id, { resourceTemplates: [] });
359     case 'logging/setLevel':
360       return result(id, {});
361     default:
362       return error(id, -32601, `method not found: ${method}`);
363   }
364 }
365 
366 async function handleMessage(msg, opts) {
367   if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return error(null, -32600, 'invalid request');
368   if (typeof msg.method !== 'string') return null; // a response to a request we never send
369   if (msg.id === undefined) return null; // notification (initialized, cancelled, ...)
370   try {
371     return await handleRequest(msg, opts);
372   } catch (e) {
373     return error(msg.id, -32603, e.message);
374   }
375 }
376 
377 async function handleLine(line, opts) {
378   let msg;
379   try {
380     msg = JSON.parse(line);
381   } catch {
382     return error(null, -32700, 'parse error');
383   }
384   if (!Array.isArray(msg)) return handleMessage(msg, opts);
385   const replies = (await Promise.all(msg.map((m) => handleMessage(m, opts)))).filter(Boolean);
386   return replies.length ? replies : null;
387 }
388 
389 /** Serve MCP on stdin/stdout. `prompts: false` when running as a plugin (skills cover slash commands). */
390 export function serve({ prompts = true } = {}) {
391   const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
392   const inFlight = new Set();
393   // Requests run concurrently (a headless triage must not stall a ping); replies carry their ids.
394   rl.on('line', (line) => {
395     if (!line.trim()) return;
396     const job = handleLine(line, { prompts }).then((reply) => reply && send(reply));
397     inFlight.add(job);
398     job.finally(() => inFlight.delete(job));
399   });
400   rl.on('close', () => Promise.allSettled([...inFlight]).then(() => process.exit(0)));
401 }