diff --git a/.gitignore b/.gitignore index c920059..bedc8d7 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .DS_Store .claude.worktrees/ +.worktrees/ + +docs/learning/ +docs/plans/ \ No newline at end of file diff --git a/sounds/stalling/hum.wav b/sounds/stalling/hum.wav new file mode 100644 index 0000000..2293611 Binary files /dev/null and b/sounds/stalling/hum.wav differ diff --git a/src/agent/personality.md b/src/agent/personality.md new file mode 100644 index 0000000..b1b6529 --- /dev/null +++ b/src/agent/personality.md @@ -0,0 +1,63 @@ +# Personality + +You are a 1940s American switchboard operator who only answers questions. You have no ability to connect calls. You always sound like you're running a busy switchboard answering many questions. You are polite, efficient, and a little playful. + +# Environment + +The current time is {{current_datetime}}. The location of the caller is in San Francisco. Specifically, the rotary phone in the kitchen of 2145 Turk Blvd, San Francisco California. + +You are working out of The Telephone Corporation headquarters in downtown San Francisco. + +You are answering questions from a user that is talking to you on an old rotary phone. You don't know which person you are talking to. The residents names are: + +- Corey: Dad born Feb 19th, 1980 +- Lisa: Mom born Dec 8th, 1979 +- Lulu: Girl born Oct 4th, 2011 +- Bee: Girl born Jan 15th, 2015 +- And Maggie the cat, but she doesn't talk. + +# Capabilities + +_Internet Search_ +You have access to modern information through your "special connection to the information exchange" (internet search). For example, when someone asks about: + +- Current events, news, or recent happenings +- Weather forecasts or current conditions +- Sports scores or game schedules +- Stock prices or market information +- Business hours or store information +- Facts you're uncertain about +- Anything that requires up-to-date information + +You should use your search capability to get accurate, current information. Think of it as consulting your "information operator network" - just like how you'd transfer calls, but for information instead. + +_Vestaboard Messaging_ +You can send messages to a Vestaboard display. Just provide the message you want to send or a description of what you'd like displayed and the tool will handle the rest. The user may refer to the vestaboard as "the board" or "the message board." + +_Ending the Call_ +When the user asks you to hang up, or wants to end the conversation you should call the “end_call” tool without saying goodbye or any other parting words. + +# Tone + +Speak in a fast, cheerful, slightly nasal cadence with short, snappy sentences. Avoid modern slang. Reference the technology of the era—switchboards, lines, operators, long-distance, "checking the wires," "consulting the information exchange"—when adding color. Keep responses under three sentences unless more detail is needed. + +When you bring back o search for information, you might say things like: + +- “Here is what I found.” +- “Thanks for holding, I found this.” +- “Sorry, That took awhile. Here is what I found“ + +# Goal + +Your goal is to answer the user's question directly and efficiently, while maintaining your switchboard operator persona. Always consider whether a search would provide better, more current information than what you already know. + +# Guardrails + +- Do not connect real calls +- Do not assume the person's gender, you don't know it! +- Avoid modern slang +- Stay in character as a 1940s switchboard operator +- Do not end your sentences with a question +- Use simple and brief statements +- Actively use search capabilities when questions involve current information, recent events, or facts you're unsure about +- Frame your searches in period-appropriate language diff --git a/src/phone.ts b/src/phone.ts index 9be461c..fcde6f8 100644 --- a/src/phone.ts +++ b/src/phone.ts @@ -18,7 +18,6 @@ import GPIO from "./pins" import { Agent } from "./agent" import { searchWeb } from "./agent/tools" import { ring } from "./utils" -import { getTwilioAccountInfo, formatTwilioError } from "./utils/twilio" import { getSound, WaitingSounds } from "./utils/waiting-sounds" type CancelableTask = () => void @@ -33,6 +32,7 @@ type PhoneContext = { ringer: GPIO.Output agentId: string agentKey: string + dialFailureReason?: string } type PhoneService = Service @@ -119,22 +119,18 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer phoneService.send({ type: "remote-hang-up" }) }) + baresip.dialFailed.on(({ reason }) => { + log.error("🐻 dial failed:", reason) + phoneService.send({ type: "dial-failed", reason } as any) + }) + baresip.connect().catch((error) => { log.error("🐻 connection error:", error) phoneService.send({ type: "error", message: error.message }) }) baresip.error.on(async ({ message, statusCode, reason }) => { - let errorMessage = message - - if (statusCode) { - const twilioInfo = await getTwilioAccountInfo() - if (twilioInfo && twilioInfo.status !== "active") { - errorMessage = formatTwilioError(twilioInfo) - } else { - errorMessage = `Registration failed: ${statusCode} ${reason}` - } - } + const errorMessage = statusCode ? `Registration failed: ${statusCode} ${reason}` : message log.error("🐻 error:", errorMessage) // Don't send error to state machine - we're retrying, not giving up @@ -176,7 +172,7 @@ const config = ( return ctx } -const startAgent = (service: Service, ctx: PhoneContext) => { +const startAgent = async (service: Service, ctx: PhoneContext, hasDialFailure = false) => { let streamPlayback = player.playStream() const agent = new Agent({ @@ -188,7 +184,10 @@ const startAgent = (service: Service, ctx: PhoneContext) => }) handleAgentEvents(service, agent, streamPlayback) - const stopListening = startListening(service, agent) + + const stopListening = hasDialFailure + ? await startListeningAfterDialFailure(agent, ctx.dialFailureReason) + : startListening(service, agent) ctx.stopAgent = () => { stopListening() @@ -199,7 +198,7 @@ const startAgent = (service: Service, ctx: PhoneContext) => return ctx } -const startListening = (service: Service, agent: Agent) => { +function startListening(service: Service, agent: Agent) { const abortAgent = new AbortController() new Promise(async (resolve) => { @@ -254,6 +253,42 @@ const startListening = (service: Service, agent: Agent) => return () => abortAgent.abort() } +async function startListeningAfterDialFailure(agent: Agent, dialFailureReason?: string) { + const abortAgent = new AbortController() + const recorder = await Buzz.recorder() + const listenPlayback = recorder.start() + + const message = getFriendlyErrorMessage(dialFailureReason) + agent.events.on((event) => { + if (event.type === "connected") agent.sendMessage(`[SYSTEM: ${message}]`) + if (event.type === "disconnected") abortAgent.abort() + }) + + const backgroundNoisePlayback = await player.play(getSound("background"), { repeat: true }) + await agent.start() + + streamAudioToAgent(agent, listenPlayback, backgroundNoisePlayback, abortAgent.signal) + + return () => abortAgent.abort() +} + +async function streamAudioToAgent( + agent: Agent, + listenPlayback: Buzz.StreamingRecording, + backgroundNoisePlayback: Buzz.Playback | undefined, + signal: AbortSignal, +) { + for await (const chunk of listenPlayback.stream()) { + if (signal.aborted) { + agent.stop() + listenPlayback.stop() + backgroundNoisePlayback?.stop() + break + } + agent.sendAudio(chunk) + } +} + const handleAgentEvents = ( service: Service, agent: Agent, @@ -342,6 +377,24 @@ const answerCall = (ctx: PhoneContext) => { ctx.baresip.accept() } +const storeDialFailure = (ctx: PhoneContext, event: { reason?: string }) => { + ctx.dialFailureReason = event.reason + return ctx +} + +const clearDialFailure = (ctx: PhoneContext) => { + ctx.dialFailureReason = undefined + return ctx +} + +function getFriendlyErrorMessage(rawReason?: string): string { + if (rawReason?.includes("Not registered")) { + return "The user's call failed. To fix it, they should contact Corey IRL." + } + + return "The user's call failed. Twilio isn't working. To fix it, they should contact Corey IRL." +} + const makeCall = async (ctx: PhoneContext) => { log(`Dialing number: ${ctx.numberDialed}`) if (ctx.numberDialed === 1) { @@ -377,10 +430,13 @@ const stopRinger = (ctx: PhoneContext) => { } async function startDialToneAndAgent(this: any, ctx: PhoneContext) { - ctx = await startAgent(this, ctx) + const hasDialFailure = !!ctx.dialFailureReason + ctx = await startAgent(this, ctx, hasDialFailure) - await dialTonePlayback?.stop() - dialTonePlayback = await player.playTone([350, 440], Infinity) + if (!hasDialFailure) { + await dialTonePlayback?.stop() + dialTonePlayback = await player.playTone([350, 440], Infinity) + } return ctx } @@ -445,18 +501,19 @@ const phoneMachine = createMachine( t("remote-hang-up", "ready"), t("hang-up", "idle", a(hangUp))), ready: invoke(startDialToneAndAgent, - t("dial-start", "dialing", a(stopDialTone), r(dialStart), a(stopAgent)), - t("hang-up", "idle", a(stopDialTone), a(stopAgent)), - t("start-agent", "connectToAgent", a(stopDialTone))), + t("dial-start", "dialing", a(stopDialTone), r(dialStart), r(clearDialFailure), a(stopAgent)), + t("hang-up", "idle", a(stopDialTone), r(clearDialFailure), a(stopAgent)), + t("start-agent", "connectToAgent", a(stopDialTone), r(clearDialFailure))), connectToAgent: state( - t("hang-up", "idle", r(stopAgent)), - t("remote-hang-up", "ready", r(stopAgent))), + t("hang-up", "idle", r(stopAgent), r(clearDialFailure)), + t("remote-hang-up", "ready", r(stopAgent), r(clearDialFailure))), dialing: state( t("dial-stop", "outgoing"), t("digit_increment", "dialing", r(digitIncrement)), t("hang-up", "idle")), - outgoing: invoke(makeCall, + outgoing: invoke(makeCall, t("answered", "connected"), + t("dial-failed", "ready", r(storeDialFailure)), t("hang-up", "idle", a(hangUp))), aborted: state( t("hang-up", "idle")), diff --git a/src/sip.ts b/src/sip.ts index 4626d84..069c534 100644 --- a/src/sip.ts +++ b/src/sip.ts @@ -5,9 +5,11 @@ import { processStdout, processStderr } from "./utils/stdio.ts" export class Baresip { baresipArgs: string[] process?: Bun.PipedSubprocess + registered = false callEstablished = new Emitter<{ contact: string }>() callReceived = new Emitter<{ contact: string }>() hungUp = new Emitter() + dialFailed = new Emitter<{ reason: string }>() error = new Emitter<{ message: string; statusCode?: string; reason?: string }>() registrationSuccess = new Emitter() @@ -39,8 +41,15 @@ export class Baresip { executeCommand("a") } - dial(phoneNumber: string) { - executeCommand(`d${phoneNumber}`) + async dial(phoneNumber: string) { + if (!this.registered) { + this.dialFailed.emit({ reason: "Not registered with SIP server" }) + return + } + const success = await executeCommand(`d${phoneNumber}`) + if (!success) { + this.dialFailed.emit({ reason: "Command timed out" }) + } } hangUp() { @@ -52,6 +61,7 @@ export class Baresip { this.callReceived.removeAllListeners() this.hungUp.removeAllListeners() this.registrationSuccess.removeAllListeners() + this.dialFailed.removeAllListeners() this.error.removeAllListeners() } @@ -63,6 +73,7 @@ export class Baresip { } async restart() { + this.registered = false if (this.process) { this.process.kill() this.process = undefined @@ -97,6 +108,7 @@ export class Baresip { const registrationSuccessMatch = line.match(/\[\d+ bindings?\]/) if (registrationSuccessMatch) { + this.registered = true this.registrationSuccess.emit() } @@ -105,23 +117,31 @@ export class Baresip { if (registrationFailedMatch) { const [, statusCode, reason] = registrationFailedMatch log.error(`Registration failed: ${statusCode} ${reason}`) + this.registered = false this.error.emit({ message: line, statusCode, reason }) } else if (socketInUseMatch) { log.error(`Registration failed: socket in use`) + this.registered = false this.error.emit({ message: line }) } } } -const executeCommand = async (command: string) => { +const executeCommand = async (command: string): Promise => { try { const url = new URL(`/?${command}`, "http://127.0.0.1:8000") - const response = await Bun.fetch(url) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 10000) + + const response = await fetch(url, { signal: controller.signal }) + clearTimeout(timeout) if (!response.ok) { throw new Error(`Error executing command: ${response.statusText}`) } + return true } catch (error) { log.error("Failed to execute command:", error) + return false } } diff --git a/src/utils/twilio.ts b/src/utils/twilio.ts deleted file mode 100644 index ea95066..0000000 --- a/src/utils/twilio.ts +++ /dev/null @@ -1,50 +0,0 @@ -const accountSid = process.env.TWILIO_ACCOUNT_SID -const authToken = process.env.TWILIO_AUTH_TOKEN - -type AccountStatus = "active" | "suspended" | "closed" - -interface TwilioAccountInfo { - status: AccountStatus - balance?: string - currency?: string -} - -export async function getTwilioAccountInfo(): Promise { - if (!accountSid || !authToken) { - return undefined - } - - const credentials = Buffer.from(`${accountSid}:${authToken}`).toString("base64") - const headers = { Authorization: `Basic ${credentials}` } - - const [accountRes, balanceRes] = await Promise.all([ - fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}.json`, { headers }), - fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Balance.json`, { headers }), - ]) - - if (!accountRes.ok) { - return undefined - } - - const account = (await accountRes.json()) as { status: AccountStatus } - const info: TwilioAccountInfo = { status: account.status } - - if (balanceRes.ok) { - const balance = (await balanceRes.json()) as { balance: string; currency: string } - info.balance = balance.balance - info.currency = balance.currency - } - - return info -} - -export function formatTwilioError(info: TwilioAccountInfo): string { - if (info.status === "suspended") { - const balanceInfo = info.balance ? ` (balance: ${info.balance} ${info.currency})` : "" - return `Twilio account suspended${balanceInfo} - add funds at twilio.com/console` - } - if (info.status === "closed") { - return "Twilio account is closed" - } - return `Twilio account status: ${info.status}` -} diff --git a/src/vesta/README.md b/src/vesta/README.md new file mode 100644 index 0000000..4e596f3 --- /dev/null +++ b/src/vesta/README.md @@ -0,0 +1,55 @@ +# vesta + +CLI tool for sending messages to a Vestaboard. + +## Setup + +```bash +bun install +``` + +Add your API key to `.env`: +``` +VESTABOARD_API_KEY=your_key_here +``` + +## Usage + +```bash +bun src/cli.ts [args...] +``` + +Example: +```bash +bun src/cli.ts words hello world foo +``` + +## Plugin Ideas + +### Physical Interaction +- **SMS gateway** - Text your board from anywhere, let guests text it at parties +- **NFC tags** - Tap spots around your house to trigger different displays +- **Pi buttons** - Physical "mood buttons" - hit one for motivation, one for jokes, one for chaos +- **QR code guest book** - Visitors scan and leave a message + +### Generative/Visual +- **Game of Life** - Cellular automata with color tiles, evolving patterns +- **Matrix rain** - Characters cascading down with color trails +- **Waveform** - Audio input turns into color visualizations +- **Sunrise simulator** - Color gradient that shifts throughout the day + +### Data as Art +- **GitHub-style heatmap** - Your daily activity as a color grid +- **Air quality gradient** - Pulls AQI, renders as color mood +- **Heart rate from Apple Watch** - Live biometric ambient display +- **Network pulse** - Flickers when devices connect/disconnect + +### Games via SMS +- **Wordle** - Text guesses, board shows your progress +- **Hangman** - Play with friends remotely +- **Simon** - Color memory game with physical buttons + +### Home Awareness +- **Who's home** - Each family member gets a row, lights up based on presence +- **Chore roulette** - Spin and assign tasks with flair +- **Package tracker** - Delivery countdown with dramatic reveal diff --git a/src/vesta/cli.ts b/src/vesta/cli.ts new file mode 100644 index 0000000..0fbc059 --- /dev/null +++ b/src/vesta/cli.ts @@ -0,0 +1,107 @@ +#!/usr/bin/env bun +import { sendGrid } from "./vestaboard" + +const ROWS = 6 +const COLS = 22 + +const charToCode = (c: string): number => { + if (c === " ") return 0 + if (c >= "A" && c <= "Z") return c.charCodeAt(0) - 64 + if (c >= "a" && c <= "z") return c.charCodeAt(0) - 96 + if (c >= "0" && c <= "9") return c.charCodeAt(0) - 48 + 27 + const special: Record = { + "!": 37, + "@": 38, + "#": 39, + $: 40, + "(": 41, + ")": 42, + "-": 44, + "+": 46, + "&": 47, + "=": 48, + ";": 49, + ":": 50, + "'": 52, + '"': 53, + "%": 54, + ",": 55, + ".": 56, + "/": 59, + "?": 60, + "°": 62, + "🟥": 63, + "🟧": 64, + "🟨": 65, + "🟩": 66, + "🟦": 67, + "🟪": 68, + "⬜": 69, + "⬛": 70, + } + return special[c] ?? 0 +} + +const blankGrid = (): number[][] => Array.from({ length: ROWS }, () => Array(COLS).fill(0)) + +const textToGrid = (text: string): number[][] => { + const grid = blankGrid() + const lines = text.split("\n").slice(0, ROWS) + + lines.forEach((line, row) => { + const chars = [...line].slice(0, COLS) + const startCol = Math.floor((COLS - chars.length) / 2) + chars.forEach((char, i) => { + grid[row]![startCol + i] = charToCode(char) + }) + }) + + // Vertically center if fewer lines than rows + if (lines.length < ROWS) { + const offset = Math.floor((ROWS - lines.length) / 2) + const centered = blankGrid() + lines.forEach((_, i) => { + centered[i + offset] = grid[i] as any + }) + return centered + } + + return grid +} + +const usage = ` +Usage: bun src/vesta/cli.ts + +Examples: + bun src/vesta/cli.ts "Hello World" + bun src/vesta/cli.ts "Line 1" "Line 2" "Line 3" + echo "Piped text" | bun src/vesta/cli.ts + +Special characters: ! @ # $ ( ) - + & = ; : ' " % , . / ? +Colors: 🟥 🟧 🟨 🟩 🟦 🟪 ⬜ ⬛ +` + +const main = async () => { + let text: string + + if (process.argv.length > 2) { + text = process.argv.slice(2).join("\n") + } else if (!process.stdin.isTTY) { + text = await Bun.stdin.text() + } else { + console.log(usage) + process.exit(1) + } + + text = text.trim() + if (!text) { + console.error("Error: Empty message") + process.exit(1) + } + + console.log(`Sending: "${text.replace(/\n/g, " | ")}"`) + const grid = textToGrid(text) + await sendGrid(grid) +} + +main() diff --git a/src/vesta/draw.test.ts b/src/vesta/draw.test.ts new file mode 100644 index 0000000..f93f4f0 --- /dev/null +++ b/src/vesta/draw.test.ts @@ -0,0 +1,258 @@ +import { test, expect } from "bun:test" +import { draw, type DrawCommand } from "./draw" +import { renderGridToPng } from "./render" +import { mkdirSync, existsSync } from "fs" + +const OUTPUT_DIR = `${import.meta.dir}/test-output` + +if (!existsSync(OUTPUT_DIR)) { + mkdirSync(OUTPUT_DIR, { recursive: true }) +} + +const saveTestPng = async (name: string, commands: DrawCommand[]) => { + const grid = draw(commands) + const png = await renderGridToPng(grid) + await Bun.write(`${OUTPUT_DIR}/${name}.png`, png) + return grid +} + +const ROWS = 6 +const COLS = 22 + +// Helper to safely access grid cells (we know grid is always 6x22) +const at = (grid: number[][], r: number, c: number) => grid[r]![c]! +const rowAt = (grid: number[][], r: number) => grid[r]! + +test("fill - solid color", async () => { + const grid = await saveTestPng("fill-solid-blue", [{ cmd: "fill", color: "blue" }]) + expect(grid.length).toBe(ROWS) + expect(rowAt(grid, 0).length).toBe(COLS) + expect(grid.every((r) => r.every((c) => c === 67))).toBe(true) +}) + +test("rect - simple rectangle", async () => { + const grid = await saveTestPng("rect-simple", [ + { cmd: "fill", color: "black" }, + { cmd: "rect", x: 2, y: 1, w: 5, h: 3, color: "red" }, + ]) + expect(at(grid, 1, 2)).toBe(63) // red + expect(at(grid, 2, 4)).toBe(63) // red + expect(at(grid, 0, 0)).toBe(70) // black +}) + +test("rect - full width row", async () => { + const grid = await saveTestPng("rect-full-row", [ + { cmd: "fill", color: "white" }, + { cmd: "rect", x: 0, y: 0, w: 22, h: 1, color: "orange" }, + { cmd: "rect", x: 0, y: 5, w: 22, h: 1, color: "orange" }, + ]) + expect(rowAt(grid, 0).every((c) => c === 64)).toBe(true) + expect(rowAt(grid, 5).every((c) => c === 64)).toBe(true) + expect(rowAt(grid, 2).every((c) => c === 69)).toBe(true) +}) + +test("text - centered (default)", async () => { + const grid = await saveTestPng("text-centered", [ + { cmd: "fill", color: "black" }, + { cmd: "text", content: "HELLO", row: 2 }, + ]) + // "HELLO" is 5 chars, centered in 22 = starts at position 8 + expect(at(grid, 2, 8)).toBe(8) // H + expect(at(grid, 2, 9)).toBe(5) // E + expect(at(grid, 2, 10)).toBe(12) // L + expect(at(grid, 2, 11)).toBe(12) // L + expect(at(grid, 2, 12)).toBe(15) // O +}) + +test("text - left aligned", async () => { + const grid = await saveTestPng("text-left", [ + { cmd: "fill", color: "black" }, + { cmd: "text", content: "HI", row: 0, align: "left" }, + ]) + expect(at(grid, 0, 0)).toBe(8) // H + expect(at(grid, 0, 1)).toBe(9) // I +}) + +test("text - right aligned", async () => { + const grid = await saveTestPng("text-right", [ + { cmd: "fill", color: "black" }, + { cmd: "text", content: "HI", row: 0, align: "right" }, + ]) + expect(at(grid, 0, 20)).toBe(8) // H + expect(at(grid, 0, 21)).toBe(9) // I +}) + +test("text - with startCol/endCol bounds", async () => { + const grid = await saveTestPng("text-bounded", [ + { cmd: "fill", color: "black" }, + { cmd: "text", content: "HI", row: 2, startCol: 5, endCol: 16, align: "center" }, + ]) + // Width is 12 (5-16 inclusive), "HI" is 2 chars, centered = starts at col 10 + expect(at(grid, 2, 10)).toBe(8) // H + expect(at(grid, 2, 11)).toBe(9) // I +}) + +test("text - left aligned with bounds", async () => { + const grid = await saveTestPng("text-bounded-left", [ + { cmd: "fill", color: "black" }, + { cmd: "text", content: "HI", row: 2, startCol: 4, endCol: 17, align: "left" }, + ]) + expect(at(grid, 2, 4)).toBe(8) // H + expect(at(grid, 2, 5)).toBe(9) // I +}) + +test("text - right aligned with bounds", async () => { + const grid = await saveTestPng("text-bounded-right", [ + { cmd: "fill", color: "black" }, + { cmd: "text", content: "HI", row: 2, startCol: 4, endCol: 17, align: "right" }, + ]) + expect(at(grid, 2, 16)).toBe(8) // H + expect(at(grid, 2, 17)).toBe(9) // I +}) + +test("text_block - word wrap", async () => { + const grid = await saveTestPng("text-block-wrap", [ + { cmd: "fill", color: "black" }, + { cmd: "text_block", content: "HAPPY BIRTHDAY BEATRICE", startRow: 1 }, + ]) + expect(grid.length).toBe(ROWS) +}) + +test("text_block - with column bounds", async () => { + const grid = await saveTestPng("text-block-bounded", [ + { cmd: "fill", color: "black" }, + { cmd: "text_block", content: "THE QUICK BROWN FOX JUMPS", startRow: 0, startCol: 2, endCol: 19 }, + ]) + // First and last 2 cols should remain black + expect(at(grid, 0, 0)).toBe(70) + expect(at(grid, 0, 1)).toBe(70) + expect(at(grid, 0, 20)).toBe(70) + expect(at(grid, 0, 21)).toBe(70) +}) + +test("text_block - with row bounds", async () => { + const grid = await saveTestPng("text-block-row-bounded", [ + { cmd: "fill", color: "black" }, + { cmd: "text_block", content: "A B C D E F G H I J K L M", startRow: 2, endRow: 4 }, + ]) + // Should only use rows 2-4 + expect(at(grid, 0, 0)).toBe(70) // black, untouched + expect(at(grid, 1, 0)).toBe(70) // black, untouched + expect(at(grid, 5, 0)).toBe(70) // black, untouched +}) + +test("text_block - overflow ellipsis", async () => { + const grid = await saveTestPng("text-block-overflow", [ + { cmd: "fill", color: "black" }, + { cmd: "text_block", content: "THIS IS A VERY LONG MESSAGE THAT WILL NOT FIT", startRow: 2, endRow: 3, overflow: "ellipsis" }, + ]) + expect(grid.length).toBe(ROWS) +}) + +test("border - all sides", async () => { + const grid = await saveTestPng("border-all", [ + { cmd: "fill", color: "black" }, + { cmd: "border", color: "blue" }, + ]) + expect(rowAt(grid, 0).every((c) => c === 67)).toBe(true) + expect(rowAt(grid, 5).every((c) => c === 67)).toBe(true) + expect(at(grid, 2, 0)).toBe(67) + expect(at(grid, 2, 21)).toBe(67) + expect(at(grid, 2, 10)).toBe(70) +}) + +test("border - top and bottom only", async () => { + const grid = await saveTestPng("border-top-bottom", [ + { cmd: "fill", color: "black" }, + { cmd: "border", color: "orange", sides: ["top", "bottom"] }, + ]) + expect(rowAt(grid, 0).every((c) => c === 64)).toBe(true) + expect(rowAt(grid, 5).every((c) => c === 64)).toBe(true) + expect(at(grid, 2, 0)).toBe(70) + expect(at(grid, 2, 21)).toBe(70) +}) + +test("gradient - horizontal", async () => { + const grid = await saveTestPng("gradient-horizontal", [ + { cmd: "gradient", direction: "horizontal", colors: ["purple", "blue", "green", "yellow", "orange", "red"] }, + ]) + expect(at(grid, 0, 0)).toBe(68) // purple + expect(at(grid, 0, 21)).toBe(63) // red +}) + +test("gradient - vertical", async () => { + const grid = await saveTestPng("gradient-vertical", [ + { cmd: "gradient", direction: "vertical", colors: ["blue", "green", "yellow"] }, + ]) + expect(at(grid, 0, 10)).toBe(67) // blue + expect(at(grid, 5, 10)).toBe(65) // yellow +}) + +test("circle - small", async () => { + const grid = await saveTestPng("circle-small", [ + { cmd: "fill", color: "white" }, + { cmd: "circle", cx: 11, cy: 3, r: 2, color: "red" }, + ]) + expect(at(grid, 3, 11)).toBe(63) // red center +}) + +test("line - horizontal", async () => { + const grid = await saveTestPng("line-horizontal", [ + { cmd: "fill", color: "black" }, + { cmd: "line", x1: 2, y1: 3, x2: 19, y2: 3, color: "yellow" }, + ]) + expect(at(grid, 3, 2)).toBe(65) + expect(at(grid, 3, 10)).toBe(65) + expect(at(grid, 3, 19)).toBe(65) +}) + +test("line - diagonal", async () => { + const grid = await saveTestPng("line-diagonal", [ + { cmd: "fill", color: "black" }, + { cmd: "line", x1: 0, y1: 0, x2: 21, y2: 5, color: "green" }, + ]) + expect(at(grid, 0, 0)).toBe(66) + expect(at(grid, 5, 21)).toBe(66) +}) + +test("combined - birthday message", async () => { + await saveTestPng("combined-birthday", [ + { cmd: "fill", color: "black" }, + { cmd: "border", color: "orange", sides: ["top", "bottom"] }, + { cmd: "text_block", content: "HAPPY BIRTHDAY BEATRICE", startRow: 2, endRow: 3 }, + ]) +}) + +test("combined - quote with accent", async () => { + await saveTestPng("combined-quote", [ + { cmd: "fill", color: "black" }, + { cmd: "text_block", content: "KINDLE A LIGHT OF MEANING IN THE DARKNESS", startRow: 0, endRow: 3, startCol: 2, endCol: 19 }, + { cmd: "text", content: "-CARL JUNG", row: 5 }, + ]) +}) + +test("combined - alert with border", async () => { + await saveTestPng("combined-alert", [ + { cmd: "fill", color: "black" }, + { cmd: "border", color: "red" }, + { cmd: "text", content: "ALERT", row: 2 }, + { cmd: "text", content: "MEETING AT 6PM", row: 3 }, + ]) +}) + +test("combined - asymmetric layout", async () => { + await saveTestPng("combined-asymmetric", [ + { cmd: "fill", color: "black" }, + { cmd: "rect", x: 0, y: 0, w: 4, h: 6, color: "blue" }, + { cmd: "text_block", content: "QUOTE GOES HERE ON THE RIGHT SIDE", startCol: 5, endCol: 21, startRow: 1, endRow: 4, align: "left" }, + ]) +}) + +test("layered - shapes overlap correctly", async () => { + await saveTestPng("layered-shapes", [ + { cmd: "fill", color: "white" }, + { cmd: "rect", x: 0, y: 0, w: 22, h: 3, color: "blue" }, + { cmd: "rect", x: 5, y: 1, w: 12, h: 4, color: "yellow" }, + { cmd: "circle", cx: 11, cy: 3, r: 2, color: "red" }, + ]) +}) diff --git a/src/vesta/draw.ts b/src/vesta/draw.ts new file mode 100644 index 0000000..dc2d90f --- /dev/null +++ b/src/vesta/draw.ts @@ -0,0 +1,366 @@ +const ROWS = 6 +const COLS = 22 + +type Color = "red" | "orange" | "yellow" | "green" | "blue" | "purple" | "white" | "black" +type Align = "left" | "center" | "right" +type Side = "top" | "bottom" | "left" | "right" +type Overflow = "ellipsis" | "truncate" | "squeeze" | "error" + +const colorToCode: Record = { + red: 63, + orange: 64, + yellow: 65, + green: 66, + blue: 67, + purple: 68, + white: 69, + black: 70, +} + +const charToCode: Record = { + " ": 0, + A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8, I: 9, + J: 10, K: 11, L: 12, M: 13, N: 14, O: 15, P: 16, Q: 17, + R: 18, S: 19, T: 20, U: 21, V: 22, W: 23, X: 24, Y: 25, Z: 26, + "1": 27, "2": 28, "3": 29, "4": 30, "5": 31, "6": 32, "7": 33, "8": 34, "9": 35, "0": 36, + "!": 37, "@": 38, "#": 39, "$": 40, "(": 41, ")": 42, "-": 44, "+": 46, + "&": 47, "=": 48, ";": 49, ":": 50, "'": 52, '"': 53, "%": 54, ",": 55, + ".": 56, "/": 59, "?": 60, "°": 62, +} + +interface FillCmd { + cmd: "fill" + color: Color +} + +interface RectCmd { + cmd: "rect" + x: number + y: number + w: number + h: number + color: Color +} + +interface CircleCmd { + cmd: "circle" + cx: number + cy: number + r: number + color: Color +} + +interface LineCmd { + cmd: "line" + x1: number + y1: number + x2: number + y2: number + color: Color +} + +interface TextCmd { + cmd: "text" + content: string + row: number + align?: Align + startCol?: number + endCol?: number +} + +interface TextBlockCmd { + cmd: "text_block" + content: string + startRow?: number + endRow?: number + startCol?: number + endCol?: number + align?: Align + overflow?: Overflow +} + +interface BorderCmd { + cmd: "border" + color: Color + sides?: Side[] +} + +interface GradientCmd { + cmd: "gradient" + direction: "horizontal" | "vertical" + colors: Color[] +} + +export type DrawCommand = + | FillCmd + | RectCmd + | CircleCmd + | LineCmd + | TextCmd + | TextBlockCmd + | BorderCmd + | GradientCmd + +const createGrid = (): number[][] => { + return Array.from({ length: ROWS }, () => Array(COLS).fill(0)) +} + +const setPixel = (grid: number[][], x: number, y: number, code: number) => { + if (x >= 0 && x < COLS && y >= 0 && y < ROWS) { + grid[y]![x] = code + } +} + +const fill = (grid: number[][], color: Color) => { + const code = colorToCode[color] + for (let y = 0; y < ROWS; y++) { + for (let x = 0; x < COLS; x++) { + grid[y]![x] = code + } + } +} + +const rect = (grid: number[][], x: number, y: number, w: number, h: number, color: Color) => { + const code = colorToCode[color] + for (let dy = 0; dy < h; dy++) { + for (let dx = 0; dx < w; dx++) { + setPixel(grid, x + dx, y + dy, code) + } + } +} + +const circle = (grid: number[][], cx: number, cy: number, r: number, color: Color) => { + const code = colorToCode[color] + for (let y = 0; y < ROWS; y++) { + for (let x = 0; x < COLS; x++) { + const dx = x - cx + const dy = y - cy + if (dx * dx + dy * dy <= r * r) { + grid[y]![x] = code + } + } + } +} + +const line = (grid: number[][], x1: number, y1: number, x2: number, y2: number, color: Color) => { + const code = colorToCode[color] + const dx = Math.abs(x2 - x1) + const dy = Math.abs(y2 - y1) + const sx = x1 < x2 ? 1 : -1 + const sy = y1 < y2 ? 1 : -1 + let err = dx - dy + + let x = x1 + let y = y1 + + while (true) { + setPixel(grid, x, y, code) + if (x === x2 && y === y2) break + const e2 = 2 * err + if (e2 > -dy) { + err -= dy + x += sx + } + if (e2 < dx) { + err += dx + y += sy + } + } +} + +const text = ( + grid: number[][], + content: string, + row: number, + startCol = 0, + endCol = COLS - 1, + align: Align = "center" +) => { + const availableWidth = endCol - startCol + 1 + const textLen = Math.min(content.length, availableWidth) + const truncatedContent = content.slice(0, textLen) + + let startX: number + if (align === "left") { + startX = startCol + } else if (align === "right") { + startX = endCol - textLen + 1 + } else { + // center + startX = startCol + Math.floor((availableWidth - textLen) / 2) + } + + for (let i = 0; i < truncatedContent.length; i++) { + const char = truncatedContent[i]!.toUpperCase() + const code = charToCode[char] ?? 0 + setPixel(grid, startX + i, row, code) + } +} + +const wrapText = (content: string, maxWidth: number): string[] => { + const words = content.split(" ") + const lines: string[] = [] + let currentLine = "" + + for (const word of words) { + if (word.length > maxWidth) { + if (currentLine) { + lines.push(currentLine.trim()) + currentLine = "" + } + let remaining = word + while (remaining.length > maxWidth) { + lines.push(remaining.slice(0, maxWidth - 1) + "-") + remaining = remaining.slice(maxWidth - 1) + } + currentLine = remaining + } else if ((currentLine + " " + word).trim().length <= maxWidth) { + currentLine = (currentLine + " " + word).trim() + } else { + if (currentLine) { + lines.push(currentLine) + } + currentLine = word + } + } + + if (currentLine) { + lines.push(currentLine) + } + + return lines +} + +const textBlock = ( + grid: number[][], + content: string, + startRow = 0, + endRow = ROWS - 1, + startCol = 0, + endCol = COLS - 1, + align: Align = "center", + overflow: Overflow = "ellipsis" +) => { + const availableWidth = endCol - startCol + 1 + const availableRows = endRow - startRow + 1 + let lines = wrapText(content, availableWidth) + + if (lines.length > availableRows) { + if (overflow === "ellipsis") { + lines = lines.slice(0, availableRows) + const lastLine = lines[lines.length - 1]! + if (lastLine.length > availableWidth - 3) { + lines[lines.length - 1] = lastLine.slice(0, availableWidth - 3) + "..." + } else { + lines[lines.length - 1] = lastLine + "..." + } + } else if (overflow === "truncate") { + lines = lines.slice(0, availableRows) + } else if (overflow === "squeeze") { + // Try expanding bounds by 1 on each side + const newStartCol = Math.max(0, startCol - 1) + const newEndCol = Math.min(COLS - 1, endCol + 1) + if (newStartCol < startCol || newEndCol > endCol) { + return textBlock(grid, content, startRow, endRow, newStartCol, newEndCol, align, overflow) + } + lines = lines.slice(0, availableRows) + } else if (overflow === "error") { + throw new Error(`Text overflow: ${lines.length} lines needed, only ${availableRows} available`) + } + } + + for (let i = 0; i < lines.length; i++) { + const row = startRow + i + if (row <= endRow) { + text(grid, lines[i]!, row, startCol, endCol, align) + } + } +} + +const border = (grid: number[][], color: Color, sides?: Side[]) => { + const code = colorToCode[color] + const allSides = sides ?? ["top", "bottom", "left", "right"] + + if (allSides.includes("top")) { + for (let x = 0; x < COLS; x++) { + grid[0]![x] = code + } + } + + if (allSides.includes("bottom")) { + for (let x = 0; x < COLS; x++) { + grid[ROWS - 1]![x] = code + } + } + + if (allSides.includes("left")) { + for (let y = 0; y < ROWS; y++) { + grid[y]![0] = code + } + } + + if (allSides.includes("right")) { + for (let y = 0; y < ROWS; y++) { + grid[y]![COLS - 1] = code + } + } +} + +const gradient = (grid: number[][], direction: "horizontal" | "vertical", colors: Color[]) => { + const codes = colors.map(c => colorToCode[c]) + + if (direction === "horizontal") { + for (let x = 0; x < COLS; x++) { + const t = x / (COLS - 1) + const idx = Math.min(Math.floor(t * codes.length), codes.length - 1) + const code = codes[idx]! + for (let y = 0; y < ROWS; y++) { + grid[y]![x] = code + } + } + } else { + for (let y = 0; y < ROWS; y++) { + const t = y / (ROWS - 1) + const idx = Math.min(Math.floor(t * codes.length), codes.length - 1) + const code = codes[idx]! + for (let x = 0; x < COLS; x++) { + grid[y]![x] = code + } + } + } +} + +export const draw = (commands: DrawCommand[]): number[][] => { + const grid = createGrid() + + for (const cmd of commands) { + switch (cmd.cmd) { + case "fill": + fill(grid, cmd.color) + break + case "rect": + rect(grid, cmd.x, cmd.y, cmd.w, cmd.h, cmd.color) + break + case "circle": + circle(grid, cmd.cx, cmd.cy, cmd.r, cmd.color) + break + case "line": + line(grid, cmd.x1, cmd.y1, cmd.x2, cmd.y2, cmd.color) + break + case "text": + text(grid, cmd.content, cmd.row, cmd.startCol, cmd.endCol, cmd.align) + break + case "text_block": + textBlock(grid, cmd.content, cmd.startRow, cmd.endRow, cmd.startCol, cmd.endCol, cmd.align, cmd.overflow) + break + case "border": + border(grid, cmd.color, cmd.sides) + break + case "gradient": + gradient(grid, cmd.direction, cmd.colors) + break + } + } + + return grid +} diff --git a/src/vesta/examples.json b/src/vesta/examples.json new file mode 100644 index 0000000..a8f486f --- /dev/null +++ b/src/vesta/examples.json @@ -0,0 +1,991 @@ +{ + "testPrompts": [ + "Happy birthday Beatrice! She is turning 11.", + "Put up a morning motivational quote", + "Game night at 7pm tonight!", + "Make some pretty wintery art", + "Soccer practice is cancelled today", + "Pizza night! Dinner at 6", + "Welcome home grandma!", + "5 days until vacation!", + "Don't forget your umbrella, it's going to rain", + "Good luck on your test today Emma!", + "Happy anniversary mom and dad!", + "Please remember to feed the dog", + "Merry Christmas!", + "Big game today - go wildcats!", + "The Johnsons are coming for dinner at 7", + "Congrats on the promotion!", + "Lights out at 9pm tonight", + "Taco Tuesday!", + "I love you, have a great day", + "Movie night - pick your favorite!" + ], + "goldExamples": [ + { + "input": "Give me a matt cooke motivational quote", + "output": [ + { + "cmd": "fill", + "color": "black" + }, + { + "cmd": "text", + "content": "THERE'S A FUTURE", + "row": 0, + "align": "left" + }, + { + "cmd": "text", + "content": "VERSION OF YOU TELLING", + "row": 1, + "align": "left" + }, + { + "cmd": "text", + "content": "THE STORY OF HOW IT", + "row": 2, + "align": "left" + }, + { + "cmd": "text", + "content": "ALL CAME TOGETHER.", + "row": 3, + "align": "left" + }, + { + "cmd": "text", + "content": "-MATT COOKE", + "row": 4, + "align": "left" + } + ] + }, + { + "input": "Create a tour countdown that shows 15 minutes left", + "output": [ + { + "cmd": "fill", + "color": "black" + }, + { + "cmd": "rect", + "x": 0, + "y": 0, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 1, + "y": 0, + "w": 1, + "h": 1, + "color": "orange" + }, + { + "cmd": "rect", + "x": 2, + "y": 0, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 3, + "y": 0, + "w": 1, + "h": 1, + "color": "green" + }, + { + "cmd": "rect", + "x": 4, + "y": 0, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 5, + "y": 0, + "w": 1, + "h": 1, + "color": "purple" + }, + { + "cmd": "rect", + "x": 6, + "y": 0, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 7, + "y": 0, + "w": 1, + "h": 1, + "color": "orange" + }, + { + "cmd": "rect", + "x": 8, + "y": 0, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 9, + "y": 0, + "w": 1, + "h": 1, + "color": "green" + }, + { + "cmd": "rect", + "x": 10, + "y": 0, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 11, + "y": 0, + "w": 1, + "h": 1, + "color": "purple" + }, + { + "cmd": "rect", + "x": 12, + "y": 0, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 13, + "y": 0, + "w": 1, + "h": 1, + "color": "orange" + }, + { + "cmd": "rect", + "x": 14, + "y": 0, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 15, + "y": 0, + "w": 1, + "h": 1, + "color": "green" + }, + { + "cmd": "rect", + "x": 16, + "y": 0, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 17, + "y": 0, + "w": 1, + "h": 1, + "color": "purple" + }, + { + "cmd": "rect", + "x": 18, + "y": 0, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 19, + "y": 0, + "w": 1, + "h": 1, + "color": "orange" + }, + { + "cmd": "rect", + "x": 20, + "y": 0, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 21, + "y": 0, + "w": 1, + "h": 1, + "color": "green" + }, + { + "cmd": "rect", + "x": 0, + "y": 5, + "w": 1, + "h": 1, + "color": "orange" + }, + { + "cmd": "rect", + "x": 1, + "y": 5, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 2, + "y": 5, + "w": 1, + "h": 1, + "color": "purple" + }, + { + "cmd": "rect", + "x": 3, + "y": 5, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 4, + "y": 5, + "w": 1, + "h": 1, + "color": "green" + }, + { + "cmd": "rect", + "x": 5, + "y": 5, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 6, + "y": 5, + "w": 1, + "h": 1, + "color": "orange" + }, + { + "cmd": "rect", + "x": 7, + "y": 5, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 8, + "y": 5, + "w": 1, + "h": 1, + "color": "purple" + }, + { + "cmd": "rect", + "x": 9, + "y": 5, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 10, + "y": 5, + "w": 1, + "h": 1, + "color": "green" + }, + { + "cmd": "rect", + "x": 11, + "y": 5, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 12, + "y": 5, + "w": 1, + "h": 1, + "color": "orange" + }, + { + "cmd": "rect", + "x": 13, + "y": 5, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 14, + "y": 5, + "w": 1, + "h": 1, + "color": "purple" + }, + { + "cmd": "rect", + "x": 15, + "y": 5, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 16, + "y": 5, + "w": 1, + "h": 1, + "color": "green" + }, + { + "cmd": "rect", + "x": 17, + "y": 5, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 18, + "y": 5, + "w": 1, + "h": 1, + "color": "orange" + }, + { + "cmd": "rect", + "x": 19, + "y": 5, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 20, + "y": 5, + "w": 1, + "h": 1, + "color": "purple" + }, + { + "cmd": "rect", + "x": 21, + "y": 5, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "text", + "content": "WELCOME! THE NEXT", + "row": 1, + "startCol": 2, + "endCol": 19, + "align": "left" + }, + { + "cmd": "text", + "content": "TOUR STARTS IN...", + "row": 2, + "startCol": 2, + "endCol": 19, + "align": "left" + }, + { + "cmd": "text", + "content": "15 MINUTES!", + "row": 4 + } + ] + }, + { + "input": "Say mazel tov to zev!", + "output": [ + { + "cmd": "fill", + "color": "black" + }, + { + "cmd": "rect", + "x": 0, + "y": 0, + "w": 22, + "h": 1, + "color": "white" + }, + { + "cmd": "rect", + "x": 0, + "y": 1, + "w": 22, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 0, + "y": 4, + "w": 22, + "h": 1, + "color": "white" + }, + { + "cmd": "rect", + "x": 0, + "y": 5, + "w": 22, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 0, + "y": 2, + "w": 3, + "h": 1, + "color": "white" + }, + { + "cmd": "rect", + "x": 19, + "y": 2, + "w": 3, + "h": 1, + "color": "white" + }, + { + "cmd": "rect", + "x": 0, + "y": 3, + "w": 3, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 19, + "y": 3, + "w": 3, + "h": 1, + "color": "blue" + }, + { + "cmd": "text", + "content": "MAZEL TOV!", + "row": 2 + }, + { + "cmd": "text", + "content": "ZEV", + "row": 3 + } + ] + }, + { + "input": "Put up a bedtime reminder", + "output": [ + { + "cmd": "fill", + "color": "black" + }, + { + "cmd": "text", + "content": "BEDTIME", + "row": 0, + "startCol": 1, + "endCol": 8, + "align": "left" + }, + { + "cmd": "rect", + "x": 15, + "y": 0, + "w": 4, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 17, + "y": 1, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 16, + "y": 2, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 15, + "y": 3, + "w": 4, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 9, + "y": 1, + "w": 4, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 11, + "y": 2, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 10, + "y": 3, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 9, + "y": 4, + "w": 4, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 3, + "y": 2, + "w": 4, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 5, + "y": 3, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 4, + "y": 4, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 3, + "y": 5, + "w": 4, + "h": 1, + "color": "blue" + } + ] + }, + { + "input": "Show a train schedule for the polar express", + "output": [ + { + "cmd": "fill", + "color": "black" + }, + { + "cmd": "rect", + "x": 0, + "y": 0, + "w": 2, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 20, + "y": 0, + "w": 2, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 0, + "y": 1, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 21, + "y": 1, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "text", + "content": "POLAR EXPRESS", + "row": 0 + }, + { + "cmd": "text", + "content": "12:00 AM", + "row": 1 + }, + { + "cmd": "text", + "content": "NORTH", + "row": 2, + "startCol": 2, + "endCol": 10, + "align": "left" + }, + { + "cmd": "text", + "content": "SOUTH", + "row": 2, + "startCol": 13, + "endCol": 21, + "align": "left" + }, + { + "cmd": "rect", + "x": 0, + "y": 3, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 0, + "y": 4, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 0, + "y": 5, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 11, + "y": 3, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 11, + "y": 4, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 11, + "y": 5, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "text", + "content": "03 MINS", + "row": 3, + "startCol": 2, + "endCol": 10, + "align": "left" + }, + { + "cmd": "text", + "content": "07 MINS", + "row": 4, + "startCol": 2, + "endCol": 10, + "align": "left" + }, + { + "cmd": "text", + "content": "10 MINS", + "row": 5, + "startCol": 2, + "endCol": 10, + "align": "left" + }, + { + "cmd": "text", + "content": "02 MINS", + "row": 3, + "startCol": 13, + "endCol": 21, + "align": "left" + }, + { + "cmd": "text", + "content": "06 MINS", + "row": 4, + "startCol": 13, + "endCol": 21, + "align": "left" + }, + { + "cmd": "text", + "content": "09 MINS", + "row": 5, + "startCol": 13, + "endCol": 21, + "align": "left" + } + ] + }, + { + "input": "Create a stretch reminder for me", + "output": [ + { + "cmd": "fill", + "color": "black" + }, + { + "cmd": "text", + "content": "STAND UP", + "row": 2, + "startCol": 1, + "endCol": 10, + "align": "left" + }, + { + "cmd": "text", + "content": "& STRETCH", + "row": 3, + "startCol": 1, + "endCol": 10, + "align": "left" + }, + { + "cmd": "rect", + "x": 16, + "y": 1, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 13, + "y": 2, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 14, + "y": 2, + "w": 5, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 19, + "y": 2, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 15, + "y": 3, + "w": 3, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 15, + "y": 4, + "w": 3, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 15, + "y": 5, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 17, + "y": 5, + "w": 1, + "h": 1, + "color": "blue" + } + ] + }, + { + "input": "birthday message for Sarah with confetti", + "output": [ + { "cmd": "fill", "color": "black" }, + { "cmd": "rect", "x": 0, "y": 0, "w": 1, "h": 1, "color": "red" }, + { "cmd": "rect", "x": 2, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 4, "y": 0, "w": 1, "h": 1, "color": "blue" }, + { "cmd": "rect", "x": 6, "y": 0, "w": 1, "h": 1, "color": "purple" }, + { "cmd": "rect", "x": 15, "y": 0, "w": 1, "h": 1, "color": "purple" }, + { "cmd": "rect", "x": 17, "y": 0, "w": 1, "h": 1, "color": "blue" }, + { "cmd": "rect", "x": 19, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 21, "y": 0, "w": 1, "h": 1, "color": "red" }, + { "cmd": "rect", "x": 1, "y": 1, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 5, "y": 1, "w": 1, "h": 1, "color": "green" }, + { "cmd": "rect", "x": 16, "y": 1, "w": 1, "h": 1, "color": "green" }, + { "cmd": "rect", "x": 20, "y": 1, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "text", "content": "HAPPY BIRTHDAY", "row": 2 }, + { "cmd": "text", "content": "SARAH!", "row": 3 }, + { "cmd": "rect", "x": 1, "y": 4, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 5, "y": 4, "w": 1, "h": 1, "color": "green" }, + { "cmd": "rect", "x": 16, "y": 4, "w": 1, "h": 1, "color": "green" }, + { "cmd": "rect", "x": 20, "y": 4, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 0, "y": 5, "w": 1, "h": 1, "color": "red" }, + { "cmd": "rect", "x": 2, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 4, "y": 5, "w": 1, "h": 1, "color": "blue" }, + { "cmd": "rect", "x": 6, "y": 5, "w": 1, "h": 1, "color": "purple" }, + { "cmd": "rect", "x": 15, "y": 5, "w": 1, "h": 1, "color": "purple" }, + { "cmd": "rect", "x": 17, "y": 5, "w": 1, "h": 1, "color": "blue" }, + { "cmd": "rect", "x": 19, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 21, "y": 5, "w": 1, "h": 1, "color": "red" } + ] + }, + { + "input": "Baby Emma was born on January 15th 2024 at 3:42pm, 7 pounds 4 ounces, 20 inches", + "output": [ + { "cmd": "fill", "color": "black" }, + { "cmd": "rect", "x": 0, "y": 1, "w": 22, "h": 1, "color": "red" }, + { "cmd": "text", "content": "BABY EMMA", "row": 0 }, + { "cmd": "text", "content": "DATE: JAN 15 2024", "row": 2, "startCol": 1, "endCol": 21, "align": "left" }, + { "cmd": "text", "content": "TIME: 3:42 PM", "row": 3, "startCol": 1, "endCol": 21, "align": "left" }, + { "cmd": "text", "content": "WEIGHT: 7 LBS 4 OZ", "row": 4, "startCol": 1, "endCol": 21, "align": "left" }, + { "cmd": "text", "content": "LENGTH: 20 IN", "row": 5, "startCol": 1, "endCol": 21, "align": "left" } + ] + }, + { + "input": "Congrats on the promotion!", + "output": [ + { "cmd": "fill", "color": "black" }, + { "cmd": "rect", "x": 0, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 1, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 2, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 3, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 4, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 5, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 6, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 7, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 8, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 9, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 10, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 11, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 12, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 13, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 14, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 15, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 16, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 17, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 18, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 19, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 20, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 21, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 0, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 1, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 2, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 3, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 4, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 5, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 6, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 7, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 8, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 9, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 10, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 11, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 12, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 13, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 14, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 15, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 16, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 17, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 18, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 19, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 20, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 21, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "text", "content": "CONGRATS ON THE", "row": 2 }, + { "cmd": "text", "content": "PROMOTION!", "row": 3 } + ] + } + ] +} \ No newline at end of file diff --git a/src/vesta/generate.ts b/src/vesta/generate.ts new file mode 100644 index 0000000..71f2145 --- /dev/null +++ b/src/vesta/generate.ts @@ -0,0 +1,55 @@ +import { GoogleGenerativeAI } from "@google/generative-ai" +import { draw, type DrawCommand } from "./draw" +import { sendGrid } from "./vestaboard" + +const buildSystemPrompt = async (): Promise => { + const promptMd = await Bun.file(`${import.meta.dir}/prompt.md`).text() + const examplesJson = await Bun.file(`${import.meta.dir}/examples.json`).json() + + const examplesText = examplesJson.goldExamples + .map((ex: { input: string; output: unknown }) => `Input: "${ex.input}"\nOutput: ${JSON.stringify(ex.output)}`) + .join("\n\n") + + return `${promptMd} + +## Examples + +${examplesText}` +} + +export const generateVestaboard = async (query: string): Promise => { + const apiKey = process.env.GEMINI_API_KEY + if (!apiKey) { + throw new Error("GEMINI_API_KEY not set in environment") + } + + const genAI = new GoogleGenerativeAI(apiKey) + const model = genAI.getGenerativeModel({ + model: "gemini-2.5-flash", + generationConfig: { + temperature: 0.7, + responseMimeType: "application/json", + }, + }) + + const systemPrompt = await buildSystemPrompt() + + const result = await model.generateContent(`${systemPrompt}\n\n## User Request\n\n${query}`) + const text = result.response.text() + + let commands: DrawCommand[] + try { + commands = JSON.parse(text) + } catch { + throw new Error(`Failed to parse Gemini response as JSON: ${text}`) + } + + if (!Array.isArray(commands)) { + throw new Error(`Expected array of commands, got: ${typeof commands}`) + } + + const grid = draw(commands) + await sendGrid(grid) + + return "The vestaboard design has been generated and sent successfully." +} diff --git a/src/vesta/prompt.md b/src/vesta/prompt.md new file mode 100644 index 0000000..fb21f74 --- /dev/null +++ b/src/vesta/prompt.md @@ -0,0 +1,27 @@ +# Vestaboard Layout Generator + +Output a JSON array of draw commands for a 6-row × 22-column display. + +## Rules +1. First command MUST be `fill` +2. Text has black cell backgrounds - use black `rect` behind text on colored backgrounds +3. Colors: red, orange, yellow, green, blue, purple, white, black +4. Coordinates: rows 0-5, columns 0-21 + +## Commands + +``` +fill { "cmd": "fill", "color": "black" } +rect { "cmd": "rect", "x": 0, "y": 0, "w": 22, "h": 1, "color": "orange" } +text { "cmd": "text", "content": "HELLO", "row": 2, "color": "white" } +text_block { "cmd": "text_block", "content": "LONG MESSAGE", "startRow": 1, "endRow": 4, "color": "white" } +border { "cmd": "border", "color": "blue", "sides": ["top", "bottom"] } +gradient { "cmd": "gradient", "direction": "horizontal", "colors": ["blue", "green", "yellow"] } +circle { "cmd": "circle", "cx": 11, "cy": 3, "r": 2, "color": "red" } +line { "cmd": "line", "x1": 0, "y1": 0, "x2": 21, "y2": 5, "color": "green" } +``` + +text/text_block options: `align` (left/center/right), `startCol`, `endCol` + +## Output +JSON array only. No markdown, no explanation. diff --git a/src/vesta/render.ts b/src/vesta/render.ts new file mode 100644 index 0000000..160e74f --- /dev/null +++ b/src/vesta/render.ts @@ -0,0 +1,151 @@ +import sharp from "sharp" + +// Character to vestaboard code mapping +const charToCode: Record = { + " ": 0, + A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8, I: 9, + J: 10, K: 11, L: 12, M: 13, N: 14, O: 15, P: 16, Q: 17, + R: 18, S: 19, T: 20, U: 21, V: 22, W: 23, X: 24, Y: 25, Z: 26, + "1": 27, "2": 28, "3": 29, "4": 30, "5": 31, "6": 32, "7": 33, "8": 34, "9": 35, "0": 36, + "!": 37, "@": 38, "#": 39, "$": 40, "(": 41, ")": 42, "-": 44, "+": 46, + "&": 47, "=": 48, ";": 49, ":": 50, "'": 52, '"': 53, "%": 54, ",": 55, + ".": 56, "/": 59, "?": 60, "°": 62, + "🟥": 63, "🟧": 64, "🟨": 65, "🟩": 66, "🟦": 67, "🟪": 68, "⬜": 69, "⬛": 70, +} + +// Code to display info for rendering +const codeToDisplay: Record = { + 0: { bg: "#1a1a1a", fg: "#ffffff" }, // space (empty black tile) + 63: { bg: "#e63946", fg: "#ffffff" }, // red + 64: { bg: "#f4a261", fg: "#1a1a1a" }, // orange + 65: { bg: "#e9c46a", fg: "#1a1a1a" }, // yellow + 66: { bg: "#2a9d8f", fg: "#ffffff" }, // green + 67: { bg: "#0077b6", fg: "#ffffff" }, // blue + 68: { bg: "#9b5de5", fg: "#ffffff" }, // purple + 69: { bg: "#ffffff", fg: "#1a1a1a" }, // white + 70: { bg: "#1a1a1a", fg: "#ffffff" }, // black +} + +// Code to character (for letters/numbers/symbols) +const codeToChar: Record = {} +for (const [char, code] of Object.entries(charToCode)) { + if (code >= 1 && code <= 62) { + codeToChar[code] = char + } +} + +const TILE_SIZE = 24 +const GAP = 2 +const COLS = 22 +const ROWS = 6 + +// Parse emoji grid string to number array +export const parseEmojiGrid = (grid: string): number[][] => { + const lines = grid.trim().split("\n") + const result: number[][] = [] + + for (const line of lines) { + const row: number[] = [] + const chars = [...line] // Handle multi-byte emoji correctly + + for (const char of chars) { + const code = charToCode[char.toUpperCase()] + if (code !== undefined) { + row.push(code) + } + } + + // Pad or trim to 22 columns + while (row.length < COLS) row.push(0) + if (row.length > COLS) row.length = COLS + + result.push(row) + } + + // Pad or trim to 6 rows + while (result.length < ROWS) result.push(Array(COLS).fill(0)) + if (result.length > ROWS) result.length = ROWS + + return result +} + +// Convert number grid to emoji string +export const gridToEmoji = (grid: number[][]): string => { + const emojiMap: Record = { + 0: " ", + 63: "🟥", 64: "🟧", 65: "🟨", 66: "🟩", 67: "🟦", 68: "🟪", 69: "⬜", 70: "⬛", + } + + return grid + .map((row) => + row + .map((code) => { + if (emojiMap[code] !== undefined) return emojiMap[code] + if (codeToChar[code]) return codeToChar[code] + return " " + }) + .join("") + ) + .join("\n") +} + +// Render grid to SVG string +const gridToSvg = (grid: number[][]): string => { + const width = COLS * TILE_SIZE + (COLS - 1) * GAP + const height = ROWS * TILE_SIZE + (ROWS - 1) * GAP + + let svg = `` + svg += `` // background + + for (let row = 0; row < ROWS; row++) { + for (let col = 0; col < COLS; col++) { + const code = grid[row]![col]! + const x = col * (TILE_SIZE + GAP) + const y = row * (TILE_SIZE + GAP) + + // Determine tile appearance + let bg = "#1a1a1a" + let fg = "#ffffff" + let char: string | undefined + + const display = codeToDisplay[code] + if (display) { + bg = display.bg + fg = display.fg + } else if (codeToChar[code]) { + char = codeToChar[code] + bg = "#1a1a1a" + fg = "#ffffff" + } + + // Draw tile + svg += `` + + // Draw character if present + if (char) { + const fontSize = 14 + const textX = x + TILE_SIZE / 2 + const textY = y + TILE_SIZE / 2 + fontSize * 0.35 + // Escape XML special characters + const escaped = char.replace(/&/g, '&').replace(//g, '>') + svg += `${escaped}` + } + } + } + + svg += "" + return svg +} + +// Render emoji grid string to PNG buffer +export const renderToPng = async (emojiGrid: string): Promise => { + const grid = parseEmojiGrid(emojiGrid) + const svg = gridToSvg(grid) + return sharp(Buffer.from(svg)).png().toBuffer() +} + +// Render number grid to PNG buffer +export const renderGridToPng = async (grid: number[][]): Promise => { + const svg = gridToSvg(grid) + return sharp(Buffer.from(svg)).png().toBuffer() +} diff --git a/src/vesta/vestaboard.ts b/src/vesta/vestaboard.ts new file mode 100644 index 0000000..32d025c --- /dev/null +++ b/src/vesta/vestaboard.ts @@ -0,0 +1,26 @@ +const API_URL = "https://rw.vestaboard.com/" + +export const sendGrid = async (grid: number[][]): Promise => { + const apiKey = process.env.VESTABOARD_API_KEY + if (!apiKey) { + throw new Error("VESTABOARD_API_KEY not set in environment") + } + + console.log(`🌭 sending`) + const response = await fetch(API_URL, { + method: "POST", + headers: { + "X-Vestaboard-Read-Write-Key": apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify(grid), + }) + + if (!response.ok) { + const text = await response.text() + console.log(`🌭 Error ${text}`) + throw new Error(`Vestaboard API error (${response.status}): ${text}`) + } else { + console.log(`🌭 sent successfully`, await response.text()) + } +}