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>
This commit is contained in:
+354
@@ -0,0 +1,354 @@
|
||||
// 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"})`);
|
||||
});
|
||||
Reference in New Issue
Block a user