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_INTENTS … METIS_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-state —
buildAssistantMemoryIndicator(memory, shell)maps the activeAssistantMemoryContext.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"); aprofilescope without granted consent downgrades to awarning-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-state —
buildAssistantGroundingIndicatorsurfaces 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 identity —
buildAssistantPersonaIdentityIndicatorshows 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 kind —
memory-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-embodiedrequires apremiumentitlement plus a working renderer and licensed voice pack; absent those,buildAssistantAvatarModeStatereturns avoice-firstfallback 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-irisand the Psyche envelope validator, and the bridges surface the warnings rather than papering over them.
Related#
- Iris Memory and Identity — the durable-memory
substrate the assistant bridges into (scopes, consent, retention, recall
budgets,
MemoryEntry). - Psyche Real-Time Runtime — the real-time session envelope and event stream behind voice and avatar modes.
- Lilith Persona Policy — the persona roles and tone bands the handoff catalog realizes.
- Persona, Avatar, and Voice Packs — the synthetic avatar and voice assets avatar mode renders.
- Sophia Grounding — the grounding gate behind the grounding-state indicator.
- Product Surfaces — the customer web, customer mobile, admin web, and Studio shells the assistant is invoked from.
../features.md— the hub feature list. Backlog: see§2.5in../TODOS.md; dependency ordering in../DEPENDENCIES.md.