@memberjunction/realtime-runtime 0.0.0 → 6.2.0-edge.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1231 @@
1
+ import { Observable } from 'rxjs';
2
+ import { IMetadataProvider } from '@memberjunction/core';
3
+ import { ClientRealtimeSessionConfig, RealtimeToolDefinition, RealtimeTrackDirection } from '@memberjunction/ai';
4
+ import { AppContextSnapshot } from '@memberjunction/ai-core-plus';
5
+ import { BaseRealtimeClient, RealtimeAudioActivity } from '@memberjunction/ai-realtime-client';
6
+ import { ParsedDelegationArtifact } from './delegation-result-parser.js';
7
+ import { BaseRealtimeChannelClient } from '../channels/base-realtime-channel-client.js';
8
+ import { IRealtimeMediaHost } from '../hosts/IRealtimeMediaHost.js';
9
+ /**
10
+ * `MJ: User Settings` key for the per-user "record this voice call" consent toggle. Stored as
11
+ * the literal string `'true'`/`'false'` (read with `=== 'true'`), cross-device via
12
+ * {@link UserInfoEngine}. The pre-call picker writes it; the session service reads it as the
13
+ * default when the caller doesn't pass an explicit consent value.
14
+ */
15
+ export declare const REALTIME_RECORDING_CONSENT_KEY = "mj.realtimeVoice.recordingConsent.v1";
16
+ /**
17
+ * Connection / turn state for a real-time voice session, surfaced to the UI overlay.
18
+ * - `connecting` — negotiating the session + provider handshake
19
+ * - `listening` — connected, mic open, waiting for / hearing the user
20
+ * - `speaking` — the agent is producing audio
21
+ * - `thinking` — the agent delegated work (tool call) and is waiting on a result
22
+ * - `error` — a fatal error occurred; the session is no longer usable
23
+ * - `closed` — the session has been torn down
24
+ */
25
+ export type RealtimeConnectionState = 'connecting' | 'listening' | 'speaking' | 'thinking' | 'error' | 'closed';
26
+ /** A single caption line (one side of the conversation) shown in the live-captions list. */
27
+ export interface RealtimeCaption {
28
+ Role: 'User' | 'Assistant';
29
+ Text: string;
30
+ }
31
+ /**
32
+ * A delegated-run progress update surfaced to the UI, emitted on {@link RealtimeSessionRuntime.DelegationProgress$}.
33
+ * These originate server-side during an `invoke-target-agent` delegation (e.g. while Sage works) and let a
34
+ * future overlay render a "working" card while the realtime model narrates the same progress aloud.
35
+ */
36
+ export interface RealtimeDelegationProgress {
37
+ /** The tool/agent call this progress belongs to. */
38
+ CallID: string;
39
+ /** The raw tool name when this progress represents a direct action (e.g. `File_Storage_List_Objects`). */
40
+ ToolName?: string;
41
+ /** The delegation phase: `prompt_execution` | `action_execution` | `subagent_execution` | `decision_processing` | `direct_action`. */
42
+ Step: string;
43
+ /** Human-readable progress message. */
44
+ Message: string;
45
+ /** Optional completion percentage (0–100) when the server can estimate it. */
46
+ Percentage?: number;
47
+ }
48
+ /**
49
+ * The terminal result of a delegated tool call, emitted on {@link RealtimeSessionRuntime.DelegationResult$}
50
+ * when the delegation finishes so the overlay can flip the "working" card into a result card with real
51
+ * content + provenance.
52
+ */
53
+ export interface RealtimeDelegationResult {
54
+ /** The tool/agent call this result belongs to. */
55
+ CallID: string;
56
+ /** The raw tool name when this result represents a direct action. */
57
+ ToolName?: string;
58
+ /** Whether the delegated work succeeded. */
59
+ Success: boolean;
60
+ /** The result text — the agent's output, or an error message on failure. */
61
+ Output: string;
62
+ /**
63
+ * ID of the delegated agent run (`MJ: AI Agent Runs`) when the server reported one
64
+ * (`runId` in the tool ResultJson). Powers the overlay's gear-gated "Open run" dev link.
65
+ */
66
+ RunID?: string;
67
+ /**
68
+ * Artifacts the delegated run produced, when the server reported any (`artifacts` in the
69
+ * tool ResultJson). The overlay's tabbed surface panel auto-opens one artifact tab per
70
+ * entry and focuses the newest on arrival.
71
+ */
72
+ Artifacts?: ParsedDelegationArtifact[];
73
+ }
74
+ /**
75
+ * Handler for a CLIENT-EXECUTED UI tool (e.g. the live whiteboard's `Whiteboard_*` surface),
76
+ * registered via {@link RealtimeSessionRuntime.RegisterClientToolHandler}. Receives the tool name +
77
+ * raw arguments JSON from the realtime model and returns the result JSON string fed back as the
78
+ * `tool_response`. May be sync or async; thrown errors are wrapped into a
79
+ * `{ success: false, error }` payload by the service so the model can narrate the failure.
80
+ */
81
+ export type RealtimeClientToolHandler = (toolName: string, argsJson: string) => string | Promise<string>;
82
+ /**
83
+ * A channel's request to enter / leave the FOCUS layout, emitted on
84
+ * {@link RealtimeSessionRuntime.ChannelFocus$} when a plugin calls its context's
85
+ * `SetFocusMode`. The overlay shell subscribes: it collapses/restores the main call column
86
+ * and remembers which channel holds focus (so the floating pill's "exit" can be routed
87
+ * back via {@link BaseRealtimeChannelClient.RequestFocusExit}).
88
+ */
89
+ export interface RealtimeChannelFocusEvent {
90
+ /** The channel plugin requesting the layout change. */
91
+ Channel: BaseRealtimeChannelClient;
92
+ /** `true` to enter focus mode (surface owns the screen), `false` to leave it. */
93
+ Focused: boolean;
94
+ }
95
+ /**
96
+ * One EPHEMERAL spoken narration of delegated-run progress, emitted on
97
+ * {@link RealtimeSessionRuntime.DelegationNarration$}. These are the interim "here's what's
98
+ * happening" utterances the realtime model speaks while a delegation runs. By product
99
+ * decision they are NOT captions and NOT persisted as ConversationDetails — they exist
100
+ * only as a live note in the overlay, replaced by each newer narration.
101
+ */
102
+ export interface RealtimeDelegationNarration {
103
+ /** The narration transcript text. */
104
+ Text: string;
105
+ }
106
+ /**
107
+ * One thought/reasoning narration emitted on {@link RealtimeSessionRuntime.ThoughtNarration$}.
108
+ * Distinct from spoken progress narrations: thought summaries are authored by reasoning models
109
+ * (e.g. Gemini 3.8 Live Extended Thinking) and are NOT spoken aloud.
110
+ */
111
+ export interface RealtimeThoughtNarration {
112
+ /** Correlating call ID if associated with a delegation/turn; otherwise generated or empty. */
113
+ CallID?: string;
114
+ /** The model's thought / reasoning text. */
115
+ Text: string;
116
+ /** Whether this emission represents the complete finalized thought turn. */
117
+ IsFinal?: boolean;
118
+ }
119
+ /**
120
+ * Result shape returned by the `StartRealtimeClientSession` server mutation.
121
+ * The browser uses these values to open a client-direct realtime session.
122
+ *
123
+ * Exported because a host may mint the session ITSELF (its own mutation, carrying
124
+ * server-side context the stock mutation cannot express) and then hand the result to
125
+ * {@link RealtimeSessionRuntime.StartRealtimeSessionFromResult} to run it — this is the
126
+ * contract that path is written against.
127
+ */
128
+ export interface StartRealtimeClientSessionResult {
129
+ AgentSessionId: string;
130
+ ConversationId: string | null;
131
+ Provider: string;
132
+ Model: string;
133
+ EphemeralToken: string;
134
+ ExpiresAt: string;
135
+ /** JSON.stringify of the provider session config (instructions + tools) to apply at connect. */
136
+ SessionConfigJson: string;
137
+ /** Display name of the realtime model the session uses (e.g. "GPT Realtime 2"). Null when unknown. */
138
+ ModelName: string | null;
139
+ /**
140
+ * DB-driven progress-narration instruction template (contains a `{{ progressMessage }}`
141
+ * placeholder). Null when the deployment hasn't synced the narration prompt — the client
142
+ * falls back to its built-in wording.
143
+ */
144
+ NarrationInstructionsTemplate: string | null;
145
+ /**
146
+ * JSON map of the PRIOR session's saved channel states keyed by channel name (present only
147
+ * when the start carried `lastSessionId` and the prior session — owned by the same user —
148
+ * had saved states). Applied to the matching channel plugins via
149
+ * {@link BaseRealtimeChannelClient.RestoreState} so e.g. the whiteboard resumes where the
150
+ * last session left off.
151
+ */
152
+ PriorChannelStatesJson: string | null;
153
+ }
154
+ /**
155
+ * Host-supplied inputs that accompany an already-minted session on
156
+ * {@link RealtimeSessionRuntime.StartRealtimeSessionFromResult} — the values the RUN half needs
157
+ * that a {@link StartRealtimeClientSessionResult} cannot carry. Every field is optional and
158
+ * mirrors the same-named {@link RealtimeSessionRuntime.StartRealtimeSession} parameter, defaults
159
+ * included: omit one and the session behaves exactly as the all-in-one entry point does when that
160
+ * parameter is omitted.
161
+ */
162
+ export interface RealtimeSessionRunOptions {
163
+ /**
164
+ * The conversation the host asked its OWN mint for, or null/omitted when it asked the server to
165
+ * create one. Only the ORIGINAL request tells the two apart: a null here plus a
166
+ * `ConversationId` on the result means the server created that conversation for this session,
167
+ * which the host is told about via {@link RealtimeSessionRuntime.SessionCreatedConversationId}.
168
+ */
169
+ readonly conversationId?: string | null;
170
+ /**
171
+ * Display name of the target agent, surfaced on {@link RealtimeSessionRuntime.AgentName$} so any
172
+ * host can render it without re-resolving. Omitted ⇒ the previous name stands.
173
+ */
174
+ readonly agentName?: string | null;
175
+ /**
176
+ * EXPLICIT "record this call" consent for THIS session. Omitted/`null` ⇒ the per-user persisted
177
+ * preference (`mj.realtimeVoice.recordingConsent.v1`) is read as the default; `false` never
178
+ * records. The host is responsible for reporting its own choice to its own mint.
179
+ */
180
+ readonly recordingConsent?: boolean | null;
181
+ /**
182
+ * The application the session runs in. Stored so the live ClientContextChannel can stream
183
+ * subsequent context deltas under it. Omitted ⇒ no app layer (the pre-app behavior).
184
+ */
185
+ readonly applicationId?: string | null;
186
+ /**
187
+ * Live app-context snapshot. Omitted/`null` ⇒ the snapshot the host has already pushed via
188
+ * {@link RealtimeSessionRuntime.UpdateAppContext} stands (never clobber a good value with null).
189
+ */
190
+ readonly appContext?: AppContextSnapshot | null;
191
+ }
192
+ /**
193
+ * Drives a **client-direct** real-time voice session: the browser mints an ephemeral
194
+ * token from the MJ server, then connects DIRECTLY to the realtime provider. Audio
195
+ * frames never transit the MJ server (low latency); only tool calls and final
196
+ * transcripts are relayed back to MJ over GraphQL.
197
+ *
198
+ * This service is PROVIDER-AGNOSTIC policy/orchestration. All provider wire concerns
199
+ * (transport, event translation, the response state machine, narration-kind tagging,
200
+ * playback tracking) live in a {@link BaseRealtimeClient} driver resolved through the
201
+ * MJ ClassFactory by the server-reported `Provider` key (e.g. `'openai'` →
202
+ * `OpenAIRealtimeClient`). Future providers (Gemini Live, …) snap in by registering a
203
+ * new driver — this service does not change.
204
+ *
205
+ * The Realtime Co-Agent (server-side) fronts the conversation's current agent — the server
206
+ * bakes the companion instructions + tool set into `SessionConfigJson`, which the client
207
+ * driver applies verbatim.
208
+ *
209
+ * Lifecycle: {@link StartRealtimeSession} → live duplex → {@link EndRealtimeSession}. A start is
210
+ * two halves — MINT (the `StartRealtimeClientSession` mutation) and RUN (everything above) — and a
211
+ * host that must mint through its own server surface enters at the second half via
212
+ * {@link StartRealtimeSessionFromResult}; there is one implementation of the run half either way.
213
+ */
214
+ export declare class RealtimeSessionRuntime {
215
+ protected readonly mediaHost: IRealtimeMediaHost;
216
+ /**
217
+ * @param mediaHost the platform's media capabilities. The runtime never touches `navigator`,
218
+ * `Blob` or `FileReader` itself — microphone acquisition and audio recording
219
+ * are the host's, because both are platform-specific product decisions
220
+ * (permission UX, container format, where the bytes live).
221
+ */
222
+ constructor(mediaHost: IRealtimeMediaHost);
223
+ private _connectionState$;
224
+ private _captions$;
225
+ private _active$;
226
+ private _delegationProgress$;
227
+ private _delegationResult$;
228
+ private _delegationNarration$;
229
+ private _thoughtNarration$;
230
+ private _agentName$;
231
+ private _modelName$;
232
+ private _minimized$;
233
+ private _activeChannels$;
234
+ private _channelFocus$;
235
+ private _sessionStarted$;
236
+ private _sessionEnded$;
237
+ private _channelActivity$;
238
+ /** Current connection / turn state. */
239
+ readonly ConnectionState$: Observable<RealtimeConnectionState>;
240
+ /** Live captions for both sides of the conversation. */
241
+ readonly Captions$: Observable<RealtimeCaption[]>;
242
+ /** True while a session is open (mic button active, overlay shown). */
243
+ readonly Active$: Observable<boolean>;
244
+ /**
245
+ * Progress updates from a delegated agent run (e.g. Sage) while the realtime model waits on it.
246
+ * The future overlay subscribes to render a "working" card; the model also narrates these aloud.
247
+ */
248
+ readonly DelegationProgress$: Observable<RealtimeDelegationProgress>;
249
+ /** Terminal result of a delegation, so the overlay can complete the working card with real content. */
250
+ readonly DelegationResult$: Observable<RealtimeDelegationResult>;
251
+ /**
252
+ * EPHEMERAL spoken progress narrations (see {@link RealtimeDelegationNarration}). These are
253
+ * deliberately kept OUT of {@link Captions$} and never relayed/persisted — the overlay
254
+ * renders them as a transient "live note" near the active working card.
255
+ */
256
+ readonly DelegationNarration$: Observable<RealtimeDelegationNarration>;
257
+ /**
258
+ * Model-authored thought / reasoning narrations (see {@link RealtimeThoughtNarration}). These are
259
+ * reasoning summaries author-emitted during extended thinking, separate from spoken progress updates.
260
+ */
261
+ readonly ThoughtNarration$: Observable<RealtimeThoughtNarration>;
262
+ /** Display name of the agent the active session fronts (set at session start). */
263
+ readonly AgentName$: Observable<string>;
264
+ /**
265
+ * Display name of the realtime MODEL the active session runs on (server-reported at session
266
+ * start, e.g. "GPT Realtime 2"). `null` before a session starts / when the server didn't report
267
+ * one. The overlay banner shows it subtly next to the agent identity.
268
+ */
269
+ readonly ModelName$: Observable<string | null>;
270
+ /**
271
+ * True while the active call overlay is MINIMIZED to the host's floating "on call" pill
272
+ * (e.g. after a dev link navigated away). The mic and session stay fully live — this is
273
+ * pure presentation state, reset to `false` at session start and teardown.
274
+ */
275
+ readonly Minimized$: Observable<boolean>;
276
+ /**
277
+ * The session's ACTIVE interactive-channel plugins, resolved from the `MJ: AI Agent
278
+ * Channels` registry at session start (one instance per session, per channel). Emits
279
+ * `[]` before a session starts and after teardown. The overlay subscribes to register
280
+ * one surface tab per plugin — it never knows any concrete channel type.
281
+ */
282
+ readonly ActiveChannels$: Observable<BaseRealtimeChannelClient[]>;
283
+ /**
284
+ * Channel requests to enter / leave the FOCUS layout (see
285
+ * {@link RealtimeChannelFocusEvent}). Fired when a plugin calls its host context's
286
+ * `SetFocusMode` — e.g. the whiteboard's "Focus board" toggle.
287
+ */
288
+ readonly ChannelFocus$: Observable<RealtimeChannelFocusEvent>;
289
+ /**
290
+ * Fired EXACTLY ONCE per session after both `agentSessionId` is set AND the
291
+ * realtime client is connected. Carries the server-issued `sessionId` and the
292
+ * `ChannelName` of each plugin resolved at session mint. Consumed by
293
+ * `RealtimeSessionsAdapter` (in this package) to feed
294
+ * `@memberjunction/conversations-runtime`'s `SessionsObserver`.
295
+ *
296
+ * **Why this exists separately from `Active$`:** `Active$` flips `true` BEFORE
297
+ * `mintSession` resolves, so `agentSessionId` is still `null` at that moment.
298
+ * Subscribers correlating `(Active$, agentSessionId)` would race; this event
299
+ * removes the race.
300
+ */
301
+ readonly SessionStarted$: Observable<{
302
+ sessionId: string;
303
+ channelNames: string[];
304
+ }>;
305
+ /**
306
+ * Fired EXACTLY ONCE per session as teardown begins, with the prior
307
+ * `agentSessionId` (so subscribers can correlate against `SessionStarted$`'s
308
+ * sessionId) and the client-distinguishable reason — `'explicit'` when the
309
+ * user called `EndRealtimeSession`, `'error'` when teardown ran from a catch
310
+ * block. Server-side close paths (janitor, shutdown) do NOT propagate here —
311
+ * they happen out-of-process and have no client push channel today.
312
+ */
313
+ readonly SessionEnded$: Observable<{
314
+ sessionId: string;
315
+ reason: 'explicit' | 'error';
316
+ }>;
317
+ /**
318
+ * Fires with the channel PLUGIN every time the agent ACTS on that channel (a tool call
319
+ * was routed to its local executor — e.g. the agent drew on the whiteboard). The overlay
320
+ * uses the FIRST emission per channel to auto-reveal + focus the channel's surface tab,
321
+ * so the user discovers the surface the moment the agent starts using it. Finer-grained
322
+ * than {@link SessionStarted$}/{@link SessionEnded$} (per tool call, not per session).
323
+ */
324
+ readonly ChannelActivity$: Observable<BaseRealtimeChannelClient>;
325
+ /** Synchronous access to the session's active interactive-channel plugins. */
326
+ get ActiveChannels(): readonly BaseRealtimeChannelClient[];
327
+ /**
328
+ * The `ChannelName`s the agent has used (acted on) at least once this session. The overlay
329
+ * uses this to register a channel's surface tab only after it has come into play. A fresh
330
+ * Set snapshot so callers can't mutate the service's tracking.
331
+ */
332
+ get UsedChannelNames(): ReadonlySet<string>;
333
+ /** Whether the agent has used (acted on) the named channel at least once this session. */
334
+ HasChannelBeenUsed(channelName: string): boolean;
335
+ /** Synchronous access to the display name of the agent the active session fronts. */
336
+ get CurrentAgentName(): string;
337
+ /**
338
+ * ID of the active server-side agent session (`MJ: AI Agent Sessions`), or `null` when no
339
+ * session is open / the session hasn't been minted yet. Powers the overlay's gear-gated
340
+ * "Open session" dev link.
341
+ */
342
+ /** Conversation id the SERVER created for this session (null when the host supplied one). */
343
+ private createdConversationId;
344
+ /** The session's conversation id (supplied or server-created). */
345
+ private sessionConversationId;
346
+ /** First final user utterance of the live session (the naming seed). */
347
+ private firstUserTranscript;
348
+ /** Buffer accumulating streaming user interim deltas into a single in-progress bubble. */
349
+ private pendingUserCaption;
350
+ /** Whether an in-place interim user caption is currently placed in `_captions$`. */
351
+ private hasActiveInterimUserCaption;
352
+ /**
353
+ * When the active/last session CREATED its conversation (started without one), the new
354
+ * conversation's id — the host uses it to refresh the cached list, conditionally select
355
+ * it on close, and auto-name it. Null when the session joined an existing conversation.
356
+ */
357
+ get SessionCreatedConversationId(): string | null;
358
+ /** The first final user utterance of the session (naming seed); null before the user speaks. */
359
+ get FirstUserTranscript(): string | null;
360
+ get CurrentAgentSessionId(): string | null;
361
+ /** Synchronous access to the minimized presentation state. */
362
+ get IsMinimized(): boolean;
363
+ /**
364
+ * Minimizes / restores the active call overlay (host renders the floating pill while
365
+ * minimized). Presentation-only — the live audio session is untouched.
366
+ */
367
+ SetMinimized(minimized: boolean): void;
368
+ /** The provider-direct realtime client driving the live session (ClassFactory-resolved). */
369
+ private client;
370
+ /** The mic capture stream — acquired here (permission UX) and handed to the client. */
371
+ private localStream;
372
+ private agentSessionId;
373
+ /**
374
+ * The application the active session runs in (sources the server-side app config cascade +
375
+ * RelevantAgents → allowed-agent union, and the default-agent chain). `null` when no app context
376
+ * was supplied. Set at {@link StartRealtimeSession}; sent to the mint mutation.
377
+ */
378
+ private applicationId;
379
+ /**
380
+ * The live app-context snapshot (where the user is, what they see, the capability manifest),
381
+ * pushed by the host (Explorer) at session start and on subsequent changes via
382
+ * {@link UpdateAppContext}. The headless {@link import('../components/realtime/channels/client-context-channel').ClientContextChannel}
383
+ * subscribes to {@link AppContext$} and streams deltas to the model via `SendContextNote`.
384
+ */
385
+ private readonly _appContext$;
386
+ /** Observable of the live app-context snapshot (see {@link _appContext$}). */
387
+ readonly AppContext$: Observable<AppContextSnapshot | null>;
388
+ /**
389
+ * Push an updated app-context snapshot mid-session (the continuous-streaming half of client-context
390
+ * delivery). The host (Explorer) calls this when the user navigates / the active surface's state or
391
+ * capability manifest changes; the ClientContextChannel turns the delta into a `SendContextNote`.
392
+ * No-op semantics when no session is live — the channel simply re-reads on next start.
393
+ *
394
+ * @param snapshot The latest app-context snapshot (or null to clear).
395
+ */
396
+ UpdateAppContext(snapshot: AppContextSnapshot | null): void;
397
+ /**
398
+ * The DB-driven narration instruction template (server-resolved at session start, containing a
399
+ * `{{ progressMessage }}` placeholder). `null` when the deployment hasn't synced the narration
400
+ * prompt — {@link buildNarrationInstructions} then falls back to the built-in wording.
401
+ */
402
+ private narrationTemplate;
403
+ /**
404
+ * The active session's audio recorder (mic + agent mix), or `null` when the user didn't
405
+ * consent or the browser can't record. Created after the client connects; stopped + uploaded
406
+ * at teardown.
407
+ */
408
+ private recorder;
409
+ /** ISO timestamp of when recording started — sent to the server on session start. */
410
+ private recordingStartedAtIso;
411
+ /** Interval that flushes ~15s crash-recovery shards to the server during a recording. */
412
+ private segmentTimer;
413
+ /** 0-based index of the next recording shard to upload. */
414
+ private segmentIndex;
415
+ /** How often crash-recovery shards are flushed during a recording. */
416
+ private static readonly SegmentFlushMs;
417
+ /**
418
+ * Interval that tells the server this session is still in use, or `null` when no session is
419
+ * running. See {@link startLivenessPulse} for why the server cannot work this out itself.
420
+ */
421
+ private livenessTimer;
422
+ /**
423
+ * How often the client asserts liveness. Comfortably under `SessionJanitor`'s
424
+ * `closeThresholdMinutes` (15 by default) so several pulses must be missed in a row before a
425
+ * live session is reaped, and well above `SessionManager`'s heartbeat write-coalescing window
426
+ * so the DB sees at most a trickle of writes per session.
427
+ */
428
+ private static readonly LivenessPulseMs;
429
+ /**
430
+ * Recording-relative ms offset at which the IN-FLIGHT (not-yet-finalized) turn's audio
431
+ * actually BEGAN — captured the moment that turn's audio/text starts flowing (its first
432
+ * interim transcript), NOT inherited from the previous turn's end. `null` before the first
433
+ * turn / between turns (until the next turn's audio starts). Sent as `utteranceStartMs` on
434
+ * the turn's final transcript so per-turn timing lines up with the recording even when a
435
+ * tool-call / silence gap sits between turns (the inherit-previous-end model mis-stamped
436
+ * the post-gap turn at the pre-gap offset). See {@link markTurnAudioStart}.
437
+ */
438
+ private currentTurnStartMs;
439
+ /**
440
+ * Wall-anchor of the SESSION clock (#3832): `performance.now()` at the moment the call went
441
+ * live, or `null` before any call has. Read only through {@link nowTurnOffsetMs}.
442
+ */
443
+ private sessionClockStartMs;
444
+ /**
445
+ * Per-turn guard for {@link markTurnAudioStart}: `true` once the in-flight turn's audio-start
446
+ * offset has been captured, so mid-turn interim deltas don't overwrite it. Reset to `false`
447
+ * at each finalization so the NEXT turn re-stamps from where ITS audio begins.
448
+ */
449
+ private turnAudioStartCaptured;
450
+ /** First spoken update fires no earlier than this long after delegated work starts. */
451
+ private static readonly FirstNarrationDelayMs;
452
+ /** Minimum gap between SUBSEQUENT spoken updates (the 7–10s band; floods aggregate). */
453
+ private static readonly NarrationIntervalMs;
454
+ /** Retry delay when the fire moment finds the model busy / audio still playing. */
455
+ private static readonly NarrationBusyRetryMs;
456
+ /** Max progress messages aggregated into one spoken digest. */
457
+ private static readonly MaxDigestMessages;
458
+ /** Max prior spoken narrations chained into the instructions (anti-repetition). */
459
+ private static readonly MaxPriorNarrations;
460
+ /**
461
+ * Aggregation buffer: distinct progress messages since the last spoken update (oldest
462
+ * first, capped at {@link RealtimeSessionRuntime.MaxDigestMessages}). A flood of small
463
+ * updates becomes ONE digest; the buffer is discarded when the result lands first.
464
+ */
465
+ private pendingNarrationMessages;
466
+ /**
467
+ * Tool calls currently executing on the server. Progress events ride PubSub and can
468
+ * lag the (fast) mutation result — any progress for a call NOT in this set is stale
469
+ * (already completed) and is dropped, so we never narrate "starting up" after the
470
+ * answer was already spoken.
471
+ */
472
+ private inFlightCallIds;
473
+ /** Timer for the deferred narration; cancelled when the delegation result lands first. */
474
+ private narrationTimer;
475
+ /**
476
+ * Call ids the USER explicitly cancelled via {@link CancelDelegation} /
477
+ * {@link CancelInFlightDelegations}. Their cards were already flipped to the
478
+ * "Cancelled by user" failed result, so when the original tool mutation later resolves
479
+ * with the aborted run's outcome, {@link emitDelegationResult} skips the duplicate card
480
+ * emission (the model still receives the tool result). Cleared at teardown.
481
+ */
482
+ private cancelledCallIds;
483
+ /** Debounce window for relaying accumulated usage deltas to the server. */
484
+ private static readonly UsageFlushDebounceMs;
485
+ /** Accumulated input-token delta since the last flush. */
486
+ private pendingUsageInput;
487
+ /** Accumulated output-token delta since the last flush. */
488
+ private pendingUsageOutput;
489
+ /** Pending debounced usage flush; also force-flushed at teardown. */
490
+ private usageFlushTimer;
491
+ /** Active push-status subscription that feeds delegation progress; cleared on teardown. */
492
+ private delegationProgressSub;
493
+ /** Timestamp (ms) of the last narration we triggered; 0 = never. */
494
+ private lastDelegationNarrationAt;
495
+ /** When the current delegation burst began (first in-flight call); anchors the 5s first update. */
496
+ private delegationBurstStartedAt;
497
+ /** Spoken updates so far in this burst (1-based numbering for the instructions). */
498
+ private narrationCount;
499
+ /** What the model actually SAID for prior updates this burst — chained in so it never repeats itself. */
500
+ private spokenNarrations;
501
+ /** Tail message of the last digest, so an identical trailing progress event isn't re-buffered. */
502
+ private lastNarratedTail;
503
+ /**
504
+ * Registry of CLIENT-EXECUTED UI tool handlers, keyed by tool-name prefix (e.g.
505
+ * `'Whiteboard_'`). Tool calls whose name matches a registered prefix run LOCALLY through the
506
+ * handler (never relayed to the server); everything else takes the standard server-relay path.
507
+ * Cleared at teardown.
508
+ */
509
+ private clientToolHandlers;
510
+ /**
511
+ * Monotonic id for the current start attempt, bumped by every {@link teardown}.
512
+ *
513
+ * A session start is a multi-await sequence — mint, acquire the microphone, connect — and a host
514
+ * can end the session part-way through it (the user taps back while the mint is still in flight).
515
+ * Teardown at that moment has nothing to tear down: the stream and the client do not exist yet.
516
+ * Without this, the in-flight start then proceeds to open a microphone and a provider connection
517
+ * nobody is watching. Each start captures the generation it began under and abandons itself the
518
+ * moment it no longer matches.
519
+ */
520
+ private startGeneration;
521
+ /**
522
+ * The teardown currently running, so a second call awaits it rather than racing it.
523
+ *
524
+ * Ending a session commonly fires twice — an explicit stop followed by the host unmounting — and
525
+ * `teardown` flips `_active$` only at the end, so the second call passes the `IsActive` guard and
526
+ * runs concurrently with the first: two `Disconnect()` calls, two `CloseAgentSession` mutations,
527
+ * two `SessionEnded$` emissions for one session.
528
+ */
529
+ private teardownInFlight;
530
+ /**
531
+ * Why the last session start failed, or `null` when none has.
532
+ *
533
+ * The runtime reports failure as `'error'` on {@link ConnectionState$}, which is enough to show
534
+ * *that* something went wrong but not *what* — and the difference matters at exactly one point:
535
+ * microphone permission. A host that cannot tell "you denied the mic" from "the provider is
536
+ * down" has to show the same unhelpful copy for both. `AcquireMicrophone` rejects inside the
537
+ * runtime's own try/catch, so the host never sees that rejection itself.
538
+ */
539
+ private lastStartError;
540
+ /** Debounce window for persisting a channel's state of record after a change burst. */
541
+ private static readonly ChannelSaveDebounceMs;
542
+ /**
543
+ * Pending DEBOUNCED channel-state saves, keyed by channel name. Each entry keeps the
544
+ * LATEST serialized state plus the session id captured while the session was live —
545
+ * the teardown flush runs as the live id is being torn down, so the capture guarantees
546
+ * the final save still lands on the just-closed session.
547
+ */
548
+ private pendingChannelSaves;
549
+ /**
550
+ * `ChannelName`s the agent has ACTED ON at least once this session (the channel's first
551
+ * tool call routed to its local executor). The overlay reads this to decide which channel
552
+ * surface tabs to register: a channel earns its tab only once it's been used (the
553
+ * whiteboard is the sole exception — it tabs immediately, since a user may draw first).
554
+ * Reset at session start via {@link resetState}.
555
+ */
556
+ private usedChannelNames;
557
+ private _provider;
558
+ /**
559
+ * Metadata provider used for the GraphQL relay mutations. Falls back to the
560
+ * global default when unset (single-provider apps see no change).
561
+ */
562
+ get Provider(): IMetadataProvider;
563
+ set Provider(value: IMetadataProvider | null);
564
+ /** True when a session is currently open. */
565
+ get IsActive(): boolean;
566
+ /**
567
+ * Start a client-direct voice session fronting `targetAgentId`.
568
+ *
569
+ * @param targetAgentId The agent the Realtime Co-Agent voices on behalf of.
570
+ * @param conversationId Optional existing conversation to bind + seed context from.
571
+ * @param lastSessionId Optional prior session to chain to (resume / continuation).
572
+ * @param agentName Optional display name of the target agent — resolved by the caller
573
+ * (which knows the conversation's routing context) and surfaced on {@link AgentName$}
574
+ * so ANY host (composer trigger, chat-area overlay) can render it without re-resolving.
575
+ * @param preferredModelId Optional EXPLICIT realtime model choice (`MJ: AI Models.ID`). When
576
+ * set, the server uses exactly that model and FAILS with a clear reason if it can't (no
577
+ * silent fallback). Omit for the server's automatic (highest-PowerRank) selection.
578
+ * @param clientTools Optional EXTRA client-executed UI tool declarations to expose to the
579
+ * realtime model alongside the server's stable tool set and the interactive-channel
580
+ * tools (which are aggregated automatically from the registry-resolved plugins — see
581
+ * {@link ActiveChannels$}). The server only DECLARES these — execution stays in the
582
+ * browser via handlers registered with {@link RegisterClientToolHandler}. This is an
583
+ * extension point for hosts with bespoke (non-channel) UI tools; most callers omit it.
584
+ * @param coAgentId Optional EXPLICIT co-agent choice (`MJ: AI Agents.ID` of an Active,
585
+ * Realtime-type agent) — the highest-precedence step of the server's co-agent resolution
586
+ * chain. When set, the server uses exactly that co-agent and FAILS with a clear reason if
587
+ * it can't (no silent fallback). Omit to let server metadata drive the choice: the target
588
+ * agent's `DefaultCoAgentID`, then the type-level `AIAgentCoAgent` default row, then the global Realtime Co-Agent.
589
+ * @param configOverridesJson Optional JSON payload of SESSION CONFIG overrides (e.g.
590
+ * `{"realtime":{"modelPreference":"<modelId>"}}`), forwarded verbatim on the mint
591
+ * mutation. The server enforces the `Realtime: Advanced Session Controls`
592
+ * authorization on any overrides — hosts only populate this from authorization-gated
593
+ * pickers, and never synthesize overrides beyond what the user explicitly chose.
594
+ * Omit/`null` for the server's defaults (today's behavior).
595
+ * @param recordingConsent Optional EXPLICIT "record this call" consent for THIS session. When
596
+ * `true`, the browser records a mic + agent-audio mix and uploads it at session end. When
597
+ * omitted/`null`, the per-user persisted preference (`mj.realtimeVoice.recordingConsent.v1`
598
+ * via {@link UserInfoEngine}) is read as the default. `false` never records.
599
+ * @param mediaCollectionId Optional per-session media-kit override (`MJ: Collections.ID`). When set,
600
+ * the server-side Media channel resolves THIS collection as the agent's media kit for the session,
601
+ * taking precedence over the agent's `DefaultMediaCollectionID`. The server UUID-validates it
602
+ * (malformed ⇒ ignored, the agent default applies). Omit/`null` to use the agent default kit.
603
+ */
604
+ StartRealtimeSession(targetAgentId: string, conversationId?: string | null, lastSessionId?: string | null, agentName?: string | null, preferredModelId?: string | null, clientTools?: RealtimeToolDefinition[] | null, coAgentId?: string | null, configOverridesJson?: string | null, recordingConsent?: boolean | null, mediaCollectionId?: string | null, applicationId?: string | null, appContext?: AppContextSnapshot | null): Promise<void>;
605
+ /**
606
+ * Declares which provider keys this host can actually carry audio for.
607
+ *
608
+ * The server resolves a realtime model by rank across every configured vendor, so it can
609
+ * legitimately return a provider whose client driver this host cannot run. A browser can run all
610
+ * of them; React Native can run the WebRTC ones but not those needing a Web Audio PCM plane.
611
+ * Connecting anyway gets as far as constructing the driver's playback engine and then throws —
612
+ * a crash, where the honest answer is "this workspace's voice provider is not one this app can
613
+ * use".
614
+ *
615
+ * The default accepts everything, so existing hosts are unaffected. Override to narrow it.
616
+ *
617
+ * @param provider The `Provider` key the server stamped on the minted session.
618
+ */
619
+ protected hostCanUseProvider(_provider: string): boolean;
620
+ /**
621
+ * Closes a session that was minted but will never be connected, and reports why.
622
+ *
623
+ * Minting creates a durable `MJ: AI Agent Sessions` row server-side, so declining to connect
624
+ * still has to close it — otherwise every rejected attempt leaks an `Active` session for the
625
+ * janitor to reconcile fifteen minutes later.
626
+ */
627
+ private abortUnusableSession;
628
+ /**
629
+ * Run a session the HOST has already minted itself — the second half of
630
+ * {@link StartRealtimeSession}, without the `StartRealtimeClientSession` mutation.
631
+ *
632
+ * For hosts that must mint through their own server surface because they attach per-session
633
+ * context the stock mutation cannot carry (e.g. an interview persona baked into the companion
634
+ * prompt). They call their own mutation, shape the reply into a
635
+ * {@link StartRealtimeClientSessionResult}, and hand it here: driver resolution, the ephemeral-token
636
+ * connect, tool/transcript relays, recording, connection state and teardown are all identical to
637
+ * the all-in-one path — there is exactly one implementation of the run half.
638
+ *
639
+ * NOTE: the interactive-channel plugins are NOT started on this path. Their tool sets must be
640
+ * declared to the model AT MINT, which happened on the host's side — so a host that wants channels
641
+ * owns that half too.
642
+ *
643
+ * @param result The minted session — the same ten fields the `StartRealtimeClientSession`
644
+ * mutation returns. `EphemeralToken` and `Provider` are what actually open the call.
645
+ * @param options Host-side inputs the result cannot carry; see {@link RealtimeSessionRunOptions}.
646
+ * Every field defaults exactly as its {@link StartRealtimeSession} counterpart does.
647
+ */
648
+ StartRealtimeSessionFromResult(result: StartRealtimeClientSessionResult, options?: RealtimeSessionRunOptions): Promise<void>;
649
+ /**
650
+ * Start prologue shared by both entry points: bind the app layer, publish the agent name, reset
651
+ * per-session state, and flip the session live (which is ALSO what makes the `IsActive` guard
652
+ * suppress duplicate starts while the mint is still in flight — hence it runs before minting, not
653
+ * after). Returns the resolved recording consent, which the mint half reports to the server and
654
+ * the run half uses to decide whether to record.
655
+ */
656
+ private beginSessionStart;
657
+ /**
658
+ * The RUN half of a session start, shared by both entry points: consume the minted result, open
659
+ * the provider connection, and go live. `inputConversationId` is the conversation the START asked
660
+ * for (null ⇒ "server, make me one") — the result alone can't distinguish the two.
661
+ */
662
+ private runMintedSession;
663
+ /**
664
+ * Releases everything a start acquired after the host had already ended the session.
665
+ *
666
+ * Reached only when {@link teardown} ran while this start was awaiting the microphone or the
667
+ * provider connection. Teardown found nothing to release because nothing existed yet, so this
668
+ * start owns the cleanup: stop the microphone, close the provider connection, and close the
669
+ * server-side session row that the mint created.
670
+ */
671
+ private unwindAbandonedStart;
672
+ /**
673
+ * Why the last session start failed, or `null` when the last start succeeded or none has run.
674
+ *
675
+ * Read it when {@link ConnectionState$} reports `'error'`, to tell a denied microphone apart from
676
+ * a provider or backend failure and show copy the user can act on. Cleared at the start of every
677
+ * session.
678
+ */
679
+ get LastStartError(): Error | null;
680
+ /**
681
+ * The single failure path for a session start (mint half or run half): report it, latch the
682
+ * overlay into 'error', and unwind whatever the half-built session already opened.
683
+ */
684
+ private failSessionStart;
685
+ /**
686
+ * End the active session: stop the mic, tear down the provider connection, and close
687
+ * the server-side agent session. Safe to call when no session is active.
688
+ */
689
+ EndRealtimeSession(): Promise<void>;
690
+ /**
691
+ * Inject a typed message into the live session as a user turn.
692
+ *
693
+ * Decomposed into two steps, each mirroring an existing voice path so the typed
694
+ * turn behaves identically to a spoken one:
695
+ * 1. {@link BaseRealtimeClient.SendText} injects the text as user input and triggers a
696
+ * reply through the SAME collision-safe path tool results use — so it queues behind
697
+ * any in-flight response (progress narration / prior turn) instead of colliding.
698
+ * 2. Relay the turn through the same caption + transcript paths user speech uses
699
+ * ({@link onUserTranscript}) so it shows in the live thread AND persists to MJ.
700
+ *
701
+ * No-op when no session is open / the control channel isn't ready, or when the text is empty.
702
+ */
703
+ SendText(text: string): void;
704
+ /** Mute / unmute the local microphone track. Returns the new muted state. */
705
+ ToggleMute(): boolean;
706
+ /**
707
+ * Registers a handler for CLIENT-EXECUTED UI tools whose names start with `toolNamePrefix`
708
+ * (e.g. `'Whiteboard_'` → all `Whiteboard_*` calls). Matching tool calls execute LOCALLY via
709
+ * the handler — they are never relayed to the server — and the handler's result JSON is sent
710
+ * back to the model as the `tool_response`. Re-registering the same prefix replaces the
711
+ * handler. The registry is cleared at session teardown.
712
+ */
713
+ RegisterClientToolHandler(toolNamePrefix: string, handler: RealtimeClientToolHandler): void;
714
+ /** Removes the handler registered for `toolNamePrefix` (no-op when none is registered). */
715
+ UnregisterClientToolHandler(toolNamePrefix: string): void;
716
+ /**
717
+ * Feeds a background context note into the live model (no spoken reply is requested) — the
718
+ * perception channel interactive surfaces use (e.g. the whiteboard's coalesced scene deltas).
719
+ * No-op when no session is live.
720
+ */
721
+ SendContextNote(text: string): void;
722
+ /**
723
+ * Asks the live model to SPEAK FIRST — before the human has said anything.
724
+ *
725
+ * Every other path into the model's voice reacts to something: the human spoke, or a channel
726
+ * reported input. A host that needs the agent to open the conversation (an interviewer greeting
727
+ * a candidate, a guide introducing a task) had no way to ask for that, so the session connected
728
+ * and both sides waited for the other. The instructions are what to say, in the host's words —
729
+ * the model still speaks in its own voice and persona.
730
+ *
731
+ * Returns whether the request was DELIVERED, which is the one way this deliberately differs from
732
+ * {@link SendContextNote} beside it. A context note that is dropped costs the model a little
733
+ * perception; an opening line that is dropped is a session that sits in silence, and the host
734
+ * needs to be able to tell the two apart. `false` means no session was live (or the instructions
735
+ * were empty) — usually a host that asked before the connection reached a speaking state, which
736
+ * it can then retry.
737
+ */
738
+ RequestSpokenOpening(instructions: string): boolean;
739
+ /**
740
+ * The active client's current audio activity (per-direction RMS levels + spectrum
741
+ * bins), or `null` when no session is live or the driver attached no audio meters.
742
+ * Sampled by the overlay's animation-frame loop to drive the audio-reactive orb/EQ —
743
+ * a cheap analyser read, never provider traffic.
744
+ */
745
+ GetAudioActivity(): RealtimeAudioActivity | null;
746
+ /**
747
+ * The active {@link BaseRealtimeClient} driving the media plane, or null when not connected.
748
+ */
749
+ get Client(): BaseRealtimeClient | null;
750
+ /**
751
+ * Relays a video frame to the underlying realtime client if active.
752
+ */
753
+ SendVideoFrame(base64Image: string, mimeType?: string): void;
754
+ /**
755
+ * Checks whether a media track is established on the active realtime client.
756
+ */
757
+ IsTrackEstablished(modality: string, direction: RealtimeTrackDirection): boolean;
758
+ /**
759
+ * Reads the per-user recording-consent preference from `MJ: User Settings` (via
760
+ * {@link UserInfoEngine}'s synchronous cache). Defensive: any failure resolves to `false`
761
+ * (don't record) so a settings hiccup can never opt a user into recording.
762
+ */
763
+ private readPersistedRecordingConsent;
764
+ /**
765
+ * Starts the browser-side recorder (mic + agent-audio mix). Best-effort — any failure is
766
+ * contained so it never disturbs the live call; an unsupported browser simply records
767
+ * nothing (the recorder disables itself).
768
+ */
769
+ private startRecording;
770
+ /** Begins flushing ~15s crash-recovery shards to the server for the duration of the recording. */
771
+ private startSegmentFlushing;
772
+ /** Stops the periodic crash-recovery shard flush. */
773
+ private stopSegmentFlushing;
774
+ /**
775
+ * Starts telling the server this session is still in use (#3533).
776
+ *
777
+ * **Why the server cannot work this out on its own.** In the client-direct topology the audio
778
+ * goes browser → provider over WebRTC. The server sees the mint, a few channel actions in the
779
+ * first seconds, and then nothing at all — so `SessionManager.RecordActivity` stops being
780
+ * reached while the conversation is still going. `LastActiveAt` freezes ~45 seconds in, and
781
+ * `SessionJanitor` — which cannot distinguish an active call from an abandoned one — force-closes
782
+ * it at `closeThresholdMinutes`, mid-sentence, taking the user's surfaces with it. A session
783
+ * whose channels are all client-side (whiteboard, media) goes quiet from the server's point of
784
+ * view almost immediately.
785
+ *
786
+ * The browser is the only participant that knows the call is alive, so it is the one that has to
787
+ * say so. Raising `closeThresholdMinutes` is not the fix — it just makes the janitor slower at
788
+ * its real job (reaping rows orphaned by a crash) without making liveness observable.
789
+ *
790
+ * The pulse is best-effort by design: a failed beat is logged and skipped, never surfaced to the
791
+ * user and never allowed to end the session. Losing one beat costs nothing because the threshold
792
+ * is many beats wide; turning a transient network blip into a visible error would be a worse
793
+ * failure than the one this fixes. Write amplification is bounded on the server side too, where
794
+ * `SessionManager.Heartbeat` coalesces persisted writes.
795
+ */
796
+ private startLivenessPulse;
797
+ /** Stops the liveness pulse. Idempotent — safe on a session that never started one. */
798
+ private stopLivenessPulse;
799
+ /**
800
+ * One liveness beat. Reads the session id at fire time rather than closing over it, so a beat
801
+ * that fires during teardown finds `null` and does nothing instead of resurrecting a closed row.
802
+ */
803
+ private pulseLiveness;
804
+ /**
805
+ * Uploads the chunks captured since the last flush as one crash-recovery shard (durability only;
806
+ * the canonical file is still the full upload at teardown). Best-effort — never disturbs the call.
807
+ */
808
+ private flushRecordingSegment;
809
+ /**
810
+ * Stops the active recorder and uploads the captured audio via `UploadRealtimeRecording`.
811
+ * Fully best-effort and wrapped in try/catch — recording upload must NEVER block teardown.
812
+ * No-op when nothing was recorded or there's no session id to attach the file to.
813
+ */
814
+ private stopAndUploadRecording;
815
+ /**
816
+ * Runs the `UploadRealtimeRecording` mutation; failures are logged, never thrown. Sends the
817
+ * capture-time waveform `peaks` (max-abs per bucket, normalized 0..1) so the server can persist a
818
+ * `peaks.json` sidecar for fast waveform rendering without re-decoding the audio.
819
+ */
820
+ private uploadRecording;
821
+ /**
822
+ * Resolves, instantiates and initializes the session's interactive-channel plugins from
823
+ * the `MJ: AI Agent Channels` registry, publishes them on {@link ActiveChannels$}, and
824
+ * returns their aggregated client-executed tool declarations for the session mint.
825
+ * Tolerant by design: registry/resolution failures degrade to "no channels" — the voice
826
+ * session itself always proceeds.
827
+ */
828
+ private startChannels;
829
+ /**
830
+ * Loads the ACTIVE channel definitions from the registry and resolves each row's
831
+ * `ClientPluginClass` through the MJ ClassFactory into a per-session plugin instance —
832
+ * the client-side mirror of how realtime-model drivers resolve from `BaseRealtimeModel`
833
+ * / `BaseRealtimeClient`. Rows whose plugin class isn't registered are skipped (logged),
834
+ * never fatal.
835
+ */
836
+ private loadActiveChannels;
837
+ /**
838
+ * Reads the ACTIVE `MJ: AI Agent Channels` rows from {@link AIEngineBase}'s cached
839
+ * `AgentChannels` (provider-scoped engine instance, lazy `Config` — no RunView
840
+ * round-trip; the engine's BaseEntity-event reactivity keeps the registry fresh).
841
+ * Failures are logged and degrade to an empty list — channel availability must
842
+ * never block the voice session.
843
+ */
844
+ private fetchChannelDefinitions;
845
+ /**
846
+ * Resolves one registry row's `ClientPluginClass` via the ClassFactory (registration
847
+ * checked first, exactly like the realtime-client drivers) and instantiates a fresh
848
+ * per-session plugin. Returns `null` (logged) when no plugin is registered for the key
849
+ * — e.g. its Load function was never called or the package isn't included client-side.
850
+ */
851
+ private resolveChannelPlugin;
852
+ /**
853
+ * Wires one plugin into the session: hands it its host context and registers its
854
+ * prefix-routed local tool executor (so `<ToolNamePrefix>*` calls run in the browser
855
+ * through {@link BaseRealtimeChannelClient.ApplyAgentTool}, never the server relay).
856
+ */
857
+ private initializeChannel;
858
+ /** Builds the host-services context one channel plugin sees (its only line to the session). */
859
+ private buildChannelContext;
860
+ /**
861
+ * Host registry of surface CLIENT TOOLS (Name → handler), fed by the host (Explorer) from the
862
+ * active surface's `SetAgentClientTools`. The headless ClientContextChannel's `ContextTool` proxy
863
+ * executes against this via {@link executeAppClientTool}. Keys are lower-cased for case-insensitive
864
+ * model-supplied action names.
865
+ */
866
+ private readonly appClientToolHandlers;
867
+ /**
868
+ * Replaces the set of host-registered surface client tools the realtime ContextTool can execute.
869
+ * The host calls this at session start and whenever the active surface's tool set changes (the
870
+ * continuous-capability half of client-context delivery). Passing `[]` clears them.
871
+ *
872
+ * @param tools The current surface client tools (name + handler). Descriptions/schemas ride the
873
+ * app-context manifest separately; only the executable handler is needed here.
874
+ */
875
+ RegisterAppClientTools(tools: ReadonlyArray<{
876
+ Name: string;
877
+ Handler: (params: Record<string, unknown>) => Promise<unknown> | unknown;
878
+ }>): void;
879
+ /**
880
+ * Executes a host-registered surface client tool by name (the {@link RealtimeChannelContext.ExecuteClientTool}
881
+ * implementation). Tolerant: an unknown tool or a thrown handler resolves to a structured
882
+ * `Success: false` result the channel narrates — never throws.
883
+ *
884
+ * @param name The tool name (the model's `action`).
885
+ * @param params The tool parameters.
886
+ * @returns A structured result for the channel to serialize back to the model.
887
+ */
888
+ private executeAppClientTool;
889
+ /**
890
+ * Runs a channel-specific GraphQL operation through the live session's provider (the
891
+ * {@link RealtimeChannelContext.ExecuteServerAction} implementation). Best-effort: any
892
+ * transport/server error is logged and resolves to `null` so the calling channel can map
893
+ * the failure to a model-readable result string without `try/catch`.
894
+ */
895
+ private executeChannelServerAction;
896
+ /**
897
+ * A channel asked the live model to SPEAK in reaction to channel input (e.g. a widget
898
+ * submission) — routed through the client's spoken-update channel. No-op when the
899
+ * session isn't live; empty instructions are dropped.
900
+ */
901
+ private requestChannelSpokenResponse;
902
+ /**
903
+ * Applies the PRIOR session's saved channel states (resume continuity): parses the
904
+ * server-supplied map and offers each entry to the matching active plugin via
905
+ * {@link BaseRealtimeChannelClient.RestoreState}. Fully tolerant — malformed payloads,
906
+ * unknown channels, and plugin rejections are logged and skipped; the session start is
907
+ * never affected.
908
+ */
909
+ private applyPriorChannelStates;
910
+ /**
911
+ * Persists a channel's state as a first-class versioned artifact (`MJ: Artifacts`) via the
912
+ * `SaveSessionChannelArtifact` mutation — the channel-context capability behind e.g. the
913
+ * whiteboard's "Save to artifacts". Best-effort: returns the created Artifact ID, or null
914
+ * on any failure (logged, never thrown). Uses the live session id, falling back to the
915
+ * teardown-captured one so "save my board" works right after the call ends.
916
+ */
917
+ private saveChannelArtifact;
918
+ /** Most recent session id captured by the save pipeline (post-teardown saves). */
919
+ private lastKnownSessionIdForSaves;
920
+ /**
921
+ * Schedules the DEBOUNCED state-of-record save for a channel: each request replaces the
922
+ * pending payload (latest state wins) and re-arms the timer; the session id is captured
923
+ * while live so the teardown flush can persist onto the just-closed session.
924
+ */
925
+ private scheduleChannelSave;
926
+ /** Fires one pending channel save (best-effort; {@link SaveChannelState} logs failures). */
927
+ private flushChannelSave;
928
+ /** Final teardown flush: persist every channel's unsaved state immediately. */
929
+ private flushAllChannelSaves;
930
+ /** Disposes all channel plugins (errors contained per plugin) and clears the live set. */
931
+ private disposeChannels;
932
+ /**
933
+ * Resolves the provider-direct realtime client for `provider` through the MJ
934
+ * ClassFactory — the client-side mirror of how server drivers are resolved from
935
+ * `BaseRealtimeModel`. Throws a clear error when no driver is registered for the
936
+ * provider (e.g. its Load function was never called).
937
+ */
938
+ private createRealtimeClient;
939
+ /**
940
+ * Builds the client-direct session config the realtime client connects with.
941
+ * Aggregates tracks sourced by active channels into `requestedTracks` so the driver
942
+ * can negotiate them (e.g., establishing inbound video streaming for Whiteboard / RemoteBrowser).
943
+ */
944
+ buildClientConfig(session: StartRealtimeClientSessionResult): ClientRealtimeSessionConfig;
945
+ /**
946
+ * Parses the server-built session config JSON. On failure, logs and returns an empty
947
+ * object — the client treats an empty config as "nothing to apply", so the session
948
+ * still opens (mirroring the prior behavior of skipping the config update).
949
+ */
950
+ private parseSessionConfig;
951
+ /** Subscribes this service's policy handlers to the realtime client's events. */
952
+ private wireClientHandlers;
953
+ /** Maps a client state event onto the UI connection state. */
954
+ private onClientStateChange;
955
+ /**
956
+ * Translates {@link RealtimeClientState} into {@link RealtimeConnectionState}. `'connected'`
957
+ * is suppressed (the UI stays 'connecting' until the control channel opens → 'listening'),
958
+ * and `'closed'` never overwrites a terminal 'error' the service itself recorded.
959
+ */
960
+ private mapClientState;
961
+ /** True when the live control channel is usable (open and not torn down / failed). */
962
+ private isSessionLive;
963
+ /**
964
+ * Applies transcript policy to client transcript events. Interim deltas don't become
965
+ * captions/turns (the client already drives the speaking state) but DO mark this turn's
966
+ * audio-start offset against the recording (the first interim fires as the audio/text
967
+ * starts flowing — see {@link markTurnAudioStart}). Final NORMAL assistant turns become
968
+ * captions + persisted transcripts; final NARRATION turns are EPHEMERAL by product
969
+ * decision — emitted on {@link DelegationNarration$} only, never a caption, never
970
+ * relayed/persisted. User turns ride the caption + relay path.
971
+ */
972
+ private onClientTranscript;
973
+ /**
974
+ * Stamps the recording-relative offset at which the IN-FLIGHT turn's audio actually began,
975
+ * the moment that turn's audio/text first starts flowing (its FIRST interim transcript).
976
+ *
977
+ * This is the fix for transcript cues drifting out of sync with the audio when a tool-call /
978
+ * silence gap sits between turns: the old model inherited the next turn's start from the
979
+ * PREVIOUS turn's end (assumes contiguous turns), so a post-gap turn's cue pointed ~gap-length
980
+ * too early. Capturing the start where the audio truly begins keeps the cue aligned.
981
+ *
982
+ * Guards:
983
+ * - only when recording ({@link recorder} present),
984
+ * - only ONCE per turn ({@link turnAudioStartCaptured}) so mid-turn interim deltas don't move it,
985
+ * - NORMAL turns only — NARRATION interims are ephemeral and never persisted, so they must not
986
+ * claim the next real turn's start slot.
987
+ *
988
+ * Works for any role whose driver surfaces interim deltas (all drivers for the assistant; the
989
+ * relevant case here — the post-tool-gap assistant answer — and user-interim drivers like
990
+ * Gemini/AssemblyAI). For final-only user turns (OpenAI/xAI/ElevenLabs) no interim arrives, so
991
+ * {@link relayTranscript} falls back to the seeded/prior start — the gap case that drifts is the
992
+ * assistant answer, which always has interims.
993
+ */
994
+ private markTurnAudioStart;
995
+ /**
996
+ * The current per-turn offset in ms — the RECORDER's clock when one runs (an offset into a
997
+ * seekable file), else the SESSION clock (#3832: orderable and displayable, not seekable),
998
+ * else `null` before any call is live. One function so the two stamp sites cannot disagree
999
+ * about which clock a session is on.
1000
+ */
1001
+ private nowTurnOffsetMs;
1002
+ /**
1003
+ * Replaces the LAST caption of `role` in place (correction semantics); falls back to a
1004
+ * plain append when no such caption exists yet (e.g. the superseded turn predates this
1005
+ * client's caption window).
1006
+ */
1007
+ private replaceLastCaption;
1008
+ /** Finalizes the user turn: push a caption + relay the final transcript. */
1009
+ private onUserTranscript;
1010
+ /**
1011
+ * Routes a provider tool call: names matching a registered client-tool prefix execute
1012
+ * LOCALLY (UI tools — see {@link RegisterClientToolHandler}); everything else executes on
1013
+ * the MJ server. Either way the result feeds back to the model via
1014
+ * {@link BaseRealtimeClient.SendToolResult} so it speaks the outcome.
1015
+ */
1016
+ private handleToolCall;
1017
+ /** Finds the registered client-tool handler whose prefix matches `toolName`, or `null`. */
1018
+ private findClientToolHandler;
1019
+ /**
1020
+ * Executes one client-tool call through its handler, wrapping any thrown error into a
1021
+ * `{ success: false, error }` JSON payload so the model can narrate the failure instead of
1022
+ * the call going silent.
1023
+ */
1024
+ private executeClientTool;
1025
+ /**
1026
+ * Emits a delegation result so the overlay's "working" card flips to a result card with real
1027
+ * content. Parses the broker's `{success, output, runId}` | `{success:false, error}` shape via
1028
+ * {@link ParseDelegationResultJson}; if it isn't JSON, surfaces the raw string. The `runId`
1029
+ * (the delegated `MJ: AI Agent Runs` record) rides along as {@link RealtimeDelegationResult.RunID}
1030
+ * for the overlay's dev links, and any `artifacts` ride along as {@link RealtimeDelegationResult.Artifacts}
1031
+ * for the surface panel's artifact tabs.
1032
+ */
1033
+ private emitDelegationResult;
1034
+ /**
1035
+ * Cancels ONE in-flight delegated tool call — the overlay's per-card ✕ affordance.
1036
+ *
1037
+ * EXPLICIT USER INTENT ONLY (deliberate host policy): true barge-in never aborts
1038
+ * delegations — the narration design expects the user to talk while delegated work runs.
1039
+ * Calls the `CancelRealtimeSessionTool` mutation (ownership-gated server-side); when the
1040
+ * server reports it aborted the run, the card is flipped immediately to a FAILED
1041
+ * "Cancelled by user" result and the eventual late result from the aborted run is
1042
+ * suppressed (see {@link emitDelegationResult}).
1043
+ *
1044
+ * @returns `true` when the server aborted the in-flight run; `false` when there was
1045
+ * nothing to cancel (the work finished first — its real result is already racing in)
1046
+ * or the mutation failed (logged, never thrown).
1047
+ */
1048
+ CancelDelegation(callId: string): Promise<boolean>;
1049
+ /**
1050
+ * Cancels EVERY in-flight delegated tool call for the active session (callId-less form of
1051
+ * the `CancelRealtimeSessionTool` mutation). Exposed for host policies that need a
1052
+ * sweep-cancel (e.g. an explicit "stop everything" affordance) — NOT wired to barge-in,
1053
+ * by the same deliberate policy as {@link CancelDelegation}.
1054
+ *
1055
+ * @returns The number of in-flight runs the server aborted (0 when nothing was tracked
1056
+ * in flight client-side, nothing was in flight server-side, or the mutation failed).
1057
+ */
1058
+ CancelInFlightDelegations(): Promise<number>;
1059
+ /** Flips a cancelled call's card to the failed "Cancelled by user" result and suppresses the late real result. */
1060
+ private surfaceUserCancellation;
1061
+ /**
1062
+ * Calls the `CancelRealtimeSessionTool` mutation and unwraps its structured
1063
+ * `{ AbortedCount, Success, ErrorMessage }` result. Returns the aborted count —
1064
+ * 0 on a structured failure or a thrown transport error (both logged, never thrown).
1065
+ */
1066
+ private cancelSessionTool;
1067
+ /** Calls the `StartRealtimeClientSession` mutation to obtain an ephemeral token + config. */
1068
+ private mintSession;
1069
+ /** Calls the `ExecuteRealtimeSessionTool` mutation; returns the ResultJson string. */
1070
+ private executeSessionTool;
1071
+ /**
1072
+ * Persists an interactive channel's state of record (e.g. the whiteboard's serialized scene)
1073
+ * onto the session's `MJ: AI Agent Session Channels` row via `SaveSessionChannelState`.
1074
+ *
1075
+ * @param channelName The channel definition name (e.g. `'Whiteboard'`).
1076
+ * @param stateJson The serialized channel state.
1077
+ * @param agentSessionId Optional EXPLICIT session id. The debounced channel-save pipeline
1078
+ * captures the id while the session is live and passes it here, so the final teardown
1079
+ * flush still lands on the just-closed session. Falls back to the active session's id;
1080
+ * returns `false` when neither is available.
1081
+ * @returns Whether the server persisted the state. Failures are logged, never thrown — channel
1082
+ * persistence is best-effort and must not disturb the live call.
1083
+ */
1084
+ SaveChannelState(channelName: string, stateJson: string, agentSessionId?: string | null): Promise<boolean>;
1085
+ /**
1086
+ * Relays a final transcript turn to MJ via `RelayRealtimeTranscript`.
1087
+ *
1088
+ * When the session is being recorded, per-turn timing rides along: `utteranceEndMs` is the
1089
+ * recording-relative offset at finalization, and `utteranceStartMs` is the offset captured by
1090
+ * {@link markTurnAudioStart} when THIS turn's audio actually began (its first interim) — NOT
1091
+ * inherited from the previous turn's end. That distinction is the timing fix: when a tool-call
1092
+ * / silence gap sits between turns, the post-gap turn's audio starts much later, so inheriting
1093
+ * the prior turn's end stamped the cue ~gap-length too early. Both are omitted (left `null`)
1094
+ * when the session isn't being recorded.
1095
+ *
1096
+ * A correction (`replacesPrevious`) doesn't open a new turn, so it carries no start and doesn't
1097
+ * reset the per-turn start guard. After a normal finalization the guard is cleared so the NEXT
1098
+ * turn re-stamps its start from where ITS audio begins.
1099
+ *
1100
+ * @param replacesPrevious CORRECTION semantics: the server updates the session's most
1101
+ * recent persisted turn of this role IN PLACE instead of appending (e.g. ElevenLabs'
1102
+ * post-barge-in `agent_response_correction`).
1103
+ */
1104
+ private relayTranscript;
1105
+ /**
1106
+ * Relays a co-agent CHANNEL tool-call turn (browser_ / Whiteboard_ etc.) to the session's run for
1107
+ * observability via `RelayRealtimeToolTurn` — so the co-agent's AIPromptRun shows what it DID, not
1108
+ * just what it said. Run-only by design: deliberately NOT a `ConversationDetail` turn, so the chat
1109
+ * thread stays speech-only. Best-effort — a failed relay never disturbs the live call.
1110
+ */
1111
+ private relayToolTurn;
1112
+ /**
1113
+ * Accumulates one usage DELTA from the realtime client (per-response token counts —
1114
+ * the `OnUsage` contract shape) and schedules the debounced relay. Negative / non-finite
1115
+ * values are clamped to 0; an all-zero delta is dropped without arming the timer.
1116
+ */
1117
+ private onUsageDelta;
1118
+ /** Clamps a driver-reported token delta: undefined / negative / non-finite become 0. */
1119
+ private clampUsageDelta;
1120
+ /**
1121
+ * Relays the accumulated usage deltas to the server via `RelayRealtimeUsage` (which
1122
+ * accumulates them onto the co-agent `AIPromptRun`). Best-effort: a failed relay
1123
+ * re-accumulates the captured deltas so the next debounce / teardown flush retries —
1124
+ * usage telemetry must never disturb the live call.
1125
+ *
1126
+ * @param agentSessionId Optional EXPLICIT session id (the teardown flush runs while the
1127
+ * live id is still set, but accepts it as a parameter for symmetry with channel saves).
1128
+ */
1129
+ private flushPendingUsage;
1130
+ /** Cancels the pending debounced usage flush and zeroes the accumulators (teardown tail). */
1131
+ private resetUsageRelay;
1132
+ /**
1133
+ * Subscribes to the server's push-status topic (scoped by the GraphQL transport
1134
+ * sessionId) to receive delegated-run progress for the active voice session.
1135
+ * Each matching event is surfaced on {@link DelegationProgress$} and narrated.
1136
+ */
1137
+ private subscribeDelegationProgress;
1138
+ /**
1139
+ * Parses one push-status message and routes it: a Remote Browser screencast frame goes to the active
1140
+ * Remote Browser channel's canvas; a delegation-progress event is dispatched + narrated. Other shapes
1141
+ * (normal agent-run streams) are ignored. Screencast frames are checked FIRST and short-circuit, so the
1142
+ * delegation path is untouched.
1143
+ */
1144
+ private onDelegationStatusMessage;
1145
+ /**
1146
+ * Parses a push-status message and returns it only when it's a Remote Browser screencast frame for the
1147
+ * active session — otherwise `null` (ignored, so delegation progress falls through). Matched by
1148
+ * `resolver` + `type`, then scoped to THIS session by `agentSessionID`.
1149
+ */
1150
+ private parseScreencastFrame;
1151
+ /**
1152
+ * Forwards a screencast frame to the active Remote Browser channel plugin so it paints the frame on its
1153
+ * surface canvas. The plugin is found among the session's active channels by its `ChannelName`; located
1154
+ * via a structural guard so the service stays decoupled from the concrete channel class.
1155
+ */
1156
+ private routeScreencastFrame;
1157
+ /** Structural guard: true when the channel exposes an `OnScreencastFrame(dataBase64)` method. */
1158
+ private hasOnScreencastFrame;
1159
+ /**
1160
+ * Parses a push-status message and returns it only when it's a Remote Browser audio chunk for the active
1161
+ * session — otherwise `null` (ignored). Matched by `resolver` + `type`, then scoped to THIS session by
1162
+ * `agentSessionID`.
1163
+ */
1164
+ private parseAudioChunk;
1165
+ /**
1166
+ * Forwards an audio chunk to the active Remote Browser channel plugin so it plays the chunk through its
1167
+ * client-side audio player. The plugin is found among the session's active channels by its `ChannelName`;
1168
+ * located via a structural guard so the service stays decoupled from the concrete channel class.
1169
+ */
1170
+ private routeAudioChunk;
1171
+ /** Structural guard: true when the channel exposes an `OnAudioChunk(chunk)` method. */
1172
+ private hasOnAudioChunk;
1173
+ /**
1174
+ * Parses a push-status message and returns it only when it's a delegation
1175
+ * progress event for the active voice session — otherwise `null` (ignored).
1176
+ */
1177
+ private parseProgress;
1178
+ /** Emits the progress to the UI observable and feeds it to the realtime model. */
1179
+ private dispatchProgress;
1180
+ /**
1181
+ * Injects the progress into the model's context as a background note every time,
1182
+ * then (throttled) asks the model to briefly voice a reassuring update so the
1183
+ * background work doesn't sit in silence — without chattering or interrupting.
1184
+ */
1185
+ private narrateProgress;
1186
+ /** Adds a progress message to the digest buffer (deduped, capped, oldest-first). */
1187
+ private bufferNarrationMessage;
1188
+ /**
1189
+ * ms until the next spoken update is allowed. Two constraints, BOTH enforced:
1190
+ * - first update of a burst: no earlier than ~5s after the burst started;
1191
+ * - ~8s since the last spoken update, SESSION-global — so sequential tool calls
1192
+ * that reset the burst can never narrate faster than the interval.
1193
+ */
1194
+ private nextNarrationDelayMs;
1195
+ /**
1196
+ * Speaks the aggregated progress digest — unless the work already finished (buffer
1197
+ * cancelled) or the model is busy / audio is still playing, in which case it retries
1198
+ * shortly with the buffer intact (work is still running, so the update stays relevant).
1199
+ */
1200
+ private fireDeferredNarration;
1201
+ /** Cancels any deferred narration — the result is about to be spoken, so it's moot. */
1202
+ private cancelPendingNarration;
1203
+ /**
1204
+ * Builds the one-off instructions for a short spoken update that conveys THIS specific
1205
+ * progress message naturally — strictly first person, since the co-agent owns the work.
1206
+ * The wording is DB-driven: the server-resolved `Realtime Co-Agent - Progress Narration`
1207
+ * template (substituting `{{ progressMessage }}`) when present, otherwise the built-in
1208
+ * fallback so deployments that haven't synced the prompt behave exactly as before.
1209
+ * The client tags the resulting turn as narration, keeping it EPHEMERAL — surfaced on
1210
+ * {@link DelegationNarration$} instead of becoming a caption / persisted ConversationDetail.
1211
+ */
1212
+ private buildNarrationInstructions;
1213
+ /** Tears down the delegation progress subscription and resets the narration throttle. */
1214
+ private teardownDelegationProgress;
1215
+ /**
1216
+ * Tears down all client resources and (optionally) closes the server session.
1217
+ * @param closeServerSession when true, calls `CloseAgentSession` on the server.
1218
+ */
1219
+ private teardown;
1220
+ /** The body of {@link teardown}; never called concurrently with itself. */
1221
+ private runTeardown;
1222
+ /** Calls the `CloseAgentSession` mutation (provisioned in P4b). */
1223
+ private closeServerSession;
1224
+ /** Pushes a caption onto the live list (immutable update for change detection). */
1225
+ private appendCaption;
1226
+ /** Resets reactive + internal state at the start of a session. */
1227
+ private resetState;
1228
+ /** The GraphQL provider used for relay mutations. */
1229
+ private gql;
1230
+ }
1231
+ //# sourceMappingURL=RealtimeSessionRuntime.d.ts.map