// Tiny HTTP server for BMO's brain. POST /api/chat streams a reply as // server-sent events; /api/brain reads and sets the API key from the side // menu. In production it also serves the built SPA from dist/. // // The API key stays on this side; the browser never sees it. import { createReadStream, existsSync, statSync } from "node:fs"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; import Anthropic from "@anthropic-ai/sdk"; import { currentKey, keyHint, looksLikeKey, storeKey } from "./keystore.ts"; import { BMO_SYSTEM_PROMPT, MOODS, type Mood } from "./persona.ts"; import { BMO_TOOLS, describeAction, describeScene, parseScene, toAction } from "./tools.ts"; const PORT = Number(process.env.BMO_PORT ?? 4310); // Haiku while testing: fast and cheap. Set BMO_MODEL=claude-opus-5-5 for the big brain. const MODEL = process.env.BMO_MODEL?.trim() || "claude-haiku-5-5"; // Server-side refusal fallback only exists on the larger models; Haiku rejects it. const SUPPORTS_FALLBACK = /^claude-(opus-5|opus-5-5|fable-5-1|sonnet-5-5)$/.test(MODEL); const MAX_HISTORY = 40; const MAX_MESSAGE_CHARS = 4000; const here = path.dirname(fileURLToPath(import.meta.url)); const distDir = path.resolve(here, "../dist"); // Rebuilt whenever the key changes from the side menu. let client = new Anthropic(); type ChatTurn = { role: "user" | "assistant"; content: string }; function hasCredentials() { return Boolean(currentKey() || process.env.ANTHROPIC_AUTH_TOKEN); } /** * Only same-machine pages may call the API. Browsers always send Origin on * cross-site POSTs, so this stops other websites from spending your credits * or swapping your key through this local server. */ function isTrustedRequest(req: IncomingMessage) { const origin = req.headers.origin; if (origin) { let host: string; try { host = new URL(origin).hostname; } catch { return false; } if (host !== "localhost" && host !== "127.0.0.1" && host !== "[::1]") return false; } return (req.headers["content-type"] ?? "").startsWith("application/json"); } function sendJson(res: ServerResponse, status: number, body: unknown) { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(body)); } function brainStatus() { return { connected: hasCredentials(), keyHint: keyHint(), model: MODEL }; } async function handleSetKey(req: IncomingMessage, res: ServerResponse) { let body: unknown; try { body = await readJson(req); } catch { return sendJson(res, 400, { error: "Bad request." }); } const raw = (body as { apiKey?: unknown } | null)?.apiKey; if (raw === null) { storeKey(null); client = new Anthropic(); return sendJson(res, 200, brainStatus()); } const key = typeof raw === "string" ? raw.trim() : ""; if (!looksLikeKey(key)) { return sendJson(res, 400, { error: "That doesn't look like an Anthropic API key. It should start with sk-ant-." }); } // Try the key before saving it, with a free metadata call. const candidate = new Anthropic({ apiKey: key }); try { await candidate.models.retrieve(MODEL); } catch (err) { if (err instanceof Anthropic.AuthenticationError) { return sendJson(res, 400, { error: "Anthropic rejected that key. Double-check you copied all of it." }); } if (err instanceof Anthropic.PermissionDeniedError || err instanceof Anthropic.NotFoundError) { return sendJson(res, 400, { error: `That key can't use ${MODEL}.` }); } if (err instanceof Anthropic.APIConnectionError) { return sendJson(res, 502, { error: "Couldn't reach Anthropic to check the key. Are you online?" }); } // Rate limits or server hiccups don't mean the key is bad; save it anyway. console.warn("[bmo] key check inconclusive:", err); } storeKey(key); client = candidate; console.log(`[bmo] API key saved (${keyHint(key)})`); sendJson(res, 200, brainStatus()); } function readJson(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let body = ""; req.on("data", (chunk) => { body += chunk; if (body.length > 200_000) reject(new Error("body too large")); }); req.on("end", () => { try { resolve(JSON.parse(body || "{}")); } catch (err) { reject(err); } }); req.on("error", reject); }); } function parseHistory(input: unknown): ChatTurn[] | null { if (!input || typeof input !== "object" || !Array.isArray((input as { messages?: unknown }).messages)) { return null; } const raw = (input as { messages: unknown[] }).messages; const turns: ChatTurn[] = []; for (const m of raw) { if (!m || typeof m !== "object") return null; const { role, content } = m as { role?: unknown; content?: unknown }; if ((role !== "user" && role !== "assistant") || typeof content !== "string") return null; turns.push({ role, content: content.slice(0, MAX_MESSAGE_CHARS) }); } // Trim to the most recent turns and make sure the first one is from the user. const recent = turns.slice(-MAX_HISTORY); while (recent.length && recent[0]!.role !== "user") recent.shift(); if (!recent.length || recent[recent.length - 1]!.role !== "user") return null; return recent; } function sse(res: ServerResponse, event: string, data: unknown) { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } /** * Splits the leading "[mood] " tag off the streamed reply. Text is buffered * until the tag is resolved (or clearly absent), then flows through untouched. */ function createMoodSplitter(onMood: (mood: Mood) => void, onText: (text: string) => void) { let buffer = ""; let resolved = false; return { push(chunk: string) { if (resolved) return onText(chunk); buffer += chunk; const match = buffer.match(/^\s*\[([a-z]+)\]\s*/i); if (match) { const mood = match[1]!.toLowerCase(); onMood((MOODS as readonly string[]).includes(mood) ? (mood as Mood) : "happy"); resolved = true; const rest = buffer.slice(match[0].length); if (rest) onText(rest); } else if (buffer.length > 20 || !/^\s*\[?[a-z]*$/i.test(buffer)) { onMood("happy"); resolved = true; onText(buffer); } }, flush() { if (!resolved && buffer) { onMood("happy"); onText(buffer); } }, }; } const MAX_TOOL_ROUNDS = 4; async function handleChat(req: IncomingMessage, res: ServerResponse) { let body: unknown; try { body = await readJson(req); } catch { body = null; } const history = parseHistory(body); if (!history) { return sendJson(res, 400, { error: "Expected { messages: [{ role, content }] } ending with a user turn." }); } const scene = parseScene((body as { scene?: unknown }).scene); res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive", }); if (!hasCredentials()) { sse(res, "mood", "oops"); sse(res, "text", "BMO can't reach its brain yet! Open the ⋮ menu and paste your API key under Brain."); sse(res, "done", {}); res.end(); return; } // The live scene rides along as a mid-conversation system message after the // newest user turn, so the cached system prompt + history prefix stays stable. const messages: Anthropic.Beta.BetaMessageParam[] = [...history]; if (scene) messages.push({ role: "system", content: describeScene(scene) }); let aborted = false; let current: { abort: () => void } | null = null; req.on("close", () => { aborted = true; current?.abort(); }); // Text from later rounds (after a tool call) continues the same bubble, // separated from the earlier words by a space. let wroteText = false; const writeText = (text: string, startsSegment = true) => { if (!text) return; if (startsSegment && wroteText) text = " " + text.trimStart(); wroteText = true; sse(res, "text", text); }; try { for (let round = 0; round < MAX_TOOL_ROUNDS && !aborted; round++) { let roundText = ""; const splitter = createMoodSplitter( (mood) => sse(res, "mood", mood), (text) => { writeText(roundText ? text : text.trimStart(), !roundText); roundText += text; }, ); const stream = client.beta.messages.stream({ model: MODEL, max_tokens: 4000, output_config: { effort: "low" }, // Server-side fallback: if a safety classifier declines, the API retries on // a fallback model inside the same call instead of BMO going silent. ...(SUPPORTS_FALLBACK ? { betas: ["server-side-fallback-2026-07-01"], fallbacks: "default" as const } : {}), cache_control: { type: "ephemeral" }, system: BMO_SYSTEM_PROMPT, tools: BMO_TOOLS, tool_choice: { type: "auto" }, messages, }); current = stream; for await (const event of stream) { if (event.type === "content_block_delta" && event.delta.type === "text_delta") { splitter.push(event.delta.text); } } splitter.flush(); let final: Anthropic.Beta.BetaMessage; try { final = await stream.finalMessage(); } catch (err) { // A tool input that couldn't be parsed at all: skip the actions, keep the words. if (err instanceof Anthropic.APIError) throw err; console.warn("[bmo] unparseable tool input:", err); break; } if (final.stop_reason === "refusal") { sse(res, "mood", "oops"); writeText("Hmm, BMO can't talk about that one."); break; } const toolUses = final.content.filter((b): b is Anthropic.Beta.BetaToolUseBlock => b.type === "tool_use"); // A tool call cut off at max_tokens may carry a truncated input; don't run it. if (final.stop_reason !== "tool_use" || toolUses.length === 0) break; const results: Anthropic.Beta.BetaToolResultBlockParam[] = toolUses.map((use) => { const action = toAction(use.name, use.input); if ("error" in action) { return { type: "tool_result", tool_use_id: use.id, content: action.error, is_error: true }; } sse(res, "action", action); return { type: "tool_result", tool_use_id: use.id, content: describeAction(action) }; }); messages.push({ role: "assistant", content: final.content }); messages.push({ role: "user", content: results }); } sse(res, "done", {}); } catch (err) { if (!res.writableEnded && !aborted) { let message = "BMO's brain hiccupped. Try again?"; if (err instanceof Anthropic.AuthenticationError) message = "BMO's API key doesn't work. Check it in the ⋮ menu under Brain!"; else if (err instanceof Anthropic.RateLimitError) message = "BMO is talking too fast. Give me a second!"; else if (err instanceof Anthropic.APIError) message = `BMO's brain said error ${err.status}.`; console.error("[bmo] chat error:", err); sse(res, "mood", "oops"); writeText(message); sse(res, "done", {}); } } finally { res.end(); } } const MIME: Record = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".json": "application/json", }; function serveStatic(req: IncomingMessage, res: ServerResponse) { const urlPath = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname); let file = path.join(distDir, urlPath); if (!file.startsWith(distDir) || !existsSync(file) || statSync(file).isDirectory()) { file = path.join(distDir, "index.html"); } if (!existsSync(file)) { res.writeHead(404).end("Build the app first: npm run build"); return; } res.writeHead(200, { "content-type": MIME[path.extname(file)] ?? "application/octet-stream" }); createReadStream(file).pipe(res); } createServer((req, res) => { if (req.url?.startsWith("/api/") && req.method === "POST" && !isTrustedRequest(req)) { return sendJson(res, 403, { error: "Forbidden." }); } if (req.method === "POST" && req.url === "/api/chat") { void handleChat(req, res); return; } if (req.method === "GET" && req.url === "/api/brain") { return sendJson(res, 200, brainStatus()); } if (req.method === "POST" && req.url === "/api/brain") { void handleSetKey(req, res); return; } if (req.method === "GET" && req.url === "/api/health") { return sendJson(res, 200, { ok: true, ...brainStatus() }); } serveStatic(req, res); }).listen(PORT, "127.0.0.1", () => { console.log(`[bmo] brain listening at http://127.0.0.1:${PORT} (model ${MODEL}, key ${hasCredentials() ? "found" : "missing"})`); });