evOo-local/apps/web/src/lib/bot-persona.ts
Cursor Agent 695d024f9c
Fix CI: executor space mocks, optional image tools test, i18n catalogs
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-07 21:41:56 +00:00

514 lines
15 KiB
TypeScript

import type { ThreadSnapshot } from "@rakazo/contracts";
import { type EvooPose, mapPersonaArchetypeToIdlePose, mapVoicePhaseToPose } from "@rakazo/ui-web";
import type { StagePresencePhase } from "../components/stage/StageHero";
import type { VoicePhase } from "../components/voice/VoiceStage";
import type { GroupSpark } from "./group-stage";
/**
* Template-aligned archetypes — derived from bot fields, not a DB column.
*
* Personalization bar (Ingo): lifestyle-agnostic crew fit — office, scene, nerd, all the same
* mechanism. Tone is geek-peer OR quiet-doer, never condescending Wunderwuzzi. Onboarding harvest
* suggests lanes (`user-help-vibe.ts`); spawn/select specialists accordingly. See
* `docs/PERSONA-SELECTION.md`.
*
* Relationship over time: always-on presence, compounding via use, never creepy, not yes-men —
* `docs/OO-RELATIONSHIP.md`, seeded in user MEMORY.md.
*/
export type PersonaArchetype =
| "cto-executive"
| "chief-operator"
| "builder"
| "curator"
| "inbox-work"
| "inbox-life"
| "home-steward"
| "quant"
| "engineer"
| "specialist";
export type PersonaCadence = "deliberate" | "steady" | "urgent";
export interface BotPersonaInput {
name: string;
title: string;
description: string;
}
export interface BotPersonaProfile {
archetype: PersonaArchetype;
cadence: PersonaCadence;
/** Short role line shown under the bot name in voice mode. */
roleLine: string;
bloomAccent: "cyan" | "emerald" | "violet" | "amber" | "rose";
/** Preferred idle pose when not driven by runtime status. */
idlePose: EvooPose;
}
export interface VoiceAwarenessEvent {
kind: "subagent" | "handoff" | "delegation" | "peer-reply" | "working";
actorName: string;
detail: string;
targetName?: string;
}
const ARCHETYPE_ORDER: readonly PersonaArchetype[] = [
"cto-executive",
"chief-operator",
"builder",
"curator",
"inbox-work",
"inbox-life",
"home-steward",
"quant",
"engineer",
"specialist",
] as const;
const ARCHETYPE_RULES: ReadonlyArray<{
archetype: PersonaArchetype;
match: (haystack: string) => boolean;
}> = [
{
archetype: "cto-executive",
match: (h) =>
h.includes("steve jobs") || h.includes("autonomous cto") || h.includes("cto-steve"),
},
{
archetype: "chief-operator",
match: (h) =>
h.includes("fixer") ||
h.includes("chief of staff") ||
h.includes("chief-of-staff") ||
(h.includes("chief") && !h.includes("technology")),
},
{
archetype: "builder",
match: (h) => h.includes("eggbot") || h.includes("dr. egg") || h.includes("bot builder"),
},
{
archetype: "curator",
match: (h) => h.includes("webby") || h.includes("website admin") || h.includes("newsletter"),
},
{
archetype: "inbox-work",
match: (h) =>
(h.includes("work") && h.includes("inbox")) || h.includes("templates/work") || h === "work",
},
{
archetype: "inbox-life",
match: (h) =>
(h.includes("life") && h.includes("inbox")) || h.includes("templates/life") || h === "life",
},
{
archetype: "home-steward",
match: (h) =>
h.includes("home-robot") ||
h.includes("home robot") ||
h.includes("matter") ||
h.includes("smart home"),
},
{
archetype: "quant",
match: (h) =>
h.includes("apex") ||
h.includes("quant") ||
h.includes("trad") ||
h.includes("portfolio") ||
h.includes("alpha"),
},
{
archetype: "engineer",
match: (h) =>
h.includes("architect") ||
h.includes("devops") ||
h.includes("security") ||
h.includes("infra") ||
h.includes("code ") ||
h.endsWith(" code") ||
h.includes("engineer"),
},
];
const PROFILE: Record<PersonaArchetype, Omit<BotPersonaProfile, "archetype" | "roleLine">> = {
"cto-executive": {
cadence: "deliberate",
bloomAccent: "violet",
idlePose: "analyzing",
},
"chief-operator": {
cadence: "steady",
bloomAccent: "amber",
idlePose: "collaborating",
},
builder: {
cadence: "steady",
bloomAccent: "cyan",
idlePose: "curious",
},
curator: {
cadence: "deliberate",
bloomAccent: "emerald",
idlePose: "reading",
},
"inbox-work": {
cadence: "urgent",
bloomAccent: "cyan",
idlePose: "waiting",
},
"inbox-life": {
cadence: "steady",
bloomAccent: "rose",
idlePose: "concerned",
},
"home-steward": {
cadence: "steady",
bloomAccent: "emerald",
idlePose: "listening",
},
quant: {
cadence: "urgent",
bloomAccent: "cyan",
idlePose: "analyzing",
},
engineer: {
cadence: "steady",
bloomAccent: "cyan",
idlePose: "working",
},
specialist: {
cadence: "steady",
bloomAccent: "emerald",
idlePose: "reading",
},
};
export function resolvePersonaArchetype(input: BotPersonaInput): PersonaArchetype {
const haystack = [input.name, input.title, input.description].join(" ").toLowerCase();
for (const rule of ARCHETYPE_RULES) {
if (rule.match(haystack)) return rule.archetype;
}
return "specialist";
}
export function resolveBotPersona(input: BotPersonaInput): BotPersonaProfile {
const archetype = resolvePersonaArchetype(input);
const roleLine = input.title.trim() || fallbackRoleLine(archetype);
return { archetype, roleLine, ...PROFILE[archetype] };
}
function fallbackRoleLine(archetype: PersonaArchetype): string {
switch (archetype) {
case "cto-executive":
return "Autonomous CTO";
case "chief-operator":
return "Chief of Staff";
case "builder":
return "Bot Architect";
case "curator":
return "Site Curator";
case "inbox-work":
return "Work Inbox";
case "inbox-life":
return "Life Inbox";
case "home-steward":
return "Home Steward";
case "quant":
return "Quant Lead";
case "engineer":
return "Engineer";
default:
return "Specialist";
}
}
export function resolveTextStagePhase(input: {
running: boolean;
botStatus?: string;
streaming?: boolean;
hasMessages: boolean;
awareness?: VoiceAwarenessEvent | null;
}): StagePresencePhase {
if (input.botStatus === "error" || input.botStatus === "failed") return "error";
if (input.awareness?.kind === "subagent" || input.awareness?.kind === "handoff") {
return "thinking";
}
if (input.running) return "working";
if (input.streaming) return "speaking";
if (input.botStatus === "waiting_input") return "listening";
if (!input.hasMessages) return "idle";
return "idle";
}
export function personaTextPose(
profile: BotPersonaProfile,
phase: StagePresencePhase,
collaborating = false,
): EvooPose {
if (collaborating) return "collaborating";
switch (phase) {
case "working":
return "working";
case "listening":
return "listening";
case "speaking":
return "speaking";
case "thinking":
return "thinking";
case "error":
return "error";
default:
return profile.idlePose ?? mapPersonaArchetypeToIdlePose(profile.archetype);
}
}
export function personaBloomGlow(accent: BotPersonaProfile["bloomAccent"], energy = 0): string {
const alpha = 0.12 + energy * 0.28;
switch (accent) {
case "violet":
return `rgba(167,139,250,${alpha})`;
case "amber":
return `rgba(251,191,36,${alpha})`;
case "rose":
return `rgba(251,113,133,${alpha})`;
case "cyan":
return `rgba(34,211,238,${alpha})`;
default:
return `rgba(52,211,153,${alpha})`;
}
}
export function personaMascotPose(
profile: BotPersonaProfile,
phase: VoicePhase,
collaborating = false,
): EvooPose {
if (collaborating) return "collaborating";
const voicePose = mapVoicePhaseToPose(phase, collaborating);
if (phase === "listening") return voicePose;
if (phase === "speaking") return voicePose;
if (phase === "thinking") return voicePose;
return profile.idlePose ?? mapPersonaArchetypeToIdlePose(profile.archetype);
}
export function cadencePulseMs(cadence: PersonaCadence): number {
switch (cadence) {
case "deliberate":
return 4200;
case "urgent":
return 2400;
default:
return 3200;
}
}
/** Recent collaboration / delegation signals for voice awareness strip. */
export function extractVoiceAwareness(
snapshot: ThreadSnapshot | null,
botName: string,
): VoiceAwarenessEvent | null {
if (!snapshot) return null;
const messages = snapshot.messages.slice(-12).reverse();
for (const message of messages) {
for (let index = message.blocks.length - 1; index >= 0; index -= 1) {
const block = message.blocks[index];
if (!block) continue;
if (block.kind === "subagent" && block.status === "running") {
return {
kind: "subagent",
actorName: botName,
detail: block.task.trim(),
targetName: block.name.trim() || undefined,
};
}
if (block.kind === "handoff") {
return {
kind: "handoff",
actorName: botName,
detail: block.text.trim(),
};
}
if (block.kind === "bot_message_sent") {
return {
kind: "delegation",
actorName: botName,
detail: block.text.trim(),
targetName: block.toBotName.trim(),
};
}
if (block.kind === "bot_message_received") {
return {
kind: "peer-reply",
actorName: block.fromBotName.trim(),
detail: block.text.trim(),
};
}
}
}
if (snapshot.run && ["running", "queued", "leased"].includes(snapshot.run.status)) {
return {
kind: "working",
actorName: botName,
detail: "",
};
}
return null;
}
export function narrateAwarenessForPersona(
profile: BotPersonaProfile,
event: VoiceAwarenessEvent,
): string {
const specialist = event.targetName ?? "a specialist";
const task = event.detail ? truncate(event.detail, 96) : "";
switch (event.kind) {
case "subagent":
return narrateSubagent(profile.archetype, specialist, task);
case "handoff":
return narrateHandoff(profile.archetype, specialist, task);
case "delegation":
return narrateDelegation(profile.archetype, specialist, task);
case "peer-reply":
return narratePeerReply(profile.archetype, event.actorName, task);
case "working":
return narrateWorking(profile.archetype, event.actorName);
default:
return task;
}
}
function narrateSubagent(archetype: PersonaArchetype, name: string, task: string): string {
switch (archetype) {
case "cto-executive":
return task ? `I'm casting ${name} on this — ${task}` : `I'm bringing ${name} into the room.`;
case "chief-operator":
return task ? `Routing to ${name}: ${task}` : `Looping in ${name}.`;
case "builder":
return task ? `Spinning up ${name} for ${task}` : `Assembling ${name}.`;
case "quant":
return task ? `Running ${name} on the numbers — ${task}` : `${name} is on the data.`;
case "engineer":
return task ? `Handing off to ${name}: ${task}` : `${name} is taking the keyboard.`;
default:
return task ? `${name} is helping with ${task}` : `${name} joined the thread.`;
}
}
function narrateHandoff(archetype: PersonaArchetype, target: string, text: string): string {
const line = text ? truncate(text, 72) : "";
switch (archetype) {
case "cto-executive":
return line ? `Passing the baton to ${target} — ${line}` : `This goes to ${target} now.`;
case "chief-operator":
return line ? `Handoff to ${target}: ${line}` : `Transferring to ${target}.`;
default:
return line ? `Handing over to ${target}: ${line}` : `Connecting you with ${target}.`;
}
}
function narrateDelegation(archetype: PersonaArchetype, target: string, text: string): string {
const line = truncate(text, 72);
switch (archetype) {
case "cto-executive":
return `I asked ${target} to ${line}`;
case "chief-operator":
return `Delegated to ${target}: ${line}`;
default:
return `Asked ${target}: ${line}`;
}
}
function narratePeerReply(archetype: PersonaArchetype, name: string, task: string): string {
const line = task ? truncate(task, 72) : "";
switch (archetype) {
case "cto-executive":
return line ? `${name} pushed back — ${line}` : `${name} jumped in with a take.`;
case "chief-operator":
return line ? `${name} riffed: ${line}` : `${name} is weighing in.`;
case "builder":
return line ? `${name} geeked out: ${line}` : `${name} couldn't resist chiming in.`;
case "quant":
return line ? `${name} on the numbers: ${line}` : `${name} has a counter-read.`;
case "engineer":
return line ? `${name} fired back: ${line}` : `${name} joined the thread.`;
default:
return line ? `${name} replied — ${line}` : `${name} is riffing back…`;
}
}
/** Group stage subtitle — chemistry, not committee. See docs/GROUP-CONVERSATIONS.md */
export function narrateGroupSpark(spark: GroupSpark, speakerArchetype?: PersonaArchetype): string {
const archetype = speakerArchetype ?? "specialist";
switch (spark.kind) {
case "peer-reply":
return narratePeerReply(archetype, spark.actorName, spark.detail ?? "");
case "handoff":
return narrateHandoff(archetype, spark.actorName, spark.detail ?? "");
case "multi-working":
return spark.detail ?? "The crew is on it — more than any one Oo alone.";
default: {
const never: never = spark.kind;
return never;
}
}
}
function narrateWorking(archetype: PersonaArchetype, name: string): string {
switch (archetype) {
case "cto-executive":
return `${name} is in the work — give me a moment.`;
case "quant":
return `${name} is crunching — one beat.`;
case "engineer":
return `${name} is heads-down on this.`;
default:
return `${name} is working on your request.`;
}
}
function truncate(text: string, max: number): string {
const trimmed = text.trim();
if (trimmed.length <= max) return trimmed;
return `${trimmed.slice(0, max - 1)}…`;
}
export function listArchetypes(): readonly PersonaArchetype[] {
return ARCHETYPE_ORDER;
}
/** Non-i18n working labels for transcript glyphs (SSR-safe). */
export function personaWorkingLinesPlain(
archetype: PersonaArchetype,
botName: string,
): readonly string[] {
switch (archetype) {
case "cto-executive":
return [
`${botName} polishes the details…`,
`${botName} checks the architecture…`,
`${botName} makes it simpler…`,
`${botName} reviews the pixels…`,
];
case "quant":
return [
`${botName} scans the book…`,
`${botName} stress-tests exposure…`,
`${botName} watches the tape…`,
];
case "engineer":
return [
`${botName} reads the logs…`,
`${botName} traces the failure…`,
`${botName} runs the checks…`,
];
case "chief-operator":
return [
`${botName} aligns the team…`,
`${botName} clears the path…`,
`${botName} sequences the work…`,
];
default:
return [`${botName} is working…`, `${botName} is on it…`];
}
}