update_bot takes a look (oo1: value), validated and normalized in core. A suggested look renders as a "New look" card with the avatar preview on web and mobile; accepting applies it to the bot. Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj Co-authored-by: Claude <noreply@anthropic.com>
143 lines
4.5 KiB
TypeScript
143 lines
4.5 KiB
TypeScript
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<string> = 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<string, string> = {
|
|
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<string, unknown>,
|
|
): { 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, unknown>): 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<string, unknown>;
|
|
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<CardBlock, "suggestion">,
|
|
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) } : {}) },
|
|
};
|
|
}
|