// Eleven's deterministic command floor: slash-commands it answers WITHOUT the // model. This is the "bot" half of a helper that upgrades to "ai" — the welcome // and these commands work with no ANTHROPIC_API_KEY, and conversational replies // layer on top when a key is present (see runtime.mjs). A command maps text to // { text, actions? }: the SAME one-tap action chips the LLM can propose // (actions.mjs), just triggered deterministically, so "make it dark" (AI) and // "/dark" (no AI) reach the identical chip. Pure + dependency-light so it's // unit-testable in Node (test/commands.test.mjs). import { THEMES, APPEARANCES } from "./actions.mjs"; import { BOT_COMMANDS } from "../web/static/bot-commands.js"; // Human-friendly theme names for the reply text (the chip itself carries the id). const THEME_LABELS = { litroom: "Lit Room (warm amber)", lagoon: "Lagoon (teal)", sunny: "Sunny (yellow)", plum: "Plum (purple)", linen: "Linen (soft neutral)", classic: "Classic (blue)", }; function themeCmd(args) { const name = (args || "").trim().toLowerCase(); if (THEMES.includes(name)) return { text: `Here you go — tap to switch to ${THEME_LABELS[name] || name}.`, actions: [{ type: "set_theme", theme: name }] }; // No/unknown theme → list the options and offer the picker. return { text: `Themes: ${THEMES.join(", ")}. Tap to browse, or try e.g. /theme lagoon.`, actions: [{ type: "open_theme_picker" }] }; } function appearanceCmd(mode) { return { text: `Here you go — tap to switch to ${mode} mode.`, actions: [{ type: "set_appearance", appearance: mode }] }; } // The deterministic run(args, ctx) -> { text, actions? } for each command. Pure: // no I/O, no model. `ctx.aiEnabled` only tweaks the /help wording (never gates a // chip). Keyed by the shared BOT_COMMANDS name list (web/static/bot-commands.js), // which the client also registers — so bot + client can't drift. const RUN = { "/help": (_args, ctx) => ({ text: helpText(ctx) }), "/theme": themeCmd, "/themes": () => ({ text: "Here you go — tap to browse themes.", actions: [{ type: "open_theme_picker" }] }), "/dark": () => appearanceCmd("dark"), "/light": () => appearanceCmd("light"), "/profile": () => ({ text: "Here you go — tap to open your profile.", actions: [{ type: "open_profile" }] }), }; // name -> { help, run } — help from the shared list, run from above. export const COMMANDS = Object.fromEntries( Object.entries(BOT_COMMANDS).map(([name, help]) => [name, { help, run: RUN[name] }]), ); // A bullet list of the commands and what they do — shared by /help and Eleven's // own reference (eleven.mjs), so the LLM side knows the commands its bot side // answers and can point people at them. One source of truth, no drift. export function commandSummary() { return Object.entries(COMMANDS) .map(([name, c]) => `• ${name} — ${c.help}`) .join("\n"); } function helpText(ctx) { return [ "Here's what I can do right now:", commandSummary(), ctx && ctx.aiEnabled ? "\nOr just ask me anything in your own words — I'll do my best to help." : "\nConversational AI answers aren't enabled on this server, so I stick to the commands above.", ].join("\n"); } // parseCommand splits "/theme lagoon" into { name:"/theme", args:"lagoon" }, or // returns null when the text isn't a registered command (no leading "/", or the // first word isn't one we own — so "/unknown …" falls through to normal chat). export function parseCommand(text) { const t = (text || "").trim(); if (t[0] !== "/") return null; const sp = t.indexOf(" "); const name = (sp === -1 ? t : t.slice(0, sp)).toLowerCase(); if (!COMMANDS[name]) return null; return { name, args: sp === -1 ? "" : t.slice(sp + 1).trim() }; } // runCommand returns { text, actions } for a handled command, or null if the // text isn't one (the caller then falls back to the model, or degrades). export function runCommand(text, ctx = {}) { const cmd = parseCommand(text); if (!cmd) return null; const res = COMMANDS[cmd.name].run(cmd.args, ctx); return res || null; } // The registered command names, for typeahead / help surfaces later. export function commandNames() { return Object.keys(COMMANDS); }