Files
BMO/server/index.ts
T
peji 0f027e863b BMO: a living desktop buddy with a Claude brain
Standalone React + Vite app where BMO lives in a little garden:
- Redrawn avatar (src/avatar) with depth, cartoon controls and expressive
  faces; original bmo-preview avatar and scenery kept in src/bmo
- Idle life: wandering, chatter, boredom, sleep, pokes, weather and
  time-of-day reactions
- Chat via a local Node server streaming Claude replies (Haiku by default),
  with live scene awareness and tools to change color, outfit, sky,
  weather, move and perform
- Side menu for API key, appearance, environment and chat log

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:05:50 -04:00

355 lines
13 KiB
TypeScript

// 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<unknown> {
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<string, string> = {
".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"})`);
});