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/serve.js (22751 bytes)

1 // The dashboard with buttons. `ideamine serve` runs a web server that serves the page of `ideamine
2 // publish`, a live data.json, and an API for the slash commands: add, delete, done, reopen, start,
3 // drop, note, model, triage, build, ask, and the watcher. It has two roles:
4 //
5 // - On a PC, it runs the commands there, where the Claude Code login is. When sync is on, the
6 //   archive that it changes is on the ideamine server, and the Prompts and Memory tabs come from
7 //   there too.
8 // - On a server (sync off), it holds the archive for every machine: the machines sync with /api/db
9 //   and /api/ops, and send their prompts to /api/prompts. With memstate_url, the Memory tab shows
10 //   the memories of a memstated daemon. nginx in front of it names the server in serve_hosts.
11 //
12 // The server listens on 127.0.0.1 only, and it takes commands only from its own page.
13 
14 import { spawn } from 'node:child_process';
15 import fs from 'node:fs';
16 import http from 'node:http';
17 import os from 'node:os';
18 import path from 'node:path';
19 import { fileURLToPath } from 'node:url';
20 import * as archive from './archive.js';
21 import * as config from './config.js';
22 import * as publish from './publish.js';
23 import { renderAdded, stamp } from './render.js';
24 import * as store from './store.js';
25 import * as sync from './sync.js';
26 import { clip, splitIdeas } from './text.js';
27 import * as watch from './watch.js';
28 
29 const PAGE = new URL('../dashboard/index.html', import.meta.url);
30 const BIN = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url));
31 const MAX_BODY = 1024 * 1024;
32 const EMBED_TIMEOUT_MS = 5000; // data.json must not wait long for an embedding server that is away
33 const HEADERS = { 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' };
34 const DAY = 86400000;
35 const APPLIED_KEPT = 1000; // results of recent changes, so that a change that comes twice applies once
36 
37 const logPath = () => path.join(store.home(), 'serve.log');
38 const promptsPath = () => path.join(store.home(), 'prompts.jsonl');
39 const appliedPath = () => path.join(store.home(), 'applied.json');
40 const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
41 
42 /** The port from the setting serve_port. */
43 export function port() {
44   const value = config.get('serve_port');
45   const n = Number(value);
46   if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error(`serve_port must be a whole number from 1 to 65535, not "${value}"`);
47   return n;
48 }
49 
50 export const address = (p = port()) => `http://127.0.0.1:${p}/`;
51 
52 /** The Host names from the setting serve_hosts, for example the address that nginx answers on. */
53 const extraHosts = () => config.get('serve_hosts').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean);
54 
55 /**
56  * True when the server may answer a request. The Host header must name this server, which stops a
57  * DNS rebinding page. A POST must send JSON, and its Origin, if any, must be this server. A page of
58  * another site cannot send JSON here without a CORS preflight, and this server allows none.
59  */
60 export function allowed({ method, host, origin, type }, port, hosts = []) {
61   const h = String(host ?? '').toLowerCase();
62   if (h !== `127.0.0.1:${port}` && h !== `localhost:${port}` && !hosts.includes(h)) return false;
63   if (method === 'GET' || method === 'HEAD') return true;
64   if (method !== 'POST' || !/^application\/json\s*(;|$)/i.test(String(type ?? '').trim())) return false;
65   return origin == null || origin === `http://${h}`;
66 }
67 
68 /**
69  * Run `node <args>` in a new console window, so that Claude Code gets a terminal. Windows only.
70  * `start` opens the window. A Windows path cannot hold a quote, so quotes around each argument
71  * keep cmd.exe from reading a & or | in a path. IDEAMINE_WINDOW keeps the window open after an error.
72  */
73 export function openWindow(args, cwd) {
74   if (process.platform !== 'win32') {
75     return Promise.reject(new Error(`the page can open a terminal only on Windows. Run in a terminal: ideamine ${args.slice(1).join(' ')}`));
76   }
77   const line = ['start', '""', ...[process.execPath, ...args].map((a) => `"${a}"`)].join(' ');
78   return new Promise((resolve, reject) => {
79     const child = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/c', line], {
80       cwd,
81       env: { ...process.env, IDEAMINE_WINDOW: '1' },
82       detached: true,
83       stdio: 'ignore',
84       windowsVerbatimArguments: true,
85     });
86     child.on('error', reject);
87     child.on('spawn', () => {
88       child.unref();
89       resolve();
90     });
91   });
92 }
93 
94 function send(res, status, json, headers = {}) {
95   res.writeHead(status, { ...HEADERS, 'content-type': 'application/json; charset=utf-8', ...headers });
96   res.end(JSON.stringify(json));
97 }
98 
99 function sendPage(res) {
100   res.writeHead(200, {
101     ...HEADERS,
102     'content-type': 'text/html; charset=utf-8',
103     'content-security-policy': "frame-ancestors 'none'", // no other page can frame the buttons
104   });
105   res.end(fs.readFileSync(PAGE));
106 }
107 
108 function readBody(req) {
109   return new Promise((resolve, reject) => {
110     const chunks = [];
111     let size = 0;
112     req.on('data', (chunk) => {
113       size += chunk.length;
114       if (size > MAX_BODY) {
115         reject(new Error('the request is too big'));
116         req.destroy();
117       } else chunks.push(chunk);
118     });
119     req.on('end', () => resolve(Buffer.concat(chunks)));
120     req.on('error', reject);
121   });
122 }
123 
124 async function readJson(req) {
125   const raw = (await readBody(req)).toString('utf8');
126   let body;
127   try {
128     body = raw.trim() ? JSON.parse(raw) : {};
129   } catch {
130     throw new Error('the request is not valid JSON');
131   }
132   if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('the request must be a JSON object');
133   return body;
134 }
135 
136 /** The snapshot of `ideamine publish`, and `live`: what only this server can tell the page. */
137 async function liveData() {
138   const db = await archive.fresh();
139   const { data, note } = await publish.build(db, { timeoutMs: EMBED_TIMEOUT_MS });
140   const { hasClaude } = await import('./claude.js');
141   const w = watch.readState();
142   const synced = sync.enabled();
143   return {
144     ...data,
145     live: {
146       note,
147       window: process.platform === 'win32',
148       claude: hasClaude(),
149       watch: { on: !!w.on, status: watch.status() },
150       sync: synced ? { url: sync.serverUrl(), offline: archive.offlineReason(), waiting: sync.waiting() } : null,
151       prompts: synced || fs.existsSync(promptsPath()),
152       memory: synced || !!config.get('memstate_url'),
153     },
154   };
155 }
156 
157 /** Search by meaning on the page: the page sends its query to the embedding server through here. */
158 async function proxyEmbeddings(req, res) {
159   const endpoint = `${config.get('embed_url').replace(/\/+$/, '')}/embeddings`;
160   let upstream;
161   try {
162     upstream = await fetch(endpoint, {
163       method: 'POST',
164       headers: { 'content-type': 'application/json' },
165       body: await readBody(req),
166       signal: AbortSignal.timeout(8000),
167     });
168   } catch {
169     return send(res, 502, { ok: false, error: `cannot reach ${endpoint}` });
170   }
171   res.writeHead(upstream.status, { ...HEADERS, 'content-type': upstream.headers.get('content-type') || 'application/json' });
172   res.end(Buffer.from(await upstream.arrayBuffer()));
173 }
174 
175 /** A read of the Prompts or Memory tab on a PC with sync on: the ideamine server answers it. */
176 async function forward(res, route) {
177   const target = new URL(route, sync.serverUrl());
178   let upstream;
179   try {
180     upstream = await fetch(target, { signal: AbortSignal.timeout(15000) });
181   } catch {
182     return send(res, 502, { ok: false, error: `cannot reach ${target.origin}` });
183   }
184   res.writeHead(upstream.status, { ...HEADERS, 'content-type': upstream.headers.get('content-type') || 'application/json' });
185   res.end(Buffer.from(await upstream.arrayBuffer()));
186 }
187 
188 // ---------------------------------------------------------------------------------------------
189 // The server role: the archive for every machine, the prompt log, and the memories.
190 
191 // A change from a machine. Each result is small, because the machine reads the archive that comes
192 // with the answer.
193 const CHANGES = {
194   add: (op) =>
195     store
196       .addIdeas(op.texts, { source: op.source, project: op.project, session: op.session, tags: op.tags, host: op.host })
197       .map(({ idea, similar }) => ({ id: idea.id, similar })),
198   update: (op) => ({ id: store.updateIdea(op.id, op.patch || {}).id }),
199   remove: (op) => store.removeIdeas(op.ids || []),
200   triage: (op) => store.applyTriage(op.verdicts, { by: op.by, projects: op.projects }),
201 };
202 
203 function readApplied() {
204   try {
205     return JSON.parse(fs.readFileSync(appliedPath(), 'utf8'));
206   } catch {
207     return {};
208   }
209 }
210 
211 /**
212  * Apply changes in order. A change that came before (the same oid) is not applied again: its first
213  * result comes back. A machine sends a change again when an answer got lost on the way.
214  */
215 function applyChanges(ops) {
216   if (!Array.isArray(ops)) throw new Error('ops must be a list');
217   const applied = readApplied();
218   const results = ops.map((op) => {
219     if (op?.oid && applied[op.oid]) return applied[op.oid];
220     let result;
221     try {
222       if (!op || !Object.hasOwn(CHANGES, op.op)) throw new Error(`unknown change "${op?.op}"`);
223       result = { ok: true, value: CHANGES[op.op](op) };
224     } catch (e) {
225       result = { ok: false, error: e.message };
226     }
227     if (op?.oid) applied[op.oid] = result;
228     return result;
229   });
230   const kept = Object.entries(applied).slice(-APPLIED_KEPT);
231   store.withLock(() => store.writeAtomic(appliedPath(), JSON.stringify(Object.fromEntries(kept))));
232   return results;
233 }
234 
235 let promptCache = null;
236 
237 /** The prompt log: one JSON line for each prompt. The copy in memory is kept until the file changes. */
238 function loadPrompts() {
239   const st = fs.statSync(promptsPath(), { throwIfNoEntry: false });
240   if (!st) return { prompts: [], ids: new Set() };
241   if (promptCache?.mtime === st.mtimeMs && promptCache.size === st.size) return promptCache;
242   const prompts = [];
243   for (const line of fs.readFileSync(promptsPath(), 'utf8').split('\n')) {
244     if (!line.trim()) continue;
245     try {
246       prompts.push(JSON.parse(line));
247     } catch {
248       // A line that a crash cut in half.
249     }
250   }
251   promptCache = { mtime: st.mtimeMs, size: st.size, prompts, ids: new Set(prompts.map((p) => p.id)) };
252   return promptCache;
253 }
254 
255 const field = (v, max = 300) => (v == null ? null : String(v).slice(0, max));
256 
257 /** Add prompts that the log does not have yet. A prompt without an id, a time, or a text is skipped. */
258 function addPrompts(list) {
259   if (!Array.isArray(list)) throw new Error('prompts must be a list');
260   const { ids } = loadPrompts();
261   const add = [];
262   let skipped = 0;
263   for (const p of list) {
264     const bad = !p || typeof p.id !== 'string' || !p.id || typeof p.prompt !== 'string' || Number.isNaN(Date.parse(p.at));
265     if (bad) skipped++;
266     if (bad || ids.has(p.id)) continue;
267     ids.add(p.id);
268     add.push({ id: p.id.slice(0, 64), at: new Date(p.at).toISOString(), host: field(p.host, 100), session: field(p.session, 100), cwd: field(p.cwd), prompt: p.prompt });
269   }
270   if (add.length) store.withLock(() => fs.appendFileSync(promptsPath(), add.map((p) => `${JSON.stringify(p)}\n`).join('')));
271   return { added: add.length, skipped };
272 }
273 
274 /** The prompts of the last `days` days (all prompts for 0), oldest first. */
275 function listPrompts(days) {
276   const since = days > 0 ? Date.now() - days * DAY : 0;
277   return loadPrompts().prompts.filter((p) => Date.parse(p.at) >= since).sort((a, b) => a.at.localeCompare(b.at));
278 }
279 
280 /** A read from the memstated daemon at memstate_url. */
281 async function memstate(route, body) {
282   const base = config.get('memstate_url');
283   if (!base) throw new Error('this server shows no memories. Set memstate_url, for example: ideamine config memstate_url http://127.0.0.1:8765');
284   const url = `${base.replace(/\/+$/, '')}${route}`;
285   const init = { signal: AbortSignal.timeout(8000) };
286   if (body) Object.assign(init, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
287   let res;
288   try {
289     res = await fetch(url, init);
290   } catch {
291     throw new Error(`cannot reach memstated at ${base}`);
292   }
293   const json = await res.json().catch(() => null);
294   if (!res.ok) throw new Error(`memstated answered ${res.status}: ${json?.error || 'no reason'}`);
295   return json;
296 }
297 
298 // The Memory tab reads memstated. It never writes there.
299 const MEMORY = {
300   /** Every project, and every current memory without its text, for the list and the timeline. */
301   async overview() {
302     const { projects } = await memstate('/api/v1/projects');
303     const memories = [];
304     for (const p of projects) {
305       const { memories: list } = await memstate('/api/v1/keypaths', { project_id: p.id });
306       for (const m of list) memories.push({ id: m.id, project_id: p.id, keypath: m.keypath, category: m.category || null, version: m.version, created_at: m.created_at });
307     }
308     return { projects, memories };
309   },
310   /** The current memories of one project, with their text. */
311   async project(query) {
312     const { memories } = await memstate('/api/v1/keypaths', { project_id: query.get('id') || '', include_content: true });
313     return { memories };
314   },
315   /** Every version of one memory, oldest first. */
316   async history(query) {
317     const { versions } = await memstate('/api/v1/memories/history', { project_id: query.get('project') || '', keypath: query.get('keypath') || '' });
318     return { versions };
319   },
320 };
321 
322 // ---------------------------------------------------------------------------------------------
323 // The slash commands. Each one takes the JSON body and returns the text to show on the page.
324 
325 const ACTIONS = {
326   /** /idea. A bulleted list adds one idea per bullet. */
327   async add({ text }) {
328     const results = await archive.add(splitIdeas(String(text ?? '')), { source: 'web' });
329     return results.queued ? archive.queuedText(results) : renderAdded(results, store.load());
330   },
331 
332   /** /ideas-rm. An unknown id deletes nothing. */
333   async rm({ ids }) {
334     if (!Array.isArray(ids) || !ids.length) throw new Error('name the ideas to delete');
335     const gone = await archive.remove(ids);
336     return gone.queued ? archive.queuedText(gone) : gone.map((i) => `Removed #${i.id} · ${clip(i.title, 60)}`).join('\n');
337   },
338 
339   /** /ideas-done, /ideas-reopen, and the verbs start, drop, note, and model of the CLI. */
340   async update({ id, status, note, model }) {
341     if (!status && !note && !model) throw new Error('nothing to change');
342     const idea = await archive.update(id, { status, note, model });
343     if (idea.queued) return archive.queuedText(idea);
344     const bits = [`#${idea.id} ${clip(idea.title, 60)} → ${store.lane(idea)}`];
345     if (model) bits.push(`model ${idea.triage.model}`);
346     if (note) bits.push('note added');
347     return `✓ ${bits.join(' · ')}`;
348   },
349 
350   /** /ideas-sort */
351   async sort() {
352     const { headlessTriage } = await import('./claude.js');
353     const { headlessSummary } = await import('./mcp.js');
354     return headlessSummary(await headlessTriage());
355   },
356 
357   /**
358    * /ideas-go [N]. New ideas are triaged first. Then a new window runs `ideamine go N`: Claude Code
359    * on the recommended model, in the project of the idea.
360    */
361   async go({ id }, { open }) {
362     const { goPlan, triageFirst } = await import('./claude.js');
363     const { headlessSummary } = await import('./mcp.js');
364     const lines = [];
365     try {
366       const triaged = await triageFirst({ id: id ?? null });
367       if (triaged) lines.push(headlessSummary(triaged));
368     } catch (e) {
369       lines.push(`Triage failed: ${e.message}`);
370     }
371     const db = await archive.fresh();
372     const idea = id != null ? store.findIdea(db, id) : store.pickNext(db);
373     if (!idea) throw new Error(id != null ? `no idea #${id}` : 'Nothing is ready to build. Triage the inbox first.');
374     const { model, dir } = goPlan(idea, os.homedir());
375     await open([BIN, 'go', String(idea.id)], dir);
376     await archive.update(idea.id, { status: 'doing' });
377     lines.push(`Opened Claude Code (${model}) in ${dir} for #${idea.id} · ${clip(idea.title, 60)}`);
378     return lines.join('\n\n');
379   },
380 
381   /** /ideas <question> */
382   async ask({ question }) {
383     const q = String(question ?? '').trim();
384     if (!q) throw new Error('ask a question');
385     const { askAboutIdeas } = await import('./claude.js');
386     return (await askAboutIdeas(q)).answer || 'Claude gave no answer.';
387   },
388 
389   /** /ideas-watch [off] */
390   watch({ on }) {
391     if (on) {
392       watch.turnOn();
393       watch.kick();
394     } else watch.turnOff();
395     return watch.status();
396   },
397 };
398 
399 // These actions do not change the archive, so the dashboard server needs no new upload.
400 const READ_ONLY = new Set(['ask', 'watch']);
401 
402 /** The reads and changes of the server role. Resolves to false for a route that it does not have. */
403 async function serverRoute(req, res, route, query) {
404   if (route === 'GET /api/db') return send(res, 200, { ok: true, db: store.load() });
405   if (route === 'POST /api/ops') {
406     const { ops } = await readJson(req);
407     return send(res, 200, { ok: true, results: applyChanges(ops), db: store.load() });
408   }
409   if (route === 'POST /api/prompts') {
410     const { prompts } = await readJson(req);
411     return send(res, 200, { ok: true, ...addPrompts(prompts) });
412   }
413   if (route === 'GET /api/prompts') return send(res, 200, { ok: true, prompts: listPrompts(Number(query.get('days')) || 0) });
414   const memory = route.startsWith('GET /api/memory/') && route.slice('GET /api/memory/'.length);
415   if (memory && Object.hasOwn(MEMORY, memory)) return send(res, 200, { ok: true, ...(await MEMORY[memory](query)) });
416   return false;
417 }
418 
419 async function handle(req, res, ctx) {
420   const request = { method: req.method, host: req.headers.host, origin: req.headers.origin, type: req.headers['content-type'] };
421   if (!allowed(request, ctx.port, extraHosts())) return send(res, 403, { ok: false, error: 'forbidden' });
422   const { pathname, search, searchParams } = new URL(req.url, 'http://127.0.0.1');
423   const route = `${req.method === 'HEAD' ? 'GET' : req.method} ${pathname}`;
424   if (route === 'GET /' || route === 'GET /index.html') return sendPage(res);
425   if (route === 'GET /data.json') return send(res, 200, await liveData());
426   // A ping gets a new connection each time, so it never reaches a server that stops on an old one.
427   if (route === 'GET /api/ping') return send(res, 200, { ok: true, app: 'ideamine' }, { connection: 'close' });
428   if (route === 'POST /v1/embeddings') return proxyEmbeddings(req, res);
429   if (route === 'POST /api/stop') {
430     // After "stopped", no kept-alive connection may answer a request.
431     res.on('finish', () => ctx.server.closeAllConnections?.());
432     send(res, 200, { ok: true, message: 'stopped' }, { connection: 'close' });
433     ctx.server.close();
434     return;
435   }
436   if (sync.enabled()) {
437     if (route === 'GET /api/prompts' || route.startsWith('GET /api/memory/')) return forward(res, `${pathname.slice(1)}${search}`);
438   } else {
439     try {
440       if ((await serverRoute(req, res, route, searchParams)) !== false) return;
441     } catch (e) {
442       return send(res, 400, { ok: false, error: e.message });
443     }
444   }
445   const action = route.startsWith('POST /api/') ? route.slice('POST /api/'.length) : '';
446   if (!Object.hasOwn(ACTIONS, action)) return send(res, 404, { ok: false, error: 'not found' });
447   try {
448     const message = await ACTIONS[action](await readJson(req), ctx);
449     ctx.log(`${action}: ok`);
450     if (!READ_ONLY.has(action)) {
451       try {
452         ctx.afterChange();
453       } catch {
454         // The upload to the dashboard server must never fail a command.
455       }
456     }
457     return send(res, 200, { ok: true, message });
458   } catch (e) {
459     ctx.log(`${action}: ${e.message}`);
460     return send(res, 400, { ok: false, error: e.message });
461   }
462 }
463 
464 /**
465  * The server, not listening yet. `open` opens the window for a build, `afterChange` runs after a
466  * command changes the archive (by default it uploads the dashboard again), and `log` gets a line
467  * for each command.
468  */
469 export function createServer({ open = openWindow, afterChange = publish.kick, log = () => {} } = {}) {
470   const ctx = { open, afterChange, log, port: null };
471   ctx.server = http.createServer((req, res) => {
472     handle(req, res, ctx).catch((e) => {
473       if (!res.headersSent) send(res, 500, { ok: false, error: e.message });
474     });
475   });
476   ctx.server.on('listening', () => (ctx.port = ctx.server.address().port));
477   return ctx.server;
478 }
479 
480 /** Listen on 127.0.0.1. Resolves to { server, url }. */
481 export function start({ port: p = port(), ...options } = {}) {
482   const server = createServer(options);
483   return new Promise((resolve, reject) => {
484     server.once('error', reject);
485     server.listen(p, '127.0.0.1', () => resolve({ server, url: address(server.address().port) }));
486   });
487 }
488 
489 /** True when an ideamine server answers at `url`. */
490 async function ping(url) {
491   try {
492     const res = await fetch(new URL('api/ping', url), { signal: AbortSignal.timeout(1000) });
493     return res.ok && (await res.json()).app === 'ideamine';
494   } catch {
495     return false;
496   }
497 }
498 
499 /** Start `ideamine serve` in the background. Its output goes to serve.log, and so does a failed start. */
500 function startProcess() {
501   fs.mkdirSync(store.home(), { recursive: true });
502   const log = fs.openSync(logPath(), 'a');
503   try {
504     const child = spawn(process.execPath, [BIN, 'serve'], { cwd: store.home(), detached: true, stdio: ['ignore', log, log], windowsHide: true });
505     child.on('error', (e) => fs.appendFileSync(logPath(), `${logLine(`cannot start: ${e.message}`)}\n`));
506     child.unref();
507   } finally {
508     fs.closeSync(log);
509   }
510 }
511 
512 /** /ideas-web: start the server when it does not run. Resolves to the text for the user. */
513 export async function ensureRunning({ startServer = startProcess, waitMs = 5000 } = {}) {
514   const url = address();
515   if (await ping(url)) return `ideamine web: ${url}`;
516   startServer();
517   for (const deadline = Date.now() + waitMs; Date.now() < deadline; ) {
518     await sleep(150);
519     if (await ping(url)) return `ideamine web: ${url} (started)`;
520   }
521   return `ideamine web did not start at ${url}. The reason is in ${logPath()}.`;
522 }
523 
524 /** /ideas-web off */
525 export async function stopRunning() {
526   const url = address();
527   if (!(await ping(url))) return 'ideamine web: not running.';
528   try {
529     await fetch(new URL('api/stop', url), { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
530   } catch {
531     // The server can close the connection before its answer arrives. The ping below tells the result.
532   }
533   return (await ping(url)) ? `ideamine web: still running at ${url}` : 'ideamine web: stopped.';
534 }
535 
536 /** A line for serve.log: the time and the text. */
537 export const logLine = (text) => `${stamp(new Date().toISOString())}  ${text}`;