Files
BMO/server/tools.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

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");
}