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}`;