@kuralle-syrinx/core 4.4.0 → 4.5.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 (217) hide show
  1. package/dist/audio/alaw.d.ts +3 -0
  2. package/dist/audio/alaw.d.ts.map +1 -0
  3. package/dist/audio/alaw.js +53 -0
  4. package/dist/audio/alaw.js.map +1 -0
  5. package/dist/audio/g722.d.ts +39 -0
  6. package/dist/audio/g722.d.ts.map +1 -0
  7. package/dist/audio/g722.js +286 -0
  8. package/dist/audio/g722.js.map +1 -0
  9. package/dist/audio/index.d.ts +7 -0
  10. package/dist/audio/index.d.ts.map +1 -0
  11. package/dist/audio/index.js +8 -0
  12. package/dist/audio/index.js.map +1 -0
  13. package/dist/audio/loudness.d.ts +13 -0
  14. package/dist/audio/loudness.d.ts.map +1 -0
  15. package/dist/audio/loudness.js +65 -0
  16. package/dist/audio/loudness.js.map +1 -0
  17. package/dist/audio/mulaw.d.ts +3 -0
  18. package/dist/audio/mulaw.d.ts.map +1 -0
  19. package/dist/audio/mulaw.js +39 -0
  20. package/dist/audio/mulaw.js.map +1 -0
  21. package/dist/audio/pcm.d.ts +5 -0
  22. package/dist/audio/pcm.d.ts.map +1 -0
  23. package/dist/audio/pcm.js +40 -0
  24. package/dist/audio/pcm.js.map +1 -0
  25. package/dist/audio/resample.d.ts +12 -0
  26. package/dist/audio/resample.d.ts.map +1 -0
  27. package/dist/audio/resample.js +157 -0
  28. package/dist/audio/resample.js.map +1 -0
  29. package/dist/audio-envelope.d.ts +23 -0
  30. package/dist/audio-envelope.d.ts.map +1 -0
  31. package/dist/audio-envelope.js +114 -0
  32. package/dist/audio-envelope.js.map +1 -0
  33. package/dist/confidence-to-wait.d.ts +12 -0
  34. package/dist/confidence-to-wait.d.ts.map +1 -0
  35. package/dist/confidence-to-wait.js +18 -0
  36. package/dist/confidence-to-wait.js.map +1 -0
  37. package/dist/conversation-event.d.ts +19 -0
  38. package/dist/conversation-event.d.ts.map +1 -0
  39. package/dist/conversation-event.js +44 -0
  40. package/dist/conversation-event.js.map +1 -0
  41. package/dist/error-handler.d.ts +35 -0
  42. package/dist/error-handler.d.ts.map +1 -0
  43. package/dist/error-handler.js +136 -0
  44. package/dist/error-handler.js.map +1 -0
  45. package/dist/idle-timeout.d.ts +69 -0
  46. package/dist/idle-timeout.d.ts.map +1 -0
  47. package/dist/idle-timeout.js +149 -0
  48. package/dist/idle-timeout.js.map +1 -0
  49. package/dist/incremental-unit.d.ts +17 -0
  50. package/dist/incremental-unit.d.ts.map +1 -0
  51. package/dist/incremental-unit.js +5 -0
  52. package/dist/incremental-unit.js.map +1 -0
  53. package/dist/index.d.ts +47 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +61 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/init-chain.d.ts +35 -0
  58. package/dist/init-chain.d.ts.map +1 -0
  59. package/dist/init-chain.js +102 -0
  60. package/dist/init-chain.js.map +1 -0
  61. package/dist/init-stage-order.d.ts +5 -0
  62. package/dist/init-stage-order.d.ts.map +1 -0
  63. package/dist/init-stage-order.js +74 -0
  64. package/dist/init-stage-order.js.map +1 -0
  65. package/dist/interaction-coordinator.d.ts +45 -0
  66. package/dist/interaction-coordinator.d.ts.map +1 -0
  67. package/dist/interaction-coordinator.js +197 -0
  68. package/dist/interaction-coordinator.js.map +1 -0
  69. package/dist/interaction-policy.d.ts +103 -0
  70. package/dist/interaction-policy.d.ts.map +1 -0
  71. package/dist/interaction-policy.js +6 -0
  72. package/dist/interaction-policy.js.map +1 -0
  73. package/dist/iu-ledger.d.ts +39 -0
  74. package/dist/iu-ledger.d.ts.map +1 -0
  75. package/dist/iu-ledger.js +90 -0
  76. package/dist/iu-ledger.js.map +1 -0
  77. package/dist/latency-filler-fixtures.d.ts +7 -0
  78. package/dist/latency-filler-fixtures.d.ts.map +1 -0
  79. package/dist/latency-filler-fixtures.js +10 -0
  80. package/dist/latency-filler-fixtures.js.map +1 -0
  81. package/dist/latency-filler.d.ts +29 -0
  82. package/dist/latency-filler.d.ts.map +1 -0
  83. package/dist/latency-filler.js +102 -0
  84. package/dist/latency-filler.js.map +1 -0
  85. package/dist/mode-switcher.d.ts +24 -0
  86. package/dist/mode-switcher.d.ts.map +1 -0
  87. package/dist/mode-switcher.js +83 -0
  88. package/dist/mode-switcher.js.map +1 -0
  89. package/dist/observability-observer.d.ts +36 -0
  90. package/dist/observability-observer.d.ts.map +1 -0
  91. package/dist/observability-observer.js +141 -0
  92. package/dist/observability-observer.js.map +1 -0
  93. package/dist/observability.d.ts +68 -0
  94. package/dist/observability.d.ts.map +1 -0
  95. package/dist/observability.js +70 -0
  96. package/dist/observability.js.map +1 -0
  97. package/dist/packet-factories.d.ts +58 -0
  98. package/dist/packet-factories.d.ts.map +1 -0
  99. package/dist/packet-factories.js +181 -0
  100. package/dist/packet-factories.js.map +1 -0
  101. package/dist/packets.d.ts +532 -0
  102. package/dist/packets.d.ts.map +1 -0
  103. package/dist/packets.js +51 -0
  104. package/dist/packets.js.map +1 -0
  105. package/dist/pipeline-bus.d.ts +92 -0
  106. package/dist/pipeline-bus.d.ts.map +1 -0
  107. package/dist/pipeline-bus.js +294 -0
  108. package/dist/pipeline-bus.js.map +1 -0
  109. package/dist/plugin-contract.d.ts +73 -0
  110. package/dist/plugin-contract.d.ts.map +1 -0
  111. package/dist/plugin-contract.js +30 -0
  112. package/dist/plugin-contract.js.map +1 -0
  113. package/dist/policies/defer.d.ts +11 -0
  114. package/dist/policies/defer.d.ts.map +1 -0
  115. package/dist/policies/defer.js +13 -0
  116. package/dist/policies/defer.js.map +1 -0
  117. package/dist/policies/rule-based.d.ts +35 -0
  118. package/dist/policies/rule-based.d.ts.map +1 -0
  119. package/dist/policies/rule-based.js +130 -0
  120. package/dist/policies/rule-based.js.map +1 -0
  121. package/dist/pricing.d.ts +34 -0
  122. package/dist/pricing.d.ts.map +1 -0
  123. package/dist/pricing.js +108 -0
  124. package/dist/pricing.js.map +1 -0
  125. package/dist/primary-speaker-fixtures.d.ts +12 -0
  126. package/dist/primary-speaker-fixtures.d.ts.map +1 -0
  127. package/dist/primary-speaker-fixtures.js +37 -0
  128. package/dist/primary-speaker-fixtures.js.map +1 -0
  129. package/dist/primary-speaker-gate.d.ts +42 -0
  130. package/dist/primary-speaker-gate.d.ts.map +1 -0
  131. package/dist/primary-speaker-gate.js +178 -0
  132. package/dist/primary-speaker-gate.js.map +1 -0
  133. package/dist/provider-fallback.d.ts +28 -0
  134. package/dist/provider-fallback.d.ts.map +1 -0
  135. package/dist/provider-fallback.js +72 -0
  136. package/dist/provider-fallback.js.map +1 -0
  137. package/dist/reasoner-hedge.d.ts +21 -0
  138. package/dist/reasoner-hedge.d.ts.map +1 -0
  139. package/dist/reasoner-hedge.js +170 -0
  140. package/dist/reasoner-hedge.js.map +1 -0
  141. package/dist/reasoner-route.d.ts +24 -0
  142. package/dist/reasoner-route.d.ts.map +1 -0
  143. package/dist/reasoner-route.js +87 -0
  144. package/dist/reasoner-route.js.map +1 -0
  145. package/dist/reasoner-session-store.d.ts +18 -0
  146. package/dist/reasoner-session-store.d.ts.map +1 -0
  147. package/dist/reasoner-session-store.js +21 -0
  148. package/dist/reasoner-session-store.js.map +1 -0
  149. package/dist/reasoner.d.ts +97 -0
  150. package/dist/reasoner.d.ts.map +1 -0
  151. package/dist/reasoner.js +9 -0
  152. package/dist/reasoner.js.map +1 -0
  153. package/dist/retry.d.ts +31 -0
  154. package/dist/retry.d.ts.map +1 -0
  155. package/dist/retry.js +83 -0
  156. package/dist/retry.js.map +1 -0
  157. package/dist/scheduler.d.ts +11 -0
  158. package/dist/scheduler.d.ts.map +1 -0
  159. package/dist/scheduler.js +20 -0
  160. package/dist/scheduler.js.map +1 -0
  161. package/dist/spend-cap.d.ts +27 -0
  162. package/dist/spend-cap.d.ts.map +1 -0
  163. package/dist/spend-cap.js +38 -0
  164. package/dist/spend-cap.js.map +1 -0
  165. package/dist/tts-playout-clock.d.ts +43 -0
  166. package/dist/tts-playout-clock.d.ts.map +1 -0
  167. package/dist/tts-playout-clock.js +125 -0
  168. package/dist/tts-playout-clock.js.map +1 -0
  169. package/dist/turn-arbiter.d.ts +41 -0
  170. package/dist/turn-arbiter.d.ts.map +1 -0
  171. package/dist/turn-arbiter.js +259 -0
  172. package/dist/turn-arbiter.js.map +1 -0
  173. package/dist/voice-agent-session-util.d.ts +60 -0
  174. package/dist/voice-agent-session-util.d.ts.map +1 -0
  175. package/dist/voice-agent-session-util.js +204 -0
  176. package/dist/voice-agent-session-util.js.map +1 -0
  177. package/dist/voice-agent-session.d.ts +435 -0
  178. package/dist/voice-agent-session.d.ts.map +1 -0
  179. package/dist/voice-agent-session.js +1597 -0
  180. package/dist/voice-agent-session.js.map +1 -0
  181. package/dist/voice-text.d.ts +46 -0
  182. package/dist/voice-text.d.ts.map +1 -0
  183. package/dist/voice-text.js +209 -0
  184. package/dist/voice-text.js.map +1 -0
  185. package/package.json +19 -6
  186. package/src/audio/alaw.test.ts +87 -0
  187. package/src/audio/audio.test.ts +285 -0
  188. package/src/audio/g722.test.ts +149 -0
  189. package/src/audio/loudness.test.ts +58 -0
  190. package/src/audio-envelope.test.ts +167 -0
  191. package/src/confidence-to-wait.test.ts +21 -0
  192. package/src/error-handler.test.ts +56 -0
  193. package/src/hedge-throwing-backend.test.ts +46 -0
  194. package/src/interaction-coordinator.test.ts +529 -0
  195. package/src/iu-ledger.test.ts +276 -0
  196. package/src/latency-filler.test.ts +62 -0
  197. package/src/observability-observer.test.ts +248 -0
  198. package/src/observability.test.ts +94 -0
  199. package/src/packet-factories.test.ts +126 -0
  200. package/src/pipeline-bus.g10.test.ts +145 -0
  201. package/src/pipeline-bus.test.ts +211 -0
  202. package/src/policies/defer.test.ts +86 -0
  203. package/src/policies/rule-based.test.ts +334 -0
  204. package/src/pricing.test.ts +226 -0
  205. package/src/primary-speaker-gate.test.ts +150 -0
  206. package/src/provider-fallback.test.ts +87 -0
  207. package/src/reasoner-hedge.test.ts +361 -0
  208. package/src/reasoner-route.test.ts +248 -0
  209. package/src/reasoner.test.ts +69 -0
  210. package/src/retry.test.ts +83 -0
  211. package/src/route-throwing-spec.test.ts +44 -0
  212. package/src/spend-cap.test.ts +114 -0
  213. package/src/tts-playout-clock.test.ts +125 -0
  214. package/src/turn-arbiter.characterization.test.ts +477 -0
  215. package/src/turn-arbiter.test.ts +762 -0
  216. package/src/voice-agent-session.test.ts +4165 -0
  217. package/src/voice-text.test.ts +156 -0
@@ -0,0 +1,1597 @@
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
+ import { PipelineBusImpl, Route } from "./pipeline-bus.js";
20
+ import { runInitChain, runFinalizeChain } from "./init-chain.js";
21
+ import { IdleTimeoutManager } from "./idle-timeout.js";
22
+ import { ModeSwitcher } from "./mode-switcher.js";
23
+ import { createConversationEventStream } from "./conversation-event.js";
24
+ import { isRecoverable } from "./error-handler.js";
25
+ import { SessionState, InitStage, ErrorCategory, } from "./packets.js";
26
+ import { LatencyFillerController } from "./latency-filler.js";
27
+ import { PrimarySpeakerGate } from "./primary-speaker-gate.js";
28
+ import { takeCompleteVoiceText, isCompleteVoiceText, appendVoiceText, normalizeForSpeech } from "./voice-text.js";
29
+ import { TtsPlayoutClock } from "./tts-playout-clock.js";
30
+ import { isBackchannel } from "./turn-arbiter.js";
31
+ import { InteractionCoordinator } from "./interaction-coordinator.js";
32
+ import { isLifecycleInteractionPolicy } from "./interaction-policy.js";
33
+ import { pcm16BytesToSamples, pcm16SamplesToBytes } from "./audio/pcm.js";
34
+ import { createLoudnessState, normalizeLoudness, } from "./audio/loudness.js";
35
+ import { DeferInteractionPolicy } from "./policies/defer.js";
36
+ import { RuleBasedInteractionPolicy } from "./policies/rule-based.js";
37
+ import * as make from "./packet-factories.js";
38
+ import { pluginStage, stageOrder, isAudioStage } from "./init-stage-order.js";
39
+ import { estimatePcm16Duration, languageFromTranscripts, requireTtsAudioSampleRate, VoiceSessionWatchdogs, } from "./voice-agent-session-util.js";
40
+ import { noopMetricsExporter } from "./observability.js";
41
+ import { localizeTurn } from "./observability.js";
42
+ import { ObservabilityObserver } from "./observability-observer.js";
43
+ import { TimerScheduler } from "./scheduler.js";
44
+ /** Suffix marking a context created to speak an error fallback, so it never recurses. */
45
+ const FALLBACK_CONTEXT_SUFFIX = ":error-fallback";
46
+ /** Scheduler key for a pending tool call's G3 delayed-cue timer. */
47
+ function toolCueTimerKey(contextId, toolId) {
48
+ return `tool_cue:${contextId}:${toolId}`;
49
+ }
50
+ function interactionPlayoutTimerKey(contextId) {
51
+ return `interaction.playout:${contextId}`;
52
+ }
53
+ function turnLocalizationTimerKey(contextId) {
54
+ return `turn.localization:${contextId}`;
55
+ }
56
+ function isInfrastructureMetric(name) {
57
+ return /(?:^|\.)(?:vad|stt|tts|tool|input|pipeline)\.(?:error|malfunction|timeout|stall|cadence)|(?:^|\.)(?:stage|error)\./i.test(name);
58
+ }
59
+ function isConversationEvaluationMetric(name, value) {
60
+ if (!/(?:^|\.)(?:outcome|eval|evaluation|satisfaction|task_success|observer)(?:\.|$)/i.test(name)) {
61
+ return false;
62
+ }
63
+ return !/^(?:0|false|none|success|successful|passed|pass|good|healthy|ok)$/i.test(value.trim());
64
+ }
65
+ function isConversationControlName(name) {
66
+ return /^(?:conversation-outcome|outcome|escalation|handoff|flow-transition|node-enter|node-exit|flow-enter|flow-end)$/.test(name);
67
+ }
68
+ // =============================================================================
69
+ // Session Implementation
70
+ // =============================================================================
71
+ export class VoiceAgentSession {
72
+ bus;
73
+ debugEvents;
74
+ config;
75
+ sttForceFinalizeTimeoutMs;
76
+ _state = SessionState.Uninitialized;
77
+ plugins = new Map();
78
+ initSteps = [];
79
+ idleTimeout;
80
+ modeSwitcher;
81
+ debugPush;
82
+ eventListeners = new Map();
83
+ currentTurnId = "";
84
+ busStartPromise = null;
85
+ closePromise = null;
86
+ // Tracks which contexts are still playing out their TTS audio; turn-taking and
87
+ // the stall watchdog key on this. Pure state — see TtsPlayoutClock.
88
+ scheduler;
89
+ ttsPlayout;
90
+ interruptedGenerationContextIds = new Set();
91
+ // Turns whose generation is in-flight (eos.turn_complete emitted, not yet
92
+ // finished/interrupted). Lets a client "stop" during the reasoner TTFT gap —
93
+ // before any audio plays — still abort the turn (thinking-phase barge-in, B3).
94
+ generatingContextIds = new Set();
95
+ ttsTextBuffers = new Map();
96
+ minInterruptionMs;
97
+ primarySpeakerGate;
98
+ ruleBasedPolicy;
99
+ injectedInteractionPolicy;
100
+ activeInteractionPolicy;
101
+ interaction;
102
+ latencyFiller;
103
+ firstLlmDeltaReceived = new Set();
104
+ vaqiMissedResponseMs;
105
+ ttsStallMs;
106
+ outboundLoudnessConfig;
107
+ outboundLoudnessState;
108
+ inputCadenceTimeoutMs;
109
+ watchdogs;
110
+ observabilityObserver;
111
+ metricsExporter;
112
+ observabilityDims;
113
+ turnUserStoppedAtMs = new Map();
114
+ turnTimings = new Map();
115
+ pendingTurnLatency = new Map();
116
+ /** Running usage totals per stage, summed across the session; emitted at close. */
117
+ usageByStage = new Map();
118
+ speakerEnrollmentContextId = null;
119
+ firstTtsAudioFired = new Set();
120
+ pendingInteractionPlayoutTimers = new Set();
121
+ errorFallbackText;
122
+ fallbackInjectedContexts = new Set();
123
+ // G3: pending tool calls per context (toolId → toolName) driving the tool_call_cue lifecycle.
124
+ delayCueAfterMs;
125
+ pendingToolCues = new Map();
126
+ endpointingOwner;
127
+ fullDuplex;
128
+ emitsBackchannel;
129
+ userSpeaking = false;
130
+ lastFinalizedContextId = "";
131
+ sttPartialWordTimings = new Map();
132
+ turnLocalizationStates = new Map();
133
+ emittedTurnLocalizations = new Set();
134
+ constructor(config) {
135
+ const owner = config.endpointingOwner;
136
+ if (owner !== undefined && owner !== "provider_stt" && owner !== "smart_turn" && owner !== "timer") {
137
+ throw new Error(`Unsupported endpointingOwner: ${owner}`);
138
+ }
139
+ this.endpointingOwner = owner ?? "provider_stt";
140
+ this.fullDuplex = config.fullDuplex === true;
141
+ this.emitsBackchannel = config.emitsBackchannel === true;
142
+ this.config = config;
143
+ this.outboundLoudnessConfig = config.outboundLoudness ?? null;
144
+ this.outboundLoudnessState = this.outboundLoudnessConfig ? createLoudnessState() : null;
145
+ this.scheduler = config.scheduler ?? new TimerScheduler();
146
+ this.ttsPlayout = new TtsPlayoutClock(this.scheduler);
147
+ this.sttForceFinalizeTimeoutMs = config.sttForceFinalizeTimeoutMs ?? 7000;
148
+ this.minInterruptionMs = config.minInterruptionMs ?? 280;
149
+ this.delayCueAfterMs = config.delayCueAfterMs ?? 2000;
150
+ this.primarySpeakerGate = new PrimarySpeakerGate({
151
+ enabled: config.primarySpeakerBargeInEnabled !== false,
152
+ onDecision: (decision) => {
153
+ this.emitAcousticSignal(decision.contextId, Date.now(), decision.signal, {
154
+ accepted: decision.accepted,
155
+ frameCount: decision.frameCount,
156
+ primaryHits: decision.primaryHits,
157
+ echoRejectedFrames: decision.echoRejectedFrames,
158
+ });
159
+ },
160
+ });
161
+ this.latencyFiller = new LatencyFillerController({
162
+ enabled: config.latencyFillerEnabled === true,
163
+ });
164
+ this.vaqiMissedResponseMs = config.vaqiMissedResponseMs ?? 4000;
165
+ this.ttsStallMs = config.ttsStallMs ?? 15000;
166
+ this.inputCadenceTimeoutMs = config.inputCadenceTimeoutMs ?? 0;
167
+ this.errorFallbackText = config.errorFallbackText ?? "Sorry, I'm having trouble right now. Could you try again?";
168
+ // Debug events
169
+ const [stream, push] = createConversationEventStream();
170
+ this.debugEvents = stream;
171
+ this.debugPush = push;
172
+ // PipelineBus
173
+ this.bus = new PipelineBusImpl({
174
+ ...config.busConfig,
175
+ onPacket: (route, packet) => {
176
+ this.debugPush({
177
+ component: "bus",
178
+ type: "packet",
179
+ data: {
180
+ context_id: packet.contextId,
181
+ route: Route[route] ?? String(route),
182
+ kind: packet.kind,
183
+ },
184
+ timestampMs: packet.timestampMs,
185
+ });
186
+ },
187
+ onBackgroundDrop: (dropped) => {
188
+ this.debugPush({
189
+ component: "pipeline",
190
+ type: "background_dropped",
191
+ data: {
192
+ context_id: dropped.contextId,
193
+ kind: dropped.kind,
194
+ },
195
+ timestampMs: Date.now(),
196
+ });
197
+ },
198
+ });
199
+ this.injectedInteractionPolicy = config.interactionPolicy ?? null;
200
+ this.ruleBasedPolicy = new RuleBasedInteractionPolicy({
201
+ bus: this.bus,
202
+ primarySpeakerGate: this.primarySpeakerGate,
203
+ ttsPlayout: this.ttsPlayout,
204
+ minInterruptionMs: this.minInterruptionMs,
205
+ });
206
+ const coordinatorPolicy = this.fullDuplex
207
+ ? new DeferInteractionPolicy()
208
+ : (this.injectedInteractionPolicy ?? this.ruleBasedPolicy);
209
+ this.activeInteractionPolicy = coordinatorPolicy;
210
+ coordinatorPolicy.setAcousticSignalSink?.((signal) => {
211
+ this.emitAcousticSignal(signal.contextId, signal.timestampMs, signal.signal, signal.payload);
212
+ });
213
+ this.interaction = new InteractionCoordinator({
214
+ bus: this.bus,
215
+ policy: coordinatorPolicy,
216
+ executor: this.ruleBasedPolicy.arbiter,
217
+ scheduler: this.scheduler,
218
+ caps: { emitsBackchannel: this.emitsBackchannel },
219
+ isUserSpeaking: () => this.userSpeaking,
220
+ isTtsActive: () => this.ttsPlayout.activeContexts().length > 0,
221
+ onBackchannelEmitted: (contextId) => {
222
+ this.timingFor(contextId).backchannelUsed = true;
223
+ },
224
+ endpointingOwner: this.endpointingOwner,
225
+ wasForceFinalized: (contextId) => this.watchdogs.wasForceFinalized(contextId),
226
+ });
227
+ this.watchdogs = new VoiceSessionWatchdogs({
228
+ bus: this.bus,
229
+ plugins: this.plugins,
230
+ ttsPlayout: this.ttsPlayout,
231
+ sttForceFinalizeTimeoutMs: this.sttForceFinalizeTimeoutMs,
232
+ vaqiMissedResponseMs: this.vaqiMissedResponseMs,
233
+ ttsStallMs: this.ttsStallMs,
234
+ inputCadenceTimeoutMs: this.inputCadenceTimeoutMs,
235
+ getSessionState: () => this._state,
236
+ isGenerationInterrupted: (contextId) => this.interruptedGenerationContextIds.has(contextId),
237
+ onVaqiMissedResponseFired: (contextId) => {
238
+ this.turnUserStoppedAtMs.delete(contextId);
239
+ },
240
+ scheduler: this.scheduler,
241
+ });
242
+ const obs = config.observability;
243
+ this.metricsExporter = config.metricsExporter ?? noopMetricsExporter;
244
+ this.observabilityDims = {
245
+ provider: obs?.provider ?? "unknown",
246
+ model: obs?.model ?? "unknown",
247
+ region: obs?.region ?? "unknown",
248
+ layer: obs?.layer,
249
+ };
250
+ this.observabilityObserver = new ObservabilityObserver({
251
+ bus: this.bus,
252
+ exporter: this.metricsExporter,
253
+ sessionId: obs?.sessionId ?? "",
254
+ dims: this.observabilityDims,
255
+ getContextId: () => this.currentContextId,
256
+ });
257
+ // Idle timeout — starts after bus handlers are wired
258
+ this.idleTimeout = new IdleTimeoutManager(this.bus, config.idleTimeout, this.scheduler);
259
+ // Mode switcher
260
+ this.modeSwitcher = new ModeSwitcher(this.bus);
261
+ }
262
+ get turnArbiter() {
263
+ return this.ruleBasedPolicy.arbiter;
264
+ }
265
+ // =========================================================================
266
+ // Public API
267
+ // =========================================================================
268
+ get state() {
269
+ return this._state;
270
+ }
271
+ get currentContextId() {
272
+ return this.currentTurnId;
273
+ }
274
+ /** Register a plugin. Must be called before start(). */
275
+ registerPlugin(name, plugin) {
276
+ this.plugins.set(name, plugin);
277
+ }
278
+ /** Start the session. Runs init chain, starts bus draining. */
279
+ async start() {
280
+ if (this._state !== SessionState.Uninitialized) {
281
+ throw new Error(`Cannot start session in state ${this._state}`);
282
+ }
283
+ this._state = SessionState.Initializing;
284
+ // 1. Wire all bus handlers
285
+ this.wireBusHandlers();
286
+ this.interaction.initialize();
287
+ // 2. Start bus drain loop
288
+ this.busStartPromise = this.bus.start();
289
+ // 3. Build init chain from registered plugins
290
+ this.buildInitChain();
291
+ // 4. Run init chain
292
+ try {
293
+ await runInitChain(this.bus, this.initSteps);
294
+ }
295
+ catch (err) {
296
+ this._state = SessionState.Failed;
297
+ throw err;
298
+ }
299
+ this._state = SessionState.Ready;
300
+ }
301
+ /**
302
+ * Best-effort warm of registered plugins' remote/expensive resources. Call after start() to
303
+ * wake scaled-to-zero endpoints before the first user turn. The host decides when to invoke
304
+ * this — it is not called automatically from start(). Never throws; failed plugin prewarms
305
+ * are swallowed and may emit a prewarm.failed metric on the bus.
306
+ */
307
+ async prewarm() {
308
+ const entries = [...this.plugins.entries()];
309
+ const results = await Promise.allSettled(entries.map(async ([, plugin]) => {
310
+ await plugin.prewarm?.();
311
+ }));
312
+ for (let i = 0; i < results.length; i++) {
313
+ const result = results[i];
314
+ if (result?.status === "rejected") {
315
+ const [name] = entries[i];
316
+ this.bus.push(Route.Background, make.metric("", "prewarm.failed", name));
317
+ }
318
+ }
319
+ }
320
+ /** Shut down the session. Runs finalize chain in reverse order. */
321
+ async close() {
322
+ if (this._state === SessionState.Closed)
323
+ return;
324
+ if (this.closePromise)
325
+ return await this.closePromise;
326
+ this.closePromise = this.closeOnce();
327
+ return await this.closePromise;
328
+ }
329
+ async closeOnce() {
330
+ this._state = SessionState.Finalizing;
331
+ // 1. Stop idle timeout
332
+ this.idleTimeout.dispose();
333
+ this.interaction.dispose();
334
+ this.watchdogs.dispose();
335
+ this.observabilityObserver.dispose();
336
+ this.ttsPlayout.clear();
337
+ for (const contextId of this.pendingInteractionPlayoutTimers) {
338
+ this.scheduler.cancel(interactionPlayoutTimerKey(contextId));
339
+ }
340
+ this.pendingInteractionPlayoutTimers.clear();
341
+ this.turnArbiter.clear();
342
+ this.turnUserStoppedAtMs.clear();
343
+ this.turnTimings.clear();
344
+ for (const contextId of this.turnLocalizationStates.keys()) {
345
+ this.scheduler.cancel(turnLocalizationTimerKey(contextId));
346
+ this.emitTurnLocalization(contextId);
347
+ }
348
+ this.turnLocalizationStates.clear();
349
+ this.emittedTurnLocalizations.clear();
350
+ this.pendingTurnLatency.clear();
351
+ this.firstTtsAudioFired.clear();
352
+ this.fallbackInjectedContexts.clear();
353
+ this.ttsTextBuffers.clear();
354
+ this.interruptedGenerationContextIds.clear();
355
+ this.firstLlmDeltaReceived.clear();
356
+ this.sttPartialWordTimings.clear();
357
+ for (const [contextId, pending] of this.pendingToolCues) {
358
+ for (const toolId of pending.keys())
359
+ this.scheduler.cancel(toolCueTimerKey(contextId, toolId));
360
+ }
361
+ this.pendingToolCues.clear();
362
+ // 2. Run finalize chain (reverse order)
363
+ await runFinalizeChain(this.initSteps);
364
+ // 2b. Emit the end-of-session usage manifest before the bus stops — the metering seam.
365
+ this.emitSessionUsage();
366
+ // 3. Stop bus
367
+ this.bus.stop();
368
+ await this.busStartPromise;
369
+ // 4. Emit debug event
370
+ this.emitDebug("session", "disconnected", { reason: "close" });
371
+ this._state = SessionState.Closed;
372
+ }
373
+ /** Switch between text and audio mode. */
374
+ async switchMode(mode) {
375
+ this.bus.push(Route.Main, make.modeSwitchRequested(this.currentTurnId, Date.now(), mode));
376
+ }
377
+ requestClientInterrupt(contextId) {
378
+ // Playing out → the arbiter owns the barge-in (primary-speaker reset + metrics).
379
+ if (this.ttsPlayout.isActive(contextId)) {
380
+ this.turnArbiter.commitClientInterrupt(contextId);
381
+ return;
382
+ }
383
+ // Thinking-phase barge-in (B3): no audio yet, but a generation is in-flight —
384
+ // abort it so "stop" during the reasoner TTFT gap is honored, not dropped.
385
+ if (this.generatingContextIds.has(contextId)) {
386
+ this.bus.push(Route.Critical, make.interruptDetected(contextId, Date.now(), "client"));
387
+ }
388
+ }
389
+ // =========================================================================
390
+ // Legacy Event Emitter (client-side consumers)
391
+ // =========================================================================
392
+ on(event, handler) {
393
+ if (!this.eventListeners.has(event)) {
394
+ this.eventListeners.set(event, new Set());
395
+ }
396
+ this.eventListeners.get(event).add(handler);
397
+ }
398
+ off(event, handler) {
399
+ this.eventListeners.get(event)?.delete(handler);
400
+ }
401
+ emit(event, payload) {
402
+ const listeners = this.eventListeners.get(event);
403
+ if (!listeners)
404
+ return;
405
+ for (const fn of listeners) {
406
+ try {
407
+ fn(payload);
408
+ }
409
+ catch {
410
+ // Don't let listener errors crash the session
411
+ }
412
+ }
413
+ }
414
+ // =========================================================================
415
+ // Bus Handler Wiring
416
+ // =========================================================================
417
+ wireBusHandlers() {
418
+ // Input pipeline
419
+ this.bus.on("user.audio_received", this.handleUserAudio.bind(this));
420
+ this.bus.on("user.text_received", this.handleUserText.bind(this));
421
+ // STT results
422
+ this.bus.on("stt.audio", this.handleSttAudio.bind(this));
423
+ this.bus.on("stt.interim", this.handleSttInterim.bind(this));
424
+ this.bus.on("stt.partial", this.handleSttPartial.bind(this));
425
+ this.bus.on("stt.result", this.handleSttResult.bind(this));
426
+ // VAD
427
+ this.bus.on("vad.speech_started", this.handleVadSpeechStarted.bind(this));
428
+ this.bus.on("vad.speech_activity", this.handleVadSpeechActivity.bind(this));
429
+ this.bus.on("vad.speech_ended", this.handleVadSpeechEnded.bind(this));
430
+ this.bus.on("vad.audio", this.handleVadAudioForSpeakerGate.bind(this));
431
+ // EOS
432
+ this.bus.on("turn.change", (pkt) => {
433
+ this.lastFinalizedContextId = "";
434
+ if (pkt.previousContextId) {
435
+ this.sttPartialWordTimings.delete(pkt.previousContextId);
436
+ this.carryTurnTimingAcrossContextChange(pkt.previousContextId, pkt.contextId);
437
+ }
438
+ });
439
+ this.bus.on("eos.turn_complete", this.handleTurnComplete.bind(this));
440
+ this.bus.on("eos.interim", this.handleEosInterim.bind(this));
441
+ // LLM
442
+ this.bus.on("llm.delta", this.handleLlmDelta.bind(this));
443
+ this.bus.on("llm.done", this.handleLlmDone.bind(this));
444
+ this.bus.on("llm.tool_call", this.handleLlmToolCall.bind(this));
445
+ this.bus.on("llm.tool_result", this.handleLlmToolResult.bind(this));
446
+ this.bus.on("metric.conversation", this.handleConversationMetric.bind(this));
447
+ this.bus.on("usage.recorded", (pkt) => this.handleUsageRecorded(pkt));
448
+ // Delegate (Responder-Thinker) observability — G2, RFC bimodel-delegate-seam
449
+ this.bus.on("delegate.query", this.handleDelegateQuery.bind(this));
450
+ this.bus.on("delegate.result", this.handleDelegateResult.bind(this));
451
+ // TTS
452
+ this.bus.on("tts.audio", this.handleTtsAudio.bind(this));
453
+ this.bus.on("tts.end", this.handleTtsEnd.bind(this));
454
+ this.bus.on("tts.playout_progress", this.handleTtsPlayoutProgress.bind(this));
455
+ // Interrupts
456
+ this.bus.on("interrupt.detected", this.handleInterruptDetected.bind(this));
457
+ // Errors
458
+ this.bus.on("stt.error", this.handleComponentError.bind(this));
459
+ this.bus.on("tts.error", this.handleComponentError.bind(this));
460
+ this.bus.on("vad.error", this.handleComponentError.bind(this));
461
+ this.bus.on("llm.error", this.handleComponentError.bind(this));
462
+ this.bus.on("pipeline.error", this.handleComponentError.bind(this));
463
+ // Lifecycle
464
+ this.bus.on("init.failed", this.handleInitFailed.bind(this));
465
+ // Behavior
466
+ this.bus.on("behavior.idle_timeout_start", (pkt) => {
467
+ this.idleTimeout.handleStart(pkt);
468
+ });
469
+ this.bus.on("behavior.idle_timeout_stop", (pkt) => {
470
+ this.idleTimeout.handleStop(pkt);
471
+ });
472
+ // Injected messages — push through LLM path for natural TTS
473
+ this.bus.on("inject.message", this.handleInjectMessage.bind(this));
474
+ // Per-turn STT reconfigure (keyterms / endpointing / EOT thresholds)
475
+ this.bus.on("stt.reconfigure", this.handleSttReconfigure.bind(this));
476
+ // Disconnect
477
+ this.bus.on("session.disconnect", this.handleDisconnect.bind(this));
478
+ // Mode switching
479
+ this.bus.on("mode.switch_requested", async (pkt) => {
480
+ await this.modeSwitcher.handleSwitchRequested(pkt);
481
+ });
482
+ this.observabilityObserver.wire();
483
+ }
484
+ // =========================================================================
485
+ // Handler Implementations
486
+ // =========================================================================
487
+ handleUserAudio(pkt) {
488
+ if (this.shouldEnrollPrimarySpeaker(pkt.contextId)) {
489
+ this.primarySpeakerGate.enrollUserTurnChunk(pkt.audio);
490
+ }
491
+ if (this.endpointingOwner === "provider_stt") {
492
+ this.bus.push(Route.Main, make.recordUserAudio(pkt.contextId, pkt.timestampMs, pkt.audio), make.vadAudio(pkt.contextId, pkt.timestampMs, pkt.audio), make.sttAudio(pkt.contextId, pkt.timestampMs, pkt.audio));
493
+ }
494
+ else {
495
+ this.bus.push(Route.Main, make.recordUserAudio(pkt.contextId, pkt.timestampMs, pkt.audio), make.vadAudio(pkt.contextId, pkt.timestampMs, pkt.audio), make.sttAudio(pkt.contextId, pkt.timestampMs, pkt.audio), make.eosAudio(pkt.contextId, pkt.timestampMs, pkt.audio));
496
+ }
497
+ this.debugPush({
498
+ component: "input",
499
+ type: "audio_received",
500
+ data: { context_id: pkt.contextId, bytes: String(pkt.audio.length) },
501
+ timestampMs: pkt.timestampMs,
502
+ });
503
+ this.observeAudioFrame(pkt);
504
+ this.watchdogs.scheduleInputCadenceWatchdog(pkt.contextId);
505
+ }
506
+ observeAudioFrame(pkt) {
507
+ if (pkt.audio.byteLength < 2 || pkt.audio.byteLength % 2 !== 0)
508
+ return;
509
+ this.interaction.observe({
510
+ kind: "audio_frame",
511
+ contextId: pkt.contextId,
512
+ timestampMs: pkt.timestampMs,
513
+ audio: pcm16BytesToSamples(pkt.audio),
514
+ sampleRateHz: pkt.sampleRateHz ?? 16_000,
515
+ });
516
+ }
517
+ handleSttAudio(pkt) {
518
+ this.watchdogs.scheduleSttForceFinalize(pkt.contextId);
519
+ }
520
+ handleUserText(pkt) {
521
+ // Typed input is an immediate turn end — but nothing endpointed it and no one
522
+ // decided the caller stopped talking, because they typed. Label it as `text` so
523
+ // the timeline never claims a speech endpointer fired on a typed turn.
524
+ this.bus.push(Route.Main, make.eosTurnComplete(pkt.contextId, pkt.timestampMs, pkt.text, [], {
525
+ owner: "text",
526
+ reason: "typed",
527
+ }));
528
+ }
529
+ handleSttPartial(pkt) {
530
+ if (pkt.wordTimings) {
531
+ this.sttPartialWordTimings.set(pkt.contextId, pkt.wordTimings);
532
+ }
533
+ }
534
+ handleSttInterim(pkt) {
535
+ this.observeSttForBargeIn(pkt.contextId, pkt.timestampMs, pkt.text, "stt_partial");
536
+ this.currentTurnId = pkt.contextId;
537
+ this.emit("user_input_partial", {
538
+ tsMs: pkt.timestampMs,
539
+ turnId: pkt.contextId,
540
+ text: pkt.text,
541
+ });
542
+ this.debugPush({
543
+ component: "stt",
544
+ type: "interim",
545
+ data: { context_id: pkt.contextId, text: pkt.text },
546
+ timestampMs: pkt.timestampMs,
547
+ });
548
+ }
549
+ handleSttResult(pkt) {
550
+ this.watchdogs.clearSttForceFinalizeIfContext(pkt.contextId);
551
+ this.observeSttForBargeIn(pkt.contextId, pkt.timestampMs, pkt.text, "stt_final", pkt.confidence);
552
+ this.currentTurnId = pkt.contextId;
553
+ this.emit("user_input_final", {
554
+ tsMs: pkt.timestampMs,
555
+ turnId: pkt.contextId,
556
+ text: pkt.text,
557
+ confidence: pkt.confidence,
558
+ });
559
+ this.debugPush({
560
+ component: "stt",
561
+ type: "final",
562
+ data: {
563
+ context_id: pkt.contextId,
564
+ text: pkt.text,
565
+ confidence: String(pkt.confidence),
566
+ },
567
+ timestampMs: pkt.timestampMs,
568
+ });
569
+ }
570
+ handleVadAudioForSpeakerGate(pkt) {
571
+ if (this.interaction.observeBargeInAudio(pkt))
572
+ return;
573
+ if (this.shouldEnrollPrimarySpeaker(pkt.contextId)) {
574
+ this.primarySpeakerGate.enrollUserTurnChunk(pkt.audio);
575
+ }
576
+ }
577
+ shouldEnrollPrimarySpeaker(contextId) {
578
+ return (!this.latestActiveTtsContextId() &&
579
+ this.speakerEnrollmentContextId === contextId);
580
+ }
581
+ // Deployments that delegate endpointing to the provider STT register no VAD
582
+ // plugin, so vad.speech_started never fires and barge-in would stay dormant.
583
+ // Provider transcripts arriving while TTS playout is active are the speech
584
+ // evidence instead (echo of our own playout is mitigated by client AEC plus
585
+ // the arbiter's backchannel / low-confidence suppression).
586
+ observeSttForBargeIn(contextId, timestampMs, text, kind, confidence) {
587
+ const interruptedContextId = this.endpointingOwner === "provider_stt" && text.trim()
588
+ ? this.latestActiveTtsContextId() ?? undefined
589
+ : undefined;
590
+ const wordTimings = this.sttPartialWordTimings.get(contextId);
591
+ if (kind === "stt_partial") {
592
+ this.interaction.observe({
593
+ kind: "stt_partial",
594
+ contextId,
595
+ timestampMs,
596
+ text,
597
+ interruptedContextId,
598
+ ...(wordTimings ? { wordTimings } : {}),
599
+ });
600
+ return;
601
+ }
602
+ this.interaction.observe({
603
+ kind: "stt_final",
604
+ contextId,
605
+ timestampMs,
606
+ text,
607
+ confidence,
608
+ interruptedContextId,
609
+ ...(wordTimings ? { wordTimings } : {}),
610
+ });
611
+ }
612
+ handleVadSpeechStarted(pkt) {
613
+ this.lastFinalizedContextId = "";
614
+ this.userSpeaking = true;
615
+ if (this.latencyFiller.isFillerOnly(this.currentTurnId)) {
616
+ this.cancelLatencyFillerTurn(this.currentTurnId, pkt.timestampMs);
617
+ }
618
+ this.emit("user_started_speaking", {
619
+ tsMs: pkt.timestampMs,
620
+ turnId: pkt.contextId,
621
+ });
622
+ this.debugPush({
623
+ component: "vad",
624
+ type: "speech_started",
625
+ data: {
626
+ context_id: pkt.contextId,
627
+ confidence: String(pkt.confidence),
628
+ },
629
+ timestampMs: pkt.timestampMs,
630
+ });
631
+ const interruptedContextId = this.latestActiveTtsContextId();
632
+ if (!interruptedContextId) {
633
+ this.speakerEnrollmentContextId = pkt.contextId;
634
+ }
635
+ this.interaction.observe({
636
+ kind: "vad_speech_started",
637
+ contextId: pkt.contextId,
638
+ timestampMs: pkt.timestampMs,
639
+ confidence: pkt.confidence,
640
+ ...(interruptedContextId ? { interruptedContextId } : {}),
641
+ });
642
+ }
643
+ handleVadSpeechActivity(pkt) {
644
+ this.interaction.observe({
645
+ kind: "vad_speech_activity",
646
+ contextId: pkt.contextId,
647
+ timestampMs: pkt.timestampMs,
648
+ });
649
+ }
650
+ handleVadSpeechEnded(pkt) {
651
+ this.userSpeaking = false;
652
+ this.emit("user_stopped_speaking", {
653
+ tsMs: pkt.timestampMs,
654
+ turnId: pkt.contextId,
655
+ });
656
+ this.debugPush({
657
+ component: "vad",
658
+ type: "speech_ended",
659
+ data: { context_id: pkt.contextId },
660
+ timestampMs: pkt.timestampMs,
661
+ });
662
+ if (this.speakerEnrollmentContextId === pkt.contextId) {
663
+ this.speakerEnrollmentContextId = null;
664
+ }
665
+ this.interaction.observe({
666
+ kind: "vad_speech_ended",
667
+ contextId: pkt.contextId,
668
+ timestampMs: pkt.timestampMs,
669
+ hasActiveTts: Boolean(this.latestActiveTtsContextId()),
670
+ });
671
+ this.turnUserStoppedAtMs.set(pkt.contextId, pkt.timestampMs);
672
+ this.timingFor(pkt.contextId).speechEndedMs = pkt.timestampMs;
673
+ this.watchdogs.startVaqiMissedResponseTimer(pkt.contextId, pkt.timestampMs);
674
+ }
675
+ /**
676
+ * A realtime front rotates its contextId when the provider starts responding
677
+ * (`RealtimeBridge.onResponseStarted`), so the user-side anchors (`speechEndedMs`, `eosMs`)
678
+ * are recorded under the PREVIOUS context while `tts.audio` — and therefore the
679
+ * `turn_latency` emit — lands under the new one. Without carrying the record across the
680
+ * rotation, `emitTurnLatency` finds no anchor and silently drops every native turn.
681
+ *
682
+ * Only fills gaps: anything the new context already recorded wins.
683
+ */
684
+ carryTurnTimingAcrossContextChange(previousContextId, contextId) {
685
+ const previous = this.turnTimings.get(previousContextId);
686
+ if (!previous)
687
+ return;
688
+ this.turnTimings.delete(previousContextId);
689
+ const current = this.turnTimings.get(contextId);
690
+ this.turnTimings.set(contextId, { ...previous, ...current });
691
+ }
692
+ timingFor(contextId) {
693
+ let timing = this.turnTimings.get(contextId);
694
+ if (!timing) {
695
+ timing = {};
696
+ this.turnTimings.set(contextId, timing);
697
+ }
698
+ return timing;
699
+ }
700
+ /** Emit a deferred turn_latency if one is pending, then clear it. Safe to call twice. */
701
+ flushPendingTurnLatency(contextId) {
702
+ const firstAudioMs = this.pendingTurnLatency.get(contextId);
703
+ if (firstAudioMs === undefined)
704
+ return;
705
+ this.pendingTurnLatency.delete(contextId);
706
+ this.emitTurnLatency(contextId, firstAudioMs);
707
+ }
708
+ emitTurnLatency(contextId, firstAudioMs) {
709
+ const timing = this.timingFor(contextId);
710
+ this.turnTimings.delete(contextId);
711
+ const anchorMs = timing.speechEndedMs ?? timing.eosMs;
712
+ if (anchorMs === undefined)
713
+ return; // text-injected or fallback turn — not a voice TTFA
714
+ const anchor = timing.speechEndedMs !== undefined ? "speech_end" : "eos";
715
+ const eouDelayMs = timing.speechEndedMs !== undefined &&
716
+ timing.eosMs !== undefined &&
717
+ timing.eosMs <= firstAudioMs
718
+ ? timing.eosMs - timing.speechEndedMs
719
+ : undefined;
720
+ const llmAnchorMs = timing.eosMs ?? timing.speechEndedMs;
721
+ const llmTtftMs = llmAnchorMs !== undefined && timing.firstLlmDeltaMs !== undefined
722
+ ? timing.firstLlmDeltaMs - llmAnchorMs
723
+ : undefined;
724
+ const textAggregationMs = timing.firstLlmDeltaMs !== undefined && timing.firstTtsTextMs !== undefined
725
+ ? timing.firstTtsTextMs - timing.firstLlmDeltaMs
726
+ : undefined;
727
+ const ttsTtfbMs = timing.firstTtsTextMs !== undefined
728
+ ? firstAudioMs - timing.firstTtsTextMs
729
+ : undefined;
730
+ const attributedMs = [eouDelayMs, llmTtftMs, textAggregationMs, ttsTtfbMs]
731
+ .filter((value) => value !== undefined)
732
+ .reduce((sum, value) => sum + value, 0);
733
+ const ttfaMs = firstAudioMs - anchorMs;
734
+ const unattributedMs = ttfaMs - attributedMs;
735
+ if (unattributedMs >= 500)
736
+ this.markInfrastructureBreach(contextId);
737
+ const fillerUsed = timing.fillerUsed === true;
738
+ const backchannelUsed = timing.backchannelUsed === true;
739
+ this.emit("turn_latency", {
740
+ tsMs: firstAudioMs,
741
+ turnId: contextId,
742
+ ttfaMs,
743
+ anchor,
744
+ ...(eouDelayMs !== undefined ? { eouDelayMs } : {}),
745
+ ...(llmTtftMs !== undefined ? { llmTtftMs } : {}),
746
+ ...(textAggregationMs !== undefined ? { textAggregationMs } : {}),
747
+ ...(ttsTtfbMs !== undefined ? { ttsTtfbMs } : {}),
748
+ unattributedMs,
749
+ ...(timing.llmCallCount !== undefined ? { llmCallCount: timing.llmCallCount } : {}),
750
+ ...(timing.llmPassTtftMs !== undefined ? { llmPassTtftMs: [...timing.llmPassTtftMs] } : {}),
751
+ fillerUsed,
752
+ backchannelUsed,
753
+ });
754
+ const tags = {
755
+ ...this.observabilityDims,
756
+ layer: "infrastructure",
757
+ cancelled: this.interruptedGenerationContextIds.has(contextId) ? "true" : "false",
758
+ anchor,
759
+ filler_used: String(fillerUsed),
760
+ backchannel_used: String(backchannelUsed),
761
+ };
762
+ const histograms = [
763
+ ["turn.ttfa_ms", ttfaMs],
764
+ ["turn.eou_delay_ms", eouDelayMs],
765
+ ["turn.llm_ttft_ms", llmTtftMs],
766
+ ["turn.text_aggregation_ms", textAggregationMs],
767
+ ["turn.tts_ttfb_ms", ttsTtfbMs],
768
+ ["turn.unattributed_ms", unattributedMs],
769
+ ];
770
+ for (const [name, valueMs] of histograms) {
771
+ if (valueMs !== undefined)
772
+ this.metricsExporter.observeHistogram(name, valueMs, tags);
773
+ }
774
+ }
775
+ handleTurnComplete(pkt) {
776
+ if (this.lastFinalizedContextId === pkt.contextId) {
777
+ this.bus.push(Route.Background, make.metric(pkt.contextId, "eos.duplicate_dropped", "1"));
778
+ return;
779
+ }
780
+ // A backchannel ("uh-huh", "okay") uttered WHILE the assistant is still speaking
781
+ // is not a turn (B4). Dropping it here does two things: it does NOT cancel the
782
+ // assistant's in-flight answer (the supersede path below would otherwise kill it),
783
+ // and it does NOT spawn a second LLM response to the backchannel (the double-reply
784
+ // bug). A backchannel with no assistant currently speaking IS a real turn — only
785
+ // suppress when another context's TTS is actively playing. (English-only classifier
786
+ // today — locale-aware backchannels are a separate improvement.)
787
+ const otherTtsActive = this.ttsPlayout.activeContexts().some((c) => c !== pkt.contextId);
788
+ if (otherTtsActive && isBackchannel(pkt.text)) {
789
+ this.bus.push(Route.Background, make.metric(pkt.contextId, "turn.backchannel_dropped", pkt.text));
790
+ this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "backchannel", { text: pkt.text });
791
+ return;
792
+ }
793
+ if (this.emittedTurnLocalizations.has(pkt.contextId)) {
794
+ this.emittedTurnLocalizations.delete(pkt.contextId);
795
+ this.turnLocalizationStates.delete(pkt.contextId);
796
+ }
797
+ this.lastFinalizedContextId = pkt.contextId;
798
+ this.interaction.reset(pkt.contextId);
799
+ // The force-finalize flag has now been read by whoever emitted this eos (and
800
+ // captured by the metrics tracker via its metric) — retire it so the per-call
801
+ // Set does not grow unbounded on a stable transport contextId.
802
+ this.watchdogs.clearForceFinalized(pkt.contextId);
803
+ // Re-arm per-turn guard state for the next turn. Transports with a stable
804
+ // per-call contextId (telephony callSid) reuse one id across turns, so these
805
+ // Sets must be cleared at the turn boundary or turn 2+ inherits stale flags:
806
+ // - firstTtsAudioFired: else vaqi.latency_ms is never emitted again
807
+ // - interruptedGenerationContextIds: else turn N+1's LLM/TTS packets are dropped after a prior barge-in
808
+ // - fallbackInjectedContexts: else only one error fallback can ever be spoken per call
809
+ this.firstTtsAudioFired.delete(pkt.contextId);
810
+ this.pendingTurnLatency.delete(pkt.contextId);
811
+ this.interruptedGenerationContextIds.delete(pkt.contextId);
812
+ this.fallbackInjectedContexts.delete(pkt.contextId);
813
+ // Supersede (L1): a new turn must cancel any still-active prior-turn TTS or
814
+ // generation. Without this, a false-EOS (early endpoint on a mid-sentence
815
+ // pause) starts turn N, the user resumes, turn N's already-emitted audio
816
+ // keeps synthesizing, and it plays over the user while turn N+1 is answered.
817
+ // The bridge supersedes the *LLM*; only the session can stop the *TTS*.
818
+ for (const activeCtx of this.ttsPlayout.activeContexts()) {
819
+ if (activeCtx !== pkt.contextId)
820
+ this.cancelStaleGeneration(activeCtx, pkt.timestampMs);
821
+ }
822
+ this.generatingContextIds.add(pkt.contextId);
823
+ this.currentTurnId = pkt.contextId;
824
+ this.idleTimeout.setContextId(pkt.contextId);
825
+ this.emit("user_input_final", {
826
+ tsMs: pkt.timestampMs,
827
+ turnId: pkt.contextId,
828
+ text: pkt.text,
829
+ confidence: 1.0,
830
+ });
831
+ this.debugPush({
832
+ component: "eos",
833
+ type: "turn_complete",
834
+ data: { context_id: pkt.contextId, text: pkt.text },
835
+ timestampMs: pkt.timestampMs,
836
+ });
837
+ // Stop idle timeout while the LLM processes. The user just spoke — that is
838
+ // genuine engagement, so reset the idle *escalation* count (P2): a user who
839
+ // answers the first "are you there?" must not be escalated straight to the
840
+ // disconnect prompt later in the call.
841
+ this.bus.push(Route.Main, make.stopIdleTimeout(pkt.contextId, Date.now(), true));
842
+ const timing = this.timingFor(pkt.contextId);
843
+ timing.eosMs = pkt.timestampMs;
844
+ const fillerText = this.latencyFiller.start(pkt.contextId, pkt.text, pkt.timestampMs);
845
+ if (fillerText) {
846
+ timing.fillerUsed = true;
847
+ timing.firstTtsTextMs ??= pkt.timestampMs;
848
+ this.bus.push(Route.Main, make.ttsText(pkt.contextId, Date.now(), fillerText));
849
+ this.bus.push(Route.Background, make.metric(pkt.contextId, "filler.started", fillerText));
850
+ }
851
+ this.bus.push(Route.Main, make.userInput(pkt.contextId, Date.now(), pkt.text, languageFromTranscripts(pkt.transcripts)));
852
+ }
853
+ handleEosInterim(pkt) {
854
+ this.debugPush({
855
+ component: "eos",
856
+ type: "interim",
857
+ data: { context_id: pkt.contextId, text: pkt.text },
858
+ timestampMs: pkt.timestampMs,
859
+ });
860
+ }
861
+ handleLlmDelta(pkt) {
862
+ if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
863
+ this.bus.push(Route.Background, make.metric(pkt.contextId, "llm.delta_ignored_after_interrupt", String(pkt.text.length)));
864
+ return;
865
+ }
866
+ let deltaText = pkt.text;
867
+ if (!this.firstLlmDeltaReceived.has(pkt.contextId)) {
868
+ this.firstLlmDeltaReceived.add(pkt.contextId);
869
+ const timing = this.turnTimings.get(pkt.contextId);
870
+ if (timing)
871
+ timing.firstLlmDeltaMs ??= pkt.timestampMs;
872
+ if (this.latencyFiller.isActive(pkt.contextId)) {
873
+ deltaText = this.latencyFiller.spliceLlmDelta(pkt.contextId, deltaText);
874
+ this.bus.push(Route.Background, make.metric(pkt.contextId, "filler.spliced", "1"));
875
+ }
876
+ }
877
+ this.emit("agent_text_delta", {
878
+ tsMs: pkt.timestampMs,
879
+ turnId: pkt.contextId,
880
+ delta: deltaText,
881
+ });
882
+ this.debugPush({
883
+ component: "llm",
884
+ type: "delta",
885
+ data: { context_id: pkt.contextId, text: deltaText },
886
+ timestampMs: pkt.timestampMs,
887
+ });
888
+ this.bufferTtsText(pkt.contextId, deltaText, pkt.timestampMs);
889
+ }
890
+ handleLlmDone(pkt) {
891
+ this.generatingContextIds.delete(pkt.contextId);
892
+ if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
893
+ this.ttsTextBuffers.delete(pkt.contextId);
894
+ this.bus.push(Route.Background, make.metric(pkt.contextId, "llm.done_ignored_after_interrupt", "1"));
895
+ return;
896
+ }
897
+ const spokenText = this.flushTtsText(pkt.contextId, pkt.timestampMs);
898
+ this.emit("agent_finished", {
899
+ tsMs: pkt.timestampMs,
900
+ turnId: pkt.contextId,
901
+ });
902
+ this.debugPush({
903
+ component: "llm",
904
+ type: "done",
905
+ data: { context_id: pkt.contextId },
906
+ timestampMs: pkt.timestampMs,
907
+ });
908
+ // Start idle timeout after agent finishes
909
+ this.bus.push(Route.Main, make.startIdleTimeout(pkt.contextId, Date.now()));
910
+ this.bus.push(Route.Main, make.ttsDone(pkt.contextId, Date.now(), spokenText));
911
+ }
912
+ bufferTtsText(contextId, text, tsMs) {
913
+ const buffer = this.ttsTextBuffers.get(contextId) ?? { pending: "", emitted: "" };
914
+ buffer.pending += text;
915
+ const complete = takeCompleteVoiceText(buffer.pending);
916
+ if (complete.text) {
917
+ const timing = this.turnTimings.get(contextId);
918
+ if (timing)
919
+ timing.firstTtsTextMs ??= tsMs ?? Date.now();
920
+ // Strip markdown before it reaches TTS, and track the SPOKEN form as the heard
921
+ // prefix so barge-in truncation reconciles against what was actually said.
922
+ const spoken = normalizeForSpeech(complete.text);
923
+ this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), spoken));
924
+ buffer.emitted = appendVoiceText(buffer.emitted, spoken);
925
+ }
926
+ buffer.pending = complete.remaining;
927
+ this.ttsTextBuffers.set(contextId, buffer);
928
+ }
929
+ flushTtsText(contextId, tsMs) {
930
+ const buffer = this.ttsTextBuffers.get(contextId);
931
+ if (!buffer)
932
+ return "";
933
+ const tail = normalizeForSpeech(buffer.pending.trim());
934
+ if (tail) {
935
+ const timing = this.turnTimings.get(contextId);
936
+ if (timing)
937
+ timing.firstTtsTextMs ??= tsMs ?? Date.now();
938
+ this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), tail));
939
+ buffer.emitted = appendVoiceText(buffer.emitted, tail);
940
+ buffer.pending = "";
941
+ this.bus.push(Route.Background, make.metric(contextId, isCompleteVoiceText(tail) ? "tts.final_text_flushed" : "tts.final_tail_flushed", tail));
942
+ }
943
+ this.ttsTextBuffers.delete(contextId);
944
+ this.latencyFiller.clear(contextId);
945
+ this.firstLlmDeltaReceived.delete(contextId);
946
+ return buffer.emitted.trim();
947
+ }
948
+ cancelLatencyFillerTurn(contextId, timestampMs) {
949
+ const cancelled = this.latencyFiller.cancel(contextId);
950
+ if (!cancelled)
951
+ return;
952
+ this.bus.push(Route.Background, make.metric(contextId, "filler.cancelled", cancelled.text, timestampMs));
953
+ this.turnArbiter.emitInterruptDetected(contextId);
954
+ }
955
+ handleLlmToolCall(pkt) {
956
+ this.emit("agent_tool_call", {
957
+ tsMs: pkt.timestampMs,
958
+ turnId: pkt.contextId,
959
+ id: pkt.toolId,
960
+ name: pkt.toolName,
961
+ args: pkt.toolArgs,
962
+ });
963
+ this.debugPush({
964
+ component: "tool",
965
+ type: "call_started",
966
+ data: {
967
+ context_id: pkt.contextId,
968
+ tool_id: pkt.toolId,
969
+ tool_name: pkt.toolName,
970
+ },
971
+ timestampMs: pkt.timestampMs,
972
+ });
973
+ // G3: arm the typed preamble/filler lifecycle for this pending tool call.
974
+ const pending = this.pendingToolCues.get(pkt.contextId) ?? new Map();
975
+ pending.set(pkt.toolId, pkt.toolName);
976
+ this.pendingToolCues.set(pkt.contextId, pending);
977
+ this.emitToolCallCue(pkt.contextId, "started", pkt.toolId, pkt.toolName);
978
+ if (this.delayCueAfterMs > 0) {
979
+ const afterMs = this.delayCueAfterMs;
980
+ this.scheduler.schedule(toolCueTimerKey(pkt.contextId, pkt.toolId), afterMs, () => {
981
+ if (!this.pendingToolCues.get(pkt.contextId)?.has(pkt.toolId))
982
+ return;
983
+ this.emitToolCallCue(pkt.contextId, "delayed", pkt.toolId, pkt.toolName, afterMs);
984
+ });
985
+ }
986
+ }
987
+ emitToolCallCue(contextId, phase, toolId, toolName, afterMs) {
988
+ const tsMs = Date.now();
989
+ this.emit("tool_call_cue", {
990
+ tsMs,
991
+ turnId: contextId,
992
+ phase,
993
+ toolId,
994
+ toolName,
995
+ ...(afterMs !== undefined ? { afterMs } : {}),
996
+ });
997
+ this.interaction.observe({
998
+ kind: "delegate_state",
999
+ contextId,
1000
+ timestampMs: tsMs,
1001
+ delegateInFlight: phase === "started" || phase === "delayed",
1002
+ toolCallPhase: phase,
1003
+ });
1004
+ }
1005
+ /** G3: resolve one pending tool call with a terminal cue phase. */
1006
+ resolveToolCue(contextId, toolId, phase) {
1007
+ const pending = this.pendingToolCues.get(contextId);
1008
+ const toolName = pending?.get(toolId);
1009
+ if (toolName === undefined)
1010
+ return;
1011
+ pending.delete(toolId);
1012
+ if (pending.size === 0)
1013
+ this.pendingToolCues.delete(contextId);
1014
+ this.scheduler.cancel(toolCueTimerKey(contextId, toolId));
1015
+ this.emitToolCallCue(contextId, phase, toolId, toolName);
1016
+ }
1017
+ /** G3: fail every pending tool call for a context (error / barge-in / supersede). */
1018
+ failPendingToolCues(contextId) {
1019
+ const pending = this.pendingToolCues.get(contextId);
1020
+ if (!pending)
1021
+ return;
1022
+ for (const toolId of [...pending.keys()])
1023
+ this.resolveToolCue(contextId, toolId, "failed");
1024
+ }
1025
+ handleDelegateQuery(pkt) {
1026
+ this.emit("delegate_query", {
1027
+ tsMs: pkt.timestampMs,
1028
+ turnId: pkt.contextId,
1029
+ query: pkt.query,
1030
+ toolId: pkt.toolId,
1031
+ toolName: pkt.toolName,
1032
+ });
1033
+ this.debugPush({
1034
+ component: "delegate",
1035
+ type: "query",
1036
+ data: {
1037
+ context_id: pkt.contextId,
1038
+ query: pkt.query,
1039
+ ...(pkt.toolName ? { tool_name: pkt.toolName } : {}),
1040
+ },
1041
+ timestampMs: pkt.timestampMs,
1042
+ });
1043
+ }
1044
+ handleDelegateResult(pkt) {
1045
+ if (pkt.control && isConversationControlName(pkt.control.name)) {
1046
+ this.markConversationFlag(pkt.contextId);
1047
+ }
1048
+ this.emit("delegate_result", {
1049
+ tsMs: pkt.timestampMs,
1050
+ turnId: pkt.contextId,
1051
+ query: pkt.query,
1052
+ answer: pkt.answer,
1053
+ durationMs: pkt.durationMs,
1054
+ grounded: pkt.grounded,
1055
+ toolId: pkt.toolId,
1056
+ toolName: pkt.toolName,
1057
+ ...(pkt.control ? { control: pkt.control } : {}),
1058
+ ...(pkt.blocked ? { blocked: pkt.blocked } : {}),
1059
+ });
1060
+ this.debugPush({
1061
+ component: "delegate",
1062
+ type: "result",
1063
+ data: {
1064
+ context_id: pkt.contextId,
1065
+ duration_ms: String(pkt.durationMs),
1066
+ grounded: String(pkt.grounded),
1067
+ },
1068
+ timestampMs: pkt.timestampMs,
1069
+ });
1070
+ }
1071
+ handleLlmToolResult(pkt) {
1072
+ this.resolveToolCue(pkt.contextId, pkt.toolId, "complete");
1073
+ this.emit("agent_tool_result", {
1074
+ tsMs: pkt.timestampMs,
1075
+ turnId: pkt.contextId,
1076
+ id: pkt.toolId,
1077
+ result: pkt.result,
1078
+ durationMs: 0,
1079
+ });
1080
+ this.debugPush({
1081
+ component: "tool",
1082
+ type: "call_completed",
1083
+ data: {
1084
+ context_id: pkt.contextId,
1085
+ tool_id: pkt.toolId,
1086
+ tool_name: pkt.toolName,
1087
+ },
1088
+ timestampMs: pkt.timestampMs,
1089
+ });
1090
+ }
1091
+ handleConversationMetric(pkt) {
1092
+ if (isInfrastructureMetric(pkt.name))
1093
+ this.markInfrastructureBreach(pkt.contextId);
1094
+ if (isConversationEvaluationMetric(pkt.name, pkt.value))
1095
+ this.markConversationFlag(pkt.contextId);
1096
+ if (pkt.name === "input.cadence_stall_ms") {
1097
+ this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "cadence", { value: pkt.value });
1098
+ }
1099
+ if (pkt.name === "llm.call_started") {
1100
+ const timing = this.timingFor(pkt.contextId);
1101
+ timing.llmCallCount = (timing.llmCallCount ?? 0) + 1;
1102
+ return;
1103
+ }
1104
+ if (pkt.name === "llm.pass_ttft_ms") {
1105
+ const ttftMs = Number(pkt.value);
1106
+ if (!Number.isFinite(ttftMs))
1107
+ return;
1108
+ const timing = this.timingFor(pkt.contextId);
1109
+ (timing.llmPassTtftMs ??= []).push(ttftMs);
1110
+ }
1111
+ }
1112
+ emitAcousticSignal(contextId, timestampMs, signal, payload) {
1113
+ this.bus.push(Route.Background, make.acousticSignal(contextId, timestampMs, signal, payload));
1114
+ this.metricsExporter.observeCounter?.(`acoustic.${signal}`, 1, {
1115
+ provider: this.observabilityDims.provider,
1116
+ model: this.observabilityDims.model,
1117
+ region: this.observabilityDims.region,
1118
+ layer: "conversation",
1119
+ });
1120
+ }
1121
+ localizationStateFor(contextId) {
1122
+ const existing = this.turnLocalizationStates.get(contextId);
1123
+ if (existing)
1124
+ return existing;
1125
+ const state = { infrastructureBreached: false, conversationFlagged: false };
1126
+ this.turnLocalizationStates.set(contextId, state);
1127
+ return state;
1128
+ }
1129
+ markInfrastructureBreach(contextId) {
1130
+ this.localizationStateFor(contextId).infrastructureBreached = true;
1131
+ }
1132
+ markConversationFlag(contextId) {
1133
+ this.localizationStateFor(contextId).conversationFlagged = true;
1134
+ }
1135
+ emitTurnLocalization(contextId) {
1136
+ if (this.emittedTurnLocalizations.has(contextId))
1137
+ return;
1138
+ const state = this.localizationStateFor(contextId);
1139
+ this.emittedTurnLocalizations.add(contextId);
1140
+ this.bus.push(Route.Background, make.turnLocalization(contextId, Date.now(), localizeTurn(state), state.infrastructureBreached, state.conversationFlagged));
1141
+ }
1142
+ scheduleTurnLocalization(contextId) {
1143
+ this.scheduler.schedule(turnLocalizationTimerKey(contextId), 0, () => {
1144
+ this.emitTurnLocalization(contextId);
1145
+ });
1146
+ }
1147
+ static USAGE_FIELDS = [
1148
+ "inputTokens",
1149
+ "outputTokens",
1150
+ "totalTokens",
1151
+ "cachedInputTokens",
1152
+ "reasoningTokens",
1153
+ "audioSeconds",
1154
+ "characters",
1155
+ ];
1156
+ handleUsageRecorded(pkt) {
1157
+ // Accumulate into the per-stage running total. A missing field stays missing
1158
+ // (never coerced to 0) so an un-reported dimension is distinguishable from a real 0.
1159
+ const prev = this.usageByStage.get(pkt.stage) ?? { stage: pkt.stage };
1160
+ const next = { ...prev };
1161
+ for (const field of VoiceAgentSession.USAGE_FIELDS) {
1162
+ const add = pkt[field];
1163
+ if (typeof add === "number")
1164
+ next[field] = (prev[field] ?? 0) + add;
1165
+ }
1166
+ this.usageByStage.set(pkt.stage, next);
1167
+ // Export each recorded unit as a counter, tagged low-cardinality only — same
1168
+ // discipline as turn_latency. provider/model are exemplar-safe; contextId is not,
1169
+ // and must never become a metric dimension (LiveKit caps at model/provider).
1170
+ const exporter = this.metricsExporter;
1171
+ if (exporter.observeCounter) {
1172
+ const tags = {
1173
+ stage: pkt.stage,
1174
+ provider: pkt.provider ?? "",
1175
+ model: pkt.model ?? "",
1176
+ layer: "infrastructure",
1177
+ };
1178
+ for (const field of VoiceAgentSession.USAGE_FIELDS) {
1179
+ const value = pkt[field];
1180
+ if (typeof value === "number")
1181
+ exporter.observeCounter(`usage.${field}`, value, tags);
1182
+ }
1183
+ }
1184
+ }
1185
+ emitSessionUsage() {
1186
+ if (this.usageByStage.size === 0)
1187
+ return;
1188
+ this.emit("usage", { tsMs: Date.now(), stages: [...this.usageByStage.values()] });
1189
+ }
1190
+ handleTtsAudio(pkt) {
1191
+ if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
1192
+ this.bus.push(Route.Background, make.metric(pkt.contextId, "tts.audio_ignored_after_interrupt", String(pkt.audio.byteLength)));
1193
+ return;
1194
+ }
1195
+ if (!this.firstTtsAudioFired.has(pkt.contextId)) {
1196
+ this.firstTtsAudioFired.add(pkt.contextId);
1197
+ this.watchdogs.clearVaqiIfContext(pkt.contextId);
1198
+ const userStoppedMs = this.turnUserStoppedAtMs.get(pkt.contextId);
1199
+ if (userStoppedMs !== undefined) {
1200
+ this.bus.push(Route.Background, make.metric(pkt.contextId, "vaqi.latency_ms", String(pkt.timestampMs - userStoppedMs)));
1201
+ this.turnUserStoppedAtMs.delete(pkt.contextId);
1202
+ }
1203
+ if (this.generatingContextIds.has(pkt.contextId)) {
1204
+ this.pendingTurnLatency.set(pkt.contextId, pkt.timestampMs);
1205
+ }
1206
+ else {
1207
+ this.emitTurnLatency(pkt.contextId, pkt.timestampMs);
1208
+ }
1209
+ }
1210
+ const audio = this.normalizeOutboundAudio(pkt.audio);
1211
+ // Transport subscribers receive this same packet after the core handler.
1212
+ if (audio !== pkt.audio)
1213
+ Object.assign(pkt, { audio });
1214
+ this.primarySpeakerGate.observeAssistantPlayout(audio);
1215
+ // Audio just arrived — (re)arm the stall watchdog for this turn's TTS output.
1216
+ this.watchdogs.armTtsStallTimer(pkt.contextId);
1217
+ // Mark active and advance this context's playout cursor by the chunk's
1218
+ // realtime duration.
1219
+ const sampleRateHz = requireTtsAudioSampleRate(pkt.sampleRateHz);
1220
+ const audioDurationMs = estimatePcm16Duration(audio, sampleRateHz);
1221
+ const now = Date.now();
1222
+ this.ttsPlayout.noteAudio(pkt.contextId, audioDurationMs, now);
1223
+ this.interaction.observe({
1224
+ kind: "playout_tick",
1225
+ contextId: pkt.contextId,
1226
+ timestampMs: pkt.timestampMs,
1227
+ ttsActive: true,
1228
+ audio: pcm16BytesToSamples(audio),
1229
+ sampleRateHz,
1230
+ });
1231
+ // Anchor the idle timer to when playout actually *ends* (P2), not to chunk
1232
+ // arrival. TTS streams faster than realtime, so extending by each chunk's
1233
+ // duration from arrival lets the timer fire mid-speech on a long answer.
1234
+ // playoutEnd() is cumulative across chunks, so this re-arms to durationMs
1235
+ // after the audio delivered so far finishes playing.
1236
+ const playoutEndMs = this.ttsPlayout.playoutEnd(pkt.contextId);
1237
+ this.idleTimeout.extend(playoutEndMs !== undefined ? Math.max(0, playoutEndMs - now) : audioDurationMs);
1238
+ this.debugPush({
1239
+ component: "tts",
1240
+ type: "audio",
1241
+ data: {
1242
+ context_id: pkt.contextId,
1243
+ bytes: String(audio.length),
1244
+ },
1245
+ timestampMs: pkt.timestampMs,
1246
+ });
1247
+ this.bus.push(Route.Main, make.recordAssistantAudio(pkt.contextId, Date.now(), audio, sampleRateHz));
1248
+ }
1249
+ normalizeOutboundAudio(audio) {
1250
+ if (!this.outboundLoudnessConfig ||
1251
+ !this.outboundLoudnessState ||
1252
+ audio.byteLength % 2 !== 0) {
1253
+ return audio;
1254
+ }
1255
+ const samples = pcm16BytesToSamples(audio);
1256
+ return pcm16SamplesToBytes(normalizeLoudness(samples, this.outboundLoudnessState, this.outboundLoudnessConfig));
1257
+ }
1258
+ handleTtsEnd(pkt) {
1259
+ // Generation finished, but the streamed audio is still playing out. Keep the
1260
+ // context interruptible until its playout estimate elapses, then release it.
1261
+ this.generatingContextIds.delete(pkt.contextId);
1262
+ const firstAudioMs = this.pendingTurnLatency.get(pkt.contextId);
1263
+ if (firstAudioMs !== undefined) {
1264
+ this.pendingTurnLatency.delete(pkt.contextId);
1265
+ this.emitTurnLatency(pkt.contextId, firstAudioMs);
1266
+ }
1267
+ const now = Date.now();
1268
+ this.ttsPlayout.scheduleRelease(pkt.contextId, now);
1269
+ const playoutEndMs = this.ttsPlayout.playoutEnd(pkt.contextId);
1270
+ const remainingMs = playoutEndMs === undefined ? 0 : Math.max(0, playoutEndMs - now);
1271
+ this.scheduleInteractionPlayoutTick(pkt.contextId, remainingMs);
1272
+ this.watchdogs.clearTtsStallTimerFor(pkt.contextId);
1273
+ this.debugPush({
1274
+ component: "tts",
1275
+ type: "end",
1276
+ data: {},
1277
+ timestampMs: Date.now(),
1278
+ });
1279
+ this.scheduleTurnLocalization(pkt.contextId);
1280
+ }
1281
+ scheduleInteractionPlayoutTick(contextId, delayMs) {
1282
+ this.pendingInteractionPlayoutTimers.add(contextId);
1283
+ this.scheduler.schedule(interactionPlayoutTimerKey(contextId), delayMs, () => {
1284
+ this.pendingInteractionPlayoutTimers.delete(contextId);
1285
+ if (this.ttsPlayout.isActive(contextId))
1286
+ return;
1287
+ this.interaction.observe({
1288
+ kind: "playout_tick",
1289
+ contextId,
1290
+ timestampMs: Date.now(),
1291
+ ttsActive: false,
1292
+ });
1293
+ });
1294
+ }
1295
+ cancelInteractionPlayoutTick(contextId) {
1296
+ if (!this.pendingInteractionPlayoutTimers.delete(contextId))
1297
+ return;
1298
+ this.scheduler.cancel(interactionPlayoutTimerKey(contextId));
1299
+ }
1300
+ handleTtsPlayoutProgress(pkt) {
1301
+ this.cancelInteractionPlayoutTick(pkt.contextId);
1302
+ this.ttsPlayout.noteProgress(pkt.contextId, pkt.complete, pkt.playedOutMs);
1303
+ this.interaction.observe({
1304
+ kind: "playout_tick",
1305
+ contextId: pkt.contextId,
1306
+ timestampMs: pkt.timestampMs,
1307
+ playedOutMs: pkt.playedOutMs,
1308
+ ttsActive: !pkt.complete,
1309
+ });
1310
+ }
1311
+ handleInterruptDetected(pkt) {
1312
+ this.cancelInteractionPlayoutTick(pkt.contextId);
1313
+ this.interruptedGenerationContextIds.add(pkt.contextId);
1314
+ this.failPendingToolCues(pkt.contextId); // G3: the aborted delegate's cue fails (R5)
1315
+ this.latencyFiller.cancel(pkt.contextId);
1316
+ // A barged turn still produced first audio, and its TTFA is a real measurement.
1317
+ // Deferring emission to tts.end (so later tool passes are counted) must not make
1318
+ // interrupted turns vanish from the metric: barge-in correlates with slow turns,
1319
+ // so silently dropping them biases the sample toward the fast ones — the worst
1320
+ // turns would disappear from exactly the number meant to detect them.
1321
+ this.flushPendingTurnLatency(pkt.contextId);
1322
+ this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "interruption", {
1323
+ source: pkt.source,
1324
+ });
1325
+ this.turnTimings.delete(pkt.contextId);
1326
+ this.firstLlmDeltaReceived.delete(pkt.contextId);
1327
+ this.ttsTextBuffers.delete(pkt.contextId);
1328
+ this.ttsPlayout.release(pkt.contextId);
1329
+ this.watchdogs.clearTtsStallTimerFor(pkt.contextId);
1330
+ this.debugPush({
1331
+ component: "turn",
1332
+ type: "interrupt_detected",
1333
+ data: {
1334
+ context_id: pkt.contextId,
1335
+ source: pkt.source,
1336
+ },
1337
+ timestampMs: pkt.timestampMs,
1338
+ });
1339
+ this.bus.push(Route.Background, make.metric(pkt.contextId, "interrupt.onset_to_logic_cancel_ms", String(Math.max(0, Date.now() - pkt.timestampMs))));
1340
+ // Stop idle timeout
1341
+ this.bus.push(Route.Critical, make.stopIdleTimeout(pkt.contextId, Date.now(), true));
1342
+ this.bus.push(Route.Critical, make.recordAssistantTruncate(pkt.contextId, Date.now()));
1343
+ // Interrupt TTS, then LLM
1344
+ this.bus.push(Route.Critical, make.interruptTts(pkt.contextId, pkt.timestampMs));
1345
+ this.bus.push(Route.Critical, make.interruptLlm(pkt.contextId, pkt.timestampMs));
1346
+ // Reset STT transcript state too, so a barge-in cannot leak stale finalized
1347
+ // segments into the next turn when a client reuses the same contextId
1348
+ // (the provider STT plugins listen for interrupt.stt; previously unfired).
1349
+ this.bus.push(Route.Critical, make.interruptStt(pkt.contextId, pkt.timestampMs));
1350
+ this.generatingContextIds.delete(pkt.contextId);
1351
+ }
1352
+ /**
1353
+ * Cancel a stale prior-turn generation/playout when a new turn supersedes it
1354
+ * (L1). Mirrors the interrupt teardown but without the barge-in metrics — this
1355
+ * is a turn boundary, not a user interruption. Stops leftover TTS audio, aborts
1356
+ * the LLM, and drops late deltas/audio for the stale context.
1357
+ */
1358
+ cancelStaleGeneration(contextId, timestampMs) {
1359
+ this.cancelInteractionPlayoutTick(contextId);
1360
+ this.interruptedGenerationContextIds.add(contextId);
1361
+ this.failPendingToolCues(contextId); // G3: a superseded turn's pending cue fails
1362
+ this.generatingContextIds.delete(contextId);
1363
+ this.pendingTurnLatency.delete(contextId);
1364
+ this.latencyFiller.cancel(contextId);
1365
+ this.turnTimings.delete(contextId);
1366
+ this.firstLlmDeltaReceived.delete(contextId);
1367
+ this.ttsTextBuffers.delete(contextId);
1368
+ this.ttsPlayout.release(contextId);
1369
+ this.watchdogs.clearTtsStallTimerFor(contextId);
1370
+ this.bus.push(Route.Critical, make.recordAssistantTruncate(contextId, Date.now()));
1371
+ this.bus.push(Route.Critical, make.interruptTts(contextId, timestampMs));
1372
+ this.bus.push(Route.Critical, make.interruptLlm(contextId, timestampMs));
1373
+ this.bus.push(Route.Background, make.metric(contextId, "supersede.cancelled_stale_generation", "1"));
1374
+ }
1375
+ handleComponentError(pkt) {
1376
+ this.markInfrastructureBreach(pkt.contextId);
1377
+ this.metricsExporter.observeCounter?.("error.stage", 1, {
1378
+ stage: pkt.component,
1379
+ category: pkt.category,
1380
+ layer: "infrastructure",
1381
+ });
1382
+ this.emit("error", {
1383
+ tsMs: pkt.timestampMs,
1384
+ stage: `${pkt.component}.error`,
1385
+ category: pkt.category,
1386
+ message: pkt.cause.message,
1387
+ });
1388
+ this.debugPush({
1389
+ component: pkt.component,
1390
+ type: "error",
1391
+ data: {
1392
+ category: pkt.category,
1393
+ message: pkt.cause.message,
1394
+ recoverable: String(pkt.isRecoverable),
1395
+ },
1396
+ timestampMs: pkt.timestampMs,
1397
+ });
1398
+ // G3: an LLM/bridge error while a tool call is pending means no result is coming.
1399
+ if (pkt.component === "llm" || pkt.component === "bridge") {
1400
+ this.failPendingToolCues(pkt.contextId);
1401
+ }
1402
+ this.latencyFiller.clear(pkt.contextId);
1403
+ this.generatingContextIds.delete(pkt.contextId);
1404
+ // The packet's own recoverability verdict is authoritative when present: the
1405
+ // bus marks handler exceptions (pipeline.error) recoverable by design — one
1406
+ // misbehaving handler must degrade the turn, not kill the whole call.
1407
+ const recoverable = typeof pkt.isRecoverable === "boolean" ? pkt.isRecoverable : isRecoverable(pkt.category);
1408
+ if (!recoverable) {
1409
+ // Fatal error — close session
1410
+ void this.close().catch(() => {
1411
+ // Best effort
1412
+ });
1413
+ return;
1414
+ }
1415
+ // Recoverable errors are handled by individual component retry logic. But never
1416
+ // leave the caller in unexplained silence: if the reasoning layer failed the turn,
1417
+ // speak a graceful fallback (G4 — Deepgram guide "never fail silently").
1418
+ this.maybeSpeakErrorFallback(pkt);
1419
+ this.emitTurnLocalization(pkt.contextId);
1420
+ }
1421
+ maybeSpeakErrorFallback(pkt) {
1422
+ if (!this.errorFallbackText)
1423
+ return;
1424
+ // Only the reasoning layer: a TTS/STT failure can't reliably use the same TTS path
1425
+ // for a fallback (that needs canned audio / a clarification prompt — out of scope).
1426
+ if (pkt.component !== "llm")
1427
+ return;
1428
+ const contextId = pkt.contextId;
1429
+ if (contextId.endsWith(FALLBACK_CONTEXT_SUFFIX))
1430
+ return; // never fall back for a fallback
1431
+ if (this.fallbackInjectedContexts.has(contextId))
1432
+ return; // at most once per turn
1433
+ this.fallbackInjectedContexts.add(contextId);
1434
+ this.bus.push(Route.Background, make.metric(contextId, "error.fallback_spoken", pkt.component));
1435
+ this.bus.push(Route.Main, make.injectMessage(`${contextId}${FALLBACK_CONTEXT_SUFFIX}`, Date.now(), this.errorFallbackText));
1436
+ }
1437
+ handleInitFailed(pkt) {
1438
+ this.emit("error", {
1439
+ tsMs: pkt.timestampMs,
1440
+ stage: `init.${pkt.stage}`,
1441
+ category: ErrorCategory.InternalFault,
1442
+ message: `Initialization failed: ${pkt.stage}/${pkt.component} — ${pkt.cause.message}`,
1443
+ });
1444
+ }
1445
+ handleInjectMessage(pkt) {
1446
+ if (pkt.mode === "context") {
1447
+ const bridge = this.plugins.get("bridge");
1448
+ if (bridge?.injectContext) {
1449
+ bridge.injectContext(pkt.text);
1450
+ }
1451
+ else {
1452
+ console.warn("VoiceAgentSession: context injection requested but the bridge does not support injectContext");
1453
+ }
1454
+ return;
1455
+ }
1456
+ // Inject as synthetic LLM output — goes through normal TTS path
1457
+ this.bus.push(Route.Main, make.llmDelta(pkt.contextId, Date.now(), pkt.text));
1458
+ this.bus.push(Route.Main, make.llmDone(pkt.contextId, Date.now(), pkt.text));
1459
+ }
1460
+ handleSttReconfigure(pkt) {
1461
+ const stt = this.plugins.get("stt");
1462
+ if (stt?.sttReconfigure) {
1463
+ stt.sttReconfigure.reconfigure(pkt.partial);
1464
+ return;
1465
+ }
1466
+ console.warn("VoiceAgentSession: stt.reconfigure requested but the STT plugin does not support sttReconfigure");
1467
+ }
1468
+ handleDisconnect(pkt) {
1469
+ this.emit("closed", { tsMs: pkt.timestampMs, reason: pkt.reason });
1470
+ this.debugPush({
1471
+ component: "session",
1472
+ type: "disconnect_requested",
1473
+ data: { reason: pkt.reason },
1474
+ timestampMs: pkt.timestampMs,
1475
+ });
1476
+ void this.close().catch(() => {
1477
+ // Best effort
1478
+ });
1479
+ }
1480
+ // =========================================================================
1481
+ // Init Chain
1482
+ // =========================================================================
1483
+ buildInitChain() {
1484
+ const steps = [];
1485
+ this.applyEndpointingOwnerInvariant();
1486
+ for (const [name, plugin] of this.plugins) {
1487
+ if (!this.shouldInitializePlugin(plugin))
1488
+ continue;
1489
+ steps.push({
1490
+ name,
1491
+ stage: pluginStage(name),
1492
+ run: () => {
1493
+ return plugin.initialize(this.bus, this.config.plugins[name] ?? {});
1494
+ },
1495
+ cleanup: () => plugin.close(),
1496
+ });
1497
+ }
1498
+ if (this.activeInteractionPolicy === this.injectedInteractionPolicy &&
1499
+ isLifecycleInteractionPolicy(this.activeInteractionPolicy)) {
1500
+ const policy = this.activeInteractionPolicy;
1501
+ steps.push({
1502
+ name: "interaction_policy",
1503
+ stage: InitStage.EOS,
1504
+ run: () => policy.initialize(this.config.interactionPolicyConfig ?? {}),
1505
+ cleanup: () => policy.close(),
1506
+ });
1507
+ }
1508
+ const orderedPluginSteps = steps.sort((a, b) => stageOrder(a.stage) - stageOrder(b.stage));
1509
+ this.modeSwitcher.register({
1510
+ textToAudioSteps: orderedPluginSteps,
1511
+ audioToTextCleanups: [...orderedPluginSteps]
1512
+ .reverse()
1513
+ .filter((step) => isAudioStage(step.stage))
1514
+ .map((step) => async () => {
1515
+ await step.cleanup?.();
1516
+ }),
1517
+ });
1518
+ // Behavior is always last (idle timeout, max session)
1519
+ orderedPluginSteps.push({
1520
+ name: "idle_timeout",
1521
+ stage: InitStage.Behavior,
1522
+ run: async () => {
1523
+ this.idleTimeout.start();
1524
+ },
1525
+ cleanup: async () => {
1526
+ this.idleTimeout.dispose();
1527
+ },
1528
+ });
1529
+ this.initSteps = orderedPluginSteps;
1530
+ }
1531
+ applyEndpointingOwnerInvariant() {
1532
+ if (this.activeInteractionPolicy === this.injectedInteractionPolicy) {
1533
+ for (const [name, plugin] of this.plugins) {
1534
+ const capability = plugin.endpointingCapability;
1535
+ if (!capability || capability.owner !== "provider_stt")
1536
+ continue;
1537
+ if (!capability.disableConfig) {
1538
+ throw new Error(`interactionPolicy requires ${name} to expose endpointingCapability.disableConfig`);
1539
+ }
1540
+ this.config.plugins[name] = {
1541
+ ...(this.config.plugins[name] ?? {}),
1542
+ ...capability.disableConfig,
1543
+ };
1544
+ }
1545
+ return;
1546
+ }
1547
+ if (this.endpointingOwner === "timer")
1548
+ return;
1549
+ const owner = this.endpointingOwner;
1550
+ const finalizers = [...this.plugins.entries()]
1551
+ .filter(([, plugin]) => plugin.endpointingCapability !== undefined);
1552
+ const enabledFinalizers = finalizers
1553
+ .filter(([, plugin]) => plugin.endpointingCapability?.owner === owner);
1554
+ if (finalizers.length > 0 && enabledFinalizers.length !== 1) {
1555
+ throw new Error(`endpointingOwner=${owner} requires exactly one registered ${owner} EOS finalizer; found ${String(enabledFinalizers.length)}`);
1556
+ }
1557
+ for (const [name, plugin] of finalizers) {
1558
+ if (plugin.endpointingCapability?.owner === owner)
1559
+ continue;
1560
+ const disabled = plugin.endpointingCapability?.disableConfig;
1561
+ if (!disabled)
1562
+ continue;
1563
+ this.config.plugins[name] = {
1564
+ ...(this.config.plugins[name] ?? {}),
1565
+ ...disabled,
1566
+ };
1567
+ }
1568
+ }
1569
+ shouldInitializePlugin(plugin) {
1570
+ const capability = plugin.endpointingCapability;
1571
+ if (!capability)
1572
+ return true;
1573
+ if (this.activeInteractionPolicy === this.injectedInteractionPolicy) {
1574
+ return capability.owner === "provider_stt";
1575
+ }
1576
+ if (this.endpointingOwner === "timer")
1577
+ return false;
1578
+ if (capability.owner === "smart_turn")
1579
+ return capability.owner === this.endpointingOwner;
1580
+ return true;
1581
+ }
1582
+ // =========================================================================
1583
+ // Helpers
1584
+ // =========================================================================
1585
+ emitDebug(component, type, data) {
1586
+ this.debugPush({
1587
+ component,
1588
+ type,
1589
+ data,
1590
+ timestampMs: Date.now(),
1591
+ });
1592
+ }
1593
+ latestActiveTtsContextId() {
1594
+ return this.ttsPlayout.latestActive();
1595
+ }
1596
+ }
1597
+ //# sourceMappingURL=voice-agent-session.js.map