import type { CardAct, CardBlock, CardQuantity, CardSection, SuggestionStatus, } from "@rakazo/contracts"; import { CARD_LIMITS, CardBlockSchema } from "@rakazo/contracts"; type CardChecklistItem = Extract["items"][number]; type CardStep = Extract["steps"][number]; type CardAction = Extract["actions"][number]; /** What `show_card` accepts: plain content in a fixed order, never layout. */ export interface ShowCardArgs { title?: unknown; subtitle?: unknown; meta?: unknown; facts?: unknown; stepper?: unknown; checklist?: unknown; steps?: unknown; start_label?: unknown; options?: unknown; multiple?: unknown; actions?: unknown; } const strings = (value: unknown): string[] => Array.isArray(value) ? value.map((item) => String(item ?? "").trim()).filter(Boolean) : []; const text = (value: unknown) => (typeof value === "string" ? value.trim() : ""); const field = (value: unknown, key: string): unknown => value && typeof value === "object" ? (value as Record)[key] : undefined; const positive = (value: unknown) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; function checklistItems(value: unknown): CardChecklistItem[] { if (!Array.isArray(value)) return []; const items: CardChecklistItem[] = []; for (const raw of value) { const label = typeof raw === "string" ? raw.trim() : text(field(raw, "label")); if (!label) continue; const amount = positive(field(raw, "amount")); const unit = text(field(raw, "unit")); const plural = text(field(raw, "plural")); items.push({ id: `i${items.length + 1}`, label, ...(amount ? { quantity: { amount, ...(unit ? { unit } : {}), ...(plural ? { plural } : {}) } } : {}), }); } return items; } function stepItems(value: unknown): CardStep[] { if (!Array.isArray(value)) return []; const steps: CardStep[] = []; for (const raw of value) { const stepText = typeof raw === "string" ? raw.trim() : text(field(raw, "text")); if (!stepText) continue; const minutes = positive(field(raw, "timer_minutes")); steps.push({ id: `s${steps.length + 1}`, text: stepText, ...(minutes ? { timerSeconds: Math.round(minutes * 60) } : {}), }); } return steps; } /** Build a validated card from tool arguments; the error is phrased for the model. */ export function buildCardFromTool( args: ShowCardArgs, id: string, ): { ok: true; block: CardBlock } | { error: string } { const title = text(args.title); if (!title) return { error: "title is required." }; const facts = Array.isArray(args.facts) ? args.facts .map((row) => ({ label: text(field(row, "label")), value: text(field(row, "value")) })) .filter((row) => row.label && row.value) : []; const stepperLabel = text(field(args.stepper, "label")); const stepperValue = positive(field(args.stepper, "value")); const checklist = checklistItems(args.checklist); const steps = stepItems(args.steps); const startLabel = text(args.start_label); const options = strings(args.options); const actions = strings(args.actions); const multiple = args.multiple === true; if (!facts.length && !checklist.length && !steps.length && !options.length && !actions.length) { return { error: "Add facts, checklist, steps, options or actions; a card with only a title says nothing.", }; } if (options.length === 1) return { error: "options needs at least two choices." }; if (multiple && options.length && !actions.length) { return { error: 'A multiple choice needs an action to send it, such as "Send".' }; } if (args.stepper !== undefined && (!stepperLabel || !stepperValue)) { return { error: 'stepper needs a label and a whole number value, such as "Servings", 2.' }; } if (startLabel && !steps.length) return { error: "start_label needs steps to walk through." }; const subtitle = text(args.subtitle); const meta = strings(args.meta); const sections: CardSection[] = [ { type: "header", title, ...(subtitle ? { subtitle } : {}), ...(meta.length ? { meta } : {}), }, ]; if (facts.length) sections.push({ type: "facts", rows: facts }); if (stepperLabel && stepperValue) { const value = Math.round(stepperValue); sections.push({ type: "stepper", id: "stepper", label: stepperLabel, base: value, value }); } if (checklist.length) sections.push({ type: "checklist", id: "checklist", items: checklist }); if (steps.length) sections.push({ type: "steps", id: "steps", steps }); if (options.length) { sections.push({ type: "select", id: "select", ...(multiple ? { multiple: true } : {}), options: options.map((label, index) => ({ id: `o${index + 1}`, label })), }); } const allActions: CardAction[] = [ ...(startLabel ? [{ id: "start", label: startLabel, kind: "steps" as const }] : []), ...actions.map((label, index) => ({ id: `a${index + 1}`, label, kind: "reply" as const })), ]; if (allActions.length) sections.push({ type: "actions", actions: allActions }); if (sections.length > CARD_LIMITS.sections) { return { error: `A card holds at most ${CARD_LIMITS.sections} parts including the title; leave out facts, options or actions.`, }; } const parsed = CardBlockSchema.safeParse({ kind: "card", id, sections }); if (!parsed.success) { const issue = parsed.error.issues[0]; return { error: `Card is not valid: ${issue?.path.join(".")} ${issue?.message}`.trim() }; } return { ok: true, block: parsed.data }; } export type CardActResult = | { ok: true; block: CardBlock; changed: boolean; reply?: string; /** Set when the tap settles a suggestion; the caller runs or drops the stored call. */ suggestion?: "accept" | "dismiss"; } | { ok: false; error: string }; function section( block: CardBlock, type: T, id?: string, ): Extract | undefined { return block.sections?.find( (candidate): candidate is Extract => candidate.type === type && (id === undefined || ("id" in candidate && candidate.id === id)), ); } function replaceSection(block: CardBlock, next: CardSection, index: number): CardBlock { const sections = [...(block.sections ?? [])]; sections[index] = next; return { ...block, sections }; } function sectionIndex(block: CardBlock, type: CardSection["type"], id: string) { return (block.sections ?? []).findIndex( (candidate) => candidate.type === type && "id" in candidate && candidate.id === id, ); } /** * Apply one tap. Checklists, servings, steps and timers stay usable after the card is answered * (a shopping list is still a shopping list); a choice or an action answers the card once. * Every act names the state it wants, so a retried tap changes nothing. */ export function applyCardAct( block: CardBlock, act: CardAct, now: Date = new Date(), ): CardActResult { const sections = block.sections ?? []; if (act.type === "stepper") { const index = sectionIndex(block, "stepper", act.sectionId); const stepper = sections[index]; if (stepper?.type !== "stepper") return { ok: false, error: "No such stepper." }; if (stepper.value === act.value) return { ok: true, changed: false, block }; return { ok: true, changed: true, block: replaceSection(block, { ...stepper, value: act.value }, index), }; } if (act.type === "step" || act.type === "timer") { const index = sectionIndex(block, "steps", act.sectionId); const list = sections[index]; if (list?.type !== "steps") return { ok: false, error: "No such steps." }; if (act.type === "step") { if (act.index >= list.steps.length) return { ok: false, error: "No such step." }; if ((list.current ?? 0) === act.index) return { ok: true, changed: false, block }; return { ok: true, changed: true, block: replaceSection(block, { ...list, current: act.index }, index), }; } const step = list.steps.find((candidate) => candidate.id === act.stepId); if (!step?.timerSeconds) return { ok: false, error: "This step has no timer." }; const running = Boolean(step.timerEndsAt && Date.parse(step.timerEndsAt) > now.getTime()); if (running === act.running) return { ok: true, changed: false, block }; const steps = list.steps.map((candidate) => { if (candidate.id !== step.id) return candidate; const { timerEndsAt: _ended, ...rest } = candidate; return act.running ? { ...rest, timerEndsAt: new Date(now.getTime() + (step.timerSeconds ?? 0) * 1000).toISOString(), } : rest; }); return { ok: true, changed: true, block: replaceSection(block, { ...list, steps }, index) }; } if (act.type === "toggle") { const index = sections.findIndex( (candidate) => candidate.type === "checklist" && candidate.id === act.sectionId, ); const list = sections[index]; if (list?.type !== "checklist") return { ok: false, error: "No such checklist." }; if (!list.items.some((item) => item.id === act.itemId)) { return { ok: false, error: "No such item." }; } const current = list.items.find((item) => item.id === act.itemId); if (Boolean(current?.checked) === act.checked) return { ok: true, changed: false, block }; const items = list.items.map((item) => item.id === act.itemId ? { ...item, checked: act.checked } : item, ); return { ok: true, changed: true, block: replaceSection(block, { ...list, items }, index) }; } if (act.type === "action" && block.suggestion) { const kind = section(block, "actions")?.actions.find((a) => a.id === act.actionId)?.kind; if (kind !== "accept" && kind !== "dismiss") return { ok: false, error: "No such action." }; if (block.suggestion.status !== "proposed") { return { ok: false, error: "This suggestion was already settled." }; } return { ok: true, changed: true, suggestion: kind, block: { ...block, suggestion: { ...block.suggestion, status: kind === "accept" ? "running" : "dismissed" }, }, }; } if (block.answer) return { ok: false, error: "This card was already answered." }; if (act.type === "select") { const index = sections.findIndex( (candidate) => candidate.type === "select" && candidate.id === act.sectionId, ); const select = sections[index]; if (select?.type !== "select") return { ok: false, error: "No such choice." }; const option = select.options.find((candidate) => candidate.id === act.optionId); if (!option) return { ok: false, error: "No such option." }; if (select.multiple) { const current = select.selected ?? []; const on = act.selected ?? !current.includes(option.id); if (current.includes(option.id) === on) return { ok: true, changed: false, block }; // Keep the options' order so the reply reads the way the card does. const selected = select.options .map((candidate) => candidate.id) .filter((id) => (id === option.id ? on : current.includes(id))); return { ok: true, changed: true, block: replaceSection(block, { ...select, selected }, index), }; } const next = replaceSection(block, { ...select, selected: [option.id] }, index); return { ok: true, changed: true, reply: option.label, block: { ...next, answer: { text: option.label } }, }; } const actions = section(block, "actions"); const action = actions?.actions.find((candidate) => candidate.id === act.actionId); if (!action) return { ok: false, error: "No such action." }; if (action.kind !== "reply") return { ok: false, error: "This action opens on the device." }; const select = section(block, "select"); const chosen = select?.options .filter((option) => select.selected?.includes(option.id)) .map((option) => option.label); if (select?.multiple && !chosen?.length) { return { ok: false, error: "Choose at least one option first." }; } const reply = chosen?.length ? `${action.label}: ${chosen.join(", ")}` : action.label; return { ok: true, changed: true, reply, block: { ...block, answer: { text: reply, actionId: action.id } }, }; } const SUGGESTION_HISTORY: Record = { proposed: "waiting for the user", running: "accepted, running", done: "accepted and done", failed: "accepted but failed", dismissed: "dismissed by the user", expired: "expired", }; /** The card as a later turn reads it: content plus what the user has done with it. */ export function cardHistoryText(block: CardBlock): string { if (!block.sections) return (block.lines ?? []).map((line) => `${line.k}: ${line.v}`).join("\n"); const parts: string[] = []; for (const part of block.sections) { if (part.type === "header") { parts.push([part.title, part.subtitle, ...(part.meta ?? [])].filter(Boolean).join(" · ")); } else if (part.type === "facts") { parts.push(part.rows.map((row) => `${row.label}: ${row.value}`).join("; ")); } else if (part.type === "stepper") { parts.push(`${part.label}: ${part.value}`); } else if (part.type === "steps") { parts.push(`step ${(part.current ?? 0) + 1}/${part.steps.length}`); } else if (part.type === "checklist") { const factor = cardScale(block); const done = part.items.filter((item) => item.checked).length; const open = part.items .filter((item) => !item.checked) .map((item) => checklistItemText(item, factor)); parts.push( `checklist ${done}/${part.items.length} done${open.length ? `, open: ${open.join(", ")}` : ""}`, ); } else if (part.type === "select") { const chosen = part.options.filter((option) => part.selected?.includes(option.id)); parts.push( `options: ${part.options.map((option) => option.label).join(" | ")}${ chosen.length ? ` (selected: ${chosen.map((option) => option.label).join(", ")})` : "" }`, ); } else { parts.push(`actions: ${part.actions.map((action) => action.label).join(" | ")}`); } } if (block.answer) parts.push(`answered: ${block.answer.text}`); if (block.suggestion) { parts[parts.length - 1] = `${block.suggestion.tool} ${SUGGESTION_HISTORY[block.suggestion.status]}`; if (block.suggestion.error) parts.push(block.suggestion.error); return `[suggestion: ${parts.join(" — ")}]`; } return `[card: ${parts.join(" — ")}]`; } /** How much the card's quantities scale: the stepper's value over the amount they were written for. */ export function cardScale(block: CardBlock): number { const stepper = block.sections?.find((part) => part.type === "stepper"); return stepper?.type === "stepper" ? stepper.value / stepper.base : 1; } const FRACTIONS: Array<[number, string]> = [ [0, ""], [1 / 4, "¼"], [1 / 3, "⅓"], [1 / 2, "½"], [2 / 3, "⅔"], [3 / 4, "¾"], [1, ""], ]; /** * A scaled amount as a cook would write it: kitchen fractions below ten ("1½"), whole numbers * above, and steps of five from a hundred on ("300 g", never "299.99 g"). */ export function formatCardAmount(amount: number): string { if (amount >= 100) return String(Math.round(amount / 5) * 5); if (amount >= 10) return String(Math.round(amount)); let whole = Math.floor(amount); const rest = amount - whole; const [value, glyph] = FRACTIONS.reduce((best, candidate) => Math.abs(candidate[0] - rest) < Math.abs(best[0] - rest) ? candidate : best, ); if (value === 1) whole += 1; if (whole === 0) return glyph || formatSmallAmount(amount); return `${whole}${glyph}`; } /** Below an eighth, kitchen fractions would round a pinch away; keep one decimal instead. */ function formatSmallAmount(amount: number): string { return String(Math.max(0.1, Math.round(amount * 10) / 10)); } /** A checklist entry's amount and name at the card's current scale. */ export function checklistItemDisplay( item: { label: string; quantity?: CardQuantity }, factor: number, ): { amount?: string; label: string } { if (!item.quantity) return { label: item.label }; const formatted = formatCardAmount(item.quantity.amount * factor); const many = !item.quantity.unit && formatted !== "1" && Number.parseFloat(formatted) >= 1; return { amount: [formatted, item.quantity.unit].filter(Boolean).join(" "), label: many && item.quantity.plural ? item.quantity.plural : item.label, }; } function checklistItemText(item: { label: string; quantity?: CardQuantity }, factor: number) { const shown = checklistItemDisplay(item, factor); return shown.amount ? `${shown.amount} ${shown.label}` : shown.label; } /** Seconds left on a started step timer, or null when it is not running. */ export function cardTimerRemaining(timerEndsAt: string | undefined, now: number): number | null { if (!timerEndsAt) return null; return Math.max(0, Math.ceil((Date.parse(timerEndsAt) - now) / 1000)); } /** A timer as a clock: "9:58" or "1:05:00". */ export function formatCardClock(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const rest = String(seconds % 60).padStart(2, "0"); return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}` : `${minutes}:${rest}`; } /** A timer's length in the user's language, such as "10 min". */ export function formatCardTimerLength(seconds: number, locale: string): string { const unit = (value: number, name: "second" | "minute" | "hour") => new Intl.NumberFormat(locale, { style: "unit", unit: name }).format(value); if (seconds < 60) return unit(seconds, "second"); const minutes = Math.round(seconds / 60); return minutes % 60 === 0 ? unit(minutes / 60, "hour") : unit(minutes, "minute"); }