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:
+164
@@ -0,0 +1,164 @@
|
||||
// BMO's tools: things the model can do to its own body and world. The server
|
||||
// validates each call and forwards it to the page as an "action" event; the page
|
||||
// carries it out. Results go straight back to the model so it can keep talking.
|
||||
|
||||
import type Anthropic from "@anthropic-ai/sdk";
|
||||
import {
|
||||
COLOR_LOOKS,
|
||||
COLOR_NAMES,
|
||||
OUTFIT_LOOKS,
|
||||
OUTFIT_NAMES,
|
||||
PERFORMANCES,
|
||||
SPOTS,
|
||||
TIME_MODES,
|
||||
WEATHER_MODES,
|
||||
type BmoAction,
|
||||
type SceneState,
|
||||
} from "../shared/scene.ts";
|
||||
|
||||
function enumTool(
|
||||
name: string,
|
||||
description: string,
|
||||
field: string,
|
||||
values: readonly string[],
|
||||
fieldDescription: string,
|
||||
): Anthropic.Beta.BetaTool {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
// Inputs are tiny enums, but stream them as generated anyway; we validate
|
||||
// every call ourselves below.
|
||||
eager_input_streaming: true,
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: { [field]: { type: "string", enum: [...values], description: fieldDescription } },
|
||||
required: [field],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const BMO_TOOLS: Anthropic.Beta.BetaTool[] = [
|
||||
enumTool(
|
||||
"set_color",
|
||||
"Repaint BMO's body. Use when the user asks BMO to change color, or BMO wants a new look.",
|
||||
"color",
|
||||
COLOR_NAMES,
|
||||
`Palette name. ${COLOR_NAMES.map((c) => `${c} = ${COLOR_LOOKS[c]}`).join("; ")}.`,
|
||||
),
|
||||
enumTool(
|
||||
"set_outfit",
|
||||
"Change what BMO is wearing.",
|
||||
"outfit",
|
||||
OUTFIT_NAMES,
|
||||
`Outfit name. ${OUTFIT_NAMES.map((o) => `${o} = ${OUTFIT_LOOKS[o]}`).join("; ")}.`,
|
||||
),
|
||||
enumTool(
|
||||
"set_time_of_day",
|
||||
"Change the sky in BMO's garden. 'auto' follows the user's real clock.",
|
||||
"time",
|
||||
TIME_MODES,
|
||||
"auto, day, dusk or night.",
|
||||
),
|
||||
enumTool(
|
||||
"set_weather",
|
||||
"Change the weather in BMO's garden. 'auto' lets it drift on its own; 'none' means clear skies.",
|
||||
"weather",
|
||||
WEATHER_MODES,
|
||||
"auto, none (clear), rain, snow or falling-leaves.",
|
||||
),
|
||||
enumTool(
|
||||
"move",
|
||||
"Walk to a spot on BMO's little stage. Use 'center' for 'come here' or 'stand in front of me'.",
|
||||
"to",
|
||||
SPOTS,
|
||||
"left, center or right.",
|
||||
),
|
||||
enumTool(
|
||||
"perform",
|
||||
"Do something with BMO's body or face: dance, celebrate (hearts), idea (lightbulb), confused (question mark), love (heart eyes), dizzy (spiral eyes), look_around, or nap (fall asleep until the user moves the mouse).",
|
||||
"action",
|
||||
PERFORMANCES,
|
||||
"Which animation to play.",
|
||||
),
|
||||
];
|
||||
|
||||
const FIELD: Record<BmoAction["type"], string> = {
|
||||
set_color: "color",
|
||||
set_outfit: "outfit",
|
||||
set_time_of_day: "time",
|
||||
set_weather: "weather",
|
||||
move: "to",
|
||||
perform: "action",
|
||||
};
|
||||
|
||||
const ALLOWED: Record<BmoAction["type"], readonly string[]> = {
|
||||
set_color: COLOR_NAMES,
|
||||
set_outfit: OUTFIT_NAMES,
|
||||
set_time_of_day: TIME_MODES,
|
||||
set_weather: WEATHER_MODES,
|
||||
move: SPOTS,
|
||||
perform: PERFORMANCES,
|
||||
};
|
||||
|
||||
/** Validates a tool call from the model. Returns the action or an error message. */
|
||||
export function toAction(name: string, input: unknown): BmoAction | { error: string } {
|
||||
if (!(name in FIELD)) return { error: `Unknown tool ${name}.` };
|
||||
const type = name as BmoAction["type"];
|
||||
const value = (input as Record<string, unknown> | null)?.[FIELD[type]];
|
||||
if (typeof value !== "string" || !ALLOWED[type].includes(value)) {
|
||||
return { error: `${FIELD[type]} must be one of: ${ALLOWED[type].join(", ")}.` };
|
||||
}
|
||||
return { type, [FIELD[type]]: value } as BmoAction;
|
||||
}
|
||||
|
||||
/** One-line confirmation the model sees as the tool result. */
|
||||
export function describeAction(action: BmoAction): string {
|
||||
switch (action.type) {
|
||||
case "set_color":
|
||||
return `Done. BMO is now ${COLOR_LOOKS[action.color]} (${action.color}).`;
|
||||
case "set_outfit":
|
||||
return `Done. BMO is now wearing ${OUTFIT_LOOKS[action.outfit]}.`;
|
||||
case "set_time_of_day":
|
||||
return action.time === "auto" ? "Done. The sky follows the real clock again." : `Done. It is now ${action.time} in the garden.`;
|
||||
case "set_weather":
|
||||
return action.weather === "auto"
|
||||
? "Done. The weather will drift on its own again."
|
||||
: `Done. Weather is now ${action.weather === "none" ? "clear" : action.weather}.`;
|
||||
case "move":
|
||||
return `Done. BMO walked to the ${action.to}.`;
|
||||
case "perform":
|
||||
return `Done. BMO did: ${action.action}.`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses and checks the scene state the page sends; anything odd is dropped. */
|
||||
export function parseScene(input: unknown): SceneState | null {
|
||||
if (!input || typeof input !== "object") return null;
|
||||
const s = input as Record<string, unknown>;
|
||||
const ok =
|
||||
COLOR_NAMES.includes(s.color as never) &&
|
||||
OUTFIT_NAMES.includes(s.outfit as never) &&
|
||||
["day", "dusk", "night"].includes(s.timeOfDay as string) &&
|
||||
TIME_MODES.includes(s.timeMode as never) &&
|
||||
["none", "rain", "snow", "falling-leaves"].includes(s.weather as string) &&
|
||||
WEATHER_MODES.includes(s.weatherMode as never) &&
|
||||
SPOTS.includes(s.spot as never) &&
|
||||
typeof s.localTime === "string" &&
|
||||
s.localTime.length < 40;
|
||||
return ok ? (s as unknown as SceneState) : null;
|
||||
}
|
||||
|
||||
/** The per-turn system message that tells BMO what it looks like right now. */
|
||||
export function describeScene(scene: SceneState): string {
|
||||
const weather = scene.weather === "none" ? "clear skies" : scene.weather.replace("-", " ");
|
||||
return [
|
||||
"Live scene (what the user sees right now; trust this over earlier messages):",
|
||||
`- BMO's color: ${COLOR_LOOKS[scene.color]} (palette "${scene.color}")`,
|
||||
`- BMO's outfit: ${OUTFIT_LOOKS[scene.outfit]} (outfit "${scene.outfit}")`,
|
||||
`- Garden sky: ${scene.timeOfDay}${scene.timeMode === "auto" ? " (following the real clock)" : " (set by hand)"}`,
|
||||
`- Weather: ${weather}${scene.weatherMode === "auto" ? " (drifting on its own)" : " (set by hand)"}`,
|
||||
`- BMO is standing at the ${scene.spot} of the stage`,
|
||||
`- User's local time: ${scene.localTime}`,
|
||||
].join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user