@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
package/src/pricing.ts ADDED
@@ -0,0 +1,137 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Versioned price catalog: usage.recorded quantities → USD.
4
+ // Unknown provider/model keys yield unpriced (never silent 0 for real providers).
5
+ // Declared local/self-hosted models are explicitly zero-cost.
6
+
7
+ import type { UsageRecordedPacket } from "./packets.js";
8
+
9
+ export interface SttPrice {
10
+ readonly usdPerAudioSecond: number;
11
+ }
12
+
13
+ export interface LlmPrice {
14
+ readonly usdPer1MInputTokens: number;
15
+ readonly usdPer1MOutputTokens: number;
16
+ readonly usdPer1MCachedInputTokens?: number;
17
+ }
18
+
19
+ export interface TtsPrice {
20
+ readonly usdPer1MCharacters: number;
21
+ }
22
+
23
+ export interface PriceCatalog {
24
+ readonly source: string;
25
+ readonly version: string;
26
+ /** Keys: `provider/model` (e.g. `deepgram/nova-3`). */
27
+ readonly stt: Readonly<Record<string, SttPrice>>;
28
+ readonly llm: Readonly<Record<string, LlmPrice>>;
29
+ readonly tts: Readonly<Record<string, TtsPrice>>;
30
+ }
31
+
32
+ export type CostResult =
33
+ | { readonly usd: number; readonly unpriced?: undefined }
34
+ | { readonly usd: null; readonly unpriced: true };
35
+
36
+ function catalogKey(provider: string | undefined, model: string | undefined): string | null {
37
+ if (!provider || !model) return null;
38
+ return `${provider}/${model}`;
39
+ }
40
+
41
+ /**
42
+ * Public list prices stamped into DEFAULT_PRICE_CATALOG.source.
43
+ * STT $/audio-second from $/min ÷ 60. TTS is $/1M characters. LLM is $/1M tokens.
44
+ */
45
+ export const DEFAULT_PRICE_CATALOG: PriceCatalog = {
46
+ source:
47
+ "voice-prices@3 | deepgram.com/pricing PAYG Nova-3 streaming monolingual $0.0077/min → $0.000128333/s; " +
48
+ "deepgram.com/pricing Aura-2 TTS $0.030/1k chars → $30/1M; " +
49
+ "cartesia.ai/pricing + docs.cartesia.ai/pricing ~1 credit/char, PAYG ~$50/1M chars (cloudtalk.io cartesia-pricing 2026); " +
50
+ "openai tts-1 $15/1M chars (openai.com api pricing); gpt-4o-mini-tts text $0.60/1M (community/azure listed text input rate); " +
51
+ "openai GPT-4.1-mini $0.40/$1.60 per 1M in/out (developers.openai.com/api/docs/pricing; Azure GPT-4.1-mini-2025-04-14 Global); " +
52
+ "cloud.google.com/speech-to-text/pricing V2 standard recognition $0.016/min → $0.000266667/s (latest_long); " +
53
+ "ai.google.dev/pricing Gemini 2.5 Flash TTS preview text $0.50/1M chars (Gemini API TTS text input rate, 2026); " +
54
+ "elevenlabs.io/pricing/api Flash/Turbo TTS $0.05/1k chars → $50/1M; Multilingual v2/v3 $0.10/1k → $100/1M; " +
55
+ "Scribe v2 Realtime $0.39/hour → $0.000108333/s; " +
56
+ "grok/stt + grok/* TTS + epsilon/epsilon-tts intentionally ABSENT (no public list price → costOf unpriced, never silent $0)",
57
+ version: "3",
58
+ stt: {
59
+ "deepgram/nova-3": { usdPerAudioSecond: 0.0077 / 60 },
60
+ "deepgram/nova-2": { usdPerAudioSecond: 0.0077 / 60 },
61
+ "deepgram/flux-general-en": { usdPerAudioSecond: 0.0077 / 60 },
62
+ // Google Cloud Speech-to-Text V2 standard streaming recognition list rate.
63
+ "google/latest_long": { usdPerAudioSecond: 0.016 / 60 },
64
+ "google/latest_short": { usdPerAudioSecond: 0.016 / 60 },
65
+ "google/chirp_2": { usdPerAudioSecond: 0.016 / 60 },
66
+ // ElevenLabs Scribe v2 Realtime — public list $0.39/hour.
67
+ "elevenlabs/scribe_v2_realtime": { usdPerAudioSecond: 0.39 / 3600 },
68
+ "local/whisper": { usdPerAudioSecond: 0 },
69
+ // grok/stt: no public list price — omit so costOf → unpriced
70
+ },
71
+ llm: {
72
+ "openai/gpt-4.1-mini": {
73
+ usdPer1MInputTokens: 0.4,
74
+ usdPer1MOutputTokens: 1.6,
75
+ usdPer1MCachedInputTokens: 0.1,
76
+ },
77
+ "local/llm": {
78
+ usdPer1MInputTokens: 0,
79
+ usdPer1MOutputTokens: 0,
80
+ },
81
+ },
82
+ tts: {
83
+ "cartesia/sonic-3": { usdPer1MCharacters: 50 },
84
+ "openai/gpt-4o-mini-tts": { usdPer1MCharacters: 0.6 },
85
+ "openai/tts-1": { usdPer1MCharacters: 15 },
86
+ "deepgram/aura-2-thalia-en": { usdPer1MCharacters: 30 },
87
+ // Gemini TTS preview models — Gemini API TTS text input list rate.
88
+ "gemini/gemini-3.1-flash-tts-preview": { usdPer1MCharacters: 0.5 },
89
+ "gemini/gemini-2.5-flash-preview-tts": { usdPer1MCharacters: 0.5 },
90
+ "gemini/gemini-2.5-pro-preview-tts": { usdPer1MCharacters: 0.5 },
91
+ // ElevenLabs public list — Flash/Turbo $0.05/1k, Multilingual $0.10/1k.
92
+ "elevenlabs/eleven_flash_v2_5": { usdPer1MCharacters: 50 },
93
+ "elevenlabs/eleven_flash_v2": { usdPer1MCharacters: 50 },
94
+ "elevenlabs/eleven_turbo_v2_5": { usdPer1MCharacters: 50 },
95
+ "elevenlabs/eleven_turbo_v2": { usdPer1MCharacters: 50 },
96
+ "elevenlabs/eleven_multilingual_v2": { usdPer1MCharacters: 100 },
97
+ "local/tts": { usdPer1MCharacters: 0 },
98
+ // grok/* voice keys + epsilon/epsilon-tts: no public list — omit → unpriced
99
+ },
100
+ };
101
+
102
+ export function costOf(usage: UsageRecordedPacket, catalog: PriceCatalog): CostResult {
103
+ const key = catalogKey(usage.provider, usage.model);
104
+ if (!key) return { usd: null, unpriced: true };
105
+
106
+ switch (usage.stage) {
107
+ case "stt": {
108
+ const price = catalog.stt[key];
109
+ if (!price) return { usd: null, unpriced: true };
110
+ const seconds = usage.audioSeconds ?? 0;
111
+ return { usd: seconds * price.usdPerAudioSecond };
112
+ }
113
+ case "tts": {
114
+ const price = catalog.tts[key];
115
+ if (!price) return { usd: null, unpriced: true };
116
+ const characters = usage.characters ?? 0;
117
+ return { usd: (characters / 1_000_000) * price.usdPer1MCharacters };
118
+ }
119
+ case "llm": {
120
+ const price = catalog.llm[key];
121
+ if (!price) return { usd: null, unpriced: true };
122
+ const input = usage.inputTokens ?? 0;
123
+ const output = usage.outputTokens ?? 0;
124
+ const cached = usage.cachedInputTokens ?? 0;
125
+ const cachedRate = price.usdPer1MCachedInputTokens ?? price.usdPer1MInputTokens;
126
+ // Prefer charging cached tokens at the cached rate when present; non-cached input is the remainder.
127
+ const nonCachedInput = Math.max(0, input - cached);
128
+ const usd =
129
+ (nonCachedInput / 1_000_000) * price.usdPer1MInputTokens +
130
+ (cached / 1_000_000) * cachedRate +
131
+ (output / 1_000_000) * price.usdPer1MOutputTokens;
132
+ return { usd };
133
+ }
134
+ default:
135
+ return { usd: null, unpriced: true };
136
+ }
137
+ }
@@ -19,12 +19,23 @@ export interface PrimarySpeakerGateConfig {
19
19
  readonly enabled?: boolean;
20
20
  readonly similarityThreshold?: number;
21
21
  readonly echoDominanceMargin?: number;
22
+ readonly onDecision?: (decision: PrimarySpeakerGateDecision) => void;
23
+ }
24
+
25
+ export interface PrimarySpeakerGateDecision {
26
+ readonly contextId: string;
27
+ readonly signal: "primary_speaker" | "echo_rejected";
28
+ readonly accepted: boolean;
29
+ readonly frameCount: number;
30
+ readonly primaryHits: number;
31
+ readonly echoRejectedFrames: number;
22
32
  }
23
33
 
24
34
  export class PrimarySpeakerGate {
25
35
  private readonly enabled: boolean;
26
36
  private readonly similarityThreshold: number;
27
37
  private readonly echoDominanceMargin: number;
38
+ private readonly onDecision?: PrimarySpeakerGateConfig["onDecision"];
28
39
  private profile: SpeakerFingerprint | null = null;
29
40
  private enrollFrames: SpeakerFingerprint[] = [];
30
41
  private bargeInFrames: SpeakerFingerprint[] = [];
@@ -34,6 +45,7 @@ export class PrimarySpeakerGate {
34
45
  this.enabled = config.enabled !== false;
35
46
  this.similarityThreshold = config.similarityThreshold ?? 0.72;
36
47
  this.echoDominanceMargin = config.echoDominanceMargin ?? 0.12;
48
+ this.onDecision = config.onDecision;
37
49
  }
38
50
 
39
51
  isEnabled(): boolean {
@@ -73,22 +85,30 @@ export class PrimarySpeakerGate {
73
85
  this.assistantProfile = frame;
74
86
  }
75
87
 
76
- shouldCommitBargeIn(): boolean {
88
+ shouldCommitBargeIn(contextId = ""): boolean {
77
89
  if (!this.enabled || this.profile === null) return true;
78
90
  if (this.bargeInFrames.length === 0) return false;
79
91
 
80
92
  let primaryHits = 0;
93
+ let echoRejectedFrames = 0;
81
94
  for (const frame of this.bargeInFrames) {
82
95
  const primarySim = fingerprintSimilarity(frame, this.profile);
83
96
  if (this.assistantProfile !== null) {
84
97
  const echoSim = fingerprintSimilarity(frame, this.assistantProfile);
85
- if (echoSim >= primarySim + this.echoDominanceMargin) continue;
98
+ if (echoSim >= primarySim + this.echoDominanceMargin) {
99
+ echoRejectedFrames += 1;
100
+ continue;
101
+ }
86
102
  }
87
103
  if (primarySim >= this.similarityThreshold) primaryHits += 1;
88
104
  }
89
105
 
90
106
  const required = Math.max(1, Math.ceil(this.bargeInFrames.length * 0.45));
91
- return primaryHits >= required;
107
+ const accepted = primaryHits >= required;
108
+ const decision = { contextId, accepted, frameCount: this.bargeInFrames.length, primaryHits, echoRejectedFrames };
109
+ if (echoRejectedFrames > 0) this.onDecision?.({ signal: "echo_rejected", ...decision });
110
+ this.onDecision?.({ signal: "primary_speaker", ...decision });
111
+ return accepted;
92
112
  }
93
113
 
94
114
  resetBargeInWindow(): void {
package/src/reasoner.ts CHANGED
@@ -8,6 +8,9 @@
8
8
 
9
9
  /** A reasoning backend reduced to one normalized pull-stream per turn. */
10
10
  export interface Reasoner {
11
+ /** Add transient steering context consumed by the next turn, when supported. */
12
+ injectContext?(text: string): void;
13
+
11
14
  /**
12
15
  * Drive one reasoning turn. The returned async-iterable IS the response.
13
16
  * Cancellation (barge-in) is via `turn.signal` (abort) — the adapter forwards
@@ -50,11 +53,35 @@ export type ReasoningPart =
50
53
  | { readonly type: "text-delta"; readonly text: string }
51
54
  | { readonly type: "tool-call"; readonly toolId: string; readonly toolName: string; readonly args: Record<string, unknown> }
52
55
  | { readonly type: "tool-result"; readonly toolId: string; readonly toolName: string; readonly result: string }
56
+ | { readonly type: "control"; readonly name: string; readonly payload: unknown }
57
+ | { readonly type: "blocked"; readonly userFacingMessage: string; readonly payload?: unknown }
53
58
  // Human-in-the-loop pause (step 3). ALWAYS the terminal part for the turn.
54
59
  | { readonly type: "suspended"; readonly runId: string; readonly toolId?: string; readonly prompt?: string; readonly payload: unknown }
55
60
  // (B1) Error/abort the backend surfaced. The bridge treats `error` like today's
56
61
  // thrown TextStreamPart `error`/`tool-error`/`finish-step(error)`: it drives the
57
62
  // retry/`llm.error` path. `recoverable` mirrors `categorizeLlmError`. ALWAYS terminal.
58
63
  | { readonly type: "error"; readonly cause: Error; readonly recoverable: boolean }
59
- | { readonly type: "finish"; readonly reason: "stop" | "tool" | "length"; readonly text: string };
64
+ | {
65
+ readonly type: "finish";
66
+ readonly reason: "stop" | "tool" | "length";
67
+ readonly text: string;
68
+ /**
69
+ * Billable token usage for this turn, when the backend reports it. Optional —
70
+ * a reasoner that cannot report usage omits it, and the metering path treats a
71
+ * missing field as "unknown", never zero. Consumed into `usage.recorded`.
72
+ */
73
+ readonly usage?: ReasonerUsage;
74
+ };
60
75
 
76
+ /** Token usage a reasoner may attach to its terminal `finish` part. */
77
+ export interface ReasonerUsage {
78
+ /** Provider slug (e.g. "openai"), for low-cardinality cost attribution. */
79
+ readonly provider?: string;
80
+ /** Model id (e.g. "gpt-4.1-mini"). Without this, spend cannot be attributed to a model. */
81
+ readonly model?: string;
82
+ readonly inputTokens?: number;
83
+ readonly outputTokens?: number;
84
+ readonly totalTokens?: number;
85
+ readonly cachedInputTokens?: number;
86
+ readonly reasoningTokens?: number;
87
+ }
@@ -0,0 +1,52 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Pure spend accumulator + policy. Observe (record) and control (check) are
4
+ // separated so the same usage packet is never double-counted by the guard.
5
+
6
+ import type { UsageRecordedPacket } from "./packets.js";
7
+ import { costOf, DEFAULT_PRICE_CATALOG, type PriceCatalog } from "./pricing.js";
8
+
9
+ export interface SpendCapConfig {
10
+ readonly maxUsd: number;
11
+ readonly catalog?: PriceCatalog;
12
+ }
13
+
14
+ export interface SpendCapCheck {
15
+ readonly exceeded: boolean;
16
+ readonly spentUsd: number;
17
+ }
18
+
19
+ /**
20
+ * Accumulates priced `usage.recorded` into session spend and reports whether a
21
+ * configured USD cap has been breached. Does not call providers; the session
22
+ * layer decides refuse/fallback from `check()`.
23
+ */
24
+ export class SpendCapGuard {
25
+ private readonly maxUsd: number;
26
+ private readonly catalog: PriceCatalog;
27
+ private spentUsd = 0;
28
+ private exceeded = false;
29
+
30
+ constructor(cfg: SpendCapConfig) {
31
+ if (!Number.isFinite(cfg.maxUsd) || cfg.maxUsd < 0) {
32
+ throw new Error(`SpendCapGuard maxUsd must be a non-negative finite number, got ${String(cfg.maxUsd)}`);
33
+ }
34
+ this.maxUsd = cfg.maxUsd;
35
+ this.catalog = cfg.catalog ?? DEFAULT_PRICE_CATALOG;
36
+ }
37
+
38
+ /** Observe one usage packet. Unpriced usage does not change spentUsd. */
39
+ record(usage: UsageRecordedPacket): void {
40
+ const result = costOf(usage, this.catalog);
41
+ if (result.usd === null) return;
42
+ this.spentUsd += result.usd;
43
+ if (this.spentUsd > this.maxUsd) {
44
+ this.exceeded = true;
45
+ }
46
+ }
47
+
48
+ /** Read-only policy view — never mutates spend. */
49
+ check(): SpendCapCheck {
50
+ return { exceeded: this.exceeded, spentUsd: this.spentUsd };
51
+ }
52
+ }
@@ -59,18 +59,39 @@ export function isBackchannel(text: string): boolean {
59
59
  return BACKCHANNELS.has(norm);
60
60
  }
61
61
 
62
+ export function isAcknowledgement(text: string, phrases: readonly string[]): boolean {
63
+ const norm = text
64
+ .toLowerCase()
65
+ .replace(/[^a-z\s'-]/g, "")
66
+ .trim()
67
+ .replace(/\s+/g, " ");
68
+ if (!norm) return false;
69
+ return phrases.some((p) => {
70
+ const pNorm = p
71
+ .toLowerCase()
72
+ .replace(/[^a-z\s'-]/g, "")
73
+ .trim()
74
+ .replace(/\s+/g, " ");
75
+ return pNorm === norm;
76
+ });
77
+ }
78
+
62
79
  export interface TurnArbiterDeps {
63
80
  readonly bus: PipelineBus;
64
81
  readonly primarySpeakerGate: PrimarySpeakerGate;
65
82
  readonly ttsPlayout: TtsPlayoutClock;
66
83
  readonly minInterruptionMs: number;
67
84
  readonly onInterrupt?: (interruptedContextId: string, source: "vad") => void;
85
+ /** When true, emit `interaction.duck` while evaluating a barge-in during active TTS,
86
+ * and `interaction.resume` if the pending window resolves without an interrupt. */
87
+ readonly pauseThenResolveBargeIn?: boolean;
68
88
  }
69
89
 
70
90
  export class TurnArbiter {
71
91
  private turnInterruption: TurnInterruptionState = { kind: "idle" };
72
92
  private latestInterimText = "";
73
93
  private latestInterimConfidence: number | null = null;
94
+ private ducked = false;
74
95
 
75
96
  constructor(private readonly deps: TurnArbiterDeps) {}
76
97
 
@@ -130,7 +151,7 @@ export class TurnArbiter {
130
151
  if (
131
152
  this.deps.primarySpeakerGate.isEnabled() &&
132
153
  this.deps.primarySpeakerGate.hasProfile() &&
133
- !this.deps.primarySpeakerGate.shouldCommitBargeIn()
154
+ !this.deps.primarySpeakerGate.shouldCommitBargeIn(pending.userContextId)
134
155
  ) {
135
156
  this.suppress(pending, "interrupt.suppressed_non_primary", durationMs);
136
157
  } else {
@@ -166,6 +187,7 @@ export class TurnArbiter {
166
187
  commitClientInterrupt(interruptedContextId: string): void {
167
188
  if (!this.deps.ttsPlayout.isActive(interruptedContextId)) return;
168
189
  this.turnInterruption = { kind: "idle" };
190
+ this.ducked = false;
169
191
  this.deps.primarySpeakerGate.resetBargeInWindow();
170
192
  this.deps.bus.push(Route.Background, make.metric(interruptedContextId, "interrupt.committed_after_ms", "0"));
171
193
  this.deps.bus.push(Route.Background, make.metric(interruptedContextId, "vaqi.interruption", "1"));
@@ -177,6 +199,7 @@ export class TurnArbiter {
177
199
  this.turnInterruption = { kind: "idle" };
178
200
  this.latestInterimText = "";
179
201
  this.latestInterimConfidence = null;
202
+ this.ducked = false;
180
203
  }
181
204
 
182
205
  private pendingFor(userContextId: string): PendingTurnInterruption | null {
@@ -203,6 +226,10 @@ export class TurnArbiter {
203
226
  firstSpeechMs: pkt.timestampMs,
204
227
  awaitingAudio,
205
228
  };
229
+ if (this.deps.pauseThenResolveBargeIn && this.deps.ttsPlayout.isActive(interruptedContextId) && !this.ducked) {
230
+ this.deps.bus.push(Route.Main, make.interactionDuck(interruptedContextId, pkt.timestampMs));
231
+ this.ducked = true;
232
+ }
206
233
  }
207
234
 
208
235
  private setAwaitingAudio(awaitingAudio: boolean): void {
@@ -222,7 +249,7 @@ export class TurnArbiter {
222
249
  if (nowMs - pending.firstSpeechMs < this.deps.minInterruptionMs) return;
223
250
 
224
251
  const gate = this.deps.primarySpeakerGate;
225
- if (gate.isEnabled() && gate.hasProfile() && !gate.shouldCommitBargeIn()) {
252
+ if (gate.isEnabled() && gate.hasProfile() && !gate.shouldCommitBargeIn(pending.userContextId)) {
226
253
  this.suppress(pending, "interrupt.suppressed_non_primary", nowMs - pending.firstSpeechMs);
227
254
  return;
228
255
  }
@@ -238,6 +265,7 @@ export class TurnArbiter {
238
265
  }
239
266
  this.turnInterruption = { kind: "idle" };
240
267
  gate.resetBargeInWindow();
268
+ this.ducked = false;
241
269
 
242
270
  const { bus, ttsPlayout } = this.deps;
243
271
  if (!ttsPlayout.isActive(pending.interruptedContextId)) {
@@ -281,6 +309,10 @@ export class TurnArbiter {
281
309
  } else {
282
310
  this.turnInterruption = { kind: "idle" };
283
311
  this.deps.primarySpeakerGate.resetBargeInWindow();
312
+ if (this.ducked) {
313
+ this.deps.bus.push(Route.Main, make.interactionResume(pending.interruptedContextId, Date.now()));
314
+ this.ducked = false;
315
+ }
284
316
  }
285
317
  this.deps.bus.push(
286
318
  Route.Background,
@@ -81,6 +81,8 @@ export class VoiceSessionWatchdogs {
81
81
  private readonly scheduler: Scheduler;
82
82
  private sttForceFinalizeScheduled = false;
83
83
  private pendingSttContextId = "";
84
+ /** Contexts whose STT the force-finalize watchdog has already fired on this turn. */
85
+ private readonly forceFinalizedContexts = new Set<string>();
84
86
  private vaqiMissedResponseScheduled = false;
85
87
  private vaqiMissedResponseContextId = "";
86
88
  private vaqiMissedResponseStartMs = 0;
@@ -98,6 +100,7 @@ export class VoiceSessionWatchdogs {
98
100
  this.clearVaqiMissedResponseTimer();
99
101
  this.clearTtsStallTimer();
100
102
  this.clearInputCadenceWatchdog();
103
+ this.forceFinalizedContexts.clear();
101
104
  }
102
105
 
103
106
  scheduleSttForceFinalize(contextId: string): void {
@@ -109,11 +112,26 @@ export class VoiceSessionWatchdogs {
109
112
  this.sttForceFinalizeScheduled = true;
110
113
  this.scheduler.schedule("voice.watchdog.stt_force_finalize", this.deps.sttForceFinalizeTimeoutMs, () => {
111
114
  this.sttForceFinalizeScheduled = false;
115
+ this.forceFinalizedContexts.add(contextId);
116
+ // Broadcast the decision on the bus: the completing eos may come from the STT
117
+ // plugin (which cannot know the watchdog fired), so the metrics tracker reads
118
+ // this mark to label the turn rather than each emitter re-deriving it.
119
+ this.deps.bus.push(Route.Background, make.metric(contextId, "stt.force_finalized", "1"));
112
120
  const plugin = findForceFinalizableSttPlugin(this.deps.plugins);
113
121
  plugin?.forceFinalize(contextId);
114
122
  });
115
123
  }
116
124
 
125
+ /** True when the force-finalize watchdog has already fired for this context this turn. */
126
+ wasForceFinalized(contextId: string): boolean {
127
+ return this.forceFinalizedContexts.has(contextId);
128
+ }
129
+
130
+ /** Retire the flag once the turn has completed (read by the emitter + metrics tracker). */
131
+ clearForceFinalized(contextId: string): void {
132
+ this.forceFinalizedContexts.delete(contextId);
133
+ }
134
+
117
135
  clearSttForceFinalizeIfContext(contextId: string): void {
118
136
  if (this.pendingSttContextId === contextId) {
119
137
  this.clearSttForceFinalizeTimer();