* Name 1:1 senders after their bot and reach the computer from a group In a 1:1 chat bot messages fell back to the label "evOo" because only group members were looked up; they now use the bot's name. The main conversation and project chats show the computer button and open their lead bot's computer. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Translate the stage's collaboration lines Subagent, handoff, delegation and peer-reply lines on the stage were English in every language. They now go through the catalog, with German filled in. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ --------- Co-authored-by: Claude <noreply@anthropic.com>
655 lines
19 KiB
TypeScript
655 lines
19 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";
|
|
import { i18n } from "./i18n";
|
|
|
|
/**
|
|
* 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";
|
|
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" || input.botStatus === "waiting_takeover") {
|
|
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(),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function narrateAwarenessForPersona(
|
|
profile: BotPersonaProfile,
|
|
event: VoiceAwarenessEvent,
|
|
): string {
|
|
const specialist = event.targetName ?? i18n._({ id: "a specialist", message: "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);
|
|
default:
|
|
return task;
|
|
}
|
|
}
|
|
|
|
function narrateSubagent(archetype: PersonaArchetype, name: string, task: string): string {
|
|
switch (archetype) {
|
|
case "cto-executive":
|
|
return task
|
|
? i18n._({
|
|
id: "I'm casting {name} on this — {task}",
|
|
message: "I'm casting {name} on this — {task}",
|
|
values: { name, task },
|
|
})
|
|
: i18n._({
|
|
id: "I'm bringing {name} into the room.",
|
|
message: "I'm bringing {name} into the room.",
|
|
values: { name },
|
|
});
|
|
case "chief-operator":
|
|
return task
|
|
? i18n._({
|
|
id: "Routing to {name}: {task}",
|
|
message: "Routing to {name}: {task}",
|
|
values: { name, task },
|
|
})
|
|
: i18n._({ id: "Looping in {name}.", message: "Looping in {name}.", values: { name } });
|
|
case "builder":
|
|
return task
|
|
? i18n._({
|
|
id: "Spinning up {name} for {task}",
|
|
message: "Spinning up {name} for {task}",
|
|
values: { name, task },
|
|
})
|
|
: i18n._({ id: "Assembling {name}.", message: "Assembling {name}.", values: { name } });
|
|
case "quant":
|
|
return task
|
|
? i18n._({
|
|
id: "Running {name} on the numbers — {task}",
|
|
message: "Running {name} on the numbers — {task}",
|
|
values: { name, task },
|
|
})
|
|
: i18n._({
|
|
id: "{name} is on the data.",
|
|
message: "{name} is on the data.",
|
|
values: { name },
|
|
});
|
|
case "engineer":
|
|
return task
|
|
? i18n._({
|
|
id: "Handing off to {name}: {task}",
|
|
message: "Handing off to {name}: {task}",
|
|
values: { name, task },
|
|
})
|
|
: i18n._({
|
|
id: "{name} is taking the keyboard.",
|
|
message: "{name} is taking the keyboard.",
|
|
values: { name },
|
|
});
|
|
default:
|
|
return task
|
|
? i18n._({
|
|
id: "{name} is helping with {task}",
|
|
message: "{name} is helping with {task}",
|
|
values: { name, task },
|
|
})
|
|
: i18n._({
|
|
id: "{name} joined the thread.",
|
|
message: "{name} joined the thread.",
|
|
values: { name },
|
|
});
|
|
}
|
|
}
|
|
|
|
function narrateHandoff(archetype: PersonaArchetype, target: string, text: string): string {
|
|
const line = text ? truncate(text, 72) : "";
|
|
switch (archetype) {
|
|
case "cto-executive":
|
|
return line
|
|
? i18n._({
|
|
id: "Passing the baton to {target} — {line}",
|
|
message: "Passing the baton to {target} — {line}",
|
|
values: { target, line },
|
|
})
|
|
: i18n._({
|
|
id: "This goes to {target} now.",
|
|
message: "This goes to {target} now.",
|
|
values: { target },
|
|
});
|
|
case "chief-operator":
|
|
return line
|
|
? i18n._({
|
|
id: "Handoff to {target}: {line}",
|
|
message: "Handoff to {target}: {line}",
|
|
values: { target, line },
|
|
})
|
|
: i18n._({
|
|
id: "Transferring to {target}.",
|
|
message: "Transferring to {target}.",
|
|
values: { target },
|
|
});
|
|
default:
|
|
return line
|
|
? i18n._({
|
|
id: "Handing over to {target}: {line}",
|
|
message: "Handing over to {target}: {line}",
|
|
values: { target, line },
|
|
})
|
|
: i18n._({
|
|
id: "Connecting you with {target}.",
|
|
message: "Connecting you with {target}.",
|
|
values: { target },
|
|
});
|
|
}
|
|
}
|
|
|
|
function narrateDelegation(archetype: PersonaArchetype, target: string, text: string): string {
|
|
const line = truncate(text, 72);
|
|
switch (archetype) {
|
|
case "cto-executive":
|
|
return i18n._({
|
|
id: "I asked {target} to {line}",
|
|
message: "I asked {target} to {line}",
|
|
values: { target, line },
|
|
});
|
|
case "chief-operator":
|
|
return i18n._({
|
|
id: "Delegated to {target}: {line}",
|
|
message: "Delegated to {target}: {line}",
|
|
values: { target, line },
|
|
});
|
|
default:
|
|
return i18n._({
|
|
id: "Asked {target}: {line}",
|
|
message: "Asked {target}: {line}",
|
|
values: { target, line },
|
|
});
|
|
}
|
|
}
|
|
|
|
function narratePeerReply(archetype: PersonaArchetype, name: string, task: string): string {
|
|
const line = task ? truncate(task, 72) : "";
|
|
switch (archetype) {
|
|
case "cto-executive":
|
|
return line
|
|
? i18n._({
|
|
id: "{name} pushed back — {line}",
|
|
message: "{name} pushed back — {line}",
|
|
values: { name, line },
|
|
})
|
|
: i18n._({
|
|
id: "{name} jumped in with a take.",
|
|
message: "{name} jumped in with a take.",
|
|
values: { name },
|
|
});
|
|
case "chief-operator":
|
|
return line
|
|
? i18n._({
|
|
id: "{name} riffed: {line}",
|
|
message: "{name} riffed: {line}",
|
|
values: { name, line },
|
|
})
|
|
: i18n._({
|
|
id: "{name} is weighing in.",
|
|
message: "{name} is weighing in.",
|
|
values: { name },
|
|
});
|
|
case "builder":
|
|
return line
|
|
? i18n._({
|
|
id: "{name} geeked out: {line}",
|
|
message: "{name} geeked out: {line}",
|
|
values: { name, line },
|
|
})
|
|
: i18n._({
|
|
id: "{name} couldn't resist chiming in.",
|
|
message: "{name} couldn't resist chiming in.",
|
|
values: { name },
|
|
});
|
|
case "quant":
|
|
return line
|
|
? i18n._({
|
|
id: "{name} on the numbers: {line}",
|
|
message: "{name} on the numbers: {line}",
|
|
values: { name, line },
|
|
})
|
|
: i18n._({
|
|
id: "{name} has a counter-read.",
|
|
message: "{name} has a counter-read.",
|
|
values: { name },
|
|
});
|
|
case "engineer":
|
|
return line
|
|
? i18n._({
|
|
id: "{name} fired back: {line}",
|
|
message: "{name} fired back: {line}",
|
|
values: { name, line },
|
|
})
|
|
: i18n._({
|
|
id: "{name} joined the thread.",
|
|
message: "{name} joined the thread.",
|
|
values: { name },
|
|
});
|
|
default:
|
|
return line
|
|
? i18n._({
|
|
id: "{name} replied — {line}",
|
|
message: "{name} replied — {line}",
|
|
values: { name, line },
|
|
})
|
|
: i18n._({
|
|
id: "{name} is riffing back…",
|
|
message: "{name} is riffing back…",
|
|
values: { name },
|
|
});
|
|
}
|
|
}
|
|
|
|
/** 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 ??
|
|
i18n._({
|
|
id: "The crew is on it — more than any one Oo alone.",
|
|
message: "The crew is on it — more than any one Oo alone.",
|
|
})
|
|
);
|
|
default: {
|
|
const never: never = spark.kind;
|
|
return never;
|
|
}
|
|
}
|
|
}
|
|
|
|
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…`];
|
|
}
|
|
}
|