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