evOo-local/packages/core/src/suggestions.ts
cynarAI c69f00e891
feat(avatars): bots offer a new Oo look from chat (#145)
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>
2026-10-05 04:03:10 +02:00

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) } : {}) },
};
}