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


bin/ideamine.js (14921 bytes)

1 #!/usr/bin/env node
2 // ideamine CLI. Also the entry point the plugin uses for its MCP server (`mcp`) and hook (`hook`).
3 
4 import { spawnSync } from 'node:child_process';
5 import fs from 'node:fs';
6 
7 const HELP = `ideamine: an idea inbox for Claude Code
8 
9   ideamine add <idea...>          save an idea ("-" reads stdin; a bulleted list = one idea per bullet)
10   ideamine ls [lane|-a] [here]    the queue. lanes: open inbox do maybe skip doing done dropped all
11   ideamine cat <id...>            ideas in full
12   ideamine rm <id...>             delete ideas for good
13   ideamine done|drop|start|reopen <id> [note...]
14   ideamine note <id> <text...>    append a note
15   ideamine model <id> <haiku|sonnet|opus|fable>   override the recommended model
16   ideamine next [--here]          the idea to build next
17   ideamine go [id] [--print]      open Claude Code on the idea's recommended model, in its project
18   ideamine go [id] --pipeline     the same, with the agent pipeline (/pipeline) as the request
19   ideamine sort [--model sonnet] [--limit 20] [--dry-run]
20                                   triage the inbox with one headless \`claude -p\` call
21   ideamine watch [off]            the watcher: Haiku triages new ideas and pairs them with projects
22   ideamine watch-pass             one pass of the watcher, now, in the foreground
23   ideamine find <words...>        search by meaning (nomic-embed-text), in every lane
24   ideamine groups [lane|-a]       ideas grouped by meaning
25   ideamine embed                  embed new ideas, and show how the vectors and thresholds fit
26   ideamine publish [url|off] [--dir <folder>]
27                                   upload the dashboard (index.html, data.json) now; a url also turns
28                                   on the upload after each change; --dir writes the files to a folder
29   ideamine serve [--port 4332]    the dashboard with a button for each command, at 127.0.0.1
30   ideamine sync [url|off]         share the archive of every machine through an ideamine server
31   ideamine prompts import         put the prompts of older Claude Code chats into the prompt log
32   ideamine usage [days]           tokens, models, and price for each project (0 days: all time)
33   ideamine config [key [value]]   show or change a setting (an empty value restores the default)
34   ideamine export [file.md]       Markdown export of the whole archive
35   ideamine path                   where the archive lives (override with IDEAMINE_HOME)
36   ideamine mcp                    run the MCP server on stdio
37   ideamine hook                   run the UserPromptSubmit hook (reads the hook JSON on stdin)`;
38 
39 function parseArgs(argv) {
40   const flags = {};
41   const words = [];
42   for (let i = 0; i < argv.length; i++) {
43     const a = argv[i];
44     if (a.startsWith('--')) {
45       const [key, inline] = a.slice(2).split('=', 2);
46       if (inline !== undefined) flags[key] = inline;
47       else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && ['model', 'limit', 'budget', 'query', 'dir', 'port'].includes(key)) flags[key] = argv[++i];
48       else flags[key] = true;
49     } else words.push(a);
50   }
51   return { flags, words };
52 }
53 
54 async function readStdin() {
55   let data = '';
56   for await (const chunk of process.stdin) data += chunk;
57   return data;
58 }
59 
60 function fail(message) {
61   process.stderr.write(`ideamine: ${message}\n`);
62   // A window that the dashboard opened closes when the command ends. Wait for a key, so that the
63   // user can read the error.
64   if (process.env.IDEAMINE_WINDOW && process.stdin.isTTY) {
65     spawnSync(process.env.ComSpec || 'cmd.exe', ['/d', '/c', 'pause'], { stdio: 'inherit' });
66   }
67   process.exit(1);
68 }
69 
70 async function main() {
71   const [command = 'help', ...rest] = process.argv.slice(2);
72 
73   // The two machine entry points load only what they need.
74   if (command === 'mcp') return (await import('../src/mcp.js')).serve({ prompts: !rest.includes('--plugin') });
75   if (command === 'hook') return (await import('../src/hook.js')).runHook();
76 
77   const store = await import('../src/store.js');
78   const archive = await import('../src/archive.js');
79   const render = await import('../src/render.js');
80   const { splitIdeas, clip } = await import('../src/text.js');
81   const { flags, words } = parseArgs(rest);
82   const cwd = process.cwd();
83   const needId = () => words[0] || fail(`usage: ideamine ${command} <id>`);
84   // A change that waits for the ideamine server: say so, and go on.
85   const queued = (out) => out.queued && (console.log(archive.queuedText(out)), true);
86 
87   switch (command) {
88     case 'add': {
89       const text = words.length === 1 && words[0] === '-' ? await readStdin() : words.join(' ');
90       const results = await archive.add(splitIdeas(text), { source: 'cli', project: flags['no-project'] ? null : cwd });
91       if (!queued(results)) console.log(render.renderAdded(results, store.load()));
92       break;
93     }
94     case 'ls': {
95       const lower = words.map((w) => (w === '-a' ? 'all' : w.toLowerCase()));
96       const filter = lower.find((w) => store.FILTERS.includes(w)) || 'open';
97       const here = lower.includes('here') || flags.here;
98       console.log(render.renderBoard(await archive.fresh(), { filter, project: here ? cwd : null, query: flags.query || '', cwd }));
99       break;
100     }
101     case 'cat': {
102       needId();
103       const db = await archive.fresh();
104       const ideas = words.map((id) => store.findIdea(db, id) || fail(`no idea #${id.replace(/^#/, '')}`));
105       console.log(ideas.map(render.renderIdea).join('\n\n'));
106       break;
107     }
108     case 'rm': {
109       needId();
110       const gone = await archive.remove(words);
111       if (!queued(gone)) for (const idea of gone) console.log(`Removed #${idea.id} ${clip(idea.title, 60)}`);
112       break;
113     }
114     case 'done':
115     case 'drop':
116     case 'start':
117     case 'doing':
118     case 'reopen': {
119       const status = { drop: 'dropped', start: 'doing' }[command] || command;
120       const note = words.slice(1).join(' ');
121       const idea = await archive.update(needId(), { status, note: note || undefined });
122       if (!queued(idea)) console.log(`#${idea.id} ${clip(idea.title, 60)} → ${store.lane(idea)}`);
123       break;
124     }
125     case 'note': {
126       const note = words.slice(1).join(' ') || fail('usage: ideamine note <id> <text>');
127       const idea = await archive.update(needId(), { note });
128       if (!queued(idea)) console.log(`#${idea.id}: note added`);
129       break;
130     }
131     case 'model': {
132       const idea = await archive.update(needId(), { model: words[1] || fail('usage: ideamine model <id> <haiku|sonnet|opus|fable>') });
133       if (!queued(idea)) console.log(`#${idea.id} will be built with ${idea.triage.model}`);
134       break;
135     }
136     case 'next': {
137       const idea = store.pickNext(await archive.fresh(), { project: cwd, only: !!flags.here });
138       if (!idea) {
139         console.log('Nothing is ready to build. Triage the inbox first: ideamine sort');
140         break;
141       }
142       console.log(render.renderIdea(idea));
143       break;
144     }
145     case 'go': {
146       const { goPlan, launchSession, pipelinePrompt } = await import('../src/claude.js');
147       const db = await archive.fresh();
148       const idea = words[0] ? store.findIdea(db, words[0]) : store.pickNext(db, { project: cwd });
149       if (!idea) fail(words[0] ? `no idea #${words[0]}` : 'nothing is ready to build; run: ideamine sort');
150       const plan = goPlan(idea, cwd);
151       const { model, dir } = plan;
152       const prompt = flags.pipeline ? pipelinePrompt(idea, { dir }) : plan.prompt;
153       if (flags.print) {
154         console.log(`directory: ${dir}\nmodel:     ${model}\n\n${prompt}`);
155         break;
156       }
157       queued(await archive.update(idea.id, { status: 'doing', note: flags.pipeline ? 'pipeline: started' : undefined }));
158       console.log(`Opening Claude Code (${model}) in ${dir} for #${idea.id}${flags.pipeline ? ', through the agent pipeline' : ''}…`);
159       process.exitCode = launchSession({ model, prompt, cwd: dir });
160       break;
161     }
162     case 'sort': {
163       const { headlessTriage } = await import('../src/claude.js');
164       const out = await headlessTriage({
165         model: typeof flags.model === 'string' ? flags.model : undefined,
166         limit: Number(flags.limit) || 20,
167         budget: Number(flags.budget) || 1,
168         dryRun: !!flags['dry-run'],
169       });
170       if (out.message) {
171         console.log(out.message);
172         break;
173       }
174       const ok = out.results.filter((r) => !r.error);
175       const spend = out.tokens ? ` (${out.tokens.input} in / ${out.tokens.output} out tokens, ~$${Number(out.cost || 0).toFixed(4)})` : '';
176       console.log(`Triaged ${ok.length}${spend}:`);
177       for (const r of ok) console.log(`  #${r.id} ${r.verdict.padEnd(5)} ${r.verdict === 'skip' ? '' : `${r.model.padEnd(6)} ${r.size.toUpperCase().padEnd(2)} `}${clip(r.title, 60)}`);
178       for (const r of out.results.filter((x) => x.error)) console.log(`  #${r.id} not saved: ${r.error}`);
179       break;
180     }
181     case 'watch': {
182       const watch = await import('../src/watch.js');
183       if (words[0] === 'off') watch.turnOff();
184       else {
185         watch.turnOn();
186         watch.kick();
187       }
188       console.log(watch.status());
189       break;
190     }
191     case 'watch-pass': {
192       const watch = await import('../src/watch.js');
193       if ((await watch.pass()) === 'error') process.exitCode = 1;
194       break;
195     }
196     case 'find': {
197       const embed = await import('../src/embed.js');
198       const query = words.join(' ') || fail('usage: ideamine find <words...>');
199       console.log(render.renderFound(await embed.find(await archive.fresh(), query), query, { cwd }));
200       break;
201     }
202     case 'groups': {
203       const embed = await import('../src/embed.js');
204       const lower = words.map((w) => (w === '-a' ? 'all' : w.toLowerCase()));
205       const filter = lower.find((w) => store.FILTERS.includes(w)) || 'open';
206       const ideas = store.listIdeas(await archive.fresh(), { filter });
207       console.log(render.renderGroups(await embed.groupIdeas(ideas), ideas, { cwd, scope: filter }));
208       break;
209     }
210     case 'embed': {
211       const embed = await import('../src/embed.js');
212       console.log(await embed.status(store.load()));
213       break;
214     }
215     case 'publish': {
216       const publish = await import('../src/publish.js');
217       if (flags.background) {
218         process.exitCode = (await publish.backgroundPublish()) === 'error' ? 1 : 0;
219         break;
220       }
221       const config = await import('../src/config.js');
222       if (words[0] === 'off') {
223         config.set('publish_url', '');
224         console.log(publish.status());
225         break;
226       }
227       if (words[0]) config.set('publish_url', words[0]);
228       const out = await publish.publish({ dir: typeof flags.dir === 'string' ? flags.dir : null });
229       console.log(`Published ${out.ideas} ideas and ${out.groups} groups to ${out.where}`);
230       if (out.note) console.log(out.note);
231       break;
232     }
233     case 'serve': {
234       const serve = await import('../src/serve.js');
235       const { server, url } = await serve.start({
236         ...(flags.port ? { port: Number(flags.port) } : {}),
237         log: (text) => console.log(serve.logLine(text)),
238       });
239       server.on('close', () => process.exit(0)); // /ideas-web off
240       console.log(serve.logLine(`the dashboard with buttons runs at ${url} (Ctrl+C stops it)`));
241       // Only the server role holds the archive, so only it gives ideas to coop agents.
242       if (!(await import('../src/sync.js')).enabled()) (await import('../src/coop.js')).start({ log: (text) => console.log(serve.logLine(text)) });
243       break;
244     }
245     case 'sync': {
246       const sync = await import('../src/sync.js');
247       if (flags.background) {
248         process.exitCode = (await sync.backgroundSync()) === 'error' ? 1 : 0;
249         break;
250       }
251       if (words[0]) {
252         const backup = sync.setServer(words[0] === 'off' ? '' : words[0]);
253         if (backup) console.log(`The archive that was here is in ${backup}.`);
254       }
255       if (sync.enabled()) {
256         try {
257           await sync.pull({ timeoutMs: 30000, prompts: true });
258         } catch (e) {
259           if (!(e instanceof sync.SyncError)) throw e;
260           process.exitCode = 1;
261         }
262       }
263       console.log(sync.status());
264       break;
265     }
266     case 'prompts': {
267       const sync = await import('../src/sync.js');
268       if (words[0] !== 'import') fail('usage: ideamine prompts import');
269       if (!sync.enabled()) fail('the prompt log goes to an ideamine server. Turn sync on first: ideamine sync <url>');
270       console.log(`Put ${sync.importPrompts()} prompts from ${sync.transcriptsDir()} into the prompt outbox. The next sync sends them.`);
271       break;
272     }
273     case 'usage': {
274       const usage = await import('../src/usage.js');
275       const days = Number(words[0]) || 0;
276       const { rows, files } = usage.scan();
277       const projects = usage.byProject(usage.since(rows, days));
278       const money = (n) => `$${n.toFixed(2)}`;
279       const tokens = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n));
280       const total = projects.reduce((n, p) => n + p.cost, 0);
281       console.log(`ideamine usage: ${money(total)} in ${days ? `the last ${days} days` : 'all time'}, from ${files} transcripts. The price is the price of the API.`);
282       for (const p of projects.slice(0, Number(process.env.IDEAMINE_USAGE_LIMIT) || 20)) {
283         console.log(`\n  ${money(p.cost).padStart(9)}  ${p.project}`);
284         console.log(`             in ${tokens(p.input)} · out ${tokens(p.output)} · cache write ${tokens(p.write5m + p.write1h)} · cache read ${tokens(p.read)} · ${p.messages} answers`);
285         for (const m of Object.values(p.models).sort((a, b) => b.cost - a.cost)) {
286           console.log(`             ${money(m.cost).padStart(9)}  ${m.model}${usage.priced(m.model) ? '' : ' (no price for this model)'}`);
287         }
288       }
289       break;
290     }
291     case 'config': {
292       const config = await import('../src/config.js');
293       if (words.length >= 1 && rest.length >= 2) config.set(words[0], words.slice(1).join(' '));
294       else if (words.length === 1 && rest.length === 1) {
295         console.log(config.get(words[0]));
296         break;
297       }
298       console.log(config.describe());
299       break;
300     }
301     case 'export': {
302       const md = render.renderMarkdown(await archive.fresh());
303       if (words[0]) {
304         fs.writeFileSync(words[0], md);
305         console.log(`Wrote ${words[0]}`);
306       } else process.stdout.write(md);
307       break;
308     }
309     case 'path':
310       console.log(store.dbPath());
311       break;
312     case 'version':
313     case '--version':
314     case '-v':
315       console.log(JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
316       break;
317     case 'help':
318     case '--help':
319     case '-h':
320       console.log(HELP);
321       break;
322     default:
323       fail(`unknown command "${command}". Try: ideamine help`);
324   }
325 }
326 
327 main().catch((e) => fail(e.message));