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/hook.js (9380 bytes)

1 // UserPromptSubmit hook: answers /idea, /ideas, and the local /ideas-* commands (ls, cat, rm, done,
2 // reopen, find, groups, watch, web, sync) and blocks the prompt, so the model is never called. That
3 // makes capture free, instant, and possible even when the session is out of usage. Every other
4 // prompt passes through untouched, including /ideas-go, /ideas-all, /ideas-sort, and questions,
5 // which their skills answer. With the prompt log on, each prompt goes into the prompt outbox. After
6 // each prompt, the hook lets the watcher, the dashboard, and the sync catch up.
7 
8 import * as archive from './archive.js';
9 import * as embed from './embed.js';
10 import * as publish from './publish.js';
11 import { renderAdded, renderBoard, renderFound, renderGroups, renderIdea, stamp } from './render.js';
12 import { FILTERS, findIdea, lane, listIdeas, load } from './store.js';
13 import * as sync from './sync.js';
14 import { clip, deriveTitle, splitIdeas } from './text.js';
15 import * as watch from './watch.js';
16 
17 // `/idea ...`, `/ideas ...`, `/ideas-<verb> ...`, and the plugin-qualified `/ideamine:...` forms.
18 // Each verb is a separate skill with a dash, so that the slash menu shows it.
19 const COMMAND = /^\s*\/(?:ideamine:)?(idea|ideas(?:-[a-z]+)?)(?=\s|$)([\s\S]*)$/i;
20 const STATUS_COMMANDS = { 'ideas-done': 'done', 'ideas-reopen': 'reopen' };
21 const ID = /^#?\d+$/;
22 
23 const USAGE = `Usage: /idea <text>                add an idea (a bulleted list adds one idea per bullet)
24        /ideas [question]            show the queue, or ask about your ideas
25        /ideas-ls [lane|-a] [here]   list. Lanes: inbox do maybe skip doing done dropped
26        /ideas-cat N...              show ideas in full
27        /ideas-rm N...               delete ideas for good
28        /ideas-done N [note] · /ideas-reopen N
29        /ideas-find <words>          search by meaning (all lanes)
30        /ideas-groups [lane|-a]      ideas grouped by meaning
31        /ideas-web [off]             the dashboard with a button for each command, on this PC
32        /ideas-sync [url|off]        share the archive of every machine through an ideamine server
33 These call the model:
34        /ideas-go [N]                build the next idea, or #N, on its model. New ideas are triaged first.
35        /ideas-pipeline [N]          the same, through the agent pipeline that ships with ideamine, for big ideas
36        /ideas-all                   do every idea that fits this chat. The others stay in the queue.
37        /ideas-sort                  triage the inbox now and show the queue
38        /ideas-watch [off]           Haiku triages new ideas and pairs them with projects, in the background`;
39 
40 const noIdea = (ids) => `No idea ${ids.map((id) => `#${String(id).replace(/^#/, '')}`).join(', ')}.`;
41 
42 // A read that the server could not answer shows the copy on this machine, and says so.
43 function offline(text) {
44   const reason = archive.offlineReason();
45   if (!reason) return text;
46   const pulled = sync.readState().pulled;
47   return `(The ideamine server does not answer. This is the copy from ${pulled ? stamp(pulled) : 'no sync yet'}.)\n${text}`;
48 }
49 
50 function readStdin() {
51   return new Promise((resolve) => {
52     if (process.stdin.isTTY) return resolve('');
53     let data = '';
54     process.stdin.setEncoding('utf8');
55     process.stdin.on('data', (chunk) => (data += chunk));
56     process.stdin.on('end', () => resolve(data));
57     process.stdin.on('error', () => resolve(data));
58   });
59 }
60 
61 // A search by meaning must not hold the prompt for long when the embedding server is away.
62 const SEARCH_TIMEOUT_MS = 4000;
63 
64 /** Handle one prompt. Resolves to the text to show the user, or null to let the prompt through. */
65 export async function handlePrompt(prompt, { cwd = process.cwd(), session = null } = {}) {
66   const m = COMMAND.exec(prompt || '');
67   if (!m) return null;
68   const command = m[1].toLowerCase();
69   const arg = m[2].trim();
70 
71   if (command === 'idea') {
72     if (!arg) return USAGE;
73     const texts = splitIdeas(arg);
74     const results = await archive.add(texts, { source: 'hook', project: cwd, session });
75     if (results.queued) {
76       const what = texts.length === 1 ? clip(deriveTitle(texts[0]), 70) : `${texts.length} ideas`;
77       return `💡 Saved here: ${what}. The ideamine server does not answer, so it gets the idea later (${sync.waiting()} waiting).`;
78     }
79     return renderAdded(results, load()); // the copy here is current: the server answered
80   }
81 
82   if (command === 'ideas-sync') {
83     if (/^off$/i.test(arg)) sync.setServer('');
84     else if (arg) {
85       let backup;
86       try {
87         backup = sync.setServer(arg);
88       } catch (e) {
89         return `ideamine sync: ${e.message}. Usage: /ideas-sync http://10.66.0.1/ · /ideas-sync off`;
90       }
91       await archive.fresh({ timeoutMs: 5000 });
92       if (backup) return `${sync.status()}\nThe archive that was here is in ${backup}.`;
93     }
94     return sync.status();
95   }
96 
97   if (command === 'ideas-watch') {
98     if (!arg) watch.turnOn();
99     else if (/^off$/i.test(arg)) watch.turnOff();
100     else return null;
101     return watch.status(); // runHook starts the first pass after this
102   }
103 
104   if (command === 'ideas-web') {
105     // Loaded here, not at the top: the hook runs for each prompt.
106     const serve = await import('./serve.js');
107     if (!arg) return serve.ensureRunning();
108     if (/^off$/i.test(arg)) return serve.stopRunning();
109     return null;
110   }
111 
112   const words = arg.split(/\s+/).filter(Boolean);
113   const ids = words.length > 0 && words.every((w) => ID.test(w)) ? words : null;
114   const db = await archive.fresh();
115 
116   if (command === 'ideas' && !words.length) return offline(renderBoard(db, { cwd, hints: true }));
117 
118   if (command === 'ideas-find') {
119     if (!arg) return USAGE;
120     return offline(renderFound(await embed.find(db, arg, { timeoutMs: SEARCH_TIMEOUT_MS }), arg, { cwd }));
121   }
122 
123   if (command === 'ideas-groups') {
124     const lower = words.map((w) => (w === '-a' ? 'all' : w.toLowerCase()));
125     if (lower.length <= 1 && lower.every((w) => FILTERS.includes(w))) {
126       const filter = lower[0] || 'open';
127       const ideas = listIdeas(db, { filter });
128       try {
129         const groups = await embed.groupIdeas(ideas, { timeoutMs: SEARCH_TIMEOUT_MS });
130         return offline(renderGroups(groups, ideas, { cwd, scope: filter }));
131       } catch (e) {
132         if (!(e instanceof embed.EmbedError)) throw e;
133         return `Groups need the embedding server, which does not answer: ${e.message}`;
134       }
135     }
136   }
137 
138   if (command === 'ideas-ls') {
139     const lower = words.map((w) => (w === '-a' ? 'all' : w.toLowerCase()));
140     const filters = lower.filter((w) => FILTERS.includes(w));
141     const here = lower.includes('here');
142     if (filters.length + (here ? 1 : 0) === lower.length && filters.length <= 1) {
143       return offline(renderBoard(db, { filter: filters[0] || 'open', project: here ? cwd : null, cwd, hints: true }));
144     }
145   }
146 
147   if ((command === 'ideas-cat' || command === 'ideas-rm') && ids) {
148     const missing = ids.filter((id) => !findIdea(db, id));
149     if (missing.length) return noIdea(missing);
150     if (command === 'ideas-cat') return offline(ids.map((id) => renderIdea(findIdea(db, id))).join('\n\n'));
151     const gone = await archive.remove(ids);
152     if (gone.queued) return archive.queuedText(gone);
153     return gone.map((i) => `✗ Removed #${i.id} · ${clip(i.title, 60)}`).join('\n');
154   }
155 
156   const status = STATUS_COMMANDS[command];
157   if (status && words[0] && ID.test(words[0])) {
158     if (!findIdea(db, words[0])) return noIdea([words[0]]);
159     const note = words.slice(1).join(' ');
160     const idea = await archive.update(words[0], { status, note: note || undefined });
161     if (idea.queued) return archive.queuedText(idea);
162     return `✓ #${idea.id} ${clip(idea.title, 60)} → ${lane(idea)}${note ? ' (note added)' : ''}`;
163   }
164 
165   return null; // a question, /ideas-go, -all, -sort, or other words: the skill answers with the model
166 }
167 
168 export async function runHook() {
169   const raw = await readStdin();
170   let input;
171   try {
172     input = JSON.parse(raw);
173   } catch {
174     return; // not a hook payload; stay out of the way
175   }
176   const prompt = typeof input?.prompt === 'string' ? input.prompt : '';
177   const cwd = input.cwd || process.cwd();
178   const session = input.session_id || null;
179   try {
180     if (sync.logsPrompts()) sync.logPrompt({ prompt, session, cwd });
181   } catch (e) {
182     // The prompt log must never stop a prompt.
183     process.stderr.write(`ideamine: the prompt log failed: ${e.message}\n`);
184   }
185   let message = null;
186   try {
187     message = await handlePrompt(prompt, { cwd, session });
188   } catch (e) {
189     // Never swallow the user's text: let the prompt through so the /idea skill can save it via MCP.
190     process.stderr.write(`ideamine: ${e.message}\n`);
191   }
192   if (message != null) {
193     // Blocking ends the turn before any API request. suppressOriginalPrompt keeps Claude Code from
194     // echoing the command back under our message.
195     process.stdout.write(JSON.stringify({
196       decision: 'block',
197       reason: message,
198       hookSpecificOutput: { hookEventName: 'UserPromptSubmit', suppressOriginalPrompt: true },
199     }));
200   }
201   // Each prompt lets the watcher, the dashboard, and the sync catch up, so they keep going.
202   for (const kick of [watch.kick, publish.kick, sync.kick]) {
203     try {
204       kick();
205     } catch {
206       // The watcher, the dashboard, and the sync must never stop a prompt.
207     }
208   }
209 }