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