// Agent personas — named, role-scoped sub-system prompts. // Each persona stacks on top of the main systemPrompt to specialize the // assistant for a task. Persisted in localStorage; reactive across tabs. import { useEffect, useSyncExternalStore } from "react" export type Agent = { id: string name: string role: string prompt: string } export const DEFAULT_AGENTS: Agent[] = [ { id: "operator", name: "Atlas", role: "Platform Operator", prompt: "You're the platform admin's day-to-day operator inside Arcadia Admin. Treat the signed-in user as a senior platform administrator running a multi-tenant Arcadia deployment. Default to action: when the user asks about live data, call a tool; when they ask to do something, suggest the tool call and ask for confirmation if it's a write. Prefer tenant slugs over UUIDs in conversation. Keep replies tight — operators read fast.", }, { id: "auditor", name: "Notary", role: "Auditor", prompt: "You're an audit-focused assistant inside Arcadia Admin. Specialise in audit logs, access reviews, and 'who did what when' questions. Always cite the actor_type (user / platform_admin / api_key / system) and timestamp when summarising audit entries. Be cautious about claims you can't back with a tool result — call a tool first.", }, { id: "triage", name: "Tracer", role: "Incident Triage", prompt: "You're an incident-triage assistant inside Arcadia Admin. When the user reports a problem (a tenant member can't sign in, a billing call is 402'ing, a webhook is failing), walk the diagnostic tree: identify the tenant, check tenant status, check the user's roles, check the billing-config / api-metering / feature-flag overrides as relevant. Suggest impersonation only when it's the right escalation. Keep a clear hypothesis → check → result rhythm.", }, { id: "analyst", name: "Census", role: "Platform Analyst", prompt: "You're an analyst inside Arcadia Admin. Answer numerical and aggregate questions across the platform: tenant counts by status, plan distribution, audit-log volume, growth. Always pull live data via tools — never guess from stale snapshots. Present findings in plain prose first, then a small table when the breakdown helps.", }, { id: "ui-driver", name: "Pilot", role: "UI Operator", prompt: "You specialise in driving Arcadia Admin's UI on the operator's behalf. Prefer doing over explaining. When the user asks for an action that maps to a UI element, emit an action block immediately (using `data-action` ids the host has documented). For data questions, prefer tool calls over UI navigation.", }, ] const STORAGE_KEY = "crema.agents" const ACTIVE_KEY = "crema.assistant.activeAgent" const CHANGE_EVENT = "crema:agents-change" function isAgent(v: unknown): v is Agent { return ( !!v && typeof v === "object" && typeof (v as Agent).id === "string" && typeof (v as Agent).name === "string" && typeof (v as Agent).role === "string" && typeof (v as Agent).prompt === "string" ) } // Old Vibespace agent ids — used to auto-migrate operators stuck on the // generic defaults from before Arcadia Admin had its own personas. const LEGACY_AGENT_IDS = new Set(["generalist", "coder", "writer", "researcher"]) // Retired arcadia-era persona names. If we see any of these in storage, the // operator hasn't customised their roster — re-seed with the current names // so a rename in DEFAULT_AGENTS actually reaches the UI. const RETIRED_AGENT_NAMES = new Set(["Ledger", "Beacon", "Tally", "Cursor"]) function isLegacyDefaultSet(agents: Agent[]): boolean { return ( agents.some((a) => LEGACY_AGENT_IDS.has(a.id)) || agents.some((a) => RETIRED_AGENT_NAMES.has(a.name)) ) } function readFromStorage(): Agent[] { if (typeof window === "undefined") return DEFAULT_AGENTS try { const raw = localStorage.getItem(STORAGE_KEY) if (!raw) return DEFAULT_AGENTS const parsed = JSON.parse(raw) if (!Array.isArray(parsed)) return DEFAULT_AGENTS const cleaned = parsed.filter(isAgent) if (cleaned.length === 0) return DEFAULT_AGENTS if (isLegacyDefaultSet(cleaned)) { // Auto-migrate: stored set still contains pre-arcadia personas. localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_AGENTS)) localStorage.removeItem(ACTIVE_KEY) return DEFAULT_AGENTS } return cleaned } catch { return DEFAULT_AGENTS } } export function loadAgents(): Agent[] { return readFromStorage() } export function saveAgents(next: Agent[]) { if (typeof window === "undefined") return localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) } export function resetAgents() { saveAgents(DEFAULT_AGENTS) } let cached: Agent[] | null = null function subscribe(cb: () => void): () => void { const onChange = () => { cached = null cb() } window.addEventListener(CHANGE_EVENT, onChange) window.addEventListener("storage", (e) => { if (e.key === STORAGE_KEY || e.key === ACTIVE_KEY) onChange() }) return () => { window.removeEventListener(CHANGE_EVENT, onChange) } } function getSnapshot(): Agent[] { if (!cached) cached = readFromStorage() return cached } function getServerSnapshot(): Agent[] { return DEFAULT_AGENTS } export function useAgents(): Agent[] { const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) useEffect(() => { cached = null }, []) return value } export function loadActiveAgentId(): string { if (typeof window === "undefined") return DEFAULT_AGENTS[0].id try { return localStorage.getItem(ACTIVE_KEY) ?? DEFAULT_AGENTS[0].id } catch { return DEFAULT_AGENTS[0].id } } export function saveActiveAgentId(id: string) { if (typeof window === "undefined") return localStorage.setItem(ACTIVE_KEY, id) window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) } export function composeSystemPrompt( base: string, agent: Agent | undefined, ): string { if (!agent) return base return `${base}\n\nActive persona: ${agent.name} — ${agent.role}\n${agent.prompt}` } export function newAgentId(): string { return `agent-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` }