514 lines
15 KiB
TypeScript
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…`];
|
|
}
|
|
}
|