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