Oshun Platform · Features

Assistant Experience

A focused page within the Oshun Platform Features documentation. The full map and every sibling page live in the Features hub.

14sections14 minread4tables

On this page

The Oshun assistant is the conversational surface that sits on top of every V1 domain: a member talks to it from the shell home, a customer launches it from a story or a sky chart, and an operator invokes it from the admin cockpit. This page covers the shell half of that experience: how the assistant is invoked, what travels with it from the screen, the interaction modes it runs in, how personas hand off, how it degrades safely when a subsystem is down, and how it bridges into the two substrates that own the hard policy — Iris (durable memory) and Psyche (real-time runtime). It is the feature-side companion to the two substrate pages it depends on: Iris Memory and Identity and Psyche Real-Time Runtime. Persona roles and tone come from Lilith Persona Policy; the hub for the whole set is ../features.md. Backlog for this surface lives at §2.5 in ../TODOS.md.

What ships, honestly#

The assistant shell is real, non-stub, domain-specific code, and it lives in one library: libs/oshun/shell-assistant (package @oshun/shell-assistant, version 0.1.0). The package is a pure-ESM source library — its package.json points main and types straight at ./src/index.ts, with no build step — and it describes itself as a "Voice-first cross-domain assistant for the OSHUN shell home — intent classification, domain action routing, and conversational AI orchestration across Tara, Veritas, Nyx, Arete, Nisaba, and Metis." The six customer-facing domains it routes across are exactly the OshunDomainId union: 'tara' | 'veritas' | 'nyx' | 'arete' | 'nisaba' | 'metis' (src/types.ts), re-exported as ALL_DOMAIN_IDS.

The orchestrator is the AssistantEngine (src/assistant-engine.ts), created via createAssistantEngine({ adapters }). It opens sessions with engine.createSession(userId, domains, { inputMode, locale }) and drives turns with engine.processMessage(sessionId, { text, inputMode }), returning an AssistantResponse whose shape — text, cards, navigateTo, suggestedActions — lets the shell render a spoken reply, attach domain result cards, and optionally deep-link the member to the surface that answers their question. The engine composes four named collaborators, each independently exported and tested:

Collaborator Symbol Responsibility
Intent resolver IntentResolver / createIntentResolver classifies the utterance into an AssistantIntentCategory and binds slots
Domain intents TARA_INTENTSMETIS_INTENTS, CROSS_DOMAIN_INTENTS, ALL_ASSISTANT_INTENTS per-domain intent definitions + inferDomainFromText
Action router ActionRouter / createActionRouter turns a ResolvedIntent into a DomainAction against the right adapter
Response formatter ResponseFormatter / createResponseFormatter renders the AssistantResponse (text, cards, navigation, disclosure)

Routing is genuinely cross-domain: the engine accepts an AssistantDomainAdapters bundle with one adapter interface per domain (AssistantTaraAdapter, AssistantVeritasAdapter, AssistantNyxAdapter, AssistantAreteAdapter, AssistantNisabaAdapter, AssistantMetisAdapter), so a single utterance like "what's in the sky tonight?" is classified to a Nyx intent, routed to the Nyx adapter, and rendered as a Nyx event card with a navigateTo of /nightly-highlights.

Universal invocation points#

Every place the assistant can be launched from is a typed entry in the invocation registry (src/invocation-points.ts), which the docstring calls "the single source of truth for invocation wiring." Each AssistantInvocationPoint carries id, the platformShell it belongs to, a surface kind, an accessibilityLabel, a minViewportPx, an optional keyboard shortcut, and an entrySource string such as customer-web.global-launcher. The surface kinds enumerate every real launch affordance:

global-launcher | header-button | command-palette | keyboard-shortcut | empty-state-cta | inline-help | deep-link | tab-bar | context-menu | push-notification

The registry is sliced per shell by helper functions — listCustomerWebInvocationPoints(), listCustomerMobileInvocationPoints(), listAdminWebInvocationPoints(), and listStudioWebInvocationPoints(). This is how the V1 promise of "universal invocation points across customer web, customer mobile, and admin web" is satisfied without each shell re-implementing its own launcher wiring; the same code also covers a fourth surface, Studio web, which the prose list omits.

Guarding an invocation#

A launch is not unconditional. evaluateAssistantInvocationGuard(input) returns an AssistantInvocationGuardDecision that either allows the launch or refuses it with a precise, machine-readable reason:

allowed | unknown-invocation-point | unauthenticated | viewport-too-small | context-path-mismatch | missing-scope | missing-entitlement | missing-policy-grant

The guard checks an AssistantInvocationGuardRequirements record — authenticatedSession, scopesAny, entitlementsAny, policyGrantsAny, allowedPathPrefixes, and minViewportPx — and, when it refuses, returns the specific AssistantInvocationGuardMissingRequirements it failed on so the shell can render the right upgrade or sign-in prompt instead of a dead button. This is the "consistent entitlement, policy, and context guards" line from the backlog, implemented as one reusable function rather than per-shell if ladders.

Context handoff from the current screen#

When the member launches the assistant from a page, the screen's context travels with them. buildAssistantLaunchIntent produces an AssistantLaunchIntent carrying the invocationPointId, entrySource, the activePath the user was on, any selection (highlighted text or artifact), an optional seedMessage, and an invokedAtMs timestamp. That seeds the session; the richer payload is the AssistantContextHandoff (src/context-handoff.ts), a typed envelope that sanitizes and summarizes the surrounding context before it crosses into the conversation.

The handoff is deliberately privacy-aware: sanitizeAssistantContextHandoff strips disallowed fields and emits AssistantContextHandoffSanitizationNotes, and summarizeAssistantContextHandoff produces a short summary for the prompt. The envelope models the full screen context the backlog enumerates — "current domain, artifact, selection, evidence state, memory scope, persona identity, disclosure state, and permitted tool grants travel in a typed envelope":

Handoff field family Type Purpose
Entities / artifacts AssistantContextEntity, AssistantContextArtifact the things on screen the user might ask about
Evidence state AssistantContextEvidenceState (AssistantContextEvidenceStatus) whether the screen's claims are grounded, unverified, blocked, or none
Memory scope AssistantContextMemoryScope = off | session | profile which memory tier this surface runs in
Persona identity AssistantContextPersonaIdentity (role, displayName, visible) which persona is presenting
Disclosure state AssistantContextDisclosureState per-flag AI-generated / synthetic-voice / safety / grounding disclosure
Tool grants AssistantContextToolGrant (read | write | execute) the permissions the assistant is allowed to exercise
Recent actions AssistantContextRecentAction (opened | completed | saved | shared | edited | assigned | decided | escalated) what the user just did, so follow-ups make sense

The AssistantContextDisclosureState is worth calling out because it is the backbone of "synchronized disclosure": it carries independent booleans for aiGenerated, memoryScopeVisible, personaIdentityVisible, groundingStateVisible, syntheticVoiceVisible, and safetyDisclosureVisible, so the shell can show exactly the disclosures that apply to the current state rather than a single blanket banner.

Indicators: memory, grounding, and persona posture#

Above the transcript, three indicator chips always tell the member where they stand. The indicator models (src/indicators.ts) share one tone palette — success | info | notice | warning | critical — and each carries a short label (rendered on the chip) plus a longer disclosure (rendered in the sheet expansion).

  • Memory-statebuildAssistantMemoryIndicator(memory, shell) maps the active AssistantMemoryContext.scope (off | session | profile) to a chip. "Memory off" reads differently for the admin shell ("Admin cockpit sessions do not persist memory by default") than for the customer shell ("nothing from this conversation is saved between sessions"); a profile scope without granted consent downgrades to a warning-tone "Profile memory · consent needed" chip and the disclosure explicitly states the assistant is "behaving as if memory were session-scoped." When consent is granted, the chip counts the member's enabled categories.
  • Grounding-statebuildAssistantGroundingIndicator surfaces whether the current answer is grounded, unverified, or blocked, so a member never mistakes a conversational reply for a cited one. (See Sophia Grounding for the gate behind it.)
  • Persona identitybuildAssistantPersonaIdentityIndicator shows which persona is speaking, keeping the "persona identity indicator" promise honest.

Transcript and turn history#

The transcript surface (src/transcript.ts) is paged, exportable, and disclosure-aware. buildAssistantTranscript assembles AssistantTranscriptEntry records (each a typed AssistantTranscriptEntryKind), buildAssistantTurnCursor / stepAssistantTurnCursor walk turn history with an AssistantTurnCursor, and exportAssistantTranscript produces an AssistantTranscriptExport. The transcript is the durable, user-visible record that the Iris bridge (below) mirrors into the redaction-ready conversation log for export, deletion, and audit.

Interaction modes#

Interaction modes (src/interaction-modes.ts) are pure data descriptors the shells translate into native UI primitives — the docstring is explicit that "the semantics are authored here so behavior does not drift across shells" (HTML contenteditable vs React Native TextInput vs hardware-token sheets). There are four canonical modes (ASSISTANT_INTERACTION_MODE_IDS), and the default is text-first (DEFAULT_ASSISTANT_INTERACTION_MODE):

Mode Primary input Streaming Interruption Proactive Avatar Entitlement Shells Fallback
text-first text yes yes no no free customer, admin
voice-first voice yes yes no no free (mic permission) customer text-first
streaming-text text yes yes yes no free customer, admin text-first
avatar-embodied voice yes yes yes yes premium customer voice-first

The seven user-visible "mode features" the backlog names — text-first, voice-first, streaming, interruption-aware, proactive-follow-up, persona-switching, cross-domain-carry-over — are enumerated as ASSISTANT_MODE_FEATURE_IDS, each with an AssistantModeFeatureStatus of active | available | inactive | fallback. Every mode carries its own disclosure string, e.g., voice mode discloses that "Responses are spoken back with a synthetic voice and always disclose that it is synthesized," and avatar mode that "Both the voice and the avatar are synthesized; disclosure is always visible during the session."

Mode selection and downgrade#

Mode choice is not "set and pray." selectAssistantInteractionMode(input) checks the requested mode against the shell, the member's AssistantInteractionEntitlement kinds (free | premium | enterprise | operator-admin | operator-studio), and concrete device capabilities (microphone, avatarRenderer, syntheticVoice), plus an optional reducedMotion accessibility preference. The outcome is either { ok: true, mode, downgradedFrom, downgradeReason } — the engine quietly stepping down a tier when a capability is missing — or { ok: false, reason } when no fallback exists (not-allowed-in-shell | unknown-mode | no-fallback). An AssistantModeDowngradeReason such as missing-entitlement or missing-microphone is recorded so the shell can explain why it landed the member in a lower mode rather than silently doing it.

Persona handoffs#

Persona switching is a first-class, audited transition, not a string swap. buildAssistantPersonaHandoffState resolves a transition between the canonical handoff personas (ASSISTANT_PERSONA_HANDOFF_IDS), each defined in PERSONA_DEFINITIONS (src/persona-handoffs.ts):

Handoff id Role Allowed shells Recommended domains
metis-teacher teacher customer metis, nisaba
arete-coach coach customer arete, tara, activity
stoic-scholar scholar customer veritas, nisaba, library, metis
source-lineage-guide explainer customer nisaba, veritas, library
admin-moderator moderator admin moderation, policy, admin, review
admin-reviewer reviewer admin review, rights, persona, model, admin
support-assistant support customer, admin support, profile, privacy, shell, admin

These map onto the Lilith-governed persona roles described under Lilith Persona Policy. The V1 prose names the seven canonical roles teacher, coach, explainer, steward, comparative, narrator, assistant; the shell's concrete handoff catalog realizes the customer-facing set (teacher, coach, scholar, explainer) and adds the operator personas (moderator, reviewer, support) that the admin cockpit needs. The underlying AssistantContextPersonaRole union additionally includes navigator and operator-copilot.

Crucially, every handoff carries a memory boundary. An AssistantPersonaMemoryBoundary declares its kindmemory-off | session-only | profile-visible | admin-session-only — its scope, whether it canPersist, and the restrictedToShell it is pinned to. Switching to an admin copilot, for instance, drops into an admin-session-only boundary that cannot write profile memory. Each transition also emits an AssistantPersonaHandoffAuditEvent of type assistant.persona_handoff recording fromPersonaId/toPersonaId and fromRole/toRole, with an AssistantPersonaHandoffAuditOutcome of applied or fallback. If a requested persona is not allowed in the current shell, buildAssistantPersonaHandoffState returns an AssistantPersonaHandoffFallbackState instead of failing the turn.

Safe fallback#

When a dependency degrades, the conversation must keep going while honestly disclosing what broke. buildAssistantSafeFallbackState(input) (src/safe-fallbacks.ts) computes one AssistantSafeFallbackSubsystemState per dependency across the six canonical subsystems (ASSISTANT_SAFE_FALLBACK_SUBSYSTEM_IDS): grounding, memory, persona, real-time, avatar, downstream. Each subsystem reports a status of available | degraded | unavailable | fallback, an active flag, a human reason, and a fallbackBehavior string. For example, when required grounding is blocked or missing, the grounding subsystem returns a fallback status with the behavior "The assistant keeps the transcript live but treats the next answer as conversational until evidence returns."

The aggregate AssistantSafeFallbackState always sets continuityPreserved: true, counts the activeFallbackCount, and emits a transcriptContinuityMessage such as "Transcript continuity preserved across N transcript turns; degraded systems are disclosed in-line." This is exactly the backlog requirement that "UI must preserve transcript continuity and expose what degraded" — the member never loses their conversation, and they always see which capability went dark.

Avatar assistant mode#

On supported customer surfaces, the assistant can present as a stylized synthetic avatar. buildAssistantAvatarModeState(input) (src/avatar-mode.ts) computes a gated, disclosure-synchronized state. Avatar mode is the only mode requiring a premium entitlement, and the gate also checks microphone, avatarRenderer, syntheticVoice, the reducedMotion accessibility preference, and that the shell is customer (operator cockpits cannot run avatars). Each prerequisite is reported as an AssistantAvatarGateState (available | missing | not-applicable).

When the gate fails, the state goes into fallbackActive and steps down to voice-first, recording the fallbackReason. The state also carries an AssistantAvatarModeProvenance block — personaLabel, voiceAssetLabel, avatarAssetLabel, rendererLabel, policyLabel, and the preserved memoryScope — and an AssistantAvatarModeDisclosureState that keeps syntheticVoiceVisible and syntheticAvatarVisible in lock-step, satisfying the "synchronized disclosure, provenance, and memory state" promise. The provenance defaults are honest placeholders ("Synthetic voice pack", "OSHUN avatar renderer", "Premium synthetic avatar entitlement") that real persona/voice/avatar packs override — see Persona, Avatar, and Voice Packs.

Bridges to the substrates#

The shell deliberately owns none of the durable-memory or real-time policy — it bridges to the two substrates that do. The brief calls this out, and the code backs it: the bridges are "intentionally small," stitching the canonical surfaces together "without reimplementing either."

Iris memory bridge#

createIrisMemoryBridge / IrisMemoryBridge (src/iris-memory-bridge.ts, backlog V1-IRIS-008) wires the engine into the canonical @oshun/memory-iris substrate. Per its docstring, it hydrates the runtime AssistantMemoryContext from Iris's IrisContinuityState, maintains a per-session append-only IrisConversationHistory and an ephemeral IrisSessionMemory, routes every durable write through IrisMemoryAdapter.remember so the canonical consent/opt-out policy is enforced (when consent is missing the write is suppressed and the caller gets a warning-bearing outcome, never a silent drop), and at session close promotes only items marked promotionCandidate=true into profile memory. It runs in two modes via createAdminIrisMemoryBridge: customer (consumer='assistant', promotes profile candidates) and admin (consumer='admin', suppresses promotion, keeps the operator trail in conversation scope). All of the durable policy — scopes, retention, conflict resolution, the MemoryEntry contract — lives in Iris and is documented in Iris Memory and Identity. The recall budgets that govern how many memories surface per turn are real constants in libs/oshun/memory-iris/src/recall/pipeline.ts (DEFAULT_BUDGETS): assistant 12, shell 3, notebook 999, admin 100 — so the assistant transcript draws on a deliberately small working set while a notebook context can pull far more.

Psyche session bridge#

createPsycheSessionBridge / PsycheSessionBridge (src/psyche-session-bridge.ts, backlog V1-PSY-003) wires the engine into the canonical Psyche real-time envelope and event stream. It is transport-agnostic: it "doesn't own the socket, the WebRTC negotiation, or the LiveKit client," instead minting a valid PsycheSessionEnvelope (via buildPsycheSessionEnvelope) that callers hand to the transport, and keeping a per-session PsycheSessionEventStream so audit, telemetry, and UI indicators stay synchronized. It exports PSYCHE_SESSION_PROTOCOL_VERSION and has a customer mode (consumer assistant, accepts synthetic-avatar envelopes) and an admin mode (createAdminPsycheSessionBridge, consumer admin, rejects synthetic avatars). See Psyche Real-Time Runtime for the envelope and event-stream contracts.

Platform shell split#

Customer and admin behaviors are not branched inline everywhere — they are factored into a platform shell descriptor (src/platform-shell.ts). The canonical shells are enumerated in ASSISTANT_PLATFORM_SHELL_IDS, and helpers such as resolveAssistantPlatformShell, buildAssistantPlatformPromptFrame, filterAssistantActionsForPlatform, isAssistantActionAllowedInShell, and getAssistantPlatformSessionDefaults let one engine serve both the customer web shell and the admin cockpit while filtering which actions are permitted and which session defaults apply. This is the seam (backlog V1-AST-001) that keeps a single assistant codebase serving multiple product surfaces without leaking admin-only actions into the customer shell or vice versa.

Tests and evaluations#

The backlog asks for "tests and evaluations for routing, memory, grounding, persona state, invocation, source inspection, voice/text, avatar entry/exit, disclosure, and accessibility." The library ships a __tests__ directory alongside the source, and the substrate it bridges into carries its own extensive suites — Iris ships memory-leakage, forget-completeness, conflict-resolution, crisis-suppression, and drift-detection evaluation suites (memory-evaluation-suites.test.ts, memory-safety-eval.test.ts in libs/oshun/memory-iris/src/), documented in Iris Memory and Identity. Because the shell's interaction modes, indicators, fallbacks, persona handoffs, and avatar gates are all pure functions over typed inputs, they are directly unit-testable without standing up a live model — selection downgrades, guard refusals, and disclosure flags are asserted against expected outcomes rather than against a non-deterministic LLM call.

Honest scope notes#

  • The LLM itself is provider-gated. The shell owns intent classification, routing, formatting, disclosure, and bridging; the actual conversational model call sits behind the engine's adapters and is configured per deployment. The shell's deterministic pieces (modes, guards, indicators, fallbacks) are what is exercised by the in-repo tests.
  • Avatar rendering is entitlement- and asset-gated. avatar-embodied requires a premium entitlement plus a working renderer and licensed voice pack; absent those, buildAssistantAvatarModeState returns a voice-first fallback with the gate reasons spelled out. The provenance labels default to honest placeholders until real packs are bound.
  • Memory and real-time policy live in the substrates, not here. The shell cannot write around the Iris consent/opt-out contract or mint an invalid Psyche envelope — those refusals are enforced in @oshun/memory-iris and the Psyche envelope validator, and the bridges surface the warnings rather than papering over them.