import type { CardBlock, SuggestionStatus } from "@rakazo/contracts"; import { CARD_LIMITS, SUGGESTION_TTL_MS } from "@rakazo/contracts"; import { formatCron } from "./cron.js"; import { normalizeOoLook } from "./oo-avatar.js"; /** Tools a suggestion may never wrap: they talk to the user or steer the run itself. */ export const UNSUGGESTABLE_TOOLS: ReadonlySet = new Set([ "suggest", "show_card", "ask_user", "message_user", "request_secret", "request_takeover", "run_subagent", ]); /** Plain names for builtin tools; anything else is named from its tool name. */ const TOOL_TITLES: Record = { schedule_create: "Set up a routine", schedule_cancel: "Stop a routine", remember: "Remember this", write_file: "Write a file", web_search: "Search the web", delegate_task: "Hand this to a specialist", spawn_bot: "Create a bot", update_bot: "Update profile", create_project: "Create a project", update_goal: "Set a goal", update_habit: "Track a habit", scratchpad_add: "Note this for later", "destination.write": "Save it", }; /** Arguments that say what a call is about, in the order a reader looks for them. */ const SUBJECT_KEYS = [ "name", "title", "query", "path", "bot", "content", "prompt", "task", "brief", ]; function clip(value: string, max: number) { const flat = value.replace(/\s+/g, " ").trim(); return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; } /** "gmail_send_email" reads as "Gmail send email". */ function humanizeTool(tool: string): string { const words = tool.replace(/[._-]+/g, " ").trim(); return words ? words[0]!.toUpperCase() + words.slice(1) : tool; } /** * What a suggestion will do, written by the backend from the exact call rather than by the model, * so the button always says what really happens. */ export function describeSuggestion( tool: string, args: Record, ): { title: string; details: string[]; avatar?: string } { const details: string[] = []; for (const key of SUBJECT_KEYS) { const value = args[key]; if (typeof value === "string" && value.trim()) details.push(clip(value, CARD_LIMITS.label)); if (details.length === CARD_LIMITS.meta) break; } if (tool === "schedule_create" && typeof args.cron === "string" && details.length < 3) { details.push(clip(formatCron(args.cron), CARD_LIMITS.label)); } const avatar = suggestedLook(tool, args); if (avatar) return { title: "New look", details, avatar }; return { title: TOOL_TITLES[tool] ?? humanizeTool(tool), details }; } /** The look a suggested `update_bot` call would apply, so the card can show it. */ function suggestedLook(tool: string, args: Record): string | undefined { return tool === "update_bot" ? (normalizeOoLook(args.look) ?? undefined) : undefined; } /** A suggestion card: what the call does, why, and Accept or Dismiss. */ export function buildSuggestionCard(input: { id: string; effectId: string; tool: string; args: Record; why?: string; }): CardBlock { const { title, details, avatar } = describeSuggestion(input.tool, input.args); const why = input.why?.trim(); return { kind: "card", id: input.id, sections: [ { type: "header", title, ...(why ? { subtitle: clip(why, CARD_LIMITS.title) } : {}), ...(details.length ? { meta: details } : {}), ...(avatar ? { avatar } : {}), }, { type: "actions", actions: [ { id: "accept", label: "Accept", kind: "accept" }, { id: "dismiss", label: "Dismiss", kind: "dismiss" }, ], }, ], suggestion: { effectId: input.effectId, tool: input.tool, status: "proposed" }, }; } /** A proposed suggestion past its time reads as expired, whatever is stored. */ export function suggestionStatus( card: Pick, createdAt: Date | string, now: number = Date.now(), ): SuggestionStatus | undefined { const status = card.suggestion?.status; if (status !== "proposed") return status; const created = typeof createdAt === "string" ? Date.parse(createdAt) : createdAt.getTime(); return now - created > SUGGESTION_TTL_MS ? "expired" : status; } /** The same card with its suggestion moved on. */ export function withSuggestionStatus( card: CardBlock, status: SuggestionStatus, error?: string, ): CardBlock { if (!card.suggestion) return card; const { error: _previous, ...rest } = card.suggestion; return { ...card, suggestion: { ...rest, status, ...(error ? { error: clip(error, 300) } : {}) }, }; }