@kuralle-syrinx/core 4.2.0 → 4.3.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 (49) 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 +63 -3
  8. package/src/interaction-policy.ts +12 -0
  9. package/src/observability-observer.ts +8 -4
  10. package/src/observability.ts +30 -1
  11. package/src/packet-factories.ts +110 -2
  12. package/src/packets.ts +116 -1
  13. package/src/plugin-contract.ts +41 -0
  14. package/src/policies/rule-based.ts +17 -2
  15. package/src/pricing.ts +137 -0
  16. package/src/primary-speaker-gate.ts +23 -3
  17. package/src/reasoner.ts +28 -1
  18. package/src/spend-cap.ts +52 -0
  19. package/src/turn-arbiter.ts +34 -2
  20. package/src/voice-agent-session.ts +453 -34
  21. package/src/voice-text.ts +69 -0
  22. package/src/audio/audio.test.ts +0 -285
  23. package/src/audio-envelope.test.ts +0 -167
  24. package/src/confidence-to-wait.test.ts +0 -21
  25. package/src/error-handler.test.ts +0 -56
  26. package/src/hedge-throwing-backend.test.ts +0 -46
  27. package/src/interaction-coordinator.test.ts +0 -425
  28. package/src/iu-ledger.test.ts +0 -276
  29. package/src/latency-filler.test.ts +0 -62
  30. package/src/observability-observer.test.ts +0 -245
  31. package/src/observability.test.ts +0 -85
  32. package/src/packet-factories.test.ts +0 -53
  33. package/src/pipeline-bus.g10.test.ts +0 -145
  34. package/src/pipeline-bus.test.ts +0 -211
  35. package/src/policies/defer.test.ts +0 -86
  36. package/src/policies/rule-based.test.ts +0 -269
  37. package/src/primary-speaker-gate.test.ts +0 -150
  38. package/src/provider-fallback.test.ts +0 -87
  39. package/src/reasoner-hedge.test.ts +0 -361
  40. package/src/reasoner-route.test.ts +0 -248
  41. package/src/reasoner.test.ts +0 -69
  42. package/src/retry.test.ts +0 -83
  43. package/src/route-throwing-spec.test.ts +0 -44
  44. package/src/tts-playout-clock.test.ts +0 -125
  45. package/src/turn-arbiter.characterization.test.ts +0 -477
  46. package/src/turn-arbiter.test.ts +0 -567
  47. package/src/voice-agent-session.test.ts +0 -3316
  48. package/src/voice-text.test.ts +0 -94
  49. package/tsconfig.json +0 -21
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
 
3
3
  import { Route, type PipelineBus } from "./pipeline-bus.js";
4
- import { monotonicNowMs, type MetricsExporter } from "./observability.js";
4
+ import { monotonicNowMs, type MetricsExporter, type ObservabilityLayer } from "./observability.js";
5
5
  import type {
6
6
  TurnBoundaryKind,
7
7
  VadSpeechStartedPacket,
@@ -17,6 +17,7 @@ export interface ObservabilityDims {
17
17
  readonly provider: string;
18
18
  readonly model: string;
19
19
  readonly region: string;
20
+ readonly layer?: ObservabilityLayer;
20
21
  }
21
22
 
22
23
  export interface ObservabilityObserverDeps {
@@ -124,12 +125,11 @@ export class ObservabilityObserver {
124
125
 
125
126
  const dims = this.dimsFor(speechId, cancelled);
126
127
  const tags = {
127
- sessionId: this.deps.sessionId,
128
- speechId,
129
128
  provider: dims.provider,
130
129
  model: dims.model,
131
130
  region: dims.region,
132
131
  cancelled: dims.cancelled ? "true" : "false",
132
+ layer: dims.layer ?? "conversation",
133
133
  };
134
134
 
135
135
  if (boundary === "agent_started_speaking") {
@@ -179,12 +179,16 @@ export class ObservabilityObserver {
179
179
  });
180
180
  }
181
181
 
182
- private dimsFor(speechId: string, cancelled: boolean): Required<ObservabilityDims> & { cancelled: boolean } {
182
+ private dimsFor(
183
+ speechId: string,
184
+ cancelled: boolean,
185
+ ): Required<ObservabilityDims> & { cancelled: boolean } {
183
186
  const stage = this.stageDims.get(speechId) ?? {};
184
187
  return {
185
188
  provider: stage.provider ?? this.deps.dims.provider,
186
189
  model: stage.model ?? this.deps.dims.model,
187
190
  region: stage.region ?? this.deps.dims.region,
191
+ layer: stage.layer ?? this.deps.dims.layer ?? "conversation",
188
192
  cancelled: cancelled || stage.cancelled === true,
189
193
  };
190
194
  }
@@ -7,6 +7,21 @@ export function monotonicNowMs(): number {
7
7
  return performance.timeOrigin + performance.now();
8
8
  }
9
9
 
10
+ export type ObservabilityLayer = "infrastructure" | "conversation";
11
+
12
+ export type TurnLocalizationVerdict = ObservabilityLayer | "none";
13
+
14
+ export interface TurnLocalizationSignals {
15
+ readonly infrastructureBreached: boolean;
16
+ readonly conversationFlagged: boolean;
17
+ }
18
+
19
+ export function localizeTurn(signals: TurnLocalizationSignals): TurnLocalizationVerdict {
20
+ if (signals.infrastructureBreached) return "infrastructure";
21
+ if (signals.conversationFlagged) return "conversation";
22
+ return "none";
23
+ }
24
+
10
25
  /** One step of a reconstructed turn timeline, with elapsed ms since the prior boundary. */
11
26
  export interface TurnTimelineStep {
12
27
  readonly boundary: TurnBoundaryEventPacket["boundary"];
@@ -46,7 +61,8 @@ export function reconstructTurnTimeline(
46
61
  }
47
62
 
48
63
  export interface MetricTags {
49
- readonly [key: string]: string;
64
+ readonly [key: string]: string | undefined;
65
+ readonly layer?: ObservabilityLayer;
50
66
  }
51
67
 
52
68
  export interface SpanHandle {
@@ -56,12 +72,20 @@ export interface SpanHandle {
56
72
  /** Export seam — implementations (Prometheus/OTel) live in optional packages, NOT here. */
57
73
  export interface MetricsExporter {
58
74
  observeHistogram(name: string, valueMs: number, tags: MetricTags): void;
75
+ /**
76
+ * A monotonic count (tokens, characters, audio-seconds). Counters, not histograms:
77
+ * usage sums to a bill, latency distributes to a percentile — different instruments.
78
+ * Optional so an existing exporter that predates it still satisfies the interface;
79
+ * producers call it as `exporter.observeCounter?.(...)`.
80
+ */
81
+ observeCounter?(name: string, value: number, tags: MetricTags): void;
59
82
  startSpan(name: string, tags: MetricTags): SpanHandle;
60
83
  }
61
84
 
62
85
  /** Default no-op exporter (core never depends on a backend). */
63
86
  export const noopMetricsExporter: MetricsExporter = {
64
87
  observeHistogram() {},
88
+ observeCounter() {},
65
89
  startSpan() {
66
90
  return { end() {} };
67
91
  },
@@ -70,12 +94,17 @@ export const noopMetricsExporter: MetricsExporter = {
70
94
  /** In-memory exporter for tests + incident reconstruction. */
71
95
  export class InMemoryMetricsExporter implements MetricsExporter {
72
96
  readonly histograms: Array<{ name: string; valueMs: number; tags: MetricTags }> = [];
97
+ readonly counters: Array<{ name: string; value: number; tags: MetricTags }> = [];
73
98
  readonly spans: Array<{ name: string; tags: MetricTags; durationMs?: number }> = [];
74
99
 
75
100
  observeHistogram(name: string, valueMs: number, tags: MetricTags): void {
76
101
  this.histograms.push({ name, valueMs, tags });
77
102
  }
78
103
 
104
+ observeCounter(name: string, value: number, tags: MetricTags): void {
105
+ this.counters.push({ name, value, tags });
106
+ }
107
+
79
108
  startSpan(name: string, tags: MetricTags): SpanHandle {
80
109
  const startMs = monotonicNowMs();
81
110
  const spanIndex = this.spans.length;
@@ -10,11 +10,18 @@
10
10
  import type { WordTiming } from "./interaction-policy.js";
11
11
  import { ErrorCategory } from "./packets.js";
12
12
  import type {
13
+ UsageRecordedPacket,
13
14
  ConversationMetricPacket,
15
+ AcousticSignalPacket,
16
+ AcousticSignal,
17
+ TurnLocalizationPacket,
14
18
  TurnBoundaryKind,
15
19
  TurnBoundaryEventPacket,
16
20
  DtmfDigit,
17
21
  DtmfReceivedPacket,
22
+ DtmfSendPacket,
23
+ CallTransferPacket,
24
+ CallTransferMode,
18
25
  RecordUserAudioPacket,
19
26
  RecordAssistantAudioDataPacket,
20
27
  RecordAssistantAudioTruncatePacket,
@@ -35,6 +42,7 @@ import type {
35
42
  InterruptLlmPacket,
36
43
  InterruptSttPacket,
37
44
  InjectMessagePacket,
45
+ SttReconfigurePacket,
38
46
  LlmDeltaPacket,
39
47
  LlmResponseDonePacket,
40
48
  ReasoningSuspendedPacket,
@@ -43,7 +51,10 @@ import type {
43
51
  StopIdleTimeoutPacket,
44
52
  ModeSwitchRequestedPacket,
45
53
  InteractionBackchannelPacket,
54
+ InteractionDuckPacket,
55
+ InteractionResumePacket,
46
56
  } from "./packets.js";
57
+ import type { SttReconfigurePartial } from "./plugin-contract.js";
47
58
 
48
59
  const DTMF_DIGITS = new Set<DtmfDigit>(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"]);
49
60
 
@@ -63,6 +74,36 @@ export function dtmfReceived(
63
74
  return { kind: "dtmf.received", contextId, timestampMs, digit, provider, rawDigit };
64
75
  }
65
76
 
77
+ const DTMF_SEND_CHARS = /^[0-9*#wW]+$/;
78
+
79
+ /** Validate + build a `dtmf.send` packet. Throws on empty/invalid digit strings. */
80
+ export function dtmfSend(contextId: string, timestampMs: number, digits: string): DtmfSendPacket {
81
+ if (!digits || !DTMF_SEND_CHARS.test(digits)) {
82
+ throw new Error(`Invalid dtmf.send digits: ${JSON.stringify(digits)} (expected [0-9*#wW]+)`);
83
+ }
84
+ return { kind: "dtmf.send", contextId, timestampMs, digits };
85
+ }
86
+
87
+ /** Build a `call.transfer` packet. */
88
+ export function callTransfer(
89
+ contextId: string,
90
+ timestampMs: number,
91
+ mode: CallTransferMode,
92
+ target: string,
93
+ summary?: string,
94
+ ): CallTransferPacket {
95
+ const trimmed = target.trim();
96
+ if (!trimmed) throw new Error("call.transfer target must be a non-empty E.164 / SIP URI");
97
+ return {
98
+ kind: "call.transfer",
99
+ contextId,
100
+ timestampMs,
101
+ mode,
102
+ target: trimmed,
103
+ ...(summary !== undefined ? { summary } : {}),
104
+ };
105
+ }
106
+
66
107
  export function metric(
67
108
  contextId: string,
68
109
  name: string,
@@ -72,6 +113,46 @@ export function metric(
72
113
  return { kind: "metric.conversation", contextId, timestampMs, name, value };
73
114
  }
74
115
 
116
+ export function acousticSignal(
117
+ contextId: string,
118
+ timestampMs: number,
119
+ signal: AcousticSignal,
120
+ payload?: Readonly<Record<string, unknown>>,
121
+ ): AcousticSignalPacket {
122
+ return {
123
+ kind: "acoustic.signal",
124
+ contextId,
125
+ timestampMs,
126
+ signal,
127
+ ...(payload ? { payload } : {}),
128
+ };
129
+ }
130
+
131
+ export function turnLocalization(
132
+ contextId: string,
133
+ timestampMs: number,
134
+ value: TurnLocalizationPacket["value"],
135
+ infrastructureBreached: boolean,
136
+ conversationFlagged: boolean,
137
+ ): TurnLocalizationPacket {
138
+ return {
139
+ kind: "turn.localization",
140
+ contextId,
141
+ timestampMs,
142
+ value,
143
+ infrastructureBreached,
144
+ conversationFlagged,
145
+ };
146
+ }
147
+
148
+ export function usageRecorded(
149
+ contextId: string,
150
+ fields: Omit<UsageRecordedPacket, "kind" | "contextId" | "timestampMs">,
151
+ timestampMs: number = Date.now(),
152
+ ): UsageRecordedPacket {
153
+ return { kind: "usage.recorded", contextId, timestampMs, ...fields };
154
+ }
155
+
75
156
  export function turnBoundary(
76
157
  contextId: string,
77
158
  timestampMs: number,
@@ -210,8 +291,21 @@ export function ttsError(
210
291
  return { kind: "tts.error", contextId, timestampMs, component: "tts", category, cause, isRecoverable };
211
292
  }
212
293
 
213
- export function injectMessage(contextId: string, timestampMs: number, text: string): InjectMessagePacket {
214
- return { kind: "inject.message", contextId, timestampMs, text };
294
+ export function injectMessage(
295
+ contextId: string,
296
+ timestampMs: number,
297
+ text: string,
298
+ mode?: InjectMessagePacket["mode"],
299
+ ): InjectMessagePacket {
300
+ return { kind: "inject.message", contextId, timestampMs, text, ...(mode ? { mode } : {}) };
301
+ }
302
+
303
+ export function sttReconfigure(
304
+ contextId: string,
305
+ timestampMs: number,
306
+ partial: SttReconfigurePartial,
307
+ ): SttReconfigurePacket {
308
+ return { kind: "stt.reconfigure", contextId, timestampMs, partial };
215
309
  }
216
310
 
217
311
  export function llmDelta(contextId: string, timestampMs: number, text: string): LlmDeltaPacket {
@@ -268,3 +362,17 @@ export function interactionBackchannel(
268
362
  ): InteractionBackchannelPacket {
269
363
  return { kind: "interaction.backchannel", contextId, timestampMs, cue };
270
364
  }
365
+
366
+ export function interactionDuck(
367
+ contextId: string,
368
+ timestampMs: number,
369
+ ): InteractionDuckPacket {
370
+ return { kind: "interaction.duck", contextId, timestampMs };
371
+ }
372
+
373
+ export function interactionResume(
374
+ contextId: string,
375
+ timestampMs: number,
376
+ ): InteractionResumePacket {
377
+ return { kind: "interaction.resume", contextId, timestampMs };
378
+ }
package/src/packets.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  // Lifecycle: InitStepCompleted, InitFailed, InitCompleted
11
11
 
12
12
  import type { WordTiming } from "./interaction-policy.js";
13
+ import type { SttReconfigurePartial } from "./plugin-contract.js";
13
14
 
14
15
  // =============================================================================
15
16
  // Base Types
@@ -199,6 +200,16 @@ export interface FinalizeSttPacket extends VoicePacket {
199
200
  readonly kind: "stt.finalize";
200
201
  }
201
202
 
203
+ /**
204
+ * Per-turn STT reconfigure actuation. Session routes to the stt plugin's
205
+ * `sttReconfigure` seam when present (warn-and-no-op otherwise).
206
+ * Call at a turn boundary so reconnect-based STTs do not drop mid-utterance audio.
207
+ */
208
+ export interface SttReconfigurePacket extends VoicePacket {
209
+ readonly kind: "stt.reconfigure";
210
+ readonly partial: SttReconfigurePartial;
211
+ }
212
+
202
213
  export interface SttErrorPacket extends VoicePacket, VoiceErrorPacket {
203
214
  readonly kind: "stt.error";
204
215
  readonly component: "stt";
@@ -280,6 +291,32 @@ export interface DtmfReceivedPacket extends VoicePacket {
280
291
  readonly rawDigit: string;
281
292
  }
282
293
 
294
+ /**
295
+ * Outbound DTMF request (IVR navigation). Digits may include pause syntax
296
+ * `w` (0.5s) / `W` (1s). Mechanism unit-tested; live carrier decode unverified.
297
+ */
298
+ export interface DtmfSendPacket extends VoicePacket {
299
+ readonly kind: "dtmf.send";
300
+ /** Digits in `[0-9*#wW]+`. */
301
+ readonly digits: string;
302
+ }
303
+
304
+ export type CallTransferMode = "warm" | "cold" | "sip_refer";
305
+
306
+ /**
307
+ * Outbound call transfer. Prefer Call-Control transfer over SIP REFER where
308
+ * answer-rate matters (REFER drops STIR/SHAKEN attestation). Mechanism
309
+ * unit-tested; live transfer bridge unverified against a carrier.
310
+ */
311
+ export interface CallTransferPacket extends VoicePacket {
312
+ readonly kind: "call.transfer";
313
+ readonly mode: CallTransferMode;
314
+ /** E.164 number or SIP URI. */
315
+ readonly target: string;
316
+ /** Warm-handoff context for the receiving agent/human (mode `"warm"`). */
317
+ readonly summary?: string;
318
+ }
319
+
283
320
  // =============================================================================
284
321
  // LLM Pipeline Packets
285
322
  // =============================================================================
@@ -354,6 +391,14 @@ export interface DelegateResultPacket extends VoicePacket {
354
391
  readonly grounded: boolean;
355
392
  readonly toolId?: string;
356
393
  readonly toolName?: string;
394
+ readonly control?: {
395
+ readonly name: string;
396
+ readonly payload: unknown;
397
+ };
398
+ readonly blocked?: {
399
+ readonly userFacingMessage: string;
400
+ readonly payload?: unknown;
401
+ };
357
402
  }
358
403
 
359
404
  export interface ReasoningResumePacket extends VoicePacket {
@@ -480,6 +525,16 @@ export interface InteractionBackchannelPacket extends VoicePacket {
480
525
  readonly cue: string;
481
526
  }
482
527
 
528
+ /** Signal to attenuate active TTS while a barge-in is being evaluated (pending window). */
529
+ export interface InteractionDuckPacket extends VoicePacket {
530
+ readonly kind: "interaction.duck";
531
+ }
532
+
533
+ /** Signal to restore attenuated TTS when the pending window resolved without an interrupt. */
534
+ export interface InteractionResumePacket extends VoicePacket {
535
+ readonly kind: "interaction.resume";
536
+ }
537
+
483
538
  // =============================================================================
484
539
  // Behavior Packets
485
540
  // =============================================================================
@@ -496,6 +551,8 @@ export interface StopIdleTimeoutPacket extends VoicePacket {
496
551
  export interface InjectMessagePacket extends VoicePacket {
497
552
  readonly kind: "inject.message";
498
553
  readonly text: string;
554
+ /** Defaults to speak for compatibility with existing inject.message producers. */
555
+ readonly mode?: "speak" | "context";
499
556
  }
500
557
 
501
558
  export interface DisconnectRequestedPacket extends VoicePacket {
@@ -537,6 +594,58 @@ export interface ConversationMetricPacket extends VoicePacket {
537
594
  readonly value: string;
538
595
  }
539
596
 
597
+ export type AcousticSignal =
598
+ | "prosody"
599
+ | "backchannel"
600
+ | "interruption"
601
+ | "primary_speaker"
602
+ | "echo_rejected"
603
+ | "cadence";
604
+
605
+ export interface AcousticSignalPacket extends VoicePacket {
606
+ readonly kind: "acoustic.signal";
607
+ readonly signal: AcousticSignal;
608
+ readonly payload?: Readonly<Record<string, unknown>>;
609
+ }
610
+
611
+ export type TurnLocalizationVerdict = "infrastructure" | "conversation" | "none";
612
+
613
+ export interface TurnLocalizationPacket extends VoicePacket {
614
+ readonly kind: "turn.localization";
615
+ readonly value: TurnLocalizationVerdict;
616
+ readonly infrastructureBreached: boolean;
617
+ readonly conversationFlagged: boolean;
618
+ }
619
+
620
+ /** The pipeline stage that consumed resources. Billing planes group cost by this. */
621
+ export type UsageStage = "llm" | "stt" | "tts";
622
+
623
+ /**
624
+ * One unit of billable resource consumption, recorded where it happens.
625
+ *
626
+ * The full shape is defined up front — LLM tokens, STT audio-seconds, TTS characters —
627
+ * so a producer added later needs no schema change; only the LLM producer is wired today
628
+ * (the AI SDK finish part carries usage the bridge had been dropping). `VoiceAgentSession`
629
+ * accumulates these into an end-of-session `session.usage` manifest and exports them as
630
+ * counters — the load-bearing seam for metering, spend caps, and eventual per-tenant billing.
631
+ * Fields not applicable to a stage are simply absent (tokens on STT/TTS, seconds on LLM).
632
+ */
633
+ export interface UsageRecordedPacket extends VoicePacket {
634
+ readonly kind: "usage.recorded";
635
+ readonly stage: UsageStage;
636
+ readonly provider?: string;
637
+ readonly model?: string;
638
+ // LLM
639
+ readonly inputTokens?: number;
640
+ readonly outputTokens?: number;
641
+ readonly totalTokens?: number;
642
+ readonly cachedInputTokens?: number;
643
+ readonly reasoningTokens?: number;
644
+ // STT / TTS
645
+ readonly audioSeconds?: number;
646
+ readonly characters?: number;
647
+ }
648
+
540
649
  export type TurnBoundaryKind =
541
650
  | "user_started_speaking"
542
651
  | "user_stopped_speaking"
@@ -583,6 +692,7 @@ export type InputPacket =
583
692
  | SttPartialPacket
584
693
  | SttResultPacket
585
694
  | FinalizeSttPacket
695
+ | SttReconfigurePacket
586
696
  | SttErrorPacket
587
697
  | EndOfSpeechAudioPacket
588
698
  | EndOfSpeechPacket
@@ -628,7 +738,12 @@ export type AnyErrorPacket =
628
738
  | InitializationFailedPacket;
629
739
 
630
740
  /** Observability packets (Background route). */
631
- export type ObservabilityPacket = ConversationMetricPacket | TurnBoundaryEventPacket;
741
+ export type ObservabilityPacket =
742
+ | ConversationMetricPacket
743
+ | TurnBoundaryEventPacket
744
+ | UsageRecordedPacket
745
+ | AcousticSignalPacket
746
+ | TurnLocalizationPacket;
632
747
 
633
748
  /** Delegate (Responder-Thinker) lifecycle packets (Background route). */
634
749
  export type DelegatePacket = DelegateQueryPacket | DelegateResultPacket;
@@ -22,9 +22,43 @@ export interface EndpointingCapability {
22
22
  readonly disableConfig?: PluginConfig;
23
23
  }
24
24
 
25
+ /**
26
+ * Vendor-agnostic mid-stream STT reconfiguration. Per-turn reconfigure is a COMMODITY STT capability
27
+ * (Deepgram Flux `Configure`, AssemblyAI `UpdateConfiguration`, Speechmatics `SetRecognitionConfig`) —
28
+ * the value of THIS seam is normalizing those differing wire shapes behind one interface so an
29
+ * InteractionPolicy can actuate any STT without vendor-specific plumbing. All fields optional; a plugin
30
+ * applies what it supports and ignores the rest (best-effort — e.g. Deepgram ignores `contextText`).
31
+ */
32
+ export interface SttReconfigurePartial {
33
+ readonly keyterms?: readonly string[];
34
+ readonly eotThreshold?: number;
35
+ readonly eagerEotThreshold?: number;
36
+ readonly eotTimeoutMs?: number;
37
+ /** Silence-based endpointing (ms). Nova-style; Flux may ignore (it uses eotTimeoutMs). */
38
+ readonly endpointingMs?: number;
39
+ readonly vadThreshold?: number;
40
+ readonly languageHints?: readonly string[];
41
+ /**
42
+ * Hard recognition-language switch (e.g. "en-US" → "es-ES", or Nova-3 "multi" for code-switch).
43
+ * Distinct from `languageHints` (soft bias): this changes the recognizer's language. Providers
44
+ * that carry language in the connection URL (Nova) reconnect to apply it; model-fixed providers
45
+ * (Flux, `flux-general-en`) ignore it and rely on `languageHints`.
46
+ */
47
+ readonly language?: string;
48
+ /** AssemblyAI-style agent-context biasing (the agent's own prior reply). Ignored where unsupported. */
49
+ readonly contextText?: string;
50
+ }
51
+
52
+ export interface SttReconfigure {
53
+ reconfigure(partial: SttReconfigurePartial): void;
54
+ }
55
+
25
56
  export interface VoicePlugin {
26
57
  readonly endpointingCapability?: EndpointingCapability;
27
58
 
59
+ /** Present when the STT supports mid-stream reconfiguration (see {@link SttReconfigure}). */
60
+ readonly sttReconfigure?: SttReconfigure;
61
+
28
62
  /**
29
63
  * Initialize the plugin. Called during the init chain.
30
64
  * Connect to provider, start streams, register bus handlers if needed.
@@ -34,6 +68,13 @@ export interface VoicePlugin {
34
68
  */
35
69
  initialize(bus: PipelineBus, config: PluginConfig): Promise<void>;
36
70
 
71
+ /**
72
+ * Best-effort warm of remote/expensive resources (open connections, wake a scaled-to-zero
73
+ * endpoint). Called AFTER initialize, before the first turn. Must not throw fatally — swallow
74
+ * errors internally. Optional; plugins with nothing to warm omit it.
75
+ */
76
+ prewarm?(): Promise<void>;
77
+
37
78
  /**
38
79
  * Tear down the plugin. Called during the finalize chain (reverse order).
39
80
  * Close connections, flush buffers, release resources.
@@ -11,11 +11,19 @@ export interface RuleBasedInteractionPolicyDeps {
11
11
  readonly primarySpeakerGate: PrimarySpeakerGate;
12
12
  readonly ttsPlayout: TtsPlayoutClock;
13
13
  readonly minInterruptionMs: number;
14
+ /** When true, the underlying TurnArbiter emits duck/resume signals during barge-in evaluation. */
15
+ readonly pauseThenResolveBargeIn?: boolean;
16
+ /** Tenant-level backchannel knobs. Default (undefined) = current behavior (`mm_hmm`). */
17
+ readonly backchannel?: {
18
+ readonly enabled?: boolean;
19
+ readonly cues?: readonly string[];
20
+ };
14
21
  }
15
22
 
16
23
  export class RuleBasedInteractionPolicy implements InteractionPolicy {
17
24
  readonly arbiter: TurnArbiter;
18
25
  private readonly ttsPlayout: TtsPlayoutClock;
26
+ private readonly backchannelConfig: RuleBasedInteractionPolicyDeps["backchannel"];
19
27
  private pending: InteractionDecision[] = [];
20
28
  private bargeInAudioConsumed = false;
21
29
  private delegateGapOpen = false;
@@ -24,8 +32,13 @@ export class RuleBasedInteractionPolicy implements InteractionPolicy {
24
32
 
25
33
  constructor(deps: RuleBasedInteractionPolicyDeps) {
26
34
  this.ttsPlayout = deps.ttsPlayout;
35
+ this.backchannelConfig = deps.backchannel;
27
36
  this.arbiter = new TurnArbiter({
28
- ...deps,
37
+ bus: deps.bus,
38
+ primarySpeakerGate: deps.primarySpeakerGate,
39
+ ttsPlayout: deps.ttsPlayout,
40
+ minInterruptionMs: deps.minInterruptionMs,
41
+ pauseThenResolveBargeIn: deps.pauseThenResolveBargeIn,
29
42
  onInterrupt: (id) => this.pending.push({ kind: "interrupt", interruptedContextId: id }),
30
43
  });
31
44
  }
@@ -109,7 +122,9 @@ export class RuleBasedInteractionPolicy implements InteractionPolicy {
109
122
  if (this.depsTtsActive()) return [];
110
123
  if (this.userSpeaking) return [];
111
124
  this.delegateCuePlayed = true;
112
- return [{ kind: "backchannel", cue: "mm_hmm" }];
125
+ if (this.backchannelConfig?.enabled === false) return [];
126
+ const cue = this.backchannelConfig?.cues?.[0] ?? "mm_hmm";
127
+ return [{ kind: "backchannel", cue }];
113
128
  }
114
129
  case "complete":
115
130
  case "failed":