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 (18999 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-all', description: 'Do every idea that fits this chat', arguments: [] },
158   { name: 'ideas-sort', description: 'Triage the inbox now and show the queue', arguments: [] },
159   { name: 'ideas-watch', description: 'Turn the background watcher on or off', arguments: [{ name: 'off', required: false }] },
160   { name: 'ideas-web', description: 'Start or stop the dashboard with buttons on this PC', arguments: [{ name: 'off', required: false }] },
161   { name: 'ideas-sync', description: 'Share one archive between your machines through an ideamine server', arguments: [{ name: 'url', required: false }] },
162 ];
163 
164 function skillBody(name, args) {
165   const file = new URL(`skills/${name}/SKILL.md`, ROOT);
166   const text = fs.readFileSync(fileURLToPath(file), 'utf8').replace(/^---[\s\S]*?\n---\s*\n/, '');
167   return text.replaceAll('$ARGUMENTS', args || '').trim();
168 }
169 
170 function summarizeTriage(results, how = '') {
171   const ok = results.filter((r) => !r.error);
172   const tally = ['do', 'maybe', 'skip'].map((v) => `${ok.filter((r) => r.verdict === v).length} ${v}`).join(' · ');
173   const lines = [`Saved ${ok.length} verdict${ok.length === 1 ? '' : 's'}: ${tally}${how ? ` (${how})` : ''}`];
174   for (const r of ok) {
175     const meta = r.verdict === 'skip' ? '' : ` · ${r.model} · ${r.size.toUpperCase()}`;
176     lines.push(`  #${r.id} ${r.verdict}${meta}  ${clip(r.title, 60)}${r.moved ? `  → ${path.basename(r.moved)}` : ''}`);
177   }
178   for (const r of results.filter((x) => x.error)) lines.push(`  #${r.id} not saved: ${r.error}`);
179   const left = counts(load()).inbox;
180   if (left) lines.push(`${left} still in inbox.`);
181   return lines.join('\n');
182 }
183 
184 export function headlessSummary(out) {
185   return out.message || summarizeTriage(out.results, `${out.model}, ${out.tokens.input} in / ${out.tokens.output} out tokens`);
186 }
187 
188 /** The board after a triage, so the user sees every idea in its new place. */
189 function withBoard(text) {
190   return `${text}\n\n${renderBoard(load(), { cwd: currentProject(), hints: true })}`;
191 }
192 
193 // ---------------------------------------------------------------------------------------------
194 // Tool handlers return plain text for the model.
195 
196 const handlers = {
197   async idea_add({ text, tags, project }) {
198     const results = await archive.add(splitIdeas(text || ''), { source: 'mcp', project: project || currentProject(), tags });
199     return results.queued ? `Saved on this machine. ${archive.queuedText(results)}` : renderAdded(results, load());
200   },
201 
202   async idea_list({ id, filter = 'open', query = '', here = false, limit = 0, full = false, semantic = false, groups = false }) {
203     const db = await archive.fresh();
204     if (id != null) {
205       const idea = findIdea(db, id);
206       if (!idea) throw new Error(`no idea #${id}`);
207       return renderIdea(idea);
208     }
209     const project = here ? currentProject() : null;
210     if (semantic && query) {
211       const found = await embed.find(db, query, { filter, project, limit: limit || 10 });
212       if (!full) return renderFound(found, query, { cwd: currentProject() });
213       return found.results.length ? found.results.map((r) => renderIdea(r.idea)).join('\n\n') : 'No ideas match.';
214     }
215     if (groups) {
216       const ideas = listIdeas(db, { filter, query, limit, project });
217       return renderGroups(await embed.groupIdeas(ideas), ideas, { cwd: currentProject(), scope: filter });
218     }
219     if (full) {
220       const ideas = listIdeas(db, { filter, query, limit, project });
221       return ideas.length ? ideas.map(renderIdea).join('\n\n') : 'No ideas match.';
222     }
223     return renderBoard(db, { filter, query, limit, project, cwd: currentProject() });
224   },
225 
226   async idea_triage({ verdicts, ids, limit, by, headless = false, model }) {
227     if (headless) {
228       return withBoard(headlessSummary(await headlessTriage({ model, limit: limit || 20 })));
229     }
230     if (Array.isArray(verdicts) && verdicts.length) {
231       const projects = knownProjects(await archive.fresh());
232       const results = await archive.triage(verdicts, { by: by ? clip(by, 40) : 'claude', projects });
233       return results.queued ? archive.queuedText(results) : withBoard(summarizeTriage(results));
234     }
235     limit ||= 30;
236     const db = await archive.fresh();
237     const pending = pendingIdeas(db, { ids, limit });
238     if (!pending.length) return 'Nothing to triage: the inbox is empty.';
239     return (
240       triagePrompt(db, pending, knownProjects(db)) +
241       '\n\nSave every verdict in ONE idea_triage call: ' +
242       '{"verdicts":[{"id":1,"verdict":"do","impact":3,"size":"s","model":"sonnet","title":"...","why":"...","brief":"...","project":"..."}]}'
243     );
244   },
245 
246   async idea_update({ id, ...patch }) {
247     const idea = await archive.update(id, patch);
248     if (idea.queued) return archive.queuedText(idea);
249     const bits = [`#${idea.id} ${clip(idea.title, 60)} · ${lane(idea)}`];
250     if (patch.model) bits.push(`model ${idea.triage.model}`);
251     if (patch.note) bits.push('note added');
252     return `Updated ${bits.join(' · ')}`;
253   },
254 
255   async idea_next({ id, here = false, triage = false }) {
256     const out = [];
257     if (triage) {
258       try {
259         const res = await triageFirst({ id });
260         if (res) out.push(headlessSummary(res));
261       } catch (e) {
262         out.push(`Triage failed: ${e.message}`);
263       }
264     }
265     const db = await archive.fresh();
266     let idea;
267     if (id != null) {
268       idea = findIdea(db, id);
269       if (!idea) throw new Error(`no idea #${id}`);
270     } else {
271       idea = pickNext(db, { project: currentProject(), only: here });
272     }
273     if (!idea) {
274       const c = counts(db);
275       out.push(
276         c.inbox
277           ? `No idea is ready: ${c.inbox} untriaged in the inbox.${triage ? '' : ' Pass triage: true to triage them first.'}`
278           : `The queue is empty${here ? ' for this project' : ''}.`,
279       );
280       return out.join('\n\n');
281     }
282     const t = idea.triage;
283     const lines = [renderIdea(idea), ''];
284     if (!t) lines.push('recommended model: none yet (untriaged)');
285     else lines.push(`recommended model: ${t.model}`);
286     // Ideas are saved from any session, so the recorded folder can be a scratch folder that is gone.
287     const dir = idea.project;
288     lines.push(`project dir: ${dir ? `${dir} (${fs.existsSync(dir) ? 'exists' : 'does not exist'})` : '(none recorded)'}`);
289     out.push(lines.join('\n'));
290     // The whole queue, so the caller can choose an idea that fits its chat better than the first one.
291     if (id == null) out.push(`The queue:\n${renderBoard(db, { cwd: currentProject() })}`);
292     return out.join('\n\n');
293   },
294 
295   async idea_remove({ ids = [] }) {
296     if (!ids.length) throw new Error('pass the ids to delete');
297     const gone = await archive.remove(ids);
298     if (gone.queued) return archive.queuedText(gone);
299     return [`Removed ${gone.length} idea${gone.length === 1 ? '' : 's'}.`, ...gone.map(renderIdea)].join('\n\n');
300   },
301 };
302 
303 // ---------------------------------------------------------------------------------------------
304 // JSON-RPC plumbing
305 
306 function send(msg) {
307   process.stdout.write(JSON.stringify(msg) + '\n');
308 }
309 
310 function result(id, value) {
311   return { jsonrpc: '2.0', id, result: value };
312 }
313 
314 function error(id, code, message) {
315   return { jsonrpc: '2.0', id, error: { code, message } };
316 }
317 
318 async function handleRequest(msg, { prompts }) {
319   const { id, method, params = {} } = msg;
320   switch (method) {
321     case 'initialize': {
322       const asked = params.protocolVersion;
323       const protocolVersion = PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0];
324       const capabilities = { tools: { listChanged: false } };
325       if (prompts) capabilities.prompts = { listChanged: false };
326       return result(id, { protocolVersion, capabilities, serverInfo: { name: 'ideamine', version: VERSION }, instructions: INSTRUCTIONS });
327     }
328     case 'ping':
329       return result(id, {});
330     case 'tools/list':
331       return result(id, { tools: TOOLS });
332     case 'tools/call': {
333       const handler = handlers[params.name];
334       if (!handler) return error(id, -32602, `unknown tool: ${params.name}`);
335       try {
336         const text = await handler(params.arguments || {});
337         return result(id, { content: [{ type: 'text', text }] });
338       } catch (e) {
339         return result(id, { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
340       }
341     }
342     case 'prompts/list':
343       return result(id, { prompts: prompts ? PROMPTS : [] });
344     case 'prompts/get': {
345       const p = prompts && PROMPTS.find((x) => x.name === params.name);
346       if (!p) return error(id, -32602, `unknown prompt: ${params.name}`);
347       const args = Object.values(params.arguments || {}).filter(Boolean).join(' ');
348       return result(id, {
349         description: p.description,
350         messages: [{ role: 'user', content: { type: 'text', text: skillBody(p.name, args) } }],
351       });
352     }
353     case 'resources/list':
354       return result(id, { resources: [] });
355     case 'resources/templates/list':
356       return result(id, { resourceTemplates: [] });
357     case 'logging/setLevel':
358       return result(id, {});
359     default:
360       return error(id, -32601, `method not found: ${method}`);
361   }
362 }
363 
364 async function handleMessage(msg, opts) {
365   if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return error(null, -32600, 'invalid request');
366   if (typeof msg.method !== 'string') return null; // a response to a request we never send
367   if (msg.id === undefined) return null; // notification (initialized, cancelled, ...)
368   try {
369     return await handleRequest(msg, opts);
370   } catch (e) {
371     return error(msg.id, -32603, e.message);
372   }
373 }
374 
375 async function handleLine(line, opts) {
376   let msg;
377   try {
378     msg = JSON.parse(line);
379   } catch {
380     return error(null, -32700, 'parse error');
381   }
382   if (!Array.isArray(msg)) return handleMessage(msg, opts);
383   const replies = (await Promise.all(msg.map((m) => handleMessage(m, opts)))).filter(Boolean);
384   return replies.length ? replies : null;
385 }
386 
387 /** Serve MCP on stdin/stdout. `prompts: false` when running as a plugin (skills cover slash commands). */
388 export function serve({ prompts = true } = {}) {
389   const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
390   const inFlight = new Set();
391   // Requests run concurrently (a headless triage must not stall a ping); replies carry their ids.
392   rl.on('line', (line) => {
393     if (!line.trim()) return;
394     const job = handleLine(line, { prompts }).then((reply) => reply && send(reply));
395     inFlight.add(job);
396     job.finally(() => inFlight.delete(job));
397   });
398   rl.on('close', () => Promise.allSettled([...inFlight]).then(() => process.exit(0)));
399 }