@kuralle-syrinx/core 4.2.0 → 4.4.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 (51) hide show
  1. package/package.json +2 -2
  2. package/src/audio/alaw.ts +56 -0
  3. package/src/audio/g722.ts +329 -0
  4. package/src/audio/index.ts +12 -0
  5. package/src/audio/loudness.ts +87 -0
  6. package/src/idle-timeout.ts +1 -0
  7. package/src/index.ts +65 -3
  8. package/src/interaction-coordinator.ts +17 -2
  9. package/src/interaction-policy.ts +12 -0
  10. package/src/observability-observer.ts +8 -4
  11. package/src/observability.ts +30 -1
  12. package/src/packet-factories.ts +124 -3
  13. package/src/packets.ts +139 -1
  14. package/src/plugin-contract.ts +41 -0
  15. package/src/policies/rule-based.ts +17 -2
  16. package/src/pricing.ts +137 -0
  17. package/src/primary-speaker-gate.ts +23 -3
  18. package/src/reasoner.ts +28 -1
  19. package/src/spend-cap.ts +52 -0
  20. package/src/turn-arbiter.ts +34 -2
  21. package/src/voice-agent-session-util.ts +18 -0
  22. package/src/voice-agent-session.ts +469 -36
  23. package/src/voice-text.ts +69 -0
  24. package/src/audio/audio.test.ts +0 -285
  25. package/src/audio-envelope.test.ts +0 -167
  26. package/src/confidence-to-wait.test.ts +0 -21
  27. package/src/error-handler.test.ts +0 -56
  28. package/src/hedge-throwing-backend.test.ts +0 -46
  29. package/src/interaction-coordinator.test.ts +0 -425
  30. package/src/iu-ledger.test.ts +0 -276
  31. package/src/latency-filler.test.ts +0 -62
  32. package/src/observability-observer.test.ts +0 -245
  33. package/src/observability.test.ts +0 -85
  34. package/src/packet-factories.test.ts +0 -53
  35. package/src/pipeline-bus.g10.test.ts +0 -145
  36. package/src/pipeline-bus.test.ts +0 -211
  37. package/src/policies/defer.test.ts +0 -86
  38. package/src/policies/rule-based.test.ts +0 -269
  39. package/src/primary-speaker-gate.test.ts +0 -150
  40. package/src/provider-fallback.test.ts +0 -87
  41. package/src/reasoner-hedge.test.ts +0 -361
  42. package/src/reasoner-route.test.ts +0 -248
  43. package/src/reasoner.test.ts +0 -69
  44. package/src/retry.test.ts +0 -83
  45. package/src/route-throwing-spec.test.ts +0 -44
  46. package/src/tts-playout-clock.test.ts +0 -125
  47. package/src/turn-arbiter.characterization.test.ts +0 -477
  48. package/src/turn-arbiter.test.ts +0 -567
  49. package/src/voice-agent-session.test.ts +0 -3316
  50. package/src/voice-text.test.ts +0 -94
  51. package/tsconfig.json +0 -21
@@ -31,6 +31,10 @@ import type {
31
31
  InterruptionDetectedPacket,
32
32
  DelegateQueryPacket,
33
33
  DelegateResultPacket,
34
+ ConversationMetricPacket,
35
+ AcousticSignalPacket,
36
+ UsageStage,
37
+ UsageRecordedPacket,
34
38
  LlmDeltaPacket,
35
39
  LlmResponseDonePacket,
36
40
  LlmToolCallPacket,
@@ -50,6 +54,7 @@ import type {
50
54
  EndOfSpeechPacket,
51
55
  InterimEndOfSpeechPacket,
52
56
  InjectMessagePacket,
57
+ SttReconfigurePacket,
53
58
  DisconnectRequestedPacket,
54
59
  InitializationFailedPacket,
55
60
  ModeSwitchRequestedPacket,
@@ -63,12 +68,18 @@ import {
63
68
  } from "./packets.js";
64
69
  import { LatencyFillerController } from "./latency-filler.js";
65
70
  import { PrimarySpeakerGate } from "./primary-speaker-gate.js";
66
- import { takeCompleteVoiceText, isCompleteVoiceText, appendVoiceText } from "./voice-text.js";
71
+ import { takeCompleteVoiceText, isCompleteVoiceText, appendVoiceText, normalizeForSpeech } from "./voice-text.js";
67
72
  import { TtsPlayoutClock } from "./tts-playout-clock.js";
68
73
  import { TurnArbiter, isBackchannel } from "./turn-arbiter.js";
69
74
  import { InteractionCoordinator } from "./interaction-coordinator.js";
70
75
  import { isLifecycleInteractionPolicy, type InteractionPolicy, type WordTiming } from "./interaction-policy.js";
71
- import { pcm16BytesToSamples } from "./audio/pcm.js";
76
+ import { pcm16BytesToSamples, pcm16SamplesToBytes } from "./audio/pcm.js";
77
+ import {
78
+ createLoudnessState,
79
+ normalizeLoudness,
80
+ type LoudnessConfig,
81
+ type LoudnessState,
82
+ } from "./audio/loudness.js";
72
83
  import { DeferInteractionPolicy } from "./policies/defer.js";
73
84
  import { RuleBasedInteractionPolicy } from "./policies/rule-based.js";
74
85
  import * as make from "./packet-factories.js";
@@ -80,6 +91,7 @@ import {
80
91
  VoiceSessionWatchdogs,
81
92
  } from "./voice-agent-session-util.js";
82
93
  import { noopMetricsExporter, type MetricsExporter } from "./observability.js";
94
+ import { localizeTurn, type ObservabilityLayer } from "./observability.js";
83
95
  import { ObservabilityObserver } from "./observability-observer.js";
84
96
  import { TimerScheduler, type Scheduler } from "./scheduler.js";
85
97
 
@@ -141,6 +153,8 @@ export interface VoiceAgentSessionConfig {
141
153
  * instead of hanging. 0 disables. Default: 15000.
142
154
  */
143
155
  ttsStallMs?: number;
156
+ /** Optional per-session Int16 RMS normalization for outbound assistant audio. Disabled by default. */
157
+ outboundLoudness?: LoudnessConfig;
144
158
  /**
145
159
  * Max ms of silence on inbound user audio while the session is Ready before a
146
160
  * recoverable transport warning is emitted. Continuous streams (telephony, open
@@ -191,6 +205,7 @@ export interface VoiceAgentSessionConfig {
191
205
  readonly provider?: string;
192
206
  readonly model?: string;
193
207
  readonly region?: string;
208
+ readonly layer?: ObservabilityLayer;
194
209
  };
195
210
  }
196
211
 
@@ -203,7 +218,18 @@ export interface VoiceAgentSessionEvents {
203
218
  agent_tool_call: (event: { tsMs: number; turnId: string; id: string; name: string; args: Record<string, unknown> }) => void;
204
219
  agent_tool_result: (event: { tsMs: number; turnId: string; id: string; result: string; durationMs: number }) => void;
205
220
  delegate_query: (event: { tsMs: number; turnId: string; query: string; toolId?: string; toolName?: string }) => void;
206
- delegate_result: (event: { tsMs: number; turnId: string; query: string; answer: string; durationMs: number; grounded: boolean; toolId?: string; toolName?: string }) => void;
221
+ delegate_result: (event: {
222
+ tsMs: number;
223
+ turnId: string;
224
+ query: string;
225
+ answer: string;
226
+ durationMs: number;
227
+ grounded: boolean;
228
+ toolId?: string;
229
+ toolName?: string;
230
+ control?: { name: string; payload: unknown };
231
+ blocked?: { userFacingMessage: string; payload?: unknown };
232
+ }) => void;
207
233
  /**
208
234
  * G3: typed preamble/filler lifecycle for a pending tool call (Vapi-shaped:
209
235
  * started / delayed / complete / failed). `delayed` is time-triggered by
@@ -213,30 +239,60 @@ export interface VoiceAgentSessionEvents {
213
239
  */
214
240
  tool_call_cue: (event: { tsMs: number; turnId: string; phase: "started" | "delayed" | "complete" | "failed"; toolId: string; toolName: string; afterMs?: number }) => void;
215
241
  /**
216
- * Per-turn latency decomposition, emitted once at the turn's first TTS audio.
242
+ * Per-turn latency decomposition, timestamped at the turn's first TTS audio.
243
+ * When generation is still active at first audio, emission waits for `tts.end` so
244
+ * provider passes that follow a spoken tool preamble are included in the totals.
217
245
  * `ttfaMs` is anchored to the real end of user speech (VAD speech-end, falling
218
246
  * back to the endpoint decision) — `fillerUsed` flags turns where a latency filler spoke
219
247
  * first, and `backchannelUsed` flags turns where a wait-gap cue played before the answer.
220
248
  * Decomposition: eouDelayMs (speech end → endpoint) + llmTtftMs (endpoint →
221
- * first LLM delta) + ttsTtfbMs (first TTS text dispatched → first audio).
249
+ * first LLM delta) + textAggregationMs (first LLM delta → first TTS text) +
250
+ * ttsTtfbMs (first TTS text dispatched → first audio). A stage is omitted when the
251
+ * front does not produce that Syrinx packet for this turn: realtime fronts may emit
252
+ * provider audio directly, and provider-owned endpointing may complete after audio.
253
+ * `unattributedMs` is the explicit residual after subtracting only the stages present.
222
254
  */
223
255
  turn_latency: (event: {
224
256
  tsMs: number;
225
257
  turnId: string;
226
258
  ttfaMs: number;
259
+ anchor: "speech_end" | "eos";
227
260
  eouDelayMs?: number;
228
261
  llmTtftMs?: number;
262
+ textAggregationMs?: number;
229
263
  ttsTtfbMs?: number;
264
+ unattributedMs: number;
265
+ llmCallCount?: number;
266
+ llmPassTtftMs?: readonly number[];
230
267
  fillerUsed: boolean;
231
268
  backchannelUsed: boolean;
232
269
  }) => void;
233
270
  agent_first_audio: (event: { tsMs: number; turnId: string }) => void;
234
271
  agent_finished: (event: { tsMs: number; turnId: string } & Record<string, unknown>) => void;
272
+ /**
273
+ * End-of-session usage manifest — the total billable resource this session consumed,
274
+ * summed per stage. Emitted once at close. This is the metering seam a host reads to
275
+ * bill, cap spend, or attribute cost to a tenant; today only the LLM stage is populated
276
+ * (STT/TTS producers are not yet wired), so `stages` may hold a single entry.
277
+ */
278
+ usage: (event: { tsMs: number; stages: readonly SessionStageUsage[] }) => void;
235
279
  error: (event: { tsMs: number; stage: string; category: string; message: string }) => void;
236
280
  closed: (event: { tsMs: number; reason: string }) => void;
237
281
  state_changed: (event: { tsMs: number; from: SessionState; to: SessionState }) => void;
238
282
  }
239
283
 
284
+ /** Per-stage usage totals for a session. Absent fields were never reported by that stage. */
285
+ export interface SessionStageUsage {
286
+ readonly stage: UsageStage;
287
+ readonly inputTokens?: number;
288
+ readonly outputTokens?: number;
289
+ readonly totalTokens?: number;
290
+ readonly cachedInputTokens?: number;
291
+ readonly reasoningTokens?: number;
292
+ readonly audioSeconds?: number;
293
+ readonly characters?: number;
294
+ }
295
+
240
296
  type EventHandler<T> = (event: T) => void;
241
297
 
242
298
  interface TtsTextBuffer {
@@ -249,6 +305,8 @@ interface TurnTiming {
249
305
  eosMs?: number;
250
306
  firstLlmDeltaMs?: number;
251
307
  firstTtsTextMs?: number;
308
+ llmCallCount?: number;
309
+ llmPassTtftMs?: number[];
252
310
  fillerUsed?: boolean;
253
311
  backchannelUsed?: boolean;
254
312
  }
@@ -265,6 +323,25 @@ function interactionPlayoutTimerKey(contextId: string): string {
265
323
  return `interaction.playout:${contextId}`;
266
324
  }
267
325
 
326
+ function turnLocalizationTimerKey(contextId: string): string {
327
+ return `turn.localization:${contextId}`;
328
+ }
329
+
330
+ function isInfrastructureMetric(name: string): boolean {
331
+ return /(?:^|\.)(?:vad|stt|tts|tool|input|pipeline)\.(?:error|malfunction|timeout|stall|cadence)|(?:^|\.)(?:stage|error)\./i.test(name);
332
+ }
333
+
334
+ function isConversationEvaluationMetric(name: string, value: string): boolean {
335
+ if (!/(?:^|\.)(?:outcome|eval|evaluation|satisfaction|task_success|observer)(?:\.|$)/i.test(name)) {
336
+ return false;
337
+ }
338
+ return !/^(?:0|false|none|success|successful|passed|pass|good|healthy|ok)$/i.test(value.trim());
339
+ }
340
+
341
+ function isConversationControlName(name: string): boolean {
342
+ return /^(?:conversation-outcome|outcome|escalation|handoff|flow-transition|node-enter|node-exit|flow-enter|flow-end)$/.test(name);
343
+ }
344
+
268
345
  // =============================================================================
269
346
  // Session Implementation
270
347
  // =============================================================================
@@ -304,11 +381,23 @@ export class VoiceAgentSession {
304
381
  private firstLlmDeltaReceived = new Set<string>();
305
382
  private readonly vaqiMissedResponseMs: number;
306
383
  private readonly ttsStallMs: number;
384
+ private readonly outboundLoudnessConfig: LoudnessConfig | null;
385
+ private readonly outboundLoudnessState: LoudnessState | null;
307
386
  private readonly inputCadenceTimeoutMs: number;
308
387
  private readonly watchdogs!: VoiceSessionWatchdogs;
309
388
  private readonly observabilityObserver: ObservabilityObserver;
389
+ private readonly metricsExporter: MetricsExporter;
390
+ private readonly observabilityDims: {
391
+ provider: string;
392
+ model: string;
393
+ region: string;
394
+ layer?: ObservabilityLayer;
395
+ };
310
396
  private turnUserStoppedAtMs = new Map<string, number>();
311
397
  private turnTimings = new Map<string, TurnTiming>();
398
+ private pendingTurnLatency = new Map<string, number>();
399
+ /** Running usage totals per stage, summed across the session; emitted at close. */
400
+ private readonly usageByStage = new Map<UsageStage, SessionStageUsage>();
312
401
  private speakerEnrollmentContextId: string | null = null;
313
402
  private firstTtsAudioFired = new Set<string>();
314
403
  private readonly pendingInteractionPlayoutTimers = new Set<string>();
@@ -323,6 +412,11 @@ export class VoiceAgentSession {
323
412
  private userSpeaking = false;
324
413
  private lastFinalizedContextId = "";
325
414
  private readonly sttPartialWordTimings = new Map<string, readonly WordTiming[]>();
415
+ private readonly turnLocalizationStates = new Map<
416
+ string,
417
+ { infrastructureBreached: boolean; conversationFlagged: boolean }
418
+ >();
419
+ private readonly emittedTurnLocalizations = new Set<string>();
326
420
 
327
421
  constructor(config: VoiceAgentSessionConfig) {
328
422
  const owner = config.endpointingOwner;
@@ -333,6 +427,8 @@ export class VoiceAgentSession {
333
427
  this.fullDuplex = config.fullDuplex === true;
334
428
  this.emitsBackchannel = config.emitsBackchannel === true;
335
429
  this.config = config;
430
+ this.outboundLoudnessConfig = config.outboundLoudness ?? null;
431
+ this.outboundLoudnessState = this.outboundLoudnessConfig ? createLoudnessState() : null;
336
432
  this.scheduler = config.scheduler ?? new TimerScheduler();
337
433
  this.ttsPlayout = new TtsPlayoutClock(this.scheduler);
338
434
  this.sttForceFinalizeTimeoutMs = config.sttForceFinalizeTimeoutMs ?? 7000;
@@ -340,6 +436,14 @@ export class VoiceAgentSession {
340
436
  this.delayCueAfterMs = config.delayCueAfterMs ?? 2000;
341
437
  this.primarySpeakerGate = new PrimarySpeakerGate({
342
438
  enabled: config.primarySpeakerBargeInEnabled !== false,
439
+ onDecision: (decision) => {
440
+ this.emitAcousticSignal(decision.contextId, Date.now(), decision.signal, {
441
+ accepted: decision.accepted,
442
+ frameCount: decision.frameCount,
443
+ primaryHits: decision.primaryHits,
444
+ echoRejectedFrames: decision.echoRejectedFrames,
445
+ });
446
+ },
343
447
  });
344
448
  this.latencyFiller = new LatencyFillerController({
345
449
  enabled: config.latencyFillerEnabled === true,
@@ -393,6 +497,9 @@ export class VoiceAgentSession {
393
497
  ? new DeferInteractionPolicy()
394
498
  : (this.injectedInteractionPolicy ?? this.ruleBasedPolicy);
395
499
  this.activeInteractionPolicy = coordinatorPolicy;
500
+ coordinatorPolicy.setAcousticSignalSink?.((signal) => {
501
+ this.emitAcousticSignal(signal.contextId, signal.timestampMs, signal.signal, signal.payload);
502
+ });
396
503
  this.interaction = new InteractionCoordinator({
397
504
  bus: this.bus,
398
505
  policy: coordinatorPolicy,
@@ -404,6 +511,8 @@ export class VoiceAgentSession {
404
511
  onBackchannelEmitted: (contextId) => {
405
512
  this.timingFor(contextId).backchannelUsed = true;
406
513
  },
514
+ endpointingOwner: this.endpointingOwner,
515
+ wasForceFinalized: (contextId) => this.watchdogs.wasForceFinalized(contextId),
407
516
  });
408
517
  this.watchdogs = new VoiceSessionWatchdogs({
409
518
  bus: this.bus,
@@ -422,15 +531,18 @@ export class VoiceAgentSession {
422
531
  });
423
532
 
424
533
  const obs = config.observability;
534
+ this.metricsExporter = config.metricsExporter ?? noopMetricsExporter;
535
+ this.observabilityDims = {
536
+ provider: obs?.provider ?? "unknown",
537
+ model: obs?.model ?? "unknown",
538
+ region: obs?.region ?? "unknown",
539
+ layer: obs?.layer,
540
+ };
425
541
  this.observabilityObserver = new ObservabilityObserver({
426
542
  bus: this.bus,
427
- exporter: config.metricsExporter ?? noopMetricsExporter,
543
+ exporter: this.metricsExporter,
428
544
  sessionId: obs?.sessionId ?? "",
429
- dims: {
430
- provider: obs?.provider ?? "unknown",
431
- model: obs?.model ?? "unknown",
432
- region: obs?.region ?? "unknown",
433
- },
545
+ dims: this.observabilityDims,
434
546
  getContextId: () => this.currentContextId,
435
547
  });
436
548
 
@@ -490,6 +602,28 @@ export class VoiceAgentSession {
490
602
  this._state = SessionState.Ready;
491
603
  }
492
604
 
605
+ /**
606
+ * Best-effort warm of registered plugins' remote/expensive resources. Call after start() to
607
+ * wake scaled-to-zero endpoints before the first user turn. The host decides when to invoke
608
+ * this — it is not called automatically from start(). Never throws; failed plugin prewarms
609
+ * are swallowed and may emit a prewarm.failed metric on the bus.
610
+ */
611
+ async prewarm(): Promise<void> {
612
+ const entries = [...this.plugins.entries()];
613
+ const results = await Promise.allSettled(
614
+ entries.map(async ([, plugin]) => {
615
+ await plugin.prewarm?.();
616
+ }),
617
+ );
618
+ for (let i = 0; i < results.length; i++) {
619
+ const result = results[i];
620
+ if (result?.status === "rejected") {
621
+ const [name] = entries[i]!;
622
+ this.bus.push(Route.Background, make.metric("", "prewarm.failed", name));
623
+ }
624
+ }
625
+ }
626
+
493
627
  /** Shut down the session. Runs finalize chain in reverse order. */
494
628
  async close(): Promise<void> {
495
629
  if (this._state === SessionState.Closed) return;
@@ -514,6 +648,13 @@ export class VoiceAgentSession {
514
648
  this.turnArbiter.clear();
515
649
  this.turnUserStoppedAtMs.clear();
516
650
  this.turnTimings.clear();
651
+ for (const contextId of this.turnLocalizationStates.keys()) {
652
+ this.scheduler.cancel(turnLocalizationTimerKey(contextId));
653
+ this.emitTurnLocalization(contextId);
654
+ }
655
+ this.turnLocalizationStates.clear();
656
+ this.emittedTurnLocalizations.clear();
657
+ this.pendingTurnLatency.clear();
517
658
  this.firstTtsAudioFired.clear();
518
659
  this.fallbackInjectedContexts.clear();
519
660
  this.ttsTextBuffers.clear();
@@ -528,6 +669,9 @@ export class VoiceAgentSession {
528
669
  // 2. Run finalize chain (reverse order)
529
670
  await runFinalizeChain(this.initSteps);
530
671
 
672
+ // 2b. Emit the end-of-session usage manifest before the bus stops — the metering seam.
673
+ this.emitSessionUsage();
674
+
531
675
  // 3. Stop bus
532
676
  this.bus.stop();
533
677
  await this.busStartPromise;
@@ -618,6 +762,7 @@ export class VoiceAgentSession {
618
762
  this.lastFinalizedContextId = "";
619
763
  if (pkt.previousContextId) {
620
764
  this.sttPartialWordTimings.delete(pkt.previousContextId);
765
+ this.carryTurnTimingAcrossContextChange(pkt.previousContextId, pkt.contextId);
621
766
  }
622
767
  });
623
768
  this.bus.on("eos.turn_complete", this.handleTurnComplete.bind(this));
@@ -628,6 +773,8 @@ export class VoiceAgentSession {
628
773
  this.bus.on("llm.done", this.handleLlmDone.bind(this));
629
774
  this.bus.on("llm.tool_call", this.handleLlmToolCall.bind(this));
630
775
  this.bus.on("llm.tool_result", this.handleLlmToolResult.bind(this));
776
+ this.bus.on("metric.conversation", this.handleConversationMetric.bind(this));
777
+ this.bus.on("usage.recorded", (pkt) => this.handleUsageRecorded(pkt as UsageRecordedPacket));
631
778
 
632
779
  // Delegate (Responder-Thinker) observability — G2, RFC bimodel-delegate-seam
633
780
  this.bus.on("delegate.query", this.handleDelegateQuery.bind(this));
@@ -662,6 +809,9 @@ export class VoiceAgentSession {
662
809
  // Injected messages — push through LLM path for natural TTS
663
810
  this.bus.on("inject.message", this.handleInjectMessage.bind(this));
664
811
 
812
+ // Per-turn STT reconfigure (keyterms / endpointing / EOT thresholds)
813
+ this.bus.on("stt.reconfigure", this.handleSttReconfigure.bind(this));
814
+
665
815
  // Disconnect
666
816
  this.bus.on("session.disconnect", this.handleDisconnect.bind(this));
667
817
 
@@ -726,8 +876,16 @@ export class VoiceAgentSession {
726
876
  }
727
877
 
728
878
  private handleUserText(pkt: UserTextReceivedPacket): void {
729
- // Treat text input as an immediate EOS turn complete
730
- this.bus.push(Route.Main, make.eosTurnComplete(pkt.contextId, pkt.timestampMs, pkt.text, []));
879
+ // Typed input is an immediate turn end — but nothing endpointed it and no one
880
+ // decided the caller stopped talking, because they typed. Label it as `text` so
881
+ // the timeline never claims a speech endpointer fired on a typed turn.
882
+ this.bus.push(
883
+ Route.Main,
884
+ make.eosTurnComplete(pkt.contextId, pkt.timestampMs, pkt.text, [], {
885
+ owner: "text",
886
+ reason: "typed",
887
+ }),
888
+ );
731
889
  }
732
890
 
733
891
  private handleSttPartial(pkt: SttPartialPacket): void {
@@ -901,6 +1059,23 @@ export class VoiceAgentSession {
901
1059
  this.watchdogs.startVaqiMissedResponseTimer(pkt.contextId, pkt.timestampMs);
902
1060
  }
903
1061
 
1062
+ /**
1063
+ * A realtime front rotates its contextId when the provider starts responding
1064
+ * (`RealtimeBridge.onResponseStarted`), so the user-side anchors (`speechEndedMs`, `eosMs`)
1065
+ * are recorded under the PREVIOUS context while `tts.audio` — and therefore the
1066
+ * `turn_latency` emit — lands under the new one. Without carrying the record across the
1067
+ * rotation, `emitTurnLatency` finds no anchor and silently drops every native turn.
1068
+ *
1069
+ * Only fills gaps: anything the new context already recorded wins.
1070
+ */
1071
+ private carryTurnTimingAcrossContextChange(previousContextId: string, contextId: string): void {
1072
+ const previous = this.turnTimings.get(previousContextId);
1073
+ if (!previous) return;
1074
+ this.turnTimings.delete(previousContextId);
1075
+ const current = this.turnTimings.get(contextId);
1076
+ this.turnTimings.set(contextId, { ...previous, ...current });
1077
+ }
1078
+
904
1079
  private timingFor(contextId: string): TurnTiming {
905
1080
  let timing = this.turnTimings.get(contextId);
906
1081
  if (!timing) {
@@ -910,28 +1085,82 @@ export class VoiceAgentSession {
910
1085
  return timing;
911
1086
  }
912
1087
 
1088
+ /** Emit a deferred turn_latency if one is pending, then clear it. Safe to call twice. */
1089
+ private flushPendingTurnLatency(contextId: string): void {
1090
+ const firstAudioMs = this.pendingTurnLatency.get(contextId);
1091
+ if (firstAudioMs === undefined) return;
1092
+ this.pendingTurnLatency.delete(contextId);
1093
+ this.emitTurnLatency(contextId, firstAudioMs);
1094
+ }
1095
+
913
1096
  private emitTurnLatency(contextId: string, firstAudioMs: number): void {
914
- const timing = this.turnTimings.get(contextId);
1097
+ const timing = this.timingFor(contextId);
915
1098
  this.turnTimings.delete(contextId);
916
- if (!timing) return;
917
1099
  const anchorMs = timing.speechEndedMs ?? timing.eosMs;
918
1100
  if (anchorMs === undefined) return; // text-injected or fallback turn — not a voice TTFA
1101
+ const anchor = timing.speechEndedMs !== undefined ? "speech_end" : "eos";
1102
+ const eouDelayMs =
1103
+ timing.speechEndedMs !== undefined &&
1104
+ timing.eosMs !== undefined &&
1105
+ timing.eosMs <= firstAudioMs
1106
+ ? timing.eosMs - timing.speechEndedMs
1107
+ : undefined;
1108
+ const llmAnchorMs = timing.eosMs ?? timing.speechEndedMs;
1109
+ const llmTtftMs =
1110
+ llmAnchorMs !== undefined && timing.firstLlmDeltaMs !== undefined
1111
+ ? timing.firstLlmDeltaMs - llmAnchorMs
1112
+ : undefined;
1113
+ const textAggregationMs =
1114
+ timing.firstLlmDeltaMs !== undefined && timing.firstTtsTextMs !== undefined
1115
+ ? timing.firstTtsTextMs - timing.firstLlmDeltaMs
1116
+ : undefined;
1117
+ const ttsTtfbMs =
1118
+ timing.firstTtsTextMs !== undefined
1119
+ ? firstAudioMs - timing.firstTtsTextMs
1120
+ : undefined;
1121
+ const attributedMs = [eouDelayMs, llmTtftMs, textAggregationMs, ttsTtfbMs]
1122
+ .filter((value): value is number => value !== undefined)
1123
+ .reduce((sum, value) => sum + value, 0);
1124
+ const ttfaMs = firstAudioMs - anchorMs;
1125
+ const unattributedMs = ttfaMs - attributedMs;
1126
+ if (unattributedMs >= 500) this.markInfrastructureBreach(contextId);
1127
+ const fillerUsed = timing.fillerUsed === true;
1128
+ const backchannelUsed = timing.backchannelUsed === true;
919
1129
  this.emit("turn_latency", {
920
1130
  tsMs: firstAudioMs,
921
1131
  turnId: contextId,
922
- ttfaMs: firstAudioMs - anchorMs,
923
- ...(timing.speechEndedMs !== undefined && timing.eosMs !== undefined
924
- ? { eouDelayMs: timing.eosMs - timing.speechEndedMs }
925
- : {}),
926
- ...(timing.eosMs !== undefined && timing.firstLlmDeltaMs !== undefined
927
- ? { llmTtftMs: timing.firstLlmDeltaMs - timing.eosMs }
928
- : {}),
929
- ...(timing.firstTtsTextMs !== undefined
930
- ? { ttsTtfbMs: firstAudioMs - timing.firstTtsTextMs }
931
- : {}),
932
- fillerUsed: timing.fillerUsed === true,
933
- backchannelUsed: timing.backchannelUsed === true,
1132
+ ttfaMs,
1133
+ anchor,
1134
+ ...(eouDelayMs !== undefined ? { eouDelayMs } : {}),
1135
+ ...(llmTtftMs !== undefined ? { llmTtftMs } : {}),
1136
+ ...(textAggregationMs !== undefined ? { textAggregationMs } : {}),
1137
+ ...(ttsTtfbMs !== undefined ? { ttsTtfbMs } : {}),
1138
+ unattributedMs,
1139
+ ...(timing.llmCallCount !== undefined ? { llmCallCount: timing.llmCallCount } : {}),
1140
+ ...(timing.llmPassTtftMs !== undefined ? { llmPassTtftMs: [...timing.llmPassTtftMs] } : {}),
1141
+ fillerUsed,
1142
+ backchannelUsed,
934
1143
  });
1144
+
1145
+ const tags = {
1146
+ ...this.observabilityDims,
1147
+ layer: "infrastructure" as const,
1148
+ cancelled: this.interruptedGenerationContextIds.has(contextId) ? "true" : "false",
1149
+ anchor,
1150
+ filler_used: String(fillerUsed),
1151
+ backchannel_used: String(backchannelUsed),
1152
+ };
1153
+ const histograms: Array<readonly [string, number | undefined]> = [
1154
+ ["turn.ttfa_ms", ttfaMs],
1155
+ ["turn.eou_delay_ms", eouDelayMs],
1156
+ ["turn.llm_ttft_ms", llmTtftMs],
1157
+ ["turn.text_aggregation_ms", textAggregationMs],
1158
+ ["turn.tts_ttfb_ms", ttsTtfbMs],
1159
+ ["turn.unattributed_ms", unattributedMs],
1160
+ ];
1161
+ for (const [name, valueMs] of histograms) {
1162
+ if (valueMs !== undefined) this.metricsExporter.observeHistogram(name, valueMs, tags);
1163
+ }
935
1164
  }
936
1165
 
937
1166
  private handleTurnComplete(pkt: EndOfSpeechPacket): void {
@@ -950,11 +1179,20 @@ export class VoiceAgentSession {
950
1179
  const otherTtsActive = this.ttsPlayout.activeContexts().some((c) => c !== pkt.contextId);
951
1180
  if (otherTtsActive && isBackchannel(pkt.text)) {
952
1181
  this.bus.push(Route.Background, make.metric(pkt.contextId, "turn.backchannel_dropped", pkt.text));
1182
+ this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "backchannel", { text: pkt.text });
953
1183
  return;
954
1184
  }
955
1185
 
1186
+ if (this.emittedTurnLocalizations.has(pkt.contextId)) {
1187
+ this.emittedTurnLocalizations.delete(pkt.contextId);
1188
+ this.turnLocalizationStates.delete(pkt.contextId);
1189
+ }
956
1190
  this.lastFinalizedContextId = pkt.contextId;
957
1191
  this.interaction.reset(pkt.contextId);
1192
+ // The force-finalize flag has now been read by whoever emitted this eos (and
1193
+ // captured by the metrics tracker via its metric) — retire it so the per-call
1194
+ // Set does not grow unbounded on a stable transport contextId.
1195
+ this.watchdogs.clearForceFinalized(pkt.contextId);
958
1196
 
959
1197
  // Re-arm per-turn guard state for the next turn. Transports with a stable
960
1198
  // per-call contextId (telephony callSid) reuse one id across turns, so these
@@ -963,6 +1201,7 @@ export class VoiceAgentSession {
963
1201
  // - interruptedGenerationContextIds: else turn N+1's LLM/TTS packets are dropped after a prior barge-in
964
1202
  // - fallbackInjectedContexts: else only one error fallback can ever be spoken per call
965
1203
  this.firstTtsAudioFired.delete(pkt.contextId);
1204
+ this.pendingTurnLatency.delete(pkt.contextId);
966
1205
  this.interruptedGenerationContextIds.delete(pkt.contextId);
967
1206
  this.fallbackInjectedContexts.delete(pkt.contextId);
968
1207
 
@@ -1092,8 +1331,11 @@ export class VoiceAgentSession {
1092
1331
  if (complete.text) {
1093
1332
  const timing = this.turnTimings.get(contextId);
1094
1333
  if (timing) timing.firstTtsTextMs ??= tsMs ?? Date.now();
1095
- this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), complete.text));
1096
- buffer.emitted = appendVoiceText(buffer.emitted, complete.text);
1334
+ // Strip markdown before it reaches TTS, and track the SPOKEN form as the heard
1335
+ // prefix so barge-in truncation reconciles against what was actually said.
1336
+ const spoken = normalizeForSpeech(complete.text);
1337
+ this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), spoken));
1338
+ buffer.emitted = appendVoiceText(buffer.emitted, spoken);
1097
1339
  }
1098
1340
  buffer.pending = complete.remaining;
1099
1341
  this.ttsTextBuffers.set(contextId, buffer);
@@ -1102,7 +1344,7 @@ export class VoiceAgentSession {
1102
1344
  private flushTtsText(contextId: string, tsMs?: number): string {
1103
1345
  const buffer = this.ttsTextBuffers.get(contextId);
1104
1346
  if (!buffer) return "";
1105
- const tail = buffer.pending.trim();
1347
+ const tail = normalizeForSpeech(buffer.pending.trim());
1106
1348
  if (tail) {
1107
1349
  const timing = this.turnTimings.get(contextId);
1108
1350
  if (timing) timing.firstTtsTextMs ??= tsMs ?? Date.now();
@@ -1224,6 +1466,9 @@ export class VoiceAgentSession {
1224
1466
  }
1225
1467
 
1226
1468
  private handleDelegateResult(pkt: DelegateResultPacket): void {
1469
+ if (pkt.control && isConversationControlName(pkt.control.name)) {
1470
+ this.markConversationFlag(pkt.contextId);
1471
+ }
1227
1472
  this.emit("delegate_result", {
1228
1473
  tsMs: pkt.timestampMs,
1229
1474
  turnId: pkt.contextId,
@@ -1233,6 +1478,8 @@ export class VoiceAgentSession {
1233
1478
  grounded: pkt.grounded,
1234
1479
  toolId: pkt.toolId,
1235
1480
  toolName: pkt.toolName,
1481
+ ...(pkt.control ? { control: pkt.control } : {}),
1482
+ ...(pkt.blocked ? { blocked: pkt.blocked } : {}),
1236
1483
  });
1237
1484
  this.debugPush({
1238
1485
  component: "delegate",
@@ -1267,6 +1514,125 @@ export class VoiceAgentSession {
1267
1514
  });
1268
1515
  }
1269
1516
 
1517
+ private handleConversationMetric(pkt: ConversationMetricPacket): void {
1518
+ if (isInfrastructureMetric(pkt.name)) this.markInfrastructureBreach(pkt.contextId);
1519
+ if (isConversationEvaluationMetric(pkt.name, pkt.value)) this.markConversationFlag(pkt.contextId);
1520
+ if (pkt.name === "input.cadence_stall_ms") {
1521
+ this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "cadence", { value: pkt.value });
1522
+ }
1523
+ if (pkt.name === "llm.call_started") {
1524
+ const timing = this.timingFor(pkt.contextId);
1525
+ timing.llmCallCount = (timing.llmCallCount ?? 0) + 1;
1526
+ return;
1527
+ }
1528
+ if (pkt.name === "llm.pass_ttft_ms") {
1529
+ const ttftMs = Number(pkt.value);
1530
+ if (!Number.isFinite(ttftMs)) return;
1531
+ const timing = this.timingFor(pkt.contextId);
1532
+ (timing.llmPassTtftMs ??= []).push(ttftMs);
1533
+ }
1534
+ }
1535
+
1536
+ private emitAcousticSignal(
1537
+ contextId: string,
1538
+ timestampMs: number,
1539
+ signal: AcousticSignalPacket["signal"],
1540
+ payload?: Readonly<Record<string, unknown>>,
1541
+ ): void {
1542
+ this.bus.push(Route.Background, make.acousticSignal(contextId, timestampMs, signal, payload));
1543
+ this.metricsExporter.observeCounter?.(`acoustic.${signal}`, 1, {
1544
+ provider: this.observabilityDims.provider,
1545
+ model: this.observabilityDims.model,
1546
+ region: this.observabilityDims.region,
1547
+ layer: "conversation",
1548
+ });
1549
+ }
1550
+
1551
+ private localizationStateFor(contextId: string): {
1552
+ infrastructureBreached: boolean;
1553
+ conversationFlagged: boolean;
1554
+ } {
1555
+ const existing = this.turnLocalizationStates.get(contextId);
1556
+ if (existing) return existing;
1557
+ const state = { infrastructureBreached: false, conversationFlagged: false };
1558
+ this.turnLocalizationStates.set(contextId, state);
1559
+ return state;
1560
+ }
1561
+
1562
+ private markInfrastructureBreach(contextId: string): void {
1563
+ this.localizationStateFor(contextId).infrastructureBreached = true;
1564
+ }
1565
+
1566
+ private markConversationFlag(contextId: string): void {
1567
+ this.localizationStateFor(contextId).conversationFlagged = true;
1568
+ }
1569
+
1570
+ private emitTurnLocalization(contextId: string): void {
1571
+ if (this.emittedTurnLocalizations.has(contextId)) return;
1572
+ const state = this.localizationStateFor(contextId);
1573
+ this.emittedTurnLocalizations.add(contextId);
1574
+ this.bus.push(
1575
+ Route.Background,
1576
+ make.turnLocalization(
1577
+ contextId,
1578
+ Date.now(),
1579
+ localizeTurn(state),
1580
+ state.infrastructureBreached,
1581
+ state.conversationFlagged,
1582
+ ),
1583
+ );
1584
+ }
1585
+
1586
+ private scheduleTurnLocalization(contextId: string): void {
1587
+ this.scheduler.schedule(turnLocalizationTimerKey(contextId), 0, () => {
1588
+ this.emitTurnLocalization(contextId);
1589
+ });
1590
+ }
1591
+
1592
+ private static readonly USAGE_FIELDS = [
1593
+ "inputTokens",
1594
+ "outputTokens",
1595
+ "totalTokens",
1596
+ "cachedInputTokens",
1597
+ "reasoningTokens",
1598
+ "audioSeconds",
1599
+ "characters",
1600
+ ] as const;
1601
+
1602
+ private handleUsageRecorded(pkt: UsageRecordedPacket): void {
1603
+ // Accumulate into the per-stage running total. A missing field stays missing
1604
+ // (never coerced to 0) so an un-reported dimension is distinguishable from a real 0.
1605
+ const prev = this.usageByStage.get(pkt.stage) ?? { stage: pkt.stage };
1606
+ const next: Record<string, unknown> = { ...prev };
1607
+ for (const field of VoiceAgentSession.USAGE_FIELDS) {
1608
+ const add = pkt[field];
1609
+ if (typeof add === "number") next[field] = ((prev[field] as number | undefined) ?? 0) + add;
1610
+ }
1611
+ this.usageByStage.set(pkt.stage, next as unknown as SessionStageUsage);
1612
+
1613
+ // Export each recorded unit as a counter, tagged low-cardinality only — same
1614
+ // discipline as turn_latency. provider/model are exemplar-safe; contextId is not,
1615
+ // and must never become a metric dimension (LiveKit caps at model/provider).
1616
+ const exporter = this.metricsExporter;
1617
+ if (exporter.observeCounter) {
1618
+ const tags = {
1619
+ stage: pkt.stage,
1620
+ provider: pkt.provider ?? "",
1621
+ model: pkt.model ?? "",
1622
+ layer: "infrastructure" as const,
1623
+ };
1624
+ for (const field of VoiceAgentSession.USAGE_FIELDS) {
1625
+ const value = pkt[field];
1626
+ if (typeof value === "number") exporter.observeCounter(`usage.${field}`, value, tags);
1627
+ }
1628
+ }
1629
+ }
1630
+
1631
+ private emitSessionUsage(): void {
1632
+ if (this.usageByStage.size === 0) return;
1633
+ this.emit("usage", { tsMs: Date.now(), stages: [...this.usageByStage.values()] });
1634
+ }
1635
+
1270
1636
  private handleTtsAudio(pkt: TextToSpeechAudioPacket): void {
1271
1637
  if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
1272
1638
  this.bus.push(
@@ -1287,17 +1653,24 @@ export class VoiceAgentSession {
1287
1653
  );
1288
1654
  this.turnUserStoppedAtMs.delete(pkt.contextId);
1289
1655
  }
1290
- this.emitTurnLatency(pkt.contextId, pkt.timestampMs);
1656
+ if (this.generatingContextIds.has(pkt.contextId)) {
1657
+ this.pendingTurnLatency.set(pkt.contextId, pkt.timestampMs);
1658
+ } else {
1659
+ this.emitTurnLatency(pkt.contextId, pkt.timestampMs);
1660
+ }
1291
1661
  }
1292
1662
 
1293
- this.primarySpeakerGate.observeAssistantPlayout(pkt.audio);
1663
+ const audio = this.normalizeOutboundAudio(pkt.audio);
1664
+ // Transport subscribers receive this same packet after the core handler.
1665
+ if (audio !== pkt.audio) Object.assign(pkt, { audio });
1666
+ this.primarySpeakerGate.observeAssistantPlayout(audio);
1294
1667
  // Audio just arrived — (re)arm the stall watchdog for this turn's TTS output.
1295
1668
  this.watchdogs.armTtsStallTimer(pkt.contextId);
1296
1669
 
1297
1670
  // Mark active and advance this context's playout cursor by the chunk's
1298
1671
  // realtime duration.
1299
1672
  const sampleRateHz = requireTtsAudioSampleRate(pkt.sampleRateHz);
1300
- const audioDurationMs = estimatePcm16Duration(pkt.audio, sampleRateHz);
1673
+ const audioDurationMs = estimatePcm16Duration(audio, sampleRateHz);
1301
1674
  const now = Date.now();
1302
1675
  this.ttsPlayout.noteAudio(pkt.contextId, audioDurationMs, now);
1303
1676
  this.interaction.observe({
@@ -1305,7 +1678,7 @@ export class VoiceAgentSession {
1305
1678
  contextId: pkt.contextId,
1306
1679
  timestampMs: pkt.timestampMs,
1307
1680
  ttsActive: true,
1308
- audio: pcm16BytesToSamples(pkt.audio),
1681
+ audio: pcm16BytesToSamples(audio),
1309
1682
  sampleRateHz,
1310
1683
  });
1311
1684
 
@@ -1322,18 +1695,37 @@ export class VoiceAgentSession {
1322
1695
  type: "audio",
1323
1696
  data: {
1324
1697
  context_id: pkt.contextId,
1325
- bytes: String(pkt.audio.length),
1698
+ bytes: String(audio.length),
1326
1699
  },
1327
1700
  timestampMs: pkt.timestampMs,
1328
1701
  });
1329
1702
 
1330
- this.bus.push(Route.Main, make.recordAssistantAudio(pkt.contextId, Date.now(), pkt.audio, sampleRateHz));
1703
+ this.bus.push(Route.Main, make.recordAssistantAudio(pkt.contextId, Date.now(), audio, sampleRateHz));
1704
+ }
1705
+
1706
+ private normalizeOutboundAudio(audio: Uint8Array): Uint8Array {
1707
+ if (
1708
+ !this.outboundLoudnessConfig ||
1709
+ !this.outboundLoudnessState ||
1710
+ audio.byteLength % 2 !== 0
1711
+ ) {
1712
+ return audio;
1713
+ }
1714
+ const samples = pcm16BytesToSamples(audio);
1715
+ return pcm16SamplesToBytes(
1716
+ normalizeLoudness(samples, this.outboundLoudnessState, this.outboundLoudnessConfig),
1717
+ );
1331
1718
  }
1332
1719
 
1333
1720
  private handleTtsEnd(pkt: TextToSpeechEndPacket): void {
1334
1721
  // Generation finished, but the streamed audio is still playing out. Keep the
1335
1722
  // context interruptible until its playout estimate elapses, then release it.
1336
1723
  this.generatingContextIds.delete(pkt.contextId);
1724
+ const firstAudioMs = this.pendingTurnLatency.get(pkt.contextId);
1725
+ if (firstAudioMs !== undefined) {
1726
+ this.pendingTurnLatency.delete(pkt.contextId);
1727
+ this.emitTurnLatency(pkt.contextId, firstAudioMs);
1728
+ }
1337
1729
  const now = Date.now();
1338
1730
  this.ttsPlayout.scheduleRelease(pkt.contextId, now);
1339
1731
  const playoutEndMs = this.ttsPlayout.playoutEnd(pkt.contextId);
@@ -1346,6 +1738,7 @@ export class VoiceAgentSession {
1346
1738
  data: {},
1347
1739
  timestampMs: Date.now(),
1348
1740
  });
1741
+ this.scheduleTurnLocalization(pkt.contextId);
1349
1742
  }
1350
1743
 
1351
1744
  private scheduleInteractionPlayoutTick(contextId: string, delayMs: number): void {
@@ -1384,6 +1777,15 @@ export class VoiceAgentSession {
1384
1777
  this.interruptedGenerationContextIds.add(pkt.contextId);
1385
1778
  this.failPendingToolCues(pkt.contextId); // G3: the aborted delegate's cue fails (R5)
1386
1779
  this.latencyFiller.cancel(pkt.contextId);
1780
+ // A barged turn still produced first audio, and its TTFA is a real measurement.
1781
+ // Deferring emission to tts.end (so later tool passes are counted) must not make
1782
+ // interrupted turns vanish from the metric: barge-in correlates with slow turns,
1783
+ // so silently dropping them biases the sample toward the fast ones — the worst
1784
+ // turns would disappear from exactly the number meant to detect them.
1785
+ this.flushPendingTurnLatency(pkt.contextId);
1786
+ this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "interruption", {
1787
+ source: pkt.source,
1788
+ });
1387
1789
  this.turnTimings.delete(pkt.contextId);
1388
1790
  this.firstLlmDeltaReceived.delete(pkt.contextId);
1389
1791
  this.ttsTextBuffers.delete(pkt.contextId);
@@ -1434,6 +1836,7 @@ export class VoiceAgentSession {
1434
1836
  this.interruptedGenerationContextIds.add(contextId);
1435
1837
  this.failPendingToolCues(contextId); // G3: a superseded turn's pending cue fails
1436
1838
  this.generatingContextIds.delete(contextId);
1839
+ this.pendingTurnLatency.delete(contextId);
1437
1840
  this.latencyFiller.cancel(contextId);
1438
1841
  this.turnTimings.delete(contextId);
1439
1842
  this.firstLlmDeltaReceived.delete(contextId);
@@ -1447,6 +1850,12 @@ export class VoiceAgentSession {
1447
1850
  }
1448
1851
 
1449
1852
  private handleComponentError(pkt: VoiceErrorPacket): void {
1853
+ this.markInfrastructureBreach(pkt.contextId);
1854
+ this.metricsExporter.observeCounter?.("error.stage", 1, {
1855
+ stage: pkt.component,
1856
+ category: pkt.category,
1857
+ layer: "infrastructure",
1858
+ });
1450
1859
  this.emit("error", {
1451
1860
  tsMs: pkt.timestampMs,
1452
1861
  stage: `${pkt.component}.error`,
@@ -1486,6 +1895,7 @@ export class VoiceAgentSession {
1486
1895
  // leave the caller in unexplained silence: if the reasoning layer failed the turn,
1487
1896
  // speak a graceful fallback (G4 — Deepgram guide "never fail silently").
1488
1897
  this.maybeSpeakErrorFallback(pkt);
1898
+ this.emitTurnLocalization(pkt.contextId);
1489
1899
  }
1490
1900
 
1491
1901
  private maybeSpeakErrorFallback(pkt: VoiceErrorPacket): void {
@@ -1514,11 +1924,34 @@ export class VoiceAgentSession {
1514
1924
  }
1515
1925
 
1516
1926
  private handleInjectMessage(pkt: InjectMessagePacket): void {
1927
+ if (pkt.mode === "context") {
1928
+ const bridge = this.plugins.get("bridge") as (VoicePlugin & {
1929
+ injectContext?: (text: string) => void;
1930
+ }) | undefined;
1931
+ if (bridge?.injectContext) {
1932
+ bridge.injectContext(pkt.text);
1933
+ } else {
1934
+ console.warn("VoiceAgentSession: context injection requested but the bridge does not support injectContext");
1935
+ }
1936
+ return;
1937
+ }
1938
+
1517
1939
  // Inject as synthetic LLM output — goes through normal TTS path
1518
1940
  this.bus.push(Route.Main, make.llmDelta(pkt.contextId, Date.now(), pkt.text));
1519
1941
  this.bus.push(Route.Main, make.llmDone(pkt.contextId, Date.now(), pkt.text));
1520
1942
  }
1521
1943
 
1944
+ private handleSttReconfigure(pkt: SttReconfigurePacket): void {
1945
+ const stt = this.plugins.get("stt");
1946
+ if (stt?.sttReconfigure) {
1947
+ stt.sttReconfigure.reconfigure(pkt.partial);
1948
+ return;
1949
+ }
1950
+ console.warn(
1951
+ "VoiceAgentSession: stt.reconfigure requested but the STT plugin does not support sttReconfigure",
1952
+ );
1953
+ }
1954
+
1522
1955
  private handleDisconnect(pkt: DisconnectRequestedPacket): void {
1523
1956
  this.emit("closed", { tsMs: pkt.timestampMs, reason: pkt.reason });
1524
1957
  this.debugPush({