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 }