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/claude.js (9531 bytes)

1 // Running the Claude Code CLI from ideamine: headless triage and questions, and launching a session
2 // to build an idea.
3 
4 import { spawn, spawnSync } from 'node:child_process';
5 import fs from 'node:fs';
6 import { fresh, triage } from './archive.js';
7 import { knownProjects } from './projects.js';
8 import { renderMarkdown } from './render.js';
9 import { BATCH_SCHEMA, pendingIdeas, triagePrompt } from './rubric.js';
10 import { counts, findIdea, normalizeModel } from './store.js';
11 
12 /** Claude Code executable: explicit override, else the one running us (desktop app), else PATH. */
13 export function claudeBin() {
14   return process.env.IDEAMINE_CLAUDE_BIN || process.env.CLAUDE_CODE_EXECPATH || 'claude';
15 }
16 
17 function command(args) {
18   const bin = claudeBin();
19   // A .js file is run with node, which keeps a stand-in CLI portable (used by the tests).
20   return /\.[cm]?js$/i.test(bin) ? [process.execPath, [bin, ...args]] : [bin, args];
21 }
22 
23 /**
24  * Environment for a separate, independent Claude Code process: drop the variables that tie a
25  * child to the session that started us (nesting guard, host messaging, the parent's effort), and
26  * IDEAMINE_WINDOW, so that an ideamine command in that session never waits for a key.
27  */
28 function childEnv() {
29   const env = { ...process.env };
30   for (const key of Object.keys(env)) {
31     if (
32       key === 'CLAUDECODE' ||
33       key === 'IDEAMINE_WINDOW' ||
34       key === 'CLAUDE_PID' ||
35       key === 'CLAUDE_EFFORT' ||
36       key === 'AI_AGENT' ||
37       /^CLAUDE_CODE_(ENTRYPOINT|CHILD_SESSION|HOST_SESSION_ID|SESSION_ID|SESSION_ATTENDED|MESSAGING_.*|SDK_.*|EXECPATH)$/.test(key)
38     ) {
39       delete env[key];
40     }
41   }
42   return env;
43 }
44 
45 let claudeFound = null;
46 
47 /** True when the Claude Code CLI starts on this machine. The answer is kept for the life of the process. */
48 export function hasClaude() {
49   if (claudeFound === null) {
50     const [bin, argv] = command(['--version']);
51     claudeFound = spawnSync(bin, argv, { stdio: 'ignore', windowsHide: true, timeout: 20000, env: childEnv() }).status === 0;
52   }
53   return claudeFound;
54 }
55 
56 function parseLooseJson(text) {
57   const s = String(text || '').replace(/^\s*```(?:json)?\s*|\s*```\s*$/g, '');
58   const start = s.indexOf('{');
59   const end = s.lastIndexOf('}');
60   if (start < 0 || end < start) return null;
61   try {
62     return JSON.parse(s.slice(start, end + 1));
63   } catch {
64     return null;
65   }
66 }
67 
68 function run(args, input, timeoutMs, env = {}) {
69   const [bin, argv] = command(args);
70   return new Promise((resolve, reject) => {
71     const child = spawn(bin, argv, { env: { ...childEnv(), ...env }, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
72     let stdout = '';
73     let stderr = '';
74     const timer = setTimeout(() => child.kill(), timeoutMs);
75     child.stdout.on('data', (d) => (stdout += d));
76     child.stderr.on('data', (d) => (stderr += d));
77     child.on('error', (e) => {
78       clearTimeout(timer);
79       reject(e.code === 'ENOENT' ? new Error(`Claude Code CLI not found ("${bin}"). Install it or set IDEAMINE_CLAUDE_BIN.`) : e);
80     });
81     child.on('close', (code) => {
82       clearTimeout(timer);
83       resolve({ code, stdout, stderr });
84     });
85     child.stdin.end(input);
86   });
87 }
88 
89 /**
90  * Arguments for a one-shot `claude -p` call: no tools, no MCP servers, no settings, JSON output.
91  * Skipping settings drops hooks, plugins, and skill listings: ~4k fewer input tokens per call.
92  * Set IDEAMINE_SETTING_SOURCES=user if your login depends on settings.json (apiKeyHelper, env).
93  */
94 function headlessArgs({ model, budget, system, extra = [] }) {
95   const args = [
96     '-p',
97     '--model', model,
98     '--output-format', 'json',
99     ...extra,
100     '--tools', '',
101     '--strict-mcp-config',
102     '--setting-sources', process.env.IDEAMINE_SETTING_SOURCES ?? '',
103     '--no-session-persistence',
104     '--max-budget-usd', String(budget),
105     '--system-prompt', system,
106   ];
107   // Haiku takes no effort setting.
108   if (model !== 'haiku') args.push('--effort', 'low');
109   return args;
110 }
111 
112 /** Run a headless call. Returns the JSON result of the CLI, or throws with the reason. */
113 async function runHeadless(args, prompt, { timeoutMs, env = {} }) {
114   const res = await run(args, prompt, timeoutMs, env);
115   let out;
116   try {
117     out = JSON.parse(res.stdout);
118   } catch {
119     const detail = (res.stderr || res.stdout || '').trim().split(/\r?\n/).slice(-5).join('\n');
120     throw new Error(`claude exited with code ${res.code}: ${detail || 'no output'}`);
121   }
122   if (out.is_error) throw new Error(`claude: ${out.result || out.subtype || 'error'}`);
123   return out;
124 }
125 
126 /** Input tokens (with the cache) and output tokens of a headless call. */
127 function tokensOf(out) {
128   const u = out.usage || {};
129   const input = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0);
130   return { input, output: u.output_tokens || 0 };
131 }
132 
133 /**
134  * Triage the inbox with a one-shot `claude -p` call: no tools, no MCP servers, no settings, a
135  * two-line system prompt, and JSON-schema output. Runs on the user's normal Claude Code login and
136  * costs about 400 tokens per idea, whatever model the calling session uses. `ids` triages those
137  * ideas instead of the inbox. The triage also pairs each idea with a project folder of this machine.
138  */
139 export async function headlessTriage({ model = process.env.IDEAMINE_TRIAGE_MODEL || 'sonnet', ids = null, limit = 20, dryRun = false, budget = 1 } = {}) {
140   const alias = normalizeModel(model) || model;
141   const db = await fresh();
142   const pending = pendingIdeas(db, { ids, limit });
143   if (!pending.length) return { message: 'Nothing to triage: the inbox is empty.' };
144 
145   const projects = knownProjects(db);
146   const prompt = `${triagePrompt(db, pending, projects)}\n\nReturn one verdict for every idea listed above.`;
147   const args = headlessArgs({
148     model: alias,
149     budget,
150     system: 'You triage a developer\'s backlog of ideas. Follow the rubric exactly and answer only with the requested JSON.',
151     extra: ['--json-schema', JSON.stringify(BATCH_SCHEMA)],
152   });
153   // Haiku's thinking was about 70% of its output tokens and did not change the verdicts, so it gets
154   // no thinking.
155   const env = alias === 'haiku' ? { MAX_THINKING_TOKENS: '0' } : {};
156   if (dryRun) {
157     const shown = args.map((a) => (/[\s"{]/.test(a) || !a ? JSON.stringify(a) : a)).join(' ');
158     const vars = Object.entries(env).map(([k, v]) => `${k}=${v} `).join('');
159     return { message: `${vars}${claudeBin()} ${shown}\n\n${prompt}` };
160   }
161 
162   const out = await runHeadless(args, prompt, { timeoutMs: 5 * 60 * 1000, env });
163   const data = out.structured_output ?? parseLooseJson(out.result);
164   if (!Array.isArray(data?.verdicts)) throw new Error('claude answered without verdicts');
165   const results = await triage(data.verdicts, { by: `${alias} (headless)`, projects });
166   if (results.queued) return { message: `The verdicts wait for the ideamine server: ${results.reason}` };
167   return { results, model: alias, cost: out.total_cost_usd, tokens: tokensOf(out) };
168 }
169 
170 /**
171  * Answer a question about the archive, like `/ideas <question>`, with one headless call. The
172  * prompt is the Markdown export: every idea with its lane, verdict, model, and brief.
173  */
174 export async function askAboutIdeas(question, { model = 'sonnet', budget = 0.5 } = {}) {
175   const alias = normalizeModel(model) || model;
176   const args = headlessArgs({
177     model: alias,
178     budget,
179     system: 'You answer questions about a developer\'s backlog of ideas. Answer in a few short lines of plain text. Name each idea by its number, like #12.',
180   });
181   const out = await runHeadless(args, `${renderMarkdown(await fresh())}\nQuestion: ${question}`, { timeoutMs: 2 * 60 * 1000 });
182   return { answer: String(out.result || '').trim(), model: alias, cost: out.total_cost_usd, tokens: tokensOf(out) };
183 }
184 
185 /**
186  * The triage that /ideas-go needs before it picks: the idea `id` when it has no verdict, else the
187  * whole inbox. Returns the headlessTriage result, or null when every candidate has a verdict already.
188  */
189 export async function triageFirst({ id = null, model } = {}) {
190   const db = await fresh();
191   if (id != null) {
192     const idea = findIdea(db, id);
193     return idea && !idea.triage ? headlessTriage({ model, ids: [idea.id] }) : null;
194   }
195   return counts(db).inbox ? headlessTriage({ model }) : null;
196 }
197 
198 /** The opening prompt for a fresh session that builds one idea. */
199 export function buildPrompt(idea) {
200   const t = idea.triage;
201   const lines = [`Build idea #${idea.id} from my ideamine archive: ${idea.title}`, ''];
202   if (t?.brief) lines.push(t.brief, '');
203   lines.push('My original note:', idea.text, '');
204   lines.push(
205     `When you finish, record the outcome with the ideamine idea_update tool (id ${idea.id}, status "done", a one-line note), ` +
206       `or run: ideamine done ${idea.id} "<one-line outcome>"`,
207   );
208   return lines.join('\n');
209 }
210 
211 /**
212  * How `ideamine go` builds an idea: on its recommended model, in its project folder when that
213  * folder still exists, else in `fallback`.
214  */
215 export function goPlan(idea, fallback) {
216   return {
217     model: idea.triage?.model || 'sonnet',
218     dir: idea.project && fs.existsSync(idea.project) ? idea.project : fallback,
219     prompt: buildPrompt(idea),
220   };
221 }
222 
223 /** Start an interactive Claude Code session on the recommended model. */
224 export function launchSession({ model, prompt, cwd }) {
225   const [bin, argv] = command(['--model', model, prompt]);
226   const res = spawnSync(bin, argv, { cwd, stdio: 'inherit', env: childEnv() });
227   if (res.error) throw res.error;
228   return res.status ?? 0;
229 }