cynarAI 6fd136e944
feat: suggestions the user accepts or dismisses with one tap (#142)
Bots can offer an exact tool call as a card. Nothing runs until the user
taps Accept; the call then runs as its own approved run of the bot it was
meant for, and the card shows the outcome. Open suggestions also wait in
the inbox and expire after a week.


Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 02:19:31 +02:00

452 lines
18 KiB
TypeScript

import type {
CardAct,
CardBlock,
CardQuantity,
CardSection,
SuggestionStatus,
} from "@rakazo/contracts";
import { CARD_LIMITS, CardBlockSchema } from "@rakazo/contracts";
type CardChecklistItem = Extract<CardSection, { type: "checklist" }>["items"][number];
type CardStep = Extract<CardSection, { type: "steps" }>["steps"][number];
type CardAction = Extract<CardSection, { type: "actions" }>["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<string, unknown>)[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<T extends CardSection["type"]>(
block: CardBlock,
type: T,
id?: string,
): Extract<CardSection, { type: T }> | undefined {
return block.sections?.find(
(candidate): candidate is Extract<CardSection, { type: T }> =>
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<SuggestionStatus, string> = {
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");
}