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>
165 lines
5.8 KiB
TypeScript
165 lines
5.8 KiB
TypeScript
// 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");
|
|
}
|