@kuralle-syrinx/core 3.1.0 → 4.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/core",
3
- "version": "3.1.0",
3
+ "version": "4.0.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,7 +49,11 @@ export function createConversationEventStream(): [
49
49
  });
50
50
 
51
51
  const push = (event: ConversationEvent): void => {
52
- if (controller) {
52
+ // Only retain events when a reader is attached. A ReadableStream buffers
53
+ // enqueue() without bound; with no debug consumer (nothing in-tree reads this
54
+ // by default) every packet's event would be retained for the whole call and
55
+ // grow memory unbounded. `locked` is true once getReader() is called.
56
+ if (controller && stream.locked) {
53
57
  try {
54
58
  controller.enqueue(event);
55
59
  } catch {
package/src/index.ts CHANGED
@@ -63,6 +63,20 @@ export {
63
63
  type ReasoningResumePacket,
64
64
  } from "./packets.js";
65
65
 
66
+ // Pipeline packets — delegate (Responder-Thinker) observability
67
+ export {
68
+ type DelegateQueryPacket,
69
+ type DelegateResultPacket,
70
+ type DelegatePacket,
71
+ type RealtimeResumptionHandlePacket,
72
+ } from "./packets.js";
73
+
74
+ // Durable reasoner session state (G4)
75
+ export {
76
+ InMemoryReasonerSessionStore,
77
+ type ReasonerSessionStore,
78
+ } from "./reasoner-session-store.js";
79
+
66
80
  // Pipeline packets — TTS
67
81
  export {
68
82
  type TextToSpeechTextPacket,
package/src/packets.ts CHANGED
@@ -300,12 +300,58 @@ export interface ReasoningSuspendedPacket extends VoicePacket {
300
300
  readonly payload: unknown;
301
301
  }
302
302
 
303
+ // =============================================================================
304
+ // Delegate (Responder-Thinker) observability packets (Background route)
305
+ // =============================================================================
306
+
307
+ /**
308
+ * The bridge handed a query to the Reasoner. Emitted the moment the delegate
309
+ * turn starts (RealtimeBridge `runDelegate`) or the cascade turn reaches the
310
+ * reasoner (ReasoningBridge), BEFORE the stream runs. Background route:
311
+ * droppable, never blocks the hot path (RFC bimodel-delegate-seam R4).
312
+ * `toolId`/`toolName` are present on realtime delegate turns (the front-model
313
+ * tool call that triggered the delegate); absent on cascade turns.
314
+ */
315
+ export interface DelegateQueryPacket extends VoicePacket {
316
+ readonly kind: "delegate.query";
317
+ readonly query: string;
318
+ readonly toolId?: string;
319
+ readonly toolName?: string;
320
+ }
321
+
322
+ /**
323
+ * The Reasoner produced the turn's final answer. Self-contained: carries the
324
+ * `query` again so a consumer can log/persist the Q&A pair from this one packet
325
+ * without correlating against the earlier `delegate.query`. `durationMs` spans
326
+ * query → answer; `grounded` is true when the reasoner stream surfaced at least
327
+ * one `tool-result` part (e.g. a retrieval hit) while producing the answer.
328
+ */
329
+ export interface DelegateResultPacket extends VoicePacket {
330
+ readonly kind: "delegate.result";
331
+ readonly query: string;
332
+ readonly answer: string;
333
+ readonly durationMs: number;
334
+ readonly grounded: boolean;
335
+ readonly toolId?: string;
336
+ readonly toolName?: string;
337
+ }
338
+
303
339
  export interface ReasoningResumePacket extends VoicePacket {
304
340
  readonly kind: "reasoning.resume";
305
341
  readonly runId: string;
306
342
  readonly data: unknown;
307
343
  }
308
344
 
345
+ /**
346
+ * G4: a realtime provider with native session resume (Gemini Live) issued a new
347
+ * resumption handle. Background route; a durable host (e.g. cf-agents) persists
348
+ * the latest handle and passes it back on reconnect instead of replaying history.
349
+ */
350
+ export interface RealtimeResumptionHandlePacket extends VoicePacket {
351
+ readonly kind: "realtime.resumption_handle";
352
+ readonly handle: string;
353
+ }
354
+
309
355
  // =============================================================================
310
356
  // Output Pipeline Packets (LLM text → TTS audio)
311
357
  // =============================================================================
@@ -551,3 +597,6 @@ export type AnyErrorPacket =
551
597
 
552
598
  /** Observability packets (Background route). */
553
599
  export type ObservabilityPacket = ConversationMetricPacket | TurnBoundaryEventPacket;
600
+
601
+ /** Delegate (Responder-Thinker) lifecycle packets (Background route). */
602
+ export type DelegatePacket = DelegateQueryPacket | DelegateResultPacket;
@@ -171,6 +171,20 @@ describe("PipelineBusImpl", () => {
171
171
  packet: { kind: "critical.event", contextId: "critical-1" },
172
172
  });
173
173
  });
174
+
175
+ it("does not retain packets when no reader is attached (drop-on-unread)", async () => {
176
+ const bus = createBus();
177
+ // No getReader() → stream unlocked → nothing retained (else a call with no
178
+ // recorder would buffer every audio packet and OOM).
179
+ for (let i = 0; i < 1000; i++) bus.push(Route.Main, pkt("main.event", `n-${i}`));
180
+
181
+ // A reader that attaches later sees only packets pushed AFTER it attaches.
182
+ const reader = bus.allPackets.getReader();
183
+ bus.push(Route.Main, pkt("main.event", "after-reader"));
184
+ const next = await reader.read();
185
+ reader.releaseLock();
186
+ expect(next.value).toMatchObject({ packet: { contextId: "after-reader" } });
187
+ });
174
188
  });
175
189
 
176
190
  describe("error handling", () => {
@@ -239,6 +239,11 @@ export class PipelineBusImpl implements PipelineBus {
239
239
  private publishAllPackets(route: Route, packet: VoicePacket): void {
240
240
  this.onPacket?.(route, packet);
241
241
  if (!this.allPacketsController) return;
242
+ // Only retain packets when a reader is actually attached. A ReadableStream
243
+ // buffers enqueue() without bound until read, so with no consumer (the default
244
+ // deployment — recorder is optional) this would retain every audio buffer of
245
+ // the whole call and OOM. `locked` is true once getReader() is called.
246
+ if (!this.allPackets.locked) return;
242
247
  try {
243
248
  this.allPacketsController.enqueue({ route, packet });
244
249
  } catch {
@@ -0,0 +1,37 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — durable reasoner session state (G4, RFC bimodel-delegate-seam)
4
+ //
5
+ // The bridge owns conversation history (reasoner.ts §4.5); this seam makes that
6
+ // history durable so a reasoner resumes with the same context after a host
7
+ // eviction/reconnect instead of restarting amnesiac. Concrete backends: the
8
+ // in-memory impl below (Node/tests), a DO-SQLite impl in @kuralle-syrinx/cf-agents.
9
+
10
+ import type { ReasonerMessage } from "./reasoner.js";
11
+
12
+ /**
13
+ * Durable per-session reasoner conversation history. `save` persists the full
14
+ * (already bounded) history snapshot — snapshot semantics, not append, because
15
+ * barge-in truncation rewrites earlier messages to the heard prefix (G25).
16
+ */
17
+ export interface ReasonerSessionStore {
18
+ load(sessionId: string): Promise<readonly ReasonerMessage[]> | readonly ReasonerMessage[];
19
+ save(sessionId: string, messages: readonly ReasonerMessage[]): Promise<void> | void;
20
+ clear(sessionId: string): Promise<void> | void;
21
+ }
22
+
23
+ export class InMemoryReasonerSessionStore implements ReasonerSessionStore {
24
+ private readonly sessions = new Map<string, readonly ReasonerMessage[]>();
25
+
26
+ load(sessionId: string): readonly ReasonerMessage[] {
27
+ return this.sessions.get(sessionId) ?? [];
28
+ }
29
+
30
+ save(sessionId: string, messages: readonly ReasonerMessage[]): void {
31
+ this.sessions.set(sessionId, [...messages]);
32
+ }
33
+
34
+ clear(sessionId: string): void {
35
+ this.sessions.delete(sessionId);
36
+ }
37
+ }
@@ -89,6 +89,20 @@ export class TtsPlayoutClock {
89
89
  return this.active.has(contextId);
90
90
  }
91
91
 
92
+ /** All still-active contexts, insertion order (oldest first). */
93
+ activeContexts(): string[] {
94
+ return [...this.active];
95
+ }
96
+
97
+ /**
98
+ * Wall-clock estimate of when this context's audio finishes playing out, or
99
+ * undefined if unknown. Anchors the idle timer to real playout end rather than
100
+ * chunk-arrival (TTS streams faster than realtime).
101
+ */
102
+ playoutEnd(contextId: string): number | undefined {
103
+ return this.playoutEndMs.get(contextId);
104
+ }
105
+
92
106
  /** The most-recently-added still-active context (insertion order), or "" if none. */
93
107
  latestActive(): string {
94
108
  let latest = "";
@@ -49,7 +49,7 @@ const BACKCHANNELS = new Set([
49
49
  "oh",
50
50
  ]);
51
51
 
52
- function isBackchannel(text: string): boolean {
52
+ export function isBackchannel(text: string): boolean {
53
53
  const norm = text
54
54
  .toLowerCase()
55
55
  .replace(/[^a-z\s'-]/g, "")
@@ -321,6 +321,198 @@ describe("VoiceAgentSession", () => {
321
321
  await closeSession(session);
322
322
  });
323
323
 
324
+ it("G2/WBS-1: surfaces delegate.query/delegate.result packets as delegate_query/delegate_result session events", async () => {
325
+ const session = new VoiceAgentSession({ plugins: {} });
326
+ const queries: Array<{ turnId: string; query: string; toolName?: string }> = [];
327
+ const results: Array<{ turnId: string; query: string; answer: string; durationMs: number; grounded: boolean }> = [];
328
+ session.on("delegate_query", (event) => queries.push(event));
329
+ session.on("delegate_result", (event) => results.push(event));
330
+ await session.start();
331
+
332
+ session.bus.push(Route.Background, {
333
+ kind: "delegate.query",
334
+ contextId: "turn-1",
335
+ timestampMs: Date.now(),
336
+ query: "When is the deadline?",
337
+ toolId: "call_1",
338
+ toolName: "consult_knowledge",
339
+ });
340
+ session.bus.push(Route.Background, {
341
+ kind: "delegate.result",
342
+ contextId: "turn-1",
343
+ timestampMs: Date.now(),
344
+ query: "When is the deadline?",
345
+ answer: "March 31.",
346
+ durationMs: 42,
347
+ grounded: true,
348
+ toolId: "call_1",
349
+ toolName: "consult_knowledge",
350
+ });
351
+
352
+ await new Promise((resolve) => setTimeout(resolve, 20));
353
+
354
+ expect(queries).toEqual([
355
+ { tsMs: expect.any(Number), turnId: "turn-1", query: "When is the deadline?", toolId: "call_1", toolName: "consult_knowledge" },
356
+ ]);
357
+ expect(results).toEqual([
358
+ {
359
+ tsMs: expect.any(Number),
360
+ turnId: "turn-1",
361
+ query: "When is the deadline?",
362
+ answer: "March 31.",
363
+ durationMs: 42,
364
+ grounded: true,
365
+ toolId: "call_1",
366
+ toolName: "consult_knowledge",
367
+ },
368
+ ]);
369
+
370
+ await closeSession(session);
371
+ });
372
+
373
+ it("G3/WBS-3: tool-call lifecycle cues fire started → delayed → complete", async () => {
374
+ const session = new VoiceAgentSession({ plugins: {}, delayCueAfterMs: 30 });
375
+ const cues: Array<{ phase: string; turnId: string; toolId: string; toolName: string; afterMs?: number }> = [];
376
+ session.on("tool_call_cue", (event) => cues.push(event));
377
+ await session.start();
378
+
379
+ session.bus.push(Route.Main, {
380
+ kind: "llm.tool_call",
381
+ contextId: "turn-1",
382
+ timestampMs: Date.now(),
383
+ toolId: "t1",
384
+ toolName: "consult_knowledge",
385
+ toolArgs: { query: "fees" },
386
+ });
387
+
388
+ await new Promise((resolve) => setTimeout(resolve, 70));
389
+
390
+ session.bus.push(Route.Main, {
391
+ kind: "llm.tool_result",
392
+ contextId: "turn-1",
393
+ timestampMs: Date.now(),
394
+ toolId: "t1",
395
+ toolName: "consult_knowledge",
396
+ result: "answer",
397
+ });
398
+
399
+ await new Promise((resolve) => setTimeout(resolve, 20));
400
+
401
+ expect(cues.map((cue) => cue.phase)).toEqual(["started", "delayed", "complete"]);
402
+ expect(cues[0]).toMatchObject({ turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge" });
403
+ expect(cues[1]!.afterMs).toBe(30);
404
+
405
+ await closeSession(session);
406
+ });
407
+
408
+ it("G3/WBS-3: no delayed cue when the result beats the timer; timer 0 disables it", async () => {
409
+ const session = new VoiceAgentSession({ plugins: {}, delayCueAfterMs: 5000 });
410
+ const cues: Array<{ phase: string }> = [];
411
+ session.on("tool_call_cue", (event) => cues.push(event));
412
+ await session.start();
413
+
414
+ session.bus.push(Route.Main, {
415
+ kind: "llm.tool_call",
416
+ contextId: "turn-1",
417
+ timestampMs: Date.now(),
418
+ toolId: "t1",
419
+ toolName: "consult_knowledge",
420
+ toolArgs: {},
421
+ });
422
+ session.bus.push(Route.Main, {
423
+ kind: "llm.tool_result",
424
+ contextId: "turn-1",
425
+ timestampMs: Date.now(),
426
+ toolId: "t1",
427
+ toolName: "consult_knowledge",
428
+ result: "fast answer",
429
+ });
430
+
431
+ await new Promise((resolve) => setTimeout(resolve, 30));
432
+ expect(cues.map((cue) => cue.phase)).toEqual(["started", "complete"]);
433
+
434
+ await closeSession(session);
435
+
436
+ const disabled = new VoiceAgentSession({ plugins: {}, delayCueAfterMs: 0 });
437
+ const disabledCues: Array<{ phase: string }> = [];
438
+ disabled.on("tool_call_cue", (event) => disabledCues.push(event));
439
+ await disabled.start();
440
+ disabled.bus.push(Route.Main, {
441
+ kind: "llm.tool_call",
442
+ contextId: "turn-2",
443
+ timestampMs: Date.now(),
444
+ toolId: "t2",
445
+ toolName: "consult_knowledge",
446
+ toolArgs: {},
447
+ });
448
+ await new Promise((resolve) => setTimeout(resolve, 30));
449
+ expect(disabledCues.map((cue) => cue.phase)).toEqual(["started"]);
450
+ await closeSession(disabled);
451
+ });
452
+
453
+ it("G3/WBS-3: llm.error while a tool call is pending fires the failed cue", async () => {
454
+ const session = new VoiceAgentSession({ plugins: {}, delayCueAfterMs: 0 });
455
+ const cues: Array<{ phase: string; toolId: string }> = [];
456
+ session.on("tool_call_cue", (event) => cues.push(event));
457
+ await session.start();
458
+
459
+ session.bus.push(Route.Main, {
460
+ kind: "llm.tool_call",
461
+ contextId: "turn-1",
462
+ timestampMs: Date.now(),
463
+ toolId: "t1",
464
+ toolName: "consult_knowledge",
465
+ toolArgs: {},
466
+ });
467
+ await new Promise((resolve) => setTimeout(resolve, 10)); // let Main drain before the Critical error
468
+ session.bus.push(Route.Critical, {
469
+ kind: "llm.error",
470
+ contextId: "turn-1",
471
+ timestampMs: Date.now(),
472
+ component: "bridge",
473
+ category: ErrorCategory.NetworkTimeout,
474
+ cause: new Error("delegate failed"),
475
+ isRecoverable: true,
476
+ });
477
+
478
+ await new Promise((resolve) => setTimeout(resolve, 30));
479
+ expect(cues.map((cue) => cue.phase)).toEqual(["started", "failed"]);
480
+ expect(cues[1]!.toolId).toBe("t1");
481
+
482
+ await closeSession(session);
483
+ });
484
+
485
+ it("G3/WBS-3 (R5): barge-in fails the pending cue and the interrupt path is unaffected", async () => {
486
+ const session = new VoiceAgentSession({ plugins: {}, delayCueAfterMs: 5000, minInterruptionMs: 0 });
487
+ const cues: Array<{ phase: string }> = [];
488
+ const interrupts: string[] = [];
489
+ session.on("tool_call_cue", (event) => cues.push(event));
490
+ session.bus.on("interrupt.tts", (pkt) => { interrupts.push((pkt as { contextId: string }).contextId); });
491
+ await session.start();
492
+
493
+ session.bus.push(Route.Main, {
494
+ kind: "llm.tool_call",
495
+ contextId: "turn-1",
496
+ timestampMs: Date.now(),
497
+ toolId: "t1",
498
+ toolName: "consult_knowledge",
499
+ toolArgs: {},
500
+ });
501
+ await new Promise((resolve) => setTimeout(resolve, 10)); // let Main drain before the Critical interrupt
502
+ session.bus.push(Route.Critical, {
503
+ kind: "interrupt.detected",
504
+ contextId: "turn-1",
505
+ timestampMs: Date.now(),
506
+ source: "client",
507
+ });
508
+
509
+ await new Promise((resolve) => setTimeout(resolve, 30));
510
+ expect(cues.map((cue) => cue.phase)).toEqual(["started", "failed"]);
511
+ expect(interrupts).toContain("turn-1"); // barge-in cancel still flowed
512
+
513
+ await closeSession(session);
514
+ });
515
+
324
516
  it("emits normalized debug events for bus packets", async () => {
325
517
  const session = new VoiceAgentSession({ plugins: {} });
326
518
  const reader = session.debugEvents.getReader();
@@ -2485,3 +2677,85 @@ describe("VoiceAgentSession — handler errors must not kill the call", () => {
2485
2677
  await closeSession(session);
2486
2678
  });
2487
2679
  });
2680
+
2681
+ describe("VoiceAgentSession supersede + thinking-phase barge-in", () => {
2682
+ it("cancels a still-playing prior turn's TTS when a new turn completes (L1)", async () => {
2683
+ const session = new VoiceAgentSession({ plugins: {} });
2684
+ const interruptTts: InterruptTtsPacket[] = [];
2685
+ await session.start();
2686
+ session.bus.on("interrupt.tts", (pkt) => { interruptTts.push(pkt as InterruptTtsPacket); });
2687
+
2688
+ // Turn 1 is generating + has streamed audio (still playing out).
2689
+ session.bus.push(Route.Main, {
2690
+ kind: "eos.turn_complete", contextId: "turn-1", timestampMs: Date.now(), text: "one", transcripts: [],
2691
+ } satisfies EndOfSpeechPacket);
2692
+ await new Promise((r) => setTimeout(r, 5));
2693
+ session.bus.push(Route.Main, {
2694
+ kind: "tts.audio", contextId: "turn-1", timestampMs: Date.now(),
2695
+ audio: new Uint8Array(16000), sampleRateHz: 16000, // ~0.5s of audio still playing
2696
+ } satisfies TextToSpeechAudioPacket);
2697
+ await new Promise((r) => setTimeout(r, 5));
2698
+
2699
+ // Turn 2 completes while turn 1 is still playing → turn 1 must be cancelled.
2700
+ session.bus.push(Route.Main, {
2701
+ kind: "eos.turn_complete", contextId: "turn-2", timestampMs: Date.now(), text: "two", transcripts: [],
2702
+ } satisfies EndOfSpeechPacket);
2703
+ await new Promise((r) => setTimeout(r, 10));
2704
+
2705
+ expect(interruptTts.some((p) => p.contextId === "turn-1")).toBe(true);
2706
+ expect(interruptTts.some((p) => p.contextId === "turn-2")).toBe(false);
2707
+
2708
+ await closeSession(session);
2709
+ });
2710
+
2711
+ it("honors a client interrupt during the reasoner TTFT gap, before any audio (B3)", async () => {
2712
+ const session = new VoiceAgentSession({ plugins: {} });
2713
+ const interruptLlm: InterruptLlmPacket[] = [];
2714
+ await session.start();
2715
+ session.bus.on("interrupt.llm", (pkt) => { interruptLlm.push(pkt as InterruptLlmPacket); });
2716
+
2717
+ // Turn completes; generation is in-flight but NO tts.audio has played yet.
2718
+ session.bus.push(Route.Main, {
2719
+ kind: "eos.turn_complete", contextId: "turn-think", timestampMs: Date.now(), text: "q", transcripts: [],
2720
+ } satisfies EndOfSpeechPacket);
2721
+ await new Promise((r) => setTimeout(r, 5));
2722
+
2723
+ session.requestClientInterrupt("turn-think");
2724
+ await new Promise((r) => setTimeout(r, 10));
2725
+
2726
+ expect(interruptLlm.some((p) => p.contextId === "turn-think")).toBe(true);
2727
+
2728
+ await closeSession(session);
2729
+ });
2730
+
2731
+ it("drops a backchannel during the assistant's turn: no cancel, no second response (B4)", async () => {
2732
+ const session = new VoiceAgentSession({ plugins: {} });
2733
+ const interruptTts: InterruptTtsPacket[] = [];
2734
+ const userInputs: UserInputPacket[] = [];
2735
+ await session.start();
2736
+ session.bus.on("interrupt.tts", (pkt) => { interruptTts.push(pkt as InterruptTtsPacket); });
2737
+ session.bus.on("user.input", (pkt) => { userInputs.push(pkt as UserInputPacket); });
2738
+
2739
+ // Assistant is speaking turn-1 (audio actively playing out).
2740
+ session.bus.push(Route.Main, {
2741
+ kind: "eos.turn_complete", contextId: "turn-1", timestampMs: Date.now(), text: "here is the answer", transcripts: [],
2742
+ } satisfies EndOfSpeechPacket);
2743
+ await new Promise((r) => setTimeout(r, 5));
2744
+ session.bus.push(Route.Main, {
2745
+ kind: "tts.audio", contextId: "turn-1", timestampMs: Date.now(), audio: new Uint8Array(16000), sampleRateHz: 16000,
2746
+ } satisfies TextToSpeechAudioPacket);
2747
+ await new Promise((r) => setTimeout(r, 5));
2748
+
2749
+ // User says "uh-huh" (a rotated context) mid-answer — a backchannel, not a turn.
2750
+ session.bus.push(Route.Main, {
2751
+ kind: "eos.turn_complete", contextId: "turn-2", timestampMs: Date.now(), text: "uh-huh", transcripts: [],
2752
+ } satisfies EndOfSpeechPacket);
2753
+ await new Promise((r) => setTimeout(r, 10));
2754
+
2755
+ // The assistant's turn is NOT cancelled and the backchannel does NOT drive the LLM.
2756
+ expect(interruptTts.some((p) => p.contextId === "turn-1")).toBe(false);
2757
+ expect(userInputs.some((p) => p.contextId === "turn-2")).toBe(false);
2758
+
2759
+ await closeSession(session);
2760
+ });
2761
+ });
@@ -29,6 +29,8 @@ import type {
29
29
  UserAudioReceivedPacket,
30
30
  UserTextReceivedPacket,
31
31
  InterruptionDetectedPacket,
32
+ DelegateQueryPacket,
33
+ DelegateResultPacket,
32
34
  LlmDeltaPacket,
33
35
  LlmResponseDonePacket,
34
36
  LlmToolCallPacket,
@@ -61,7 +63,7 @@ import { LatencyFillerController } from "./latency-filler.js";
61
63
  import { PrimarySpeakerGate } from "./primary-speaker-gate.js";
62
64
  import { takeCompleteVoiceText, isCompleteVoiceText, appendVoiceText } from "./voice-text.js";
63
65
  import { TtsPlayoutClock } from "./tts-playout-clock.js";
64
- import { TurnArbiter } from "./turn-arbiter.js";
66
+ import { TurnArbiter, isBackchannel } from "./turn-arbiter.js";
65
67
  import * as make from "./packet-factories.js";
66
68
  import { pluginStage, stageOrder, isAudioStage } from "./init-stage-order.js";
67
69
  import {
@@ -147,6 +149,14 @@ export interface VoiceAgentSessionConfig {
147
149
  * (TTS/STT-failure fallback needs canned audio / a clarification prompt — out of scope.)
148
150
  */
149
151
  errorFallbackText?: string;
152
+ /**
153
+ * G3 (RFC bimodel-delegate-seam): ms a tool call may stay pending before the
154
+ * `tool_call_cue` session event fires its time-triggered `"delayed"` phase — the
155
+ * "still working" cue clients render during a long reasoner wait (cf. Vapi's
156
+ * `request-response-delayed` + `timingMilliseconds`). 0 disables the delayed
157
+ * phase; started/complete/failed always fire. Default: 2000.
158
+ */
159
+ delayCueAfterMs?: number;
150
160
  /**
151
161
  * Which component owns turn boundary (EOS) for this session. Defaults to
152
162
  * provider STT ownership; Smart Turn sessions must opt in explicitly.
@@ -170,6 +180,16 @@ export interface VoiceAgentSessionEvents {
170
180
  agent_text_delta: (event: { tsMs: number; turnId: string; delta: string }) => void;
171
181
  agent_tool_call: (event: { tsMs: number; turnId: string; id: string; name: string; args: Record<string, unknown> }) => void;
172
182
  agent_tool_result: (event: { tsMs: number; turnId: string; id: string; result: string; durationMs: number }) => void;
183
+ delegate_query: (event: { tsMs: number; turnId: string; query: string; toolId?: string; toolName?: string }) => void;
184
+ delegate_result: (event: { tsMs: number; turnId: string; query: string; answer: string; durationMs: number; grounded: boolean; toolId?: string; toolName?: string }) => void;
185
+ /**
186
+ * G3: typed preamble/filler lifecycle for a pending tool call (Vapi-shaped:
187
+ * started / delayed / complete / failed). `delayed` is time-triggered by
188
+ * `delayCueAfterMs` while the call is still pending; `failed` fires on an LLM/bridge
189
+ * error, a barge-in, or a superseding turn while pending (R5). Transports surface
190
+ * these as `tool_call_*` wire messages — the standard "thinking" cue.
191
+ */
192
+ tool_call_cue: (event: { tsMs: number; turnId: string; phase: "started" | "delayed" | "complete" | "failed"; toolId: string; toolName: string; afterMs?: number }) => void;
173
193
  agent_first_audio: (event: { tsMs: number; turnId: string }) => void;
174
194
  agent_finished: (event: { tsMs: number; turnId: string } & Record<string, unknown>) => void;
175
195
  error: (event: { tsMs: number; stage: string; category: string; message: string }) => void;
@@ -187,6 +207,11 @@ interface TtsTextBuffer {
187
207
  /** Suffix marking a context created to speak an error fallback, so it never recurses. */
188
208
  const FALLBACK_CONTEXT_SUFFIX = ":error-fallback";
189
209
 
210
+ /** Scheduler key for a pending tool call's G3 delayed-cue timer. */
211
+ function toolCueTimerKey(contextId: string, toolId: string): string {
212
+ return `tool_cue:${contextId}:${toolId}`;
213
+ }
214
+
190
215
  // =============================================================================
191
216
  // Session Implementation
192
217
  // =============================================================================
@@ -211,6 +236,10 @@ export class VoiceAgentSession {
211
236
  private readonly scheduler: Scheduler;
212
237
  private readonly ttsPlayout: TtsPlayoutClock;
213
238
  private interruptedGenerationContextIds = new Set<string>();
239
+ // Turns whose generation is in-flight (eos.turn_complete emitted, not yet
240
+ // finished/interrupted). Lets a client "stop" during the reasoner TTFT gap —
241
+ // before any audio plays — still abort the turn (thinking-phase barge-in, B3).
242
+ private generatingContextIds = new Set<string>();
214
243
  private ttsTextBuffers = new Map<string, TtsTextBuffer>();
215
244
  private readonly minInterruptionMs: number;
216
245
  private readonly primarySpeakerGate: PrimarySpeakerGate;
@@ -227,6 +256,9 @@ export class VoiceAgentSession {
227
256
  private firstTtsAudioFired = new Set<string>();
228
257
  private readonly errorFallbackText: string;
229
258
  private fallbackInjectedContexts = new Set<string>();
259
+ // G3: pending tool calls per context (toolId → toolName) driving the tool_call_cue lifecycle.
260
+ private readonly delayCueAfterMs: number;
261
+ private pendingToolCues = new Map<string, Map<string, string>>();
230
262
  private readonly endpointingOwner: "provider_stt" | "smart_turn" | "timer";
231
263
  private lastFinalizedContextId = "";
232
264
 
@@ -241,6 +273,7 @@ export class VoiceAgentSession {
241
273
  this.ttsPlayout = new TtsPlayoutClock(this.scheduler);
242
274
  this.sttForceFinalizeTimeoutMs = config.sttForceFinalizeTimeoutMs ?? 7000;
243
275
  this.minInterruptionMs = config.minInterruptionMs ?? 280;
276
+ this.delayCueAfterMs = config.delayCueAfterMs ?? 2000;
244
277
  this.primarySpeakerGate = new PrimarySpeakerGate({
245
278
  enabled: config.primarySpeakerBargeInEnabled !== false,
246
279
  });
@@ -394,6 +427,10 @@ export class VoiceAgentSession {
394
427
  this.ttsTextBuffers.clear();
395
428
  this.interruptedGenerationContextIds.clear();
396
429
  this.firstLlmDeltaReceived.clear();
430
+ for (const [contextId, pending] of this.pendingToolCues) {
431
+ for (const toolId of pending.keys()) this.scheduler.cancel(toolCueTimerKey(contextId, toolId));
432
+ }
433
+ this.pendingToolCues.clear();
397
434
 
398
435
  // 2. Run finalize chain (reverse order)
399
436
  await runFinalizeChain(this.initSteps);
@@ -414,7 +451,16 @@ export class VoiceAgentSession {
414
451
  }
415
452
 
416
453
  requestClientInterrupt(contextId: string): void {
417
- this.turnArbiter.commitClientInterrupt(contextId);
454
+ // Playing out → the arbiter owns the barge-in (primary-speaker reset + metrics).
455
+ if (this.ttsPlayout.isActive(contextId)) {
456
+ this.turnArbiter.commitClientInterrupt(contextId);
457
+ return;
458
+ }
459
+ // Thinking-phase barge-in (B3): no audio yet, but a generation is in-flight —
460
+ // abort it so "stop" during the reasoner TTFT gap is honored, not dropped.
461
+ if (this.generatingContextIds.has(contextId)) {
462
+ this.bus.push(Route.Critical, make.interruptDetected(contextId, Date.now(), "client"));
463
+ }
418
464
  }
419
465
 
420
466
  // =========================================================================
@@ -486,6 +532,10 @@ export class VoiceAgentSession {
486
532
  this.bus.on("llm.tool_call", this.handleLlmToolCall.bind(this));
487
533
  this.bus.on("llm.tool_result", this.handleLlmToolResult.bind(this));
488
534
 
535
+ // Delegate (Responder-Thinker) observability — G2, RFC bimodel-delegate-seam
536
+ this.bus.on("delegate.query", this.handleDelegateQuery.bind(this));
537
+ this.bus.on("delegate.result", this.handleDelegateResult.bind(this));
538
+
489
539
  // TTS
490
540
  this.bus.on("tts.audio", this.handleTtsAudio.bind(this));
491
541
  this.bus.on("tts.end", this.handleTtsEnd.bind(this));
@@ -699,6 +749,20 @@ export class VoiceAgentSession {
699
749
  this.bus.push(Route.Background, make.metric(pkt.contextId, "eos.duplicate_dropped", "1"));
700
750
  return;
701
751
  }
752
+
753
+ // A backchannel ("uh-huh", "okay") uttered WHILE the assistant is still speaking
754
+ // is not a turn (B4). Dropping it here does two things: it does NOT cancel the
755
+ // assistant's in-flight answer (the supersede path below would otherwise kill it),
756
+ // and it does NOT spawn a second LLM response to the backchannel (the double-reply
757
+ // bug). A backchannel with no assistant currently speaking IS a real turn — only
758
+ // suppress when another context's TTS is actively playing. (English-only classifier
759
+ // today — locale-aware backchannels are a separate improvement.)
760
+ const otherTtsActive = this.ttsPlayout.activeContexts().some((c) => c !== pkt.contextId);
761
+ if (otherTtsActive && isBackchannel(pkt.text)) {
762
+ this.bus.push(Route.Background, make.metric(pkt.contextId, "turn.backchannel_dropped", pkt.text));
763
+ return;
764
+ }
765
+
702
766
  this.lastFinalizedContextId = pkt.contextId;
703
767
 
704
768
  // Re-arm per-turn guard state for the next turn. Transports with a stable
@@ -711,6 +775,16 @@ export class VoiceAgentSession {
711
775
  this.interruptedGenerationContextIds.delete(pkt.contextId);
712
776
  this.fallbackInjectedContexts.delete(pkt.contextId);
713
777
 
778
+ // Supersede (L1): a new turn must cancel any still-active prior-turn TTS or
779
+ // generation. Without this, a false-EOS (early endpoint on a mid-sentence
780
+ // pause) starts turn N, the user resumes, turn N's already-emitted audio
781
+ // keeps synthesizing, and it plays over the user while turn N+1 is answered.
782
+ // The bridge supersedes the *LLM*; only the session can stop the *TTS*.
783
+ for (const activeCtx of this.ttsPlayout.activeContexts()) {
784
+ if (activeCtx !== pkt.contextId) this.cancelStaleGeneration(activeCtx, pkt.timestampMs);
785
+ }
786
+
787
+ this.generatingContextIds.add(pkt.contextId);
714
788
  this.currentTurnId = pkt.contextId;
715
789
  this.idleTimeout.setContextId(pkt.contextId);
716
790
 
@@ -727,8 +801,11 @@ export class VoiceAgentSession {
727
801
  timestampMs: pkt.timestampMs,
728
802
  });
729
803
 
730
- // Stop idle timeout while LLM is processing
731
- this.bus.push(Route.Main, make.stopIdleTimeout(pkt.contextId, Date.now(), false));
804
+ // Stop idle timeout while the LLM processes. The user just spoke — that is
805
+ // genuine engagement, so reset the idle *escalation* count (P2): a user who
806
+ // answers the first "are you there?" must not be escalated straight to the
807
+ // disconnect prompt later in the call.
808
+ this.bus.push(Route.Main, make.stopIdleTimeout(pkt.contextId, Date.now(), true));
732
809
 
733
810
  const fillerText = this.latencyFiller.start(pkt.contextId, pkt.text, pkt.timestampMs);
734
811
  if (fillerText) {
@@ -785,6 +862,7 @@ export class VoiceAgentSession {
785
862
  }
786
863
 
787
864
  private handleLlmDone(pkt: LlmResponseDonePacket): void {
865
+ this.generatingContextIds.delete(pkt.contextId);
788
866
  if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
789
867
  this.ttsTextBuffers.delete(pkt.contextId);
790
868
  this.bus.push(Route.Background, make.metric(pkt.contextId, "llm.done_ignored_after_interrupt", "1"));
@@ -865,9 +943,101 @@ export class VoiceAgentSession {
865
943
  },
866
944
  timestampMs: pkt.timestampMs,
867
945
  });
946
+
947
+ // G3: arm the typed preamble/filler lifecycle for this pending tool call.
948
+ const pending = this.pendingToolCues.get(pkt.contextId) ?? new Map<string, string>();
949
+ pending.set(pkt.toolId, pkt.toolName);
950
+ this.pendingToolCues.set(pkt.contextId, pending);
951
+ this.emitToolCallCue(pkt.contextId, "started", pkt.toolId, pkt.toolName);
952
+ if (this.delayCueAfterMs > 0) {
953
+ const afterMs = this.delayCueAfterMs;
954
+ this.scheduler.schedule(toolCueTimerKey(pkt.contextId, pkt.toolId), afterMs, () => {
955
+ if (!this.pendingToolCues.get(pkt.contextId)?.has(pkt.toolId)) return;
956
+ this.emitToolCallCue(pkt.contextId, "delayed", pkt.toolId, pkt.toolName, afterMs);
957
+ });
958
+ }
959
+ }
960
+
961
+ private emitToolCallCue(
962
+ contextId: string,
963
+ phase: "started" | "delayed" | "complete" | "failed",
964
+ toolId: string,
965
+ toolName: string,
966
+ afterMs?: number,
967
+ ): void {
968
+ this.emit("tool_call_cue", {
969
+ tsMs: Date.now(),
970
+ turnId: contextId,
971
+ phase,
972
+ toolId,
973
+ toolName,
974
+ ...(afterMs !== undefined ? { afterMs } : {}),
975
+ });
976
+ }
977
+
978
+ /** G3: resolve one pending tool call with a terminal cue phase. */
979
+ private resolveToolCue(contextId: string, toolId: string, phase: "complete" | "failed"): void {
980
+ const pending = this.pendingToolCues.get(contextId);
981
+ const toolName = pending?.get(toolId);
982
+ if (toolName === undefined) return;
983
+ pending!.delete(toolId);
984
+ if (pending!.size === 0) this.pendingToolCues.delete(contextId);
985
+ this.scheduler.cancel(toolCueTimerKey(contextId, toolId));
986
+ this.emitToolCallCue(contextId, phase, toolId, toolName);
987
+ }
988
+
989
+ /** G3: fail every pending tool call for a context (error / barge-in / supersede). */
990
+ private failPendingToolCues(contextId: string): void {
991
+ const pending = this.pendingToolCues.get(contextId);
992
+ if (!pending) return;
993
+ for (const toolId of [...pending.keys()]) this.resolveToolCue(contextId, toolId, "failed");
994
+ }
995
+
996
+ private handleDelegateQuery(pkt: DelegateQueryPacket): void {
997
+ this.emit("delegate_query", {
998
+ tsMs: pkt.timestampMs,
999
+ turnId: pkt.contextId,
1000
+ query: pkt.query,
1001
+ toolId: pkt.toolId,
1002
+ toolName: pkt.toolName,
1003
+ });
1004
+ this.debugPush({
1005
+ component: "delegate",
1006
+ type: "query",
1007
+ data: {
1008
+ context_id: pkt.contextId,
1009
+ query: pkt.query,
1010
+ ...(pkt.toolName ? { tool_name: pkt.toolName } : {}),
1011
+ },
1012
+ timestampMs: pkt.timestampMs,
1013
+ });
1014
+ }
1015
+
1016
+ private handleDelegateResult(pkt: DelegateResultPacket): void {
1017
+ this.emit("delegate_result", {
1018
+ tsMs: pkt.timestampMs,
1019
+ turnId: pkt.contextId,
1020
+ query: pkt.query,
1021
+ answer: pkt.answer,
1022
+ durationMs: pkt.durationMs,
1023
+ grounded: pkt.grounded,
1024
+ toolId: pkt.toolId,
1025
+ toolName: pkt.toolName,
1026
+ });
1027
+ this.debugPush({
1028
+ component: "delegate",
1029
+ type: "result",
1030
+ data: {
1031
+ context_id: pkt.contextId,
1032
+ duration_ms: String(pkt.durationMs),
1033
+ grounded: String(pkt.grounded),
1034
+ },
1035
+ timestampMs: pkt.timestampMs,
1036
+ });
868
1037
  }
869
1038
 
870
1039
  private handleLlmToolResult(pkt: LlmToolResultPacket): void {
1040
+ this.resolveToolCue(pkt.contextId, pkt.toolId, "complete");
871
1041
  this.emit("agent_tool_result", {
872
1042
  tsMs: pkt.timestampMs,
873
1043
  turnId: pkt.contextId,
@@ -913,14 +1083,20 @@ export class VoiceAgentSession {
913
1083
  // Audio just arrived — (re)arm the stall watchdog for this turn's TTS output.
914
1084
  this.watchdogs.armTtsStallTimer(pkt.contextId);
915
1085
 
916
- // Extend idle timeout by audio duration to prevent timeout during playback.
1086
+ // Mark active and advance this context's playout cursor by the chunk's
1087
+ // realtime duration.
917
1088
  const sampleRateHz = requireTtsAudioSampleRate(pkt.sampleRateHz);
918
1089
  const audioDurationMs = estimatePcm16Duration(pkt.audio, sampleRateHz);
919
- this.idleTimeout.extend(audioDurationMs);
1090
+ const now = Date.now();
1091
+ this.ttsPlayout.noteAudio(pkt.contextId, audioDurationMs, now);
920
1092
 
921
- // Mark active and advance this context's playout cursor by the chunk's
922
- // realtime duration.
923
- this.ttsPlayout.noteAudio(pkt.contextId, audioDurationMs, Date.now());
1093
+ // Anchor the idle timer to when playout actually *ends* (P2), not to chunk
1094
+ // arrival. TTS streams faster than realtime, so extending by each chunk's
1095
+ // duration from arrival lets the timer fire mid-speech on a long answer.
1096
+ // playoutEnd() is cumulative across chunks, so this re-arms to durationMs
1097
+ // after the audio delivered so far finishes playing.
1098
+ const playoutEndMs = this.ttsPlayout.playoutEnd(pkt.contextId);
1099
+ this.idleTimeout.extend(playoutEndMs !== undefined ? Math.max(0, playoutEndMs - now) : audioDurationMs);
924
1100
 
925
1101
  this.debugPush({
926
1102
  component: "tts",
@@ -938,6 +1114,7 @@ export class VoiceAgentSession {
938
1114
  private handleTtsEnd(pkt: TextToSpeechEndPacket): void {
939
1115
  // Generation finished, but the streamed audio is still playing out. Keep the
940
1116
  // context interruptible until its playout estimate elapses, then release it.
1117
+ this.generatingContextIds.delete(pkt.contextId);
941
1118
  this.ttsPlayout.scheduleRelease(pkt.contextId, Date.now());
942
1119
  this.watchdogs.clearTtsStallTimerFor(pkt.contextId);
943
1120
  this.debugPush({
@@ -954,6 +1131,7 @@ export class VoiceAgentSession {
954
1131
 
955
1132
  private handleInterruptDetected(pkt: InterruptionDetectedPacket): void {
956
1133
  this.interruptedGenerationContextIds.add(pkt.contextId);
1134
+ this.failPendingToolCues(pkt.contextId); // G3: the aborted delegate's cue fails (R5)
957
1135
  this.latencyFiller.cancel(pkt.contextId);
958
1136
  this.firstLlmDeltaReceived.delete(pkt.contextId);
959
1137
  this.ttsTextBuffers.delete(pkt.contextId);
@@ -990,6 +1168,28 @@ export class VoiceAgentSession {
990
1168
  // segments into the next turn when a client reuses the same contextId
991
1169
  // (the provider STT plugins listen for interrupt.stt; previously unfired).
992
1170
  this.bus.push(Route.Critical, make.interruptStt(pkt.contextId, pkt.timestampMs));
1171
+ this.generatingContextIds.delete(pkt.contextId);
1172
+ }
1173
+
1174
+ /**
1175
+ * Cancel a stale prior-turn generation/playout when a new turn supersedes it
1176
+ * (L1). Mirrors the interrupt teardown but without the barge-in metrics — this
1177
+ * is a turn boundary, not a user interruption. Stops leftover TTS audio, aborts
1178
+ * the LLM, and drops late deltas/audio for the stale context.
1179
+ */
1180
+ private cancelStaleGeneration(contextId: string, timestampMs: number): void {
1181
+ this.interruptedGenerationContextIds.add(contextId);
1182
+ this.failPendingToolCues(contextId); // G3: a superseded turn's pending cue fails
1183
+ this.generatingContextIds.delete(contextId);
1184
+ this.latencyFiller.cancel(contextId);
1185
+ this.firstLlmDeltaReceived.delete(contextId);
1186
+ this.ttsTextBuffers.delete(contextId);
1187
+ this.ttsPlayout.release(contextId);
1188
+ this.watchdogs.clearTtsStallTimerFor(contextId);
1189
+ this.bus.push(Route.Critical, make.recordAssistantTruncate(contextId, Date.now()));
1190
+ this.bus.push(Route.Critical, make.interruptTts(contextId, timestampMs));
1191
+ this.bus.push(Route.Critical, make.interruptLlm(contextId, timestampMs));
1192
+ this.bus.push(Route.Background, make.metric(contextId, "supersede.cancelled_stale_generation", "1"));
993
1193
  }
994
1194
 
995
1195
  private handleComponentError(pkt: VoiceErrorPacket): void {
@@ -1011,7 +1211,12 @@ export class VoiceAgentSession {
1011
1211
  timestampMs: pkt.timestampMs,
1012
1212
  });
1013
1213
 
1214
+ // G3: an LLM/bridge error while a tool call is pending means no result is coming.
1215
+ if (pkt.component === "llm" || pkt.component === "bridge") {
1216
+ this.failPendingToolCues(pkt.contextId);
1217
+ }
1014
1218
  this.latencyFiller.clear(pkt.contextId);
1219
+ this.generatingContextIds.delete(pkt.contextId);
1015
1220
  // The packet's own recoverability verdict is authoritative when present: the
1016
1221
  // bus marks handler exceptions (pipeline.error) recoverable by design — one
1017
1222
  // misbehaving handler must degrade the turn, not kill the whole call.
@@ -25,6 +25,19 @@ describe("isCompleteVoiceText", () => {
25
25
  expect(isCompleteVoiceText("مرحبا؟")).toBe(true); // Arabic question mark
26
26
  expect(isCompleteVoiceText("नमस्ते।")).toBe(true); // Devanagari danda
27
27
  });
28
+
29
+ it("does not treat abbreviation or decimal dots as sentence ends", () => {
30
+ // These would otherwise be voiced with a falling intonation and split from
31
+ // their continuation ("Dr." | "Smith", "twelve." | "fifty").
32
+ expect(isCompleteVoiceText("Dr.")).toBe(false);
33
+ expect(isCompleteVoiceText("e.g.")).toBe(false);
34
+ expect(isCompleteVoiceText("The total is $12.")).toBe(false);
35
+ expect(isCompleteVoiceText("Meet at 3 p.m.")).toBe(false);
36
+ expect(isCompleteVoiceText("His name is J.")).toBe(false);
37
+ // But a real sentence end after an abbreviation earlier in the text is fine.
38
+ expect(isCompleteVoiceText("Dr. Smith will see you now.")).toBe(true);
39
+ expect(isCompleteVoiceText("The total is $12.50 today.")).toBe(true);
40
+ });
28
41
  });
29
42
 
30
43
  describe("takeCompleteVoiceText", () => {
package/src/voice-text.ts CHANGED
@@ -33,12 +33,41 @@ export function takeCompleteVoiceText(text: string): { text: string; remaining:
33
33
  return { text: emitted.trimEnd(), remaining };
34
34
  }
35
35
 
36
+ // Common abbreviations whose trailing "." is not a sentence end. Lowercased,
37
+ // dots stripped, so "e.g." matches "eg". Kept small and English-centric — the
38
+ // turn-end flush handles anything that legitimately ends here.
39
+ const ABBREVIATIONS = new Set([
40
+ "mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st", "vs", "etc", "eg", "ie",
41
+ "am", "pm", "no", "vol", "inc", "ltd", "co", "gen", "gov", "sen", "rep",
42
+ "apt", "dept", "approx", "est", "min", "max",
43
+ ]);
44
+
45
+ /**
46
+ * A segment ending in "." is not necessarily a finished sentence: it may be a
47
+ * decimal point ("$12." before "50") or an abbreviation dot ("Dr." before a
48
+ * name, "e.g."). Voicing those as sentence ends produces "twelve." (falling
49
+ * intonation) … "fifty", or splits "Dr." from the name. Defer them — if nothing
50
+ * follows, the turn-end flush still speaks the tail.
51
+ */
52
+ function isFalseTerminalDot(endsWithDot: string): boolean {
53
+ const beforeDot = endsWithDot.slice(0, -1);
54
+ if (/\d$/.test(beforeDot)) return true; // decimal / ordinal: "12." , "$3."
55
+ const word = beforeDot.match(/([A-Za-z][A-Za-z.]*)$/);
56
+ if (!word) return false;
57
+ const normalized = word[1]!.replace(/\./g, "").toLowerCase();
58
+ if (ABBREVIATIONS.has(normalized)) return true;
59
+ if (normalized.length === 1) return true; // single initial: "J."
60
+ return false;
61
+ }
62
+
36
63
  export function isCompleteVoiceText(text: string): boolean {
37
64
  const trimmed = text.trim();
38
65
  for (let index = trimmed.length - 1; index >= 0; index -= 1) {
39
66
  const char = trimmed[index]!;
40
67
  if (isClosingPunctuation(char)) continue;
41
- return isTerminalPunctuation(char);
68
+ if (!isTerminalPunctuation(char)) return false;
69
+ if (char === "." && isFalseTerminalDot(trimmed.slice(0, index + 1))) return false;
70
+ return true;
42
71
  }
43
72
  return false;
44
73
  }
@@ -68,8 +97,19 @@ function isTerminalPunctuation(char: string): boolean {
68
97
  char === "॥";
69
98
  }
70
99
 
100
+ // The ICU sentence segmenter is one of the more expensive Intl allocations;
101
+ // building one per LLM delta (50–200/turn) is avoidable CPU/GC churn on the
102
+ // token→TTS latency path. It is stateless, so cache one per process. `undefined`
103
+ // = not yet computed; `null` = unavailable (fall back to the regex splitter).
104
+ let cachedSegmenter: { segment(text: string): Iterable<SentenceSegment> } | null | undefined;
105
+
106
+ function getSentenceSegmenter(): { segment(text: string): Iterable<SentenceSegment> } | null {
107
+ if (cachedSegmenter === undefined) cachedSegmenter = createSentenceSegmenter();
108
+ return cachedSegmenter;
109
+ }
110
+
71
111
  function segmentSentences(text: string): string[] {
72
- const segmenter = createSentenceSegmenter();
112
+ const segmenter = getSentenceSegmenter();
73
113
  if (segmenter) {
74
114
  return Array.from(segmenter.segment(text), (part) => part.segment);
75
115
  }