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 }