0f027e863b
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>
355 lines
13 KiB
TypeScript
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"})`);
|
|
});
|