Compare commits

...

3 Commits

Author SHA1 Message Date
98c5d48846 Merge pull request 'dial-failed-handling' (#7) from dial-failed-handling into main
Reviewed-on: #7
2026-01-22 00:47:47 +00:00
c1ab9a4bb5 so many things 2026-01-21 16:47:23 -08:00
bed1fa0eb8 Handle dial failures with error tone and return to dial tone
When a dial command times out (e.g., due to SIP registration failure):
- Play an error tone (fast busy signal)
- Return to ready state (dial tone resumes)

This provides feedback to the user when calls can't be placed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:55:32 -08:00
15 changed files with 2207 additions and 77 deletions

4
.gitignore vendored
View File

@ -34,3 +34,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.DS_Store
.claude.worktrees/
.worktrees/
docs/learning/
docs/plans/

BIN
sounds/stalling/hum.wav Normal file

Binary file not shown.

63
src/agent/personality.md Normal file
View File

@ -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

View File

@ -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<typeof phoneMachine>
@ -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<typeof phoneMachine>, ctx: PhoneContext) => {
const startAgent = async (service: Service<typeof phoneMachine>, ctx: PhoneContext, hasDialFailure = false) => {
let streamPlayback = player.playStream()
const agent = new Agent({
@ -188,7 +184,10 @@ const startAgent = (service: Service<typeof phoneMachine>, 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<typeof phoneMachine>, ctx: PhoneContext) =>
return ctx
}
const startListening = (service: Service<typeof phoneMachine>, agent: Agent) => {
function startListening(service: Service<typeof phoneMachine>, agent: Agent) {
const abortAgent = new AbortController()
new Promise<void>(async (resolve) => {
@ -254,6 +253,42 @@ const startListening = (service: Service<typeof phoneMachine>, 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<typeof phoneMachine>,
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)
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,
t("answered", "connected"),
t("dial-failed", "ready", r(storeDialFailure)),
t("hang-up", "idle", a(hangUp))),
aborted: state(
t("hang-up", "idle")),

View File

@ -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<boolean> => {
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
}
}

View File

@ -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<TwilioAccountInfo | undefined> {
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}`
}

55
src/vesta/README.md Normal file
View File

@ -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 <plugin> [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

107
src/vesta/cli.ts Normal file
View File

@ -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<string, number> = {
"!": 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 <message>
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()

258
src/vesta/draw.test.ts Normal file
View File

@ -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" },
])
})

366
src/vesta/draw.ts Normal file
View File

@ -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<Color, number> = {
red: 63,
orange: 64,
yellow: 65,
green: 66,
blue: 67,
purple: 68,
white: 69,
black: 70,
}
const charToCode: Record<string, number> = {
" ": 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
}

991
src/vesta/examples.json Normal file
View File

@ -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 }
]
}
]
}

55
src/vesta/generate.ts Normal file
View File

@ -0,0 +1,55 @@
import { GoogleGenerativeAI } from "@google/generative-ai"
import { draw, type DrawCommand } from "./draw"
import { sendGrid } from "./vestaboard"
const buildSystemPrompt = async (): Promise<string> => {
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<string> => {
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."
}

27
src/vesta/prompt.md Normal file
View File

@ -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.

151
src/vesta/render.ts Normal file
View File

@ -0,0 +1,151 @@
import sharp from "sharp"
// Character to vestaboard code mapping
const charToCode: Record<string, number> = {
" ": 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<number, { bg: string; fg: string; char?: string }> = {
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<number, string> = {}
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<number, string> = {
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 xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`
svg += `<rect width="${width}" height="${height}" fill="#0d0d0d"/>` // 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 += `<rect x="${x}" y="${y}" width="${TILE_SIZE}" height="${TILE_SIZE}" rx="2" fill="${bg}"/>`
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
svg += `<text x="${textX}" y="${textY}" font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold" fill="${fg}" text-anchor="middle">${escaped}</text>`
}
}
}
svg += "</svg>"
return svg
}
// Render emoji grid string to PNG buffer
export const renderToPng = async (emojiGrid: string): Promise<Buffer> => {
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<Buffer> => {
const svg = gridToSvg(grid)
return sharp(Buffer.from(svg)).png().toBuffer()
}

26
src/vesta/vestaboard.ts Normal file
View File

@ -0,0 +1,26 @@
const API_URL = "https://rw.vestaboard.com/"
export const sendGrid = async (grid: number[][]): Promise<void> => {
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())
}
}