@kuralle-syrinx/core 2.1.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.
Files changed (52) hide show
  1. package/README.md +41 -0
  2. package/package.json +22 -0
  3. package/src/audio/audio.test.ts +285 -0
  4. package/src/audio/index.ts +5 -0
  5. package/src/audio/mulaw.ts +42 -0
  6. package/src/audio/pcm.ts +43 -0
  7. package/src/audio/resample.ts +177 -0
  8. package/src/audio-envelope.test.ts +167 -0
  9. package/src/audio-envelope.ts +143 -0
  10. package/src/conversation-event.ts +62 -0
  11. package/src/error-handler.test.ts +56 -0
  12. package/src/error-handler.ts +149 -0
  13. package/src/idle-timeout.ts +210 -0
  14. package/src/index.ts +215 -0
  15. package/src/init-chain.ts +137 -0
  16. package/src/init-stage-order.ts +79 -0
  17. package/src/latency-filler-fixtures.ts +16 -0
  18. package/src/latency-filler.test.ts +62 -0
  19. package/src/latency-filler.ts +125 -0
  20. package/src/mode-switcher.ts +110 -0
  21. package/src/observability-observer.test.ts +245 -0
  22. package/src/observability-observer.ts +195 -0
  23. package/src/observability.test.ts +85 -0
  24. package/src/observability.ts +93 -0
  25. package/src/packet-factories.test.ts +34 -0
  26. package/src/packet-factories.ts +243 -0
  27. package/src/packets.ts +553 -0
  28. package/src/pipeline-bus.g10.test.ts +145 -0
  29. package/src/pipeline-bus.test.ts +197 -0
  30. package/src/pipeline-bus.ts +369 -0
  31. package/src/plugin-contract.ts +79 -0
  32. package/src/primary-speaker-fixtures.ts +45 -0
  33. package/src/primary-speaker-gate.test.ts +150 -0
  34. package/src/primary-speaker-gate.ts +186 -0
  35. package/src/provider-fallback.test.ts +87 -0
  36. package/src/provider-fallback.ts +88 -0
  37. package/src/reasoner.test.ts +69 -0
  38. package/src/reasoner.ts +54 -0
  39. package/src/retry.test.ts +83 -0
  40. package/src/retry.ts +106 -0
  41. package/src/scheduler.ts +28 -0
  42. package/src/tts-playout-clock.test.ts +125 -0
  43. package/src/tts-playout-clock.ts +116 -0
  44. package/src/turn-arbiter.characterization.test.ts +477 -0
  45. package/src/turn-arbiter.test.ts +567 -0
  46. package/src/turn-arbiter.ts +283 -0
  47. package/src/voice-agent-session-util.ts +240 -0
  48. package/src/voice-agent-session.test.ts +2487 -0
  49. package/src/voice-agent-session.ts +1175 -0
  50. package/src/voice-text.test.ts +81 -0
  51. package/src/voice-text.ts +102 -0
  52. package/tsconfig.json +21 -0
@@ -0,0 +1,1175 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — Voice Agent Session
4
+ //
5
+ // The central orchestrator. Wires together PipelineBus, plugins, init chain,
6
+ // error handler, idle timeout, mode switcher, and debug event stream.
7
+ //
8
+ // Breaking changes from v0.1:
9
+ // - Plugins accept PipelineBus directly (no callbacks).
10
+ // - Explicit init/finalize chains with stage-level error reporting.
11
+ // - Categorized errors with recoverable flag.
12
+ // - Priority bus with Critical/Main/Background routes.
13
+ // - Unified ConversationEvent debug stream.
14
+ // - Idle timeout with consecutive backoff.
15
+ // - Mode switching (text ↔ audio).
16
+ //
17
+ // Backward compat: on/off event emitter stays for client-side consumers
18
+ // (bridges bus packets to legacy event names like "user_started_speaking").
19
+
20
+ import { PipelineBusImpl, Route, type PipelineBus } from "./pipeline-bus.js";
21
+ import { runInitChain, runFinalizeChain, type InitStep } from "./init-chain.js";
22
+ import type { VoicePlugin, PluginConfig, EndpointingOwner } from "./plugin-contract.js";
23
+ import { IdleTimeoutManager, type IdleTimeoutConfig } from "./idle-timeout.js";
24
+ import { ModeSwitcher } from "./mode-switcher.js";
25
+ import { createConversationEventStream, type ConversationEvent } from "./conversation-event.js";
26
+ import { isRecoverable } from "./error-handler.js";
27
+ import type {
28
+ VoiceErrorPacket,
29
+ UserAudioReceivedPacket,
30
+ UserTextReceivedPacket,
31
+ InterruptionDetectedPacket,
32
+ LlmDeltaPacket,
33
+ LlmResponseDonePacket,
34
+ LlmToolCallPacket,
35
+ LlmToolResultPacket,
36
+ TextToSpeechAudioPacket,
37
+ TextToSpeechEndPacket,
38
+ TextToSpeechPlayoutProgressPacket,
39
+ SpeechToTextAudioPacket,
40
+ SttResultPacket,
41
+ SttInterimPacket,
42
+ VadAudioPacket,
43
+ VadSpeechStartedPacket,
44
+ VadSpeechActivityPacket,
45
+ VadSpeechEndedPacket,
46
+ EndOfSpeechPacket,
47
+ InterimEndOfSpeechPacket,
48
+ InjectMessagePacket,
49
+ DisconnectRequestedPacket,
50
+ InitializationFailedPacket,
51
+ ModeSwitchRequestedPacket,
52
+ StartIdleTimeoutPacket,
53
+ StopIdleTimeoutPacket,
54
+ } from "./packets.js";
55
+ import {
56
+ SessionState,
57
+ InitStage,
58
+ ErrorCategory,
59
+ } from "./packets.js";
60
+ import { LatencyFillerController } from "./latency-filler.js";
61
+ import { PrimarySpeakerGate } from "./primary-speaker-gate.js";
62
+ import { takeCompleteVoiceText, isCompleteVoiceText, appendVoiceText } from "./voice-text.js";
63
+ import { TtsPlayoutClock } from "./tts-playout-clock.js";
64
+ import { TurnArbiter } from "./turn-arbiter.js";
65
+ import * as make from "./packet-factories.js";
66
+ import { pluginStage, stageOrder, isAudioStage } from "./init-stage-order.js";
67
+ import {
68
+ estimatePcm16Duration,
69
+ languageFromTranscripts,
70
+ requireTtsAudioSampleRate,
71
+ VoiceSessionWatchdogs,
72
+ } from "./voice-agent-session-util.js";
73
+ import { noopMetricsExporter, type MetricsExporter } from "./observability.js";
74
+ import { ObservabilityObserver } from "./observability-observer.js";
75
+ import { TimerScheduler, type Scheduler } from "./scheduler.js";
76
+
77
+ // =============================================================================
78
+ // Types
79
+ // =============================================================================
80
+
81
+ export interface VoiceAgentSessionConfig {
82
+ /** Plugin configurations, keyed by plugin name. */
83
+ plugins: Record<string, PluginConfig>;
84
+ /** Idle timeout configuration. */
85
+ idleTimeout?: Partial<IdleTimeoutConfig>;
86
+ /** PipelineBus configuration. */
87
+ busConfig?: {
88
+ mainCapacity?: number;
89
+ bgCapacity?: number;
90
+ criticalBatchSize?: number;
91
+ };
92
+ /**
93
+ * Maximum ms to wait for an STT final transcript after audio injection stops.
94
+ * When this timer fires, asks the streaming STT provider to flush buffered audio.
95
+ * Default: 7000 (endpointing + 2s grace)
96
+ */
97
+ sttForceFinalizeTimeoutMs?: number;
98
+ /**
99
+ * Minimum sustained user-speech duration (ms) during assistant playback before a
100
+ * barge-in is committed. Filters transient noise, clicks, and very short blips that
101
+ * would otherwise falsely cut off the agent. The agent keeps speaking until the
102
+ * user's speech is sustained past this threshold, then interruption fires
103
+ * immediately. Set to 0 to disable the gate and interrupt on the first VAD speech
104
+ * frame (legacy behavior). Default: 280 ms.
105
+ */
106
+ minInterruptionMs?: number;
107
+ /**
108
+ * When true (default), barge-in requires sustained speech from the enrolled
109
+ * primary speaker (first user turn fingerprint) in addition to G1's time gate.
110
+ * Non-primary / echo speech emits `interrupt.suppressed_non_primary`. When no
111
+ * profile is enrolled yet, falls back to G1-only behavior.
112
+ */
113
+ primarySpeakerBargeInEnabled?: boolean;
114
+ /**
115
+ * When true, emit a short discourse connective via TTS at endpoint (before LLM
116
+ * TTFB) and splice the real response in when it arrives. Off by default.
117
+ */
118
+ latencyFillerEnabled?: boolean;
119
+ /**
120
+ * Maximum ms after a user turn ends to wait for first assistant audio before
121
+ * emitting a vaqi.missed_response metric (VAQI-M). 0 disables the check.
122
+ * Default: 4000.
123
+ */
124
+ vaqiMissedResponseMs?: number;
125
+ /**
126
+ * Max ms of silence from the TTS provider AFTER it has begun producing audio for a
127
+ * turn before the output is treated as a stalled provider. Guards against a TTS
128
+ * provider that goes silent mid-utterance without `tts.end` or an error (dead air).
129
+ * Armed only after the first `tts.audio`, so first-audio latency (which can be many
130
+ * seconds on some providers, e.g. Gemini) is never watchdogged. On breach, a
131
+ * recoverable `tts.error` (NetworkTimeout) is emitted so the turn fails visibly
132
+ * instead of hanging. 0 disables. Default: 15000.
133
+ */
134
+ ttsStallMs?: number;
135
+ /**
136
+ * Max ms of silence on inbound user audio while the session is Ready before a
137
+ * recoverable transport warning is emitted. Continuous streams (telephony, open
138
+ * mic) should set this; push-to-talk / headless sessions leave at 0 (disabled).
139
+ * Default: 0.
140
+ */
141
+ inputCadenceTimeoutMs?: number;
142
+ /**
143
+ * Spoken fallback when the reasoning (LLM) layer fails a turn with a recoverable
144
+ * error. "Never fail silently" (Deepgram guide Ch3): rather than ending the turn in
145
+ * unexplained silence, the agent speaks this line via the normal TTS path (which is
146
+ * unaffected by an LLM failure). Empty string disables. Default: a brief apology.
147
+ * (TTS/STT-failure fallback needs canned audio / a clarification prompt — out of scope.)
148
+ */
149
+ errorFallbackText?: string;
150
+ /**
151
+ * Which component owns turn boundary (EOS) for this session. Defaults to
152
+ * provider STT ownership; Smart Turn sessions must opt in explicitly.
153
+ */
154
+ endpointingOwner?: "provider_stt" | "smart_turn" | "timer";
155
+ readonly metricsExporter?: MetricsExporter;
156
+ readonly scheduler?: Scheduler;
157
+ readonly observability?: {
158
+ readonly sessionId?: string;
159
+ readonly provider?: string;
160
+ readonly model?: string;
161
+ readonly region?: string;
162
+ };
163
+ }
164
+
165
+ export interface VoiceAgentSessionEvents {
166
+ user_started_speaking: (event: { tsMs: number; turnId: string }) => void;
167
+ user_stopped_speaking: (event: { tsMs: number; turnId: string }) => void;
168
+ user_input_partial: (event: { tsMs: number; turnId: string; text: string }) => void;
169
+ user_input_final: (event: { tsMs: number; turnId: string; text: string; confidence: number }) => void;
170
+ agent_text_delta: (event: { tsMs: number; turnId: string; delta: string }) => void;
171
+ agent_tool_call: (event: { tsMs: number; turnId: string; id: string; name: string; args: Record<string, unknown> }) => void;
172
+ agent_tool_result: (event: { tsMs: number; turnId: string; id: string; result: string; durationMs: number }) => void;
173
+ agent_first_audio: (event: { tsMs: number; turnId: string }) => void;
174
+ agent_finished: (event: { tsMs: number; turnId: string } & Record<string, unknown>) => void;
175
+ error: (event: { tsMs: number; stage: string; category: string; message: string }) => void;
176
+ closed: (event: { tsMs: number; reason: string }) => void;
177
+ state_changed: (event: { tsMs: number; from: SessionState; to: SessionState }) => void;
178
+ }
179
+
180
+ type EventHandler<T> = (event: T) => void;
181
+
182
+ interface TtsTextBuffer {
183
+ pending: string;
184
+ emitted: string;
185
+ }
186
+
187
+ /** Suffix marking a context created to speak an error fallback, so it never recurses. */
188
+ const FALLBACK_CONTEXT_SUFFIX = ":error-fallback";
189
+
190
+ // =============================================================================
191
+ // Session Implementation
192
+ // =============================================================================
193
+
194
+ export class VoiceAgentSession {
195
+ readonly bus: PipelineBus;
196
+ readonly debugEvents: ReadableStream<ConversationEvent>;
197
+ private readonly config: VoiceAgentSessionConfig;
198
+ private readonly sttForceFinalizeTimeoutMs: number;
199
+ private _state: SessionState = SessionState.Uninitialized;
200
+ private plugins: Map<string, VoicePlugin> = new Map();
201
+ private initSteps: InitStep[] = [];
202
+ private idleTimeout: IdleTimeoutManager;
203
+ private modeSwitcher: ModeSwitcher;
204
+ private debugPush: (event: ConversationEvent) => void;
205
+ private eventListeners = new Map<string, Set<EventHandler<unknown>>>();
206
+ private currentTurnId = "";
207
+ private busStartPromise: Promise<void> | null = null;
208
+ private closePromise: Promise<void> | null = null;
209
+ // Tracks which contexts are still playing out their TTS audio; turn-taking and
210
+ // the stall watchdog key on this. Pure state — see TtsPlayoutClock.
211
+ private readonly scheduler: Scheduler;
212
+ private readonly ttsPlayout: TtsPlayoutClock;
213
+ private interruptedGenerationContextIds = new Set<string>();
214
+ private ttsTextBuffers = new Map<string, TtsTextBuffer>();
215
+ private readonly minInterruptionMs: number;
216
+ private readonly primarySpeakerGate: PrimarySpeakerGate;
217
+ private readonly turnArbiter!: TurnArbiter;
218
+ private readonly latencyFiller: LatencyFillerController;
219
+ private firstLlmDeltaReceived = new Set<string>();
220
+ private readonly vaqiMissedResponseMs: number;
221
+ private readonly ttsStallMs: number;
222
+ private readonly inputCadenceTimeoutMs: number;
223
+ private readonly watchdogs!: VoiceSessionWatchdogs;
224
+ private readonly observabilityObserver: ObservabilityObserver;
225
+ private turnUserStoppedAtMs = new Map<string, number>();
226
+ private speakerEnrollmentContextId: string | null = null;
227
+ private firstTtsAudioFired = new Set<string>();
228
+ private readonly errorFallbackText: string;
229
+ private fallbackInjectedContexts = new Set<string>();
230
+ private readonly endpointingOwner: "provider_stt" | "smart_turn" | "timer";
231
+ private lastFinalizedContextId = "";
232
+
233
+ constructor(config: VoiceAgentSessionConfig) {
234
+ const owner = config.endpointingOwner;
235
+ if (owner !== undefined && owner !== "provider_stt" && owner !== "smart_turn" && owner !== "timer") {
236
+ throw new Error(`Unsupported endpointingOwner: ${owner}`);
237
+ }
238
+ this.endpointingOwner = owner ?? "provider_stt";
239
+ this.config = config;
240
+ this.scheduler = config.scheduler ?? new TimerScheduler();
241
+ this.ttsPlayout = new TtsPlayoutClock(this.scheduler);
242
+ this.sttForceFinalizeTimeoutMs = config.sttForceFinalizeTimeoutMs ?? 7000;
243
+ this.minInterruptionMs = config.minInterruptionMs ?? 280;
244
+ this.primarySpeakerGate = new PrimarySpeakerGate({
245
+ enabled: config.primarySpeakerBargeInEnabled !== false,
246
+ });
247
+ this.latencyFiller = new LatencyFillerController({
248
+ enabled: config.latencyFillerEnabled === true,
249
+ });
250
+ this.vaqiMissedResponseMs = config.vaqiMissedResponseMs ?? 4000;
251
+ this.ttsStallMs = config.ttsStallMs ?? 15000;
252
+ this.inputCadenceTimeoutMs = config.inputCadenceTimeoutMs ?? 0;
253
+ this.errorFallbackText = config.errorFallbackText ?? "Sorry, I'm having trouble right now. Could you try again?";
254
+
255
+ // Debug events
256
+ const [stream, push] = createConversationEventStream();
257
+ this.debugEvents = stream;
258
+ this.debugPush = push;
259
+
260
+ // PipelineBus
261
+ this.bus = new PipelineBusImpl({
262
+ ...config.busConfig,
263
+ onPacket: (route, packet) => {
264
+ this.debugPush({
265
+ component: "bus",
266
+ type: "packet",
267
+ data: {
268
+ context_id: packet.contextId,
269
+ route: Route[route] ?? String(route),
270
+ kind: packet.kind,
271
+ },
272
+ timestampMs: packet.timestampMs,
273
+ });
274
+ },
275
+ onBackgroundDrop: (dropped) => {
276
+ this.debugPush({
277
+ component: "pipeline",
278
+ type: "background_dropped",
279
+ data: {
280
+ context_id: dropped.contextId,
281
+ kind: dropped.kind,
282
+ },
283
+ timestampMs: Date.now(),
284
+ });
285
+ },
286
+ });
287
+
288
+ this.turnArbiter = new TurnArbiter({
289
+ bus: this.bus,
290
+ primarySpeakerGate: this.primarySpeakerGate,
291
+ ttsPlayout: this.ttsPlayout,
292
+ minInterruptionMs: this.minInterruptionMs,
293
+ });
294
+ this.watchdogs = new VoiceSessionWatchdogs({
295
+ bus: this.bus,
296
+ plugins: this.plugins,
297
+ ttsPlayout: this.ttsPlayout,
298
+ sttForceFinalizeTimeoutMs: this.sttForceFinalizeTimeoutMs,
299
+ vaqiMissedResponseMs: this.vaqiMissedResponseMs,
300
+ ttsStallMs: this.ttsStallMs,
301
+ inputCadenceTimeoutMs: this.inputCadenceTimeoutMs,
302
+ getSessionState: () => this._state,
303
+ isGenerationInterrupted: (contextId) => this.interruptedGenerationContextIds.has(contextId),
304
+ onVaqiMissedResponseFired: (contextId) => {
305
+ this.turnUserStoppedAtMs.delete(contextId);
306
+ },
307
+ scheduler: this.scheduler,
308
+ });
309
+
310
+ const obs = config.observability;
311
+ this.observabilityObserver = new ObservabilityObserver({
312
+ bus: this.bus,
313
+ exporter: config.metricsExporter ?? noopMetricsExporter,
314
+ sessionId: obs?.sessionId ?? "",
315
+ dims: {
316
+ provider: obs?.provider ?? "unknown",
317
+ model: obs?.model ?? "unknown",
318
+ region: obs?.region ?? "unknown",
319
+ },
320
+ getContextId: () => this.currentContextId,
321
+ });
322
+
323
+ // Idle timeout — starts after bus handlers are wired
324
+ this.idleTimeout = new IdleTimeoutManager(this.bus, config.idleTimeout, this.scheduler);
325
+
326
+ // Mode switcher
327
+ this.modeSwitcher = new ModeSwitcher(this.bus);
328
+ }
329
+
330
+ // =========================================================================
331
+ // Public API
332
+ // =========================================================================
333
+
334
+ get state(): SessionState {
335
+ return this._state;
336
+ }
337
+
338
+ get currentContextId(): string {
339
+ return this.currentTurnId;
340
+ }
341
+
342
+ /** Register a plugin. Must be called before start(). */
343
+ registerPlugin(name: string, plugin: VoicePlugin): void {
344
+ this.plugins.set(name, plugin);
345
+ }
346
+
347
+ /** Start the session. Runs init chain, starts bus draining. */
348
+ async start(): Promise<void> {
349
+ if (this._state !== SessionState.Uninitialized) {
350
+ throw new Error(`Cannot start session in state ${this._state}`);
351
+ }
352
+ this._state = SessionState.Initializing;
353
+
354
+ // 1. Wire all bus handlers
355
+ this.wireBusHandlers();
356
+
357
+ // 2. Start bus drain loop
358
+ this.busStartPromise = this.bus.start();
359
+
360
+ // 3. Build init chain from registered plugins
361
+ this.buildInitChain();
362
+
363
+ // 4. Run init chain
364
+ try {
365
+ await runInitChain(this.bus, this.initSteps);
366
+ } catch (err) {
367
+ this._state = SessionState.Failed;
368
+ throw err;
369
+ }
370
+
371
+ this._state = SessionState.Ready;
372
+ }
373
+
374
+ /** Shut down the session. Runs finalize chain in reverse order. */
375
+ async close(): Promise<void> {
376
+ if (this._state === SessionState.Closed) return;
377
+ if (this.closePromise) return await this.closePromise;
378
+ this.closePromise = this.closeOnce();
379
+ return await this.closePromise;
380
+ }
381
+
382
+ private async closeOnce(): Promise<void> {
383
+ this._state = SessionState.Finalizing;
384
+
385
+ // 1. Stop idle timeout
386
+ this.idleTimeout.dispose();
387
+ this.watchdogs.dispose();
388
+ this.observabilityObserver.dispose();
389
+ this.ttsPlayout.clear();
390
+ this.turnArbiter.clear();
391
+ this.turnUserStoppedAtMs.clear();
392
+ this.firstTtsAudioFired.clear();
393
+ this.fallbackInjectedContexts.clear();
394
+ this.ttsTextBuffers.clear();
395
+ this.interruptedGenerationContextIds.clear();
396
+ this.firstLlmDeltaReceived.clear();
397
+
398
+ // 2. Run finalize chain (reverse order)
399
+ await runFinalizeChain(this.initSteps);
400
+
401
+ // 3. Stop bus
402
+ this.bus.stop();
403
+ await this.busStartPromise;
404
+
405
+ // 4. Emit debug event
406
+ this.emitDebug("session", "disconnected", { reason: "close" });
407
+
408
+ this._state = SessionState.Closed;
409
+ }
410
+
411
+ /** Switch between text and audio mode. */
412
+ async switchMode(mode: "text" | "audio"): Promise<void> {
413
+ this.bus.push(Route.Main, make.modeSwitchRequested(this.currentTurnId, Date.now(), mode));
414
+ }
415
+
416
+ requestClientInterrupt(contextId: string): void {
417
+ this.turnArbiter.commitClientInterrupt(contextId);
418
+ }
419
+
420
+ // =========================================================================
421
+ // Legacy Event Emitter (client-side consumers)
422
+ // =========================================================================
423
+
424
+ on<K extends keyof VoiceAgentSessionEvents>(
425
+ event: K,
426
+ handler: VoiceAgentSessionEvents[K],
427
+ ): void {
428
+ if (!this.eventListeners.has(event)) {
429
+ this.eventListeners.set(event, new Set());
430
+ }
431
+ this.eventListeners.get(event)!.add(handler as EventHandler<unknown>);
432
+ }
433
+
434
+ off<K extends keyof VoiceAgentSessionEvents>(
435
+ event: K,
436
+ handler: VoiceAgentSessionEvents[K],
437
+ ): void {
438
+ this.eventListeners.get(event)?.delete(handler as EventHandler<unknown>);
439
+ }
440
+
441
+ private emit<K extends keyof VoiceAgentSessionEvents>(
442
+ event: K,
443
+ payload: Parameters<VoiceAgentSessionEvents[K]>[0],
444
+ ): void {
445
+ const listeners = this.eventListeners.get(event);
446
+ if (!listeners) return;
447
+ for (const fn of listeners) {
448
+ try {
449
+ (fn as EventHandler<unknown>)(payload);
450
+ } catch {
451
+ // Don't let listener errors crash the session
452
+ }
453
+ }
454
+ }
455
+
456
+ // =========================================================================
457
+ // Bus Handler Wiring
458
+ // =========================================================================
459
+
460
+ private wireBusHandlers(): void {
461
+ // Input pipeline
462
+ this.bus.on("user.audio_received", this.handleUserAudio.bind(this));
463
+ this.bus.on("user.text_received", this.handleUserText.bind(this));
464
+
465
+ // STT results
466
+ this.bus.on("stt.audio", this.handleSttAudio.bind(this));
467
+ this.bus.on("stt.interim", this.handleSttInterim.bind(this));
468
+ this.bus.on("stt.result", this.handleSttResult.bind(this));
469
+
470
+ // VAD
471
+ this.bus.on("vad.speech_started", this.handleVadSpeechStarted.bind(this));
472
+ this.bus.on("vad.speech_activity", this.handleVadSpeechActivity.bind(this));
473
+ this.bus.on("vad.speech_ended", this.handleVadSpeechEnded.bind(this));
474
+ this.bus.on("vad.audio", this.handleVadAudioForSpeakerGate.bind(this));
475
+
476
+ // EOS
477
+ this.bus.on("turn.change", () => {
478
+ this.lastFinalizedContextId = "";
479
+ });
480
+ this.bus.on("eos.turn_complete", this.handleTurnComplete.bind(this));
481
+ this.bus.on("eos.interim", this.handleEosInterim.bind(this));
482
+
483
+ // LLM
484
+ this.bus.on("llm.delta", this.handleLlmDelta.bind(this));
485
+ this.bus.on("llm.done", this.handleLlmDone.bind(this));
486
+ this.bus.on("llm.tool_call", this.handleLlmToolCall.bind(this));
487
+ this.bus.on("llm.tool_result", this.handleLlmToolResult.bind(this));
488
+
489
+ // TTS
490
+ this.bus.on("tts.audio", this.handleTtsAudio.bind(this));
491
+ this.bus.on("tts.end", this.handleTtsEnd.bind(this));
492
+ this.bus.on("tts.playout_progress", this.handleTtsPlayoutProgress.bind(this));
493
+
494
+ // Interrupts
495
+ this.bus.on("interrupt.detected", this.handleInterruptDetected.bind(this));
496
+
497
+ // Errors
498
+ this.bus.on("stt.error", this.handleComponentError.bind(this));
499
+ this.bus.on("tts.error", this.handleComponentError.bind(this));
500
+ this.bus.on("vad.error", this.handleComponentError.bind(this));
501
+ this.bus.on("llm.error", this.handleComponentError.bind(this));
502
+ this.bus.on("pipeline.error", this.handleComponentError.bind(this));
503
+
504
+ // Lifecycle
505
+ this.bus.on("init.failed", this.handleInitFailed.bind(this));
506
+
507
+ // Behavior
508
+ this.bus.on<StartIdleTimeoutPacket>("behavior.idle_timeout_start", (pkt) => {
509
+ this.idleTimeout.handleStart(pkt);
510
+ });
511
+ this.bus.on<StopIdleTimeoutPacket>("behavior.idle_timeout_stop", (pkt) => {
512
+ this.idleTimeout.handleStop(pkt);
513
+ });
514
+
515
+ // Injected messages — push through LLM path for natural TTS
516
+ this.bus.on("inject.message", this.handleInjectMessage.bind(this));
517
+
518
+ // Disconnect
519
+ this.bus.on("session.disconnect", this.handleDisconnect.bind(this));
520
+
521
+ // Mode switching
522
+ this.bus.on<ModeSwitchRequestedPacket>("mode.switch_requested", async (pkt) => {
523
+ await this.modeSwitcher.handleSwitchRequested(pkt);
524
+ });
525
+
526
+ this.observabilityObserver.wire();
527
+ }
528
+
529
+ // =========================================================================
530
+ // Handler Implementations
531
+ // =========================================================================
532
+
533
+ private handleUserAudio(pkt: UserAudioReceivedPacket): void {
534
+ if (this.shouldEnrollPrimarySpeaker(pkt.contextId)) {
535
+ this.primarySpeakerGate.enrollUserTurnChunk(pkt.audio);
536
+ }
537
+ if (this.endpointingOwner === "provider_stt") {
538
+ this.bus.push(
539
+ Route.Main,
540
+ make.recordUserAudio(pkt.contextId, pkt.timestampMs, pkt.audio),
541
+ make.vadAudio(pkt.contextId, pkt.timestampMs, pkt.audio),
542
+ make.sttAudio(pkt.contextId, pkt.timestampMs, pkt.audio),
543
+ );
544
+ } else {
545
+ this.bus.push(
546
+ Route.Main,
547
+ make.recordUserAudio(pkt.contextId, pkt.timestampMs, pkt.audio),
548
+ make.vadAudio(pkt.contextId, pkt.timestampMs, pkt.audio),
549
+ make.sttAudio(pkt.contextId, pkt.timestampMs, pkt.audio),
550
+ make.eosAudio(pkt.contextId, pkt.timestampMs, pkt.audio),
551
+ );
552
+ }
553
+
554
+ this.debugPush({
555
+ component: "input",
556
+ type: "audio_received",
557
+ data: { context_id: pkt.contextId, bytes: String(pkt.audio.length) },
558
+ timestampMs: pkt.timestampMs,
559
+ });
560
+
561
+ this.watchdogs.scheduleInputCadenceWatchdog(pkt.contextId);
562
+ }
563
+
564
+ private handleSttAudio(pkt: SpeechToTextAudioPacket): void {
565
+ this.watchdogs.scheduleSttForceFinalize(pkt.contextId);
566
+ }
567
+
568
+ private handleUserText(pkt: UserTextReceivedPacket): void {
569
+ // Treat text input as an immediate EOS turn complete
570
+ this.bus.push(Route.Main, make.eosTurnComplete(pkt.contextId, pkt.timestampMs, pkt.text, []));
571
+ }
572
+
573
+ private handleSttInterim(pkt: SttInterimPacket): void {
574
+ this.turnArbiter.noteInterimEvidence(pkt.text);
575
+ this.maybeBargeInFromProviderStt(pkt.contextId, pkt.text, pkt.timestampMs);
576
+ this.currentTurnId = pkt.contextId;
577
+ this.emit("user_input_partial", {
578
+ tsMs: pkt.timestampMs,
579
+ turnId: pkt.contextId,
580
+ text: pkt.text,
581
+ });
582
+ this.debugPush({
583
+ component: "stt",
584
+ type: "interim",
585
+ data: { context_id: pkt.contextId, text: pkt.text },
586
+ timestampMs: pkt.timestampMs,
587
+ });
588
+ }
589
+
590
+ private handleSttResult(pkt: SttResultPacket): void {
591
+ this.watchdogs.clearSttForceFinalizeIfContext(pkt.contextId);
592
+ this.turnArbiter.noteInterimEvidence(pkt.text, pkt.confidence);
593
+ this.maybeBargeInFromProviderStt(pkt.contextId, pkt.text, pkt.timestampMs);
594
+ this.currentTurnId = pkt.contextId;
595
+ this.emit("user_input_final", {
596
+ tsMs: pkt.timestampMs,
597
+ turnId: pkt.contextId,
598
+ text: pkt.text,
599
+ confidence: pkt.confidence,
600
+ });
601
+ this.debugPush({
602
+ component: "stt",
603
+ type: "final",
604
+ data: {
605
+ context_id: pkt.contextId,
606
+ text: pkt.text,
607
+ confidence: String(pkt.confidence),
608
+ },
609
+ timestampMs: pkt.timestampMs,
610
+ });
611
+ }
612
+
613
+ private handleVadAudioForSpeakerGate(pkt: VadAudioPacket): void {
614
+ if (this.turnArbiter.observeBargeInAudio(pkt)) return;
615
+
616
+ if (this.shouldEnrollPrimarySpeaker(pkt.contextId)) {
617
+ this.primarySpeakerGate.enrollUserTurnChunk(pkt.audio);
618
+ }
619
+ }
620
+
621
+ private shouldEnrollPrimarySpeaker(contextId: string): boolean {
622
+ return (
623
+ !this.latestActiveTtsContextId() &&
624
+ this.speakerEnrollmentContextId === contextId
625
+ );
626
+ }
627
+
628
+ // Deployments that delegate endpointing to the provider STT register no VAD
629
+ // plugin, so vad.speech_started never fires and barge-in would stay dormant.
630
+ // Provider transcripts arriving while TTS playout is active are the speech
631
+ // evidence instead (echo of our own playout is mitigated by client AEC plus
632
+ // the arbiter's backchannel / low-confidence suppression).
633
+ private maybeBargeInFromProviderStt(contextId: string, text: string, timestampMs: number): void {
634
+ if (this.endpointingOwner !== "provider_stt") return;
635
+ if (!text.trim()) return;
636
+ const interruptedContextId = this.latestActiveTtsContextId();
637
+ if (!interruptedContextId) return;
638
+ this.turnArbiter.onProviderSttEvidence(contextId, timestampMs, interruptedContextId);
639
+ }
640
+
641
+ private handleVadSpeechStarted(pkt: VadSpeechStartedPacket): void {
642
+ this.lastFinalizedContextId = "";
643
+
644
+ if (this.latencyFiller.isFillerOnly(this.currentTurnId)) {
645
+ this.cancelLatencyFillerTurn(this.currentTurnId, pkt.timestampMs);
646
+ }
647
+
648
+ this.emit("user_started_speaking", {
649
+ tsMs: pkt.timestampMs,
650
+ turnId: pkt.contextId,
651
+ });
652
+ this.debugPush({
653
+ component: "vad",
654
+ type: "speech_started",
655
+ data: {
656
+ context_id: pkt.contextId,
657
+ confidence: String(pkt.confidence),
658
+ },
659
+ timestampMs: pkt.timestampMs,
660
+ });
661
+
662
+ const interruptedContextId = this.latestActiveTtsContextId();
663
+ if (!interruptedContextId) {
664
+ this.speakerEnrollmentContextId = pkt.contextId;
665
+ return;
666
+ }
667
+
668
+ this.turnArbiter.onSpeechStarted(pkt, interruptedContextId);
669
+ }
670
+
671
+ private handleVadSpeechActivity(pkt: VadSpeechActivityPacket): void {
672
+ this.turnArbiter.onSpeechActivity(pkt);
673
+ }
674
+
675
+ private handleVadSpeechEnded(pkt: VadSpeechEndedPacket): void {
676
+ this.emit("user_stopped_speaking", {
677
+ tsMs: pkt.timestampMs,
678
+ turnId: pkt.contextId,
679
+ });
680
+ this.debugPush({
681
+ component: "vad",
682
+ type: "speech_ended",
683
+ data: { context_id: pkt.contextId },
684
+ timestampMs: pkt.timestampMs,
685
+ });
686
+
687
+ if (this.speakerEnrollmentContextId === pkt.contextId) {
688
+ this.speakerEnrollmentContextId = null;
689
+ }
690
+
691
+ this.turnArbiter.onSpeechEnded(pkt, Boolean(this.latestActiveTtsContextId()));
692
+
693
+ this.turnUserStoppedAtMs.set(pkt.contextId, pkt.timestampMs);
694
+ this.watchdogs.startVaqiMissedResponseTimer(pkt.contextId, pkt.timestampMs);
695
+ }
696
+
697
+ private handleTurnComplete(pkt: EndOfSpeechPacket): void {
698
+ if (this.lastFinalizedContextId === pkt.contextId) {
699
+ this.bus.push(Route.Background, make.metric(pkt.contextId, "eos.duplicate_dropped", "1"));
700
+ return;
701
+ }
702
+ this.lastFinalizedContextId = pkt.contextId;
703
+
704
+ // Re-arm per-turn guard state for the next turn. Transports with a stable
705
+ // per-call contextId (telephony callSid) reuse one id across turns, so these
706
+ // Sets must be cleared at the turn boundary or turn 2+ inherits stale flags:
707
+ // - firstTtsAudioFired: else vaqi.latency_ms is never emitted again
708
+ // - interruptedGenerationContextIds: else turn N+1's LLM/TTS packets are dropped after a prior barge-in
709
+ // - fallbackInjectedContexts: else only one error fallback can ever be spoken per call
710
+ this.firstTtsAudioFired.delete(pkt.contextId);
711
+ this.interruptedGenerationContextIds.delete(pkt.contextId);
712
+ this.fallbackInjectedContexts.delete(pkt.contextId);
713
+
714
+ this.currentTurnId = pkt.contextId;
715
+ this.idleTimeout.setContextId(pkt.contextId);
716
+
717
+ this.emit("user_input_final", {
718
+ tsMs: pkt.timestampMs,
719
+ turnId: pkt.contextId,
720
+ text: pkt.text,
721
+ confidence: 1.0,
722
+ });
723
+ this.debugPush({
724
+ component: "eos",
725
+ type: "turn_complete",
726
+ data: { context_id: pkt.contextId, text: pkt.text },
727
+ timestampMs: pkt.timestampMs,
728
+ });
729
+
730
+ // Stop idle timeout while LLM is processing
731
+ this.bus.push(Route.Main, make.stopIdleTimeout(pkt.contextId, Date.now(), false));
732
+
733
+ const fillerText = this.latencyFiller.start(pkt.contextId, pkt.text, pkt.timestampMs);
734
+ if (fillerText) {
735
+ this.bus.push(Route.Main, make.ttsText(pkt.contextId, Date.now(), fillerText));
736
+ this.bus.push(Route.Background, make.metric(pkt.contextId, "filler.started", fillerText));
737
+ }
738
+
739
+ this.bus.push(
740
+ Route.Main,
741
+ make.userInput(pkt.contextId, Date.now(), pkt.text, languageFromTranscripts(pkt.transcripts)),
742
+ );
743
+ }
744
+
745
+ private handleEosInterim(pkt: InterimEndOfSpeechPacket): void {
746
+ this.debugPush({
747
+ component: "eos",
748
+ type: "interim",
749
+ data: { context_id: pkt.contextId, text: pkt.text },
750
+ timestampMs: pkt.timestampMs,
751
+ });
752
+ }
753
+
754
+ private handleLlmDelta(pkt: LlmDeltaPacket): void {
755
+ if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
756
+ this.bus.push(
757
+ Route.Background,
758
+ make.metric(pkt.contextId, "llm.delta_ignored_after_interrupt", String(pkt.text.length)),
759
+ );
760
+ return;
761
+ }
762
+
763
+ let deltaText = pkt.text;
764
+ if (!this.firstLlmDeltaReceived.has(pkt.contextId)) {
765
+ this.firstLlmDeltaReceived.add(pkt.contextId);
766
+ if (this.latencyFiller.isActive(pkt.contextId)) {
767
+ deltaText = this.latencyFiller.spliceLlmDelta(pkt.contextId, deltaText);
768
+ this.bus.push(Route.Background, make.metric(pkt.contextId, "filler.spliced", "1"));
769
+ }
770
+ }
771
+
772
+ this.emit("agent_text_delta", {
773
+ tsMs: pkt.timestampMs,
774
+ turnId: pkt.contextId,
775
+ delta: deltaText,
776
+ });
777
+ this.debugPush({
778
+ component: "llm",
779
+ type: "delta",
780
+ data: { context_id: pkt.contextId, text: deltaText },
781
+ timestampMs: pkt.timestampMs,
782
+ });
783
+
784
+ this.bufferTtsText(pkt.contextId, deltaText);
785
+ }
786
+
787
+ private handleLlmDone(pkt: LlmResponseDonePacket): void {
788
+ if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
789
+ this.ttsTextBuffers.delete(pkt.contextId);
790
+ this.bus.push(Route.Background, make.metric(pkt.contextId, "llm.done_ignored_after_interrupt", "1"));
791
+ return;
792
+ }
793
+
794
+ const spokenText = this.flushTtsText(pkt.contextId);
795
+ this.emit("agent_finished", {
796
+ tsMs: pkt.timestampMs,
797
+ turnId: pkt.contextId,
798
+ });
799
+ this.debugPush({
800
+ component: "llm",
801
+ type: "done",
802
+ data: { context_id: pkt.contextId },
803
+ timestampMs: pkt.timestampMs,
804
+ });
805
+
806
+ // Start idle timeout after agent finishes
807
+ this.bus.push(Route.Main, make.startIdleTimeout(pkt.contextId, Date.now()));
808
+
809
+ this.bus.push(Route.Main, make.ttsDone(pkt.contextId, Date.now(), spokenText));
810
+ }
811
+
812
+ private bufferTtsText(contextId: string, text: string): void {
813
+ const buffer = this.ttsTextBuffers.get(contextId) ?? { pending: "", emitted: "" };
814
+ buffer.pending += text;
815
+ const complete = takeCompleteVoiceText(buffer.pending);
816
+ if (complete.text) {
817
+ this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), complete.text));
818
+ buffer.emitted = appendVoiceText(buffer.emitted, complete.text);
819
+ }
820
+ buffer.pending = complete.remaining;
821
+ this.ttsTextBuffers.set(contextId, buffer);
822
+ }
823
+
824
+ private flushTtsText(contextId: string): string {
825
+ const buffer = this.ttsTextBuffers.get(contextId);
826
+ if (!buffer) return "";
827
+ const tail = buffer.pending.trim();
828
+ if (tail) {
829
+ this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), tail));
830
+ buffer.emitted = appendVoiceText(buffer.emitted, tail);
831
+ buffer.pending = "";
832
+ this.bus.push(
833
+ Route.Background,
834
+ make.metric(contextId, isCompleteVoiceText(tail) ? "tts.final_text_flushed" : "tts.final_tail_flushed", tail),
835
+ );
836
+ }
837
+ this.ttsTextBuffers.delete(contextId);
838
+ this.latencyFiller.clear(contextId);
839
+ this.firstLlmDeltaReceived.delete(contextId);
840
+ return buffer.emitted.trim();
841
+ }
842
+
843
+ private cancelLatencyFillerTurn(contextId: string, timestampMs: number): void {
844
+ const cancelled = this.latencyFiller.cancel(contextId);
845
+ if (!cancelled) return;
846
+ this.bus.push(Route.Background, make.metric(contextId, "filler.cancelled", cancelled.text, timestampMs));
847
+ this.turnArbiter.emitInterruptDetected(contextId);
848
+ }
849
+
850
+ private handleLlmToolCall(pkt: LlmToolCallPacket): void {
851
+ this.emit("agent_tool_call", {
852
+ tsMs: pkt.timestampMs,
853
+ turnId: pkt.contextId,
854
+ id: pkt.toolId,
855
+ name: pkt.toolName,
856
+ args: pkt.toolArgs,
857
+ });
858
+ this.debugPush({
859
+ component: "tool",
860
+ type: "call_started",
861
+ data: {
862
+ context_id: pkt.contextId,
863
+ tool_id: pkt.toolId,
864
+ tool_name: pkt.toolName,
865
+ },
866
+ timestampMs: pkt.timestampMs,
867
+ });
868
+ }
869
+
870
+ private handleLlmToolResult(pkt: LlmToolResultPacket): void {
871
+ this.emit("agent_tool_result", {
872
+ tsMs: pkt.timestampMs,
873
+ turnId: pkt.contextId,
874
+ id: pkt.toolId,
875
+ result: pkt.result,
876
+ durationMs: 0,
877
+ });
878
+ this.debugPush({
879
+ component: "tool",
880
+ type: "call_completed",
881
+ data: {
882
+ context_id: pkt.contextId,
883
+ tool_id: pkt.toolId,
884
+ tool_name: pkt.toolName,
885
+ },
886
+ timestampMs: pkt.timestampMs,
887
+ });
888
+ }
889
+
890
+ private handleTtsAudio(pkt: TextToSpeechAudioPacket): void {
891
+ if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
892
+ this.bus.push(
893
+ Route.Background,
894
+ make.metric(pkt.contextId, "tts.audio_ignored_after_interrupt", String(pkt.audio.byteLength)),
895
+ );
896
+ return;
897
+ }
898
+
899
+ if (!this.firstTtsAudioFired.has(pkt.contextId)) {
900
+ this.firstTtsAudioFired.add(pkt.contextId);
901
+ this.watchdogs.clearVaqiIfContext(pkt.contextId);
902
+ const userStoppedMs = this.turnUserStoppedAtMs.get(pkt.contextId);
903
+ if (userStoppedMs !== undefined) {
904
+ this.bus.push(
905
+ Route.Background,
906
+ make.metric(pkt.contextId, "vaqi.latency_ms", String(pkt.timestampMs - userStoppedMs)),
907
+ );
908
+ this.turnUserStoppedAtMs.delete(pkt.contextId);
909
+ }
910
+ }
911
+
912
+ this.primarySpeakerGate.observeAssistantPlayout(pkt.audio);
913
+ // Audio just arrived — (re)arm the stall watchdog for this turn's TTS output.
914
+ this.watchdogs.armTtsStallTimer(pkt.contextId);
915
+
916
+ // Extend idle timeout by audio duration to prevent timeout during playback.
917
+ const sampleRateHz = requireTtsAudioSampleRate(pkt.sampleRateHz);
918
+ const audioDurationMs = estimatePcm16Duration(pkt.audio, sampleRateHz);
919
+ this.idleTimeout.extend(audioDurationMs);
920
+
921
+ // Mark active and advance this context's playout cursor by the chunk's
922
+ // realtime duration.
923
+ this.ttsPlayout.noteAudio(pkt.contextId, audioDurationMs, Date.now());
924
+
925
+ this.debugPush({
926
+ component: "tts",
927
+ type: "audio",
928
+ data: {
929
+ context_id: pkt.contextId,
930
+ bytes: String(pkt.audio.length),
931
+ },
932
+ timestampMs: pkt.timestampMs,
933
+ });
934
+
935
+ this.bus.push(Route.Main, make.recordAssistantAudio(pkt.contextId, Date.now(), pkt.audio, sampleRateHz));
936
+ }
937
+
938
+ private handleTtsEnd(pkt: TextToSpeechEndPacket): void {
939
+ // Generation finished, but the streamed audio is still playing out. Keep the
940
+ // context interruptible until its playout estimate elapses, then release it.
941
+ this.ttsPlayout.scheduleRelease(pkt.contextId, Date.now());
942
+ this.watchdogs.clearTtsStallTimerFor(pkt.contextId);
943
+ this.debugPush({
944
+ component: "tts",
945
+ type: "end",
946
+ data: {},
947
+ timestampMs: Date.now(),
948
+ });
949
+ }
950
+
951
+ private handleTtsPlayoutProgress(pkt: TextToSpeechPlayoutProgressPacket): void {
952
+ this.ttsPlayout.noteProgress(pkt.contextId, pkt.complete, pkt.playedOutMs);
953
+ }
954
+
955
+ private handleInterruptDetected(pkt: InterruptionDetectedPacket): void {
956
+ this.interruptedGenerationContextIds.add(pkt.contextId);
957
+ this.latencyFiller.cancel(pkt.contextId);
958
+ this.firstLlmDeltaReceived.delete(pkt.contextId);
959
+ this.ttsTextBuffers.delete(pkt.contextId);
960
+ this.ttsPlayout.release(pkt.contextId);
961
+ this.watchdogs.clearTtsStallTimerFor(pkt.contextId);
962
+ this.debugPush({
963
+ component: "turn",
964
+ type: "interrupt_detected",
965
+ data: {
966
+ context_id: pkt.contextId,
967
+ source: pkt.source,
968
+ },
969
+ timestampMs: pkt.timestampMs,
970
+ });
971
+
972
+ this.bus.push(
973
+ Route.Background,
974
+ make.metric(
975
+ pkt.contextId,
976
+ "interrupt.onset_to_logic_cancel_ms",
977
+ String(Math.max(0, Date.now() - pkt.timestampMs)),
978
+ ),
979
+ );
980
+
981
+ // Stop idle timeout
982
+ this.bus.push(Route.Critical, make.stopIdleTimeout(pkt.contextId, Date.now(), true));
983
+
984
+ this.bus.push(Route.Critical, make.recordAssistantTruncate(pkt.contextId, Date.now()));
985
+
986
+ // Interrupt TTS, then LLM
987
+ this.bus.push(Route.Critical, make.interruptTts(pkt.contextId, pkt.timestampMs));
988
+ this.bus.push(Route.Critical, make.interruptLlm(pkt.contextId, pkt.timestampMs));
989
+ // Reset STT transcript state too, so a barge-in cannot leak stale finalized
990
+ // segments into the next turn when a client reuses the same contextId
991
+ // (the provider STT plugins listen for interrupt.stt; previously unfired).
992
+ this.bus.push(Route.Critical, make.interruptStt(pkt.contextId, pkt.timestampMs));
993
+ }
994
+
995
+ private handleComponentError(pkt: VoiceErrorPacket): void {
996
+ this.emit("error", {
997
+ tsMs: pkt.timestampMs,
998
+ stage: `${pkt.component}.error`,
999
+ category: pkt.category,
1000
+ message: pkt.cause.message,
1001
+ });
1002
+
1003
+ this.debugPush({
1004
+ component: pkt.component,
1005
+ type: "error",
1006
+ data: {
1007
+ category: pkt.category,
1008
+ message: pkt.cause.message,
1009
+ recoverable: String(pkt.isRecoverable),
1010
+ },
1011
+ timestampMs: pkt.timestampMs,
1012
+ });
1013
+
1014
+ this.latencyFiller.clear(pkt.contextId);
1015
+ // The packet's own recoverability verdict is authoritative when present: the
1016
+ // bus marks handler exceptions (pipeline.error) recoverable by design — one
1017
+ // misbehaving handler must degrade the turn, not kill the whole call.
1018
+ const recoverable = typeof pkt.isRecoverable === "boolean" ? pkt.isRecoverable : isRecoverable(pkt.category);
1019
+ if (!recoverable) {
1020
+ // Fatal error — close session
1021
+ void this.close().catch(() => {
1022
+ // Best effort
1023
+ });
1024
+ return;
1025
+ }
1026
+ // Recoverable errors are handled by individual component retry logic. But never
1027
+ // leave the caller in unexplained silence: if the reasoning layer failed the turn,
1028
+ // speak a graceful fallback (G4 — Deepgram guide "never fail silently").
1029
+ this.maybeSpeakErrorFallback(pkt);
1030
+ }
1031
+
1032
+ private maybeSpeakErrorFallback(pkt: VoiceErrorPacket): void {
1033
+ if (!this.errorFallbackText) return;
1034
+ // Only the reasoning layer: a TTS/STT failure can't reliably use the same TTS path
1035
+ // for a fallback (that needs canned audio / a clarification prompt — out of scope).
1036
+ if (pkt.component !== "llm") return;
1037
+ const contextId = pkt.contextId;
1038
+ if (contextId.endsWith(FALLBACK_CONTEXT_SUFFIX)) return; // never fall back for a fallback
1039
+ if (this.fallbackInjectedContexts.has(contextId)) return; // at most once per turn
1040
+ this.fallbackInjectedContexts.add(contextId);
1041
+ this.bus.push(Route.Background, make.metric(contextId, "error.fallback_spoken", pkt.component));
1042
+ this.bus.push(
1043
+ Route.Main,
1044
+ make.injectMessage(`${contextId}${FALLBACK_CONTEXT_SUFFIX}`, Date.now(), this.errorFallbackText),
1045
+ );
1046
+ }
1047
+
1048
+ private handleInitFailed(pkt: InitializationFailedPacket): void {
1049
+ this.emit("error", {
1050
+ tsMs: pkt.timestampMs,
1051
+ stage: `init.${pkt.stage}`,
1052
+ category: ErrorCategory.InternalFault,
1053
+ message: `Initialization failed: ${pkt.stage}/${pkt.component} — ${pkt.cause.message}`,
1054
+ });
1055
+ }
1056
+
1057
+ private handleInjectMessage(pkt: InjectMessagePacket): void {
1058
+ // Inject as synthetic LLM output — goes through normal TTS path
1059
+ this.bus.push(Route.Main, make.llmDelta(pkt.contextId, Date.now(), pkt.text));
1060
+ this.bus.push(Route.Main, make.llmDone(pkt.contextId, Date.now(), pkt.text));
1061
+ }
1062
+
1063
+ private handleDisconnect(pkt: DisconnectRequestedPacket): void {
1064
+ this.emit("closed", { tsMs: pkt.timestampMs, reason: pkt.reason });
1065
+ this.debugPush({
1066
+ component: "session",
1067
+ type: "disconnect_requested",
1068
+ data: { reason: pkt.reason },
1069
+ timestampMs: pkt.timestampMs,
1070
+ });
1071
+ void this.close().catch(() => {
1072
+ // Best effort
1073
+ });
1074
+ }
1075
+
1076
+ // =========================================================================
1077
+ // Init Chain
1078
+ // =========================================================================
1079
+
1080
+ private buildInitChain(): void {
1081
+ const steps: InitStep[] = [];
1082
+ this.applyEndpointingOwnerInvariant();
1083
+
1084
+ for (const [name, plugin] of this.plugins) {
1085
+ if (!this.shouldInitializePlugin(plugin)) continue;
1086
+ steps.push({
1087
+ name,
1088
+ stage: pluginStage(name),
1089
+ run: () => {
1090
+ return plugin.initialize(this.bus, this.config.plugins[name] ?? {});
1091
+ },
1092
+ cleanup: () => plugin.close(),
1093
+ });
1094
+ }
1095
+
1096
+ const orderedPluginSteps = steps.sort(
1097
+ (a, b) => stageOrder(a.stage) - stageOrder(b.stage),
1098
+ );
1099
+ this.modeSwitcher.register({
1100
+ textToAudioSteps: orderedPluginSteps,
1101
+ audioToTextCleanups: [...orderedPluginSteps]
1102
+ .reverse()
1103
+ .filter((step) => isAudioStage(step.stage))
1104
+ .map((step) => async () => {
1105
+ await step.cleanup?.();
1106
+ }),
1107
+ });
1108
+
1109
+ // Behavior is always last (idle timeout, max session)
1110
+ orderedPluginSteps.push({
1111
+ name: "idle_timeout",
1112
+ stage: InitStage.Behavior,
1113
+ run: async () => {
1114
+ this.idleTimeout.start();
1115
+ },
1116
+ cleanup: async () => {
1117
+ this.idleTimeout.dispose();
1118
+ },
1119
+ });
1120
+
1121
+ this.initSteps = orderedPluginSteps;
1122
+ }
1123
+
1124
+ private applyEndpointingOwnerInvariant(): void {
1125
+ if (this.endpointingOwner === "timer") return;
1126
+ const owner: EndpointingOwner = this.endpointingOwner;
1127
+ const finalizers = [...this.plugins.entries()]
1128
+ .filter(([, plugin]) => plugin.endpointingCapability !== undefined);
1129
+ const enabledFinalizers = finalizers
1130
+ .filter(([, plugin]) => plugin.endpointingCapability?.owner === owner);
1131
+ if (finalizers.length > 0 && enabledFinalizers.length !== 1) {
1132
+ throw new Error(
1133
+ `endpointingOwner=${owner} requires exactly one registered ${owner} EOS finalizer; found ${String(enabledFinalizers.length)}`,
1134
+ );
1135
+ }
1136
+ for (const [name, plugin] of finalizers) {
1137
+ if (plugin.endpointingCapability?.owner === owner) continue;
1138
+ const disabled = plugin.endpointingCapability?.disableConfig;
1139
+ if (!disabled) continue;
1140
+ this.config.plugins[name] = {
1141
+ ...(this.config.plugins[name] ?? {}),
1142
+ ...disabled,
1143
+ };
1144
+ }
1145
+ }
1146
+
1147
+ private shouldInitializePlugin(plugin: VoicePlugin): boolean {
1148
+ const capability = plugin.endpointingCapability;
1149
+ if (!capability) return true;
1150
+ if (this.endpointingOwner === "timer") return false;
1151
+ if (capability.owner === "smart_turn") return capability.owner === this.endpointingOwner;
1152
+ return true;
1153
+ }
1154
+
1155
+ // =========================================================================
1156
+ // Helpers
1157
+ // =========================================================================
1158
+
1159
+ private emitDebug(
1160
+ component: string,
1161
+ type: string,
1162
+ data: Record<string, string>,
1163
+ ): void {
1164
+ this.debugPush({
1165
+ component,
1166
+ type,
1167
+ data,
1168
+ timestampMs: Date.now(),
1169
+ });
1170
+ }
1171
+
1172
+ private latestActiveTtsContextId(): string {
1173
+ return this.ttsPlayout.latestActive();
1174
+ }
1175
+ }