* Name a run's chat once when its group carries the bot's name A project's main conversation is named after its lead bot, so Today showed "Chief · Chief". Web and mobile now share one title helper that drops the repeated name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Drop the "1:1 with" line under a conversation's title The stage header repeated what the chat already shows, such as "1:1 with Chief" under a project's name. The title and the phase pill remain. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Let the stage's translated thinking lines speak while a bot works A running bot raised a "working" awareness event whose English line, such as "Chief is working on your request.", replaced the translated thinking lines and added an "In progress" strip next to the phase pill. The strip now only shows collaboration. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Ask bots to describe their steps without naming tools Long runs showed lines such as "I'm using browser_navigate" in the chat. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Announce the stage phase as status and keep following after a tap The working strip used to be the only status region while a bot worked; the stage's phase pill now carries that role. A tap at the latest message no longer stops the transcript from following new messages. 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>
494 lines
14 KiB
TypeScript
494 lines
14 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";
|
|
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 ?? "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 ? `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 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…`];
|
|
}
|
|
}
|