@kuralle-syrinx/aisdk 4.4.1 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,728 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — AI SDK Bridge Plugin
4
+ //
5
+ // Bridges the PipelineBus to Vercel AI SDK for LLM inference.
6
+ // Listens for EOS turn completions, calls LLM, pushes deltas + done + tool calls
7
+ // into the bus. Handles LLM interrupts via AbortController.
8
+ import { Route, categorizeLlmError, isRecoverable, readRetryConfig, waitForRetryDelay, ErrorCategory, InMemoryIuLedger, } from "@kuralle-syrinx/core";
9
+ export { fromAiSdkAgent, fromStreamText, fromStreamFactory, } from "./from-ai-sdk.js";
10
+ export class ReasoningBridge {
11
+ reasoner;
12
+ opts;
13
+ bus = null;
14
+ timeoutMs = 30_000;
15
+ maxHistoryTurns = 12;
16
+ history = [];
17
+ transientContextMessages = new Set();
18
+ activeGeneration = null;
19
+ // At most one speculative draft at a time; `hold` gates its side effects.
20
+ speculativeDraft = null;
21
+ iuLedger;
22
+ epochByContext = new Map();
23
+ turnEpochCounter = 0;
24
+ retryConfig = readRetryConfig({});
25
+ disposers = [];
26
+ // G2/G25: per-turn state so a barged-in turn is remembered as what the user HEARD,
27
+ // not the full generated reply. Precision ladder:
28
+ // 1. Word timestamps (tts.word_timestamps) + playout position (tts.playout_progress)
29
+ // → exact spoken prefix at word boundaries.
30
+ // 2. Fallback: spokenByContext (text sent to TTS) — approximate; may include audio
31
+ // that was queued but not yet played out (TTS streams faster than realtime).
32
+ // `spokenByContext` accumulates tts.text; `assistantMsgByContext` holds the live
33
+ // history message object so it can be rewritten in place; `turnUserText` lets a
34
+ // mid-generation interrupt still record the turn.
35
+ spokenByContext = new Map();
36
+ turnUserText = new Map();
37
+ assistantMsgByContext = new Map();
38
+ // G25: word-level timestamps from TTS plugin (cumulative from context audio start).
39
+ wordTimestampsByContext = new Map();
40
+ // G25: latest playout position (ms from context audio start) from the paced transport.
41
+ // Present whenever a paced transport is wired — this includes the browser WebSocket
42
+ // path (it routes through the shared paced playout pipeline + PlayoutProgressEmitter)
43
+ // as well as telnyx/twilio/smartpbx. Only headless-direct (no playout clock) falls
44
+ // back to spokenByContext.
45
+ playedOutMsByContext = new Map();
46
+ constructor(reasoner, opts = {}) {
47
+ this.reasoner = reasoner;
48
+ this.opts = opts;
49
+ if (this.opts.onResumeConflict === "replay") {
50
+ throw new Error("onResumeConflict 'replay' not yet supported — use 'restart'");
51
+ }
52
+ }
53
+ injectContext(text) {
54
+ const message = { role: "system", content: text };
55
+ this.history.push(message);
56
+ this.transientContextMessages.add(message);
57
+ }
58
+ async initialize(bus, config) {
59
+ this.bus = bus;
60
+ this.iuLedger = new InMemoryIuLedger((a) => {
61
+ const detail = a.kind === "terminal_op"
62
+ ? `${a.op} on ${a.state} IU`
63
+ : `${a.op} on unknown IU`;
64
+ this.bus?.push(Route.Background, {
65
+ kind: "llm.error",
66
+ contextId: a.id.contextId,
67
+ timestampMs: Date.now(),
68
+ component: "iu_ledger",
69
+ category: ErrorCategory.InternalFault,
70
+ cause: new Error(`iu_ledger anomaly: ${a.kind} ${detail}`),
71
+ isRecoverable: true,
72
+ });
73
+ });
74
+ this.timeoutMs = readPositiveIntegerConfig(config["timeout_ms"], 30_000);
75
+ this.maxHistoryTurns = readPositiveIntegerConfig(config["max_history_turns"], 12);
76
+ this.retryConfig = readRetryConfig(config);
77
+ // G4: resume from durable history — the reasoner's next turn sees the same
78
+ // context as before the eviction/reconnect (R6). Load-only: nothing is spoken.
79
+ if (this.opts.sessionStore && this.opts.sessionId) {
80
+ const stored = await this.opts.sessionStore.load(this.opts.sessionId);
81
+ this.history = stored.map((message) => ({ ...message }));
82
+ }
83
+ // Listen for EOS turn completions
84
+ this.disposers.push(
85
+ // Concurrent producer: a turn's LLM generation streams its own packets over
86
+ // (potentially) several seconds. Running it fire-and-forget keeps the pipeline
87
+ // bus drain loop free, so the llm.delta -> tts.text streaming it produces is
88
+ // dispatched as it arrives (not deferred until generation ends), and Critical
89
+ // interrupts are handled promptly mid-generation. processTurn supersedes any
90
+ // still-in-flight generation (see below).
91
+ bus.on("eos.turn_complete", async (pkt) => {
92
+ const eos = pkt;
93
+ // R2: a draft for this exact transcript is already generating (or done) —
94
+ // promote it instead of paying a second LLM call. Flux guarantees the
95
+ // EndOfTurn transcript matches the preceding EagerEndOfTurn when no
96
+ // TurnResumed intervened, so commit-as-is is safe.
97
+ const draft = this.speculativeDraft;
98
+ if (draft &&
99
+ draft.contextId === eos.contextId &&
100
+ draft.userText === eos.text &&
101
+ !draft.controller.signal.aborted &&
102
+ this.iuLedger.get(draft.id)?.state === "hypothesized") {
103
+ this.iuLedger.commit(draft.id);
104
+ this.speculativeDraft = null;
105
+ this.metric(eos.contextId, "speculative.draft_promoted");
106
+ for (const flush of draft.hold.buffered.splice(0))
107
+ flush();
108
+ return;
109
+ }
110
+ // Stale, mismatched, or failed draft: its speculation was wrong — drop it
111
+ // and answer the confirmed transcript fresh.
112
+ this.discardDraft();
113
+ await this.processTurn(eos.text, eos.contextId);
114
+ }, { concurrent: true }),
115
+ // Track what was actually sent to TTS (fallback spoken approximation), per turn.
116
+ bus.on("tts.text", (pkt) => {
117
+ const t = pkt;
118
+ this.spokenByContext.set(t.contextId, (this.spokenByContext.get(t.contextId) ?? "") + t.text);
119
+ }),
120
+ // G25: accumulate word-level timestamps from the TTS plugin (Cartesia etc.).
121
+ // These arrive as cumulative offsets from the context audio start and enable
122
+ // word-boundary precision when computing the spoken prefix on barge-in.
123
+ bus.on("tts.word_timestamps", (pkt) => {
124
+ const t = pkt;
125
+ const existing = this.wordTimestampsByContext.get(t.contextId);
126
+ if (existing) {
127
+ for (const w of t.words)
128
+ existing.push(w);
129
+ }
130
+ else {
131
+ this.wordTimestampsByContext.set(t.contextId, [...t.words]);
132
+ }
133
+ }),
134
+ // G25: track realtime playout position from the paced transport. Absent on
135
+ // headless/browser paths; in that case we fall back to spokenByContext.
136
+ bus.on("tts.playout_progress", (pkt) => {
137
+ const p = pkt;
138
+ this.playedOutMsByContext.set(p.contextId, p.playedOutMs);
139
+ }),
140
+ // Listen for LLM interrupts. Abort generation AND rewrite the interrupted turn's
141
+ // history to the spoken prefix (G2/G25), so the model isn't left believing it
142
+ // said words the user never heard (nor amnesiac about the exchange).
143
+ bus.on("interrupt.llm", (pkt) => {
144
+ const contextId = pkt.contextId;
145
+ if (this.speculativeDraft?.contextId === contextId)
146
+ this.discardDraft();
147
+ if (this.activeGeneration?.contextId === contextId) {
148
+ this.activeGeneration.controller.abort();
149
+ this.activeGeneration = null;
150
+ }
151
+ this.commitInterruptedHistory(contextId);
152
+ if (this.opts.runStore && this.opts.onResumeConflict !== "replay") {
153
+ void Promise.resolve(this.opts.runStore.discard(contextId)).catch(() => undefined);
154
+ }
155
+ }));
156
+ if (this.opts.speculative) {
157
+ this.disposers.push(bus.on("eos.interim", async (pkt) => {
158
+ const interim = pkt;
159
+ const text = (interim.text ?? "").trim();
160
+ if (!text)
161
+ return;
162
+ await this.runDraft(text, interim.contextId);
163
+ }, { concurrent: true }), bus.on("eos.retracted", (pkt) => {
164
+ const contextId = pkt.contextId;
165
+ if (this.speculativeDraft?.contextId === contextId)
166
+ this.discardDraft();
167
+ }));
168
+ }
169
+ }
170
+ /** Start (or restart, if a newer eager endpoint supersedes) the speculative draft. */
171
+ async runDraft(userText, contextId) {
172
+ this.discardDraft();
173
+ this.metric(contextId, "speculative.draft_started");
174
+ const id = this.iuIdFor(contextId);
175
+ this.iuLedger.add({ id, kind: "user_turn", state: "hypothesized" });
176
+ const controller = new AbortController();
177
+ const hold = { buffered: [] };
178
+ this.speculativeDraft = { contextId, userText, controller, hold, id };
179
+ await this.processTurn(userText, contextId, hold, controller, id);
180
+ }
181
+ iuIdFor(contextId) {
182
+ let epoch = this.epochByContext.get(contextId);
183
+ if (epoch === undefined) {
184
+ epoch = ++this.turnEpochCounter;
185
+ this.epochByContext.set(contextId, epoch);
186
+ }
187
+ return { contextId, iuId: contextId, epoch };
188
+ }
189
+ assistantIuIdFor(contextId) {
190
+ let epoch = this.epochByContext.get(contextId);
191
+ if (epoch === undefined) {
192
+ epoch = ++this.turnEpochCounter;
193
+ this.epochByContext.set(contextId, epoch);
194
+ }
195
+ return { contextId, iuId: `${contextId}#assistant`, epoch };
196
+ }
197
+ discardDraft() {
198
+ const draft = this.speculativeDraft;
199
+ if (!draft)
200
+ return;
201
+ this.speculativeDraft = null;
202
+ const committed = this.iuLedger.get(draft.id)?.state === "committed";
203
+ if (!committed) {
204
+ this.metric(draft.contextId, "speculative.draft_discarded");
205
+ this.iuLedger.revoke(draft.id);
206
+ draft.controller.abort();
207
+ }
208
+ }
209
+ metric(contextId, name, value = "1") {
210
+ this.bus?.push(Route.Background, {
211
+ kind: "metric.conversation",
212
+ contextId,
213
+ timestampMs: Date.now(),
214
+ name,
215
+ value,
216
+ });
217
+ }
218
+ async processTurn(userText, contextId, hold, presetController, iuId) {
219
+ if (!this.bus)
220
+ return;
221
+ this.turnUserText.set(contextId, userText);
222
+ // Handlers are concurrent, so a new turn can begin while a prior generation is
223
+ // still in flight. Supersede it: abort the previous controller before starting.
224
+ this.activeGeneration?.controller.abort();
225
+ const controller = presetController ?? new AbortController();
226
+ this.activeGeneration = { contextId, controller };
227
+ const aid = this.assistantIuIdFor(contextId);
228
+ this.iuLedger.add({ id: aid, kind: "assistant_response", state: "hypothesized" });
229
+ const signal = controller.signal;
230
+ // R2: while a speculative hold is unpromoted, every push/mutation buffers.
231
+ // Packets are constructed eagerly (their timestamps are event time); only
232
+ // delivery is deferred. Promotion replays in order, then later effects run live.
233
+ const isBuffering = () => hold !== undefined &&
234
+ iuId !== undefined &&
235
+ this.iuLedger.get(iuId)?.state !== "committed";
236
+ const push = (route, packet) => {
237
+ if (isBuffering()) {
238
+ if (packet.kind === "llm.error" && iuId)
239
+ this.iuLedger.revoke(iuId);
240
+ hold.buffered.push(() => this.bus?.push(route, packet));
241
+ return;
242
+ }
243
+ this.bus?.push(route, packet);
244
+ };
245
+ const defer = (fn) => {
246
+ if (isBuffering())
247
+ hold.buffered.push(fn);
248
+ else
249
+ fn();
250
+ };
251
+ let reply = "";
252
+ let emittedDelta = false;
253
+ let committed = false;
254
+ let grounded = false;
255
+ let passStartedMs = 0;
256
+ let passTtftRecorded = false;
257
+ const recordPassTtft = () => {
258
+ if (passTtftRecorded)
259
+ return;
260
+ passTtftRecorded = true;
261
+ const firstOutputMs = Date.now();
262
+ push(Route.Main, {
263
+ kind: "metric.conversation",
264
+ contextId,
265
+ timestampMs: firstOutputMs,
266
+ name: "llm.pass_ttft_ms",
267
+ value: String(firstOutputMs - passStartedMs),
268
+ });
269
+ };
270
+ // G2 observability: the turn's query is on its way to the reasoner (Background
271
+ // route, droppable — RFC bimodel-delegate-seam R4). Cascade turns have no
272
+ // front-model tool call, so toolId/toolName are absent.
273
+ const queryStartedMs = Date.now();
274
+ push(Route.Background, {
275
+ kind: "delegate.query",
276
+ contextId,
277
+ timestampMs: queryStartedMs,
278
+ query: userText,
279
+ });
280
+ try {
281
+ for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt += 1) {
282
+ grounded = false;
283
+ passStartedMs = Date.now();
284
+ passTtftRecorded = false;
285
+ push(Route.Main, {
286
+ kind: "metric.conversation",
287
+ contextId,
288
+ timestampMs: passStartedMs,
289
+ name: "llm.call_started",
290
+ value: "1",
291
+ });
292
+ try {
293
+ // Drafts never consume a suspended-run pointer: takePending mutates the
294
+ // store, and a retracted draft would silently lose the resume.
295
+ const pending = this.opts.runStore && !hold
296
+ ? await Promise.resolve(this.opts.runStore.takePending(contextId))
297
+ : null;
298
+ const resuming = pending !== null;
299
+ const turn = pending
300
+ ? { userText, messages: this.history, signal, resume: { runId: pending.runId, data: userText } }
301
+ : { userText, messages: this.history, signal };
302
+ let finishReason = null;
303
+ for await (const part of withStreamIdleTimeout(this.reasoner.stream(turn), this.timeoutMs, signal)) {
304
+ if (signal.aborted)
305
+ return;
306
+ switch (part.type) {
307
+ case "text-delta":
308
+ reply += part.text;
309
+ emittedDelta = true;
310
+ recordPassTtft();
311
+ push(Route.Main, {
312
+ kind: "llm.delta",
313
+ contextId,
314
+ timestampMs: Date.now(),
315
+ text: part.text,
316
+ });
317
+ break;
318
+ case "tool-call":
319
+ recordPassTtft();
320
+ push(Route.Main, {
321
+ kind: "llm.tool_call",
322
+ contextId,
323
+ timestampMs: Date.now(),
324
+ toolId: part.toolId,
325
+ toolName: part.toolName,
326
+ toolArgs: part.args,
327
+ });
328
+ break;
329
+ case "tool-result":
330
+ grounded = true;
331
+ push(Route.Main, {
332
+ kind: "llm.tool_result",
333
+ contextId,
334
+ timestampMs: Date.now(),
335
+ toolId: part.toolId,
336
+ toolName: part.toolName,
337
+ result: part.result,
338
+ });
339
+ passStartedMs = Date.now();
340
+ passTtftRecorded = false;
341
+ push(Route.Main, {
342
+ kind: "metric.conversation",
343
+ contextId,
344
+ timestampMs: passStartedMs,
345
+ name: "llm.call_started",
346
+ value: "1",
347
+ });
348
+ break;
349
+ case "control":
350
+ push(Route.Background, {
351
+ kind: "delegate.result",
352
+ contextId,
353
+ timestampMs: Date.now(),
354
+ query: userText,
355
+ answer: reply,
356
+ durationMs: Date.now() - queryStartedMs,
357
+ grounded,
358
+ control: {
359
+ name: part.name,
360
+ payload: part.payload,
361
+ },
362
+ });
363
+ break;
364
+ case "blocked": {
365
+ if (signal.aborted)
366
+ return;
367
+ const safeMessage = part.userFacingMessage;
368
+ const blockedMs = Date.now();
369
+ push(Route.Main, {
370
+ kind: "llm.delta",
371
+ contextId,
372
+ timestampMs: blockedMs,
373
+ text: safeMessage,
374
+ });
375
+ push(Route.Main, {
376
+ kind: "llm.done",
377
+ contextId,
378
+ timestampMs: blockedMs,
379
+ text: safeMessage,
380
+ });
381
+ push(Route.Background, {
382
+ kind: "delegate.result",
383
+ contextId,
384
+ timestampMs: blockedMs,
385
+ query: userText,
386
+ answer: safeMessage,
387
+ durationMs: blockedMs - queryStartedMs,
388
+ grounded,
389
+ blocked: {
390
+ userFacingMessage: safeMessage,
391
+ payload: part.payload,
392
+ },
393
+ });
394
+ defer(() => this.rememberTurn(userText, safeMessage, contextId));
395
+ committed = true;
396
+ return;
397
+ }
398
+ case "error":
399
+ throw part.cause;
400
+ case "finish":
401
+ push(Route.Background, {
402
+ kind: "metric.conversation",
403
+ contextId,
404
+ timestampMs: Date.now(),
405
+ name: "llm.finish_reason",
406
+ value: part.reason,
407
+ });
408
+ // Record billable token usage — the field the bridge used to drop.
409
+ // A turn with tool calls produces several finish parts; the session
410
+ // accumulator sums them, so emit per-finish rather than once per turn.
411
+ if (part.usage) {
412
+ push(Route.Background, {
413
+ kind: "usage.recorded",
414
+ contextId,
415
+ timestampMs: Date.now(),
416
+ stage: "llm",
417
+ ...part.usage,
418
+ });
419
+ }
420
+ finishReason = part.reason;
421
+ break;
422
+ case "suspended": {
423
+ if (part.prompt && !emittedDelta) {
424
+ push(Route.Main, {
425
+ kind: "llm.delta",
426
+ contextId,
427
+ timestampMs: Date.now(),
428
+ text: part.prompt,
429
+ });
430
+ reply += part.prompt;
431
+ }
432
+ if (signal.aborted)
433
+ return;
434
+ push(Route.Main, {
435
+ kind: "llm.done",
436
+ contextId,
437
+ timestampMs: Date.now(),
438
+ text: reply,
439
+ });
440
+ defer(() => this.rememberTurn(userText, reply, contextId));
441
+ push(Route.Background, {
442
+ kind: "reasoning.suspended",
443
+ contextId,
444
+ timestampMs: Date.now(),
445
+ runId: part.runId,
446
+ prompt: part.prompt,
447
+ payload: part.payload,
448
+ });
449
+ if (this.opts.runStore) {
450
+ const store = this.opts.runStore;
451
+ const runId = part.runId;
452
+ if (isBuffering()) {
453
+ hold.buffered.push(() => void Promise.resolve(store.save(contextId, runId)).catch(() => undefined));
454
+ }
455
+ else {
456
+ await Promise.resolve(store.save(contextId, runId));
457
+ }
458
+ }
459
+ committed = true;
460
+ return;
461
+ }
462
+ }
463
+ }
464
+ // A non-"stop" finish must fail the TURN, never the call (L2). Killing the
465
+ // session on a token-cap or unfinished-tool-loop hangs up the caller
466
+ // mid-conversation. `length` = token cap: the streamed reply is truncated
467
+ // but usable, so accept it and continue (fall through to llm.done). Any
468
+ // other non-"stop" reason (tool loop ended, null) = fail the turn
469
+ // recoverably — the caller hears the graceful fallback, the call stays up.
470
+ if (finishReason !== "stop" && finishReason !== "length") {
471
+ if (signal.aborted)
472
+ return;
473
+ push(Route.Critical, {
474
+ kind: "llm.error",
475
+ contextId,
476
+ timestampMs: Date.now(),
477
+ component: "bridge",
478
+ category: ErrorCategory.InternalFault,
479
+ cause: new Error(`AI SDK turn ended on finishReason "${finishReason ?? "null"}"`),
480
+ isRecoverable: true,
481
+ });
482
+ return;
483
+ }
484
+ if (finishReason === "length") {
485
+ push(Route.Background, {
486
+ kind: "metric.conversation",
487
+ contextId,
488
+ timestampMs: Date.now(),
489
+ name: "llm.finish_length_truncated",
490
+ value: "1",
491
+ });
492
+ }
493
+ // Interrupted as generation finished — the interrupt handler owns the history
494
+ // for this turn (spoken prefix); don't commit the full reply or emit llm.done.
495
+ if (signal.aborted)
496
+ return;
497
+ const answeredMs = Date.now();
498
+ push(Route.Main, {
499
+ kind: "llm.done",
500
+ contextId,
501
+ timestampMs: answeredMs,
502
+ text: reply,
503
+ });
504
+ // G2 observability: the reasoner produced the turn's final answer.
505
+ push(Route.Background, {
506
+ kind: "delegate.result",
507
+ contextId,
508
+ timestampMs: answeredMs,
509
+ query: userText,
510
+ answer: reply,
511
+ durationMs: answeredMs - queryStartedMs,
512
+ grounded,
513
+ });
514
+ defer(() => this.rememberTurn(userText, reply, contextId));
515
+ if (this.opts.runStore && resuming) {
516
+ await Promise.resolve(this.opts.runStore.discard(contextId));
517
+ }
518
+ committed = true;
519
+ return;
520
+ }
521
+ catch (err) {
522
+ if (signal.aborted)
523
+ return;
524
+ const category = categorizeLlmError(err);
525
+ const recoverable = isRecoverable(category);
526
+ if (!recoverable || emittedDelta || attempt >= this.retryConfig.maxAttempts) {
527
+ push(Route.Critical, {
528
+ kind: "llm.error",
529
+ contextId,
530
+ timestampMs: Date.now(),
531
+ component: "bridge",
532
+ category,
533
+ cause: err instanceof Error ? err : new Error(String(err)),
534
+ isRecoverable: recoverable,
535
+ });
536
+ return;
537
+ }
538
+ push(Route.Background, {
539
+ kind: "metric.conversation",
540
+ contextId,
541
+ timestampMs: Date.now(),
542
+ name: "llm.retry",
543
+ value: String(attempt + 1),
544
+ });
545
+ await waitForRetryDelay(attempt, this.retryConfig, signal);
546
+ }
547
+ }
548
+ }
549
+ finally {
550
+ if (this.activeGeneration?.controller === controller) {
551
+ this.activeGeneration = null;
552
+ }
553
+ if (!committed)
554
+ this.clearTurnState(contextId);
555
+ }
556
+ }
557
+ async close() {
558
+ this.discardDraft();
559
+ this.activeGeneration?.controller.abort();
560
+ this.activeGeneration = null;
561
+ for (const dispose of this.disposers.splice(0))
562
+ dispose();
563
+ this.spokenByContext.clear();
564
+ this.turnUserText.clear();
565
+ this.assistantMsgByContext.clear();
566
+ this.wordTimestampsByContext.clear();
567
+ this.playedOutMsByContext.clear();
568
+ this.transientContextMessages.clear();
569
+ this.bus = null;
570
+ }
571
+ rememberTurn(userText, assistantText, contextId) {
572
+ const assistantMsg = { role: "assistant", content: assistantText };
573
+ this.history.push({ role: "user", content: userText }, assistantMsg);
574
+ this.assistantMsgByContext.set(contextId, assistantMsg);
575
+ this.iuLedger.commit(this.assistantIuIdFor(contextId));
576
+ this.trimHistory();
577
+ this.persistHistory();
578
+ }
579
+ /** G4: persist the bounded history snapshot, best-effort off the hot path. */
580
+ persistHistory() {
581
+ const store = this.opts.sessionStore;
582
+ const sessionId = this.opts.sessionId;
583
+ if (!store || !sessionId)
584
+ return;
585
+ try {
586
+ void Promise.resolve(store.save(sessionId, this.history
587
+ .filter((message) => !this.transientContextMessages.has(message))
588
+ .map((message) => ({ ...message })))).catch(() => undefined);
589
+ }
590
+ catch {
591
+ /* persistence must never fail the turn */
592
+ }
593
+ }
594
+ /**
595
+ * G25: compute the spoken prefix — the assistant text the user actually heard before
596
+ * the barge-in. Uses word timestamps + playout position when available (exact at word
597
+ * boundaries), otherwise falls back to the accumulated text sent to TTS (approximate).
598
+ */
599
+ computeSpokenPrefix(contextId) {
600
+ const words = this.wordTimestampsByContext.get(contextId);
601
+ const playedOutMs = this.playedOutMsByContext.get(contextId);
602
+ if (words && words.length > 0 && playedOutMs !== undefined && playedOutMs > 0) {
603
+ const heard = words.filter((w) => w.endMs <= playedOutMs);
604
+ return heard.map((w) => w.word).join(" ");
605
+ }
606
+ return (this.spokenByContext.get(contextId) ?? "").trim();
607
+ }
608
+ /**
609
+ * Barge-in: rewrite the interrupted turn's history to what the user actually HEARD,
610
+ * not the full generated reply. Precision ladder (G25):
611
+ * 1. Word timestamps + playout position → exact word-boundary prefix.
612
+ * 2. Fallback: text sent to TTS — approximate (may include unplayed audio since
613
+ * TTS streams faster than realtime; headless/browser paths have no playout clock).
614
+ * If the turn was committed (generation done before barge-in), truncates in place.
615
+ * If mid-generation (not yet committed), records what was sent. Either way the user
616
+ * utterance is preserved: neither divergent nor amnesiac.
617
+ */
618
+ commitInterruptedHistory(contextId) {
619
+ const spoken = this.computeSpokenPrefix(contextId);
620
+ const aid = this.assistantIuIdFor(contextId);
621
+ const ms = this.playedOutMsByContext.get(contextId);
622
+ const prefix = { chars: spoken.length, ms };
623
+ const assistantIu = this.iuLedger.get(aid);
624
+ if (assistantIu?.state === "hypothesized") {
625
+ this.iuLedger.commit(aid, prefix);
626
+ }
627
+ else if (assistantIu?.state === "committed") {
628
+ assistantIu.committedPrefix = prefix;
629
+ }
630
+ const existing = this.assistantMsgByContext.get(contextId);
631
+ if (existing) {
632
+ if (spoken) {
633
+ existing.content = spoken;
634
+ }
635
+ else {
636
+ const idx = this.history.indexOf(existing);
637
+ if (idx >= 0)
638
+ this.history.splice(idx, 1);
639
+ }
640
+ }
641
+ else {
642
+ const userText = this.turnUserText.get(contextId);
643
+ if (userText !== undefined) {
644
+ this.history.push({ role: "user", content: userText });
645
+ if (spoken)
646
+ this.history.push({ role: "assistant", content: spoken });
647
+ this.trimHistory();
648
+ }
649
+ }
650
+ this.bus?.push(Route.Background, {
651
+ kind: "metric.conversation",
652
+ contextId,
653
+ timestampMs: Date.now(),
654
+ name: "llm.history_truncated_to_spoken",
655
+ value: String(spoken.length),
656
+ });
657
+ this.persistHistory(); // G4: the durable snapshot reflects the heard prefix
658
+ this.clearTurnState(contextId);
659
+ }
660
+ trimHistory() {
661
+ const maxMessages = this.maxHistoryTurns * 2;
662
+ if (this.history.length > maxMessages) {
663
+ this.history = this.history.slice(this.history.length - maxMessages);
664
+ }
665
+ // Drop tracked per-turn state that has aged out of the history window.
666
+ for (const [ctx, msg] of this.assistantMsgByContext) {
667
+ if (!this.history.includes(msg))
668
+ this.clearTurnState(ctx);
669
+ }
670
+ for (const message of this.transientContextMessages) {
671
+ if (!this.history.includes(message))
672
+ this.transientContextMessages.delete(message);
673
+ }
674
+ }
675
+ clearTurnState(contextId) {
676
+ const aid = this.assistantIuIdFor(contextId);
677
+ if (this.iuLedger.get(aid)?.state === "hypothesized") {
678
+ this.iuLedger.revoke(aid);
679
+ }
680
+ this.spokenByContext.delete(contextId);
681
+ this.turnUserText.delete(contextId);
682
+ this.assistantMsgByContext.delete(contextId);
683
+ this.wordTimestampsByContext.delete(contextId);
684
+ this.playedOutMsByContext.delete(contextId);
685
+ }
686
+ }
687
+ function readPositiveIntegerConfig(value, fallback) {
688
+ if (typeof value !== "number" || !Number.isFinite(value))
689
+ return fallback;
690
+ const integer = Math.floor(value);
691
+ return integer > 0 ? integer : fallback;
692
+ }
693
+ async function* withStreamIdleTimeout(source, timeoutMs, signal) {
694
+ const iterator = source[Symbol.asyncIterator]();
695
+ for (;;) {
696
+ const next = await nextWithTimeout(iterator, timeoutMs, signal);
697
+ if (next.done === true)
698
+ return;
699
+ yield next.value;
700
+ }
701
+ }
702
+ async function nextWithTimeout(iterator, timeoutMs, signal) {
703
+ return await new Promise((resolve, reject) => {
704
+ if (signal.aborted) {
705
+ reject(new Error("AI SDK stream aborted"));
706
+ return;
707
+ }
708
+ const timeout = setTimeout(() => {
709
+ void iterator.return?.(undefined);
710
+ reject(new Error(`AI SDK stream idle timeout after ${String(timeoutMs)}ms`));
711
+ }, timeoutMs);
712
+ const onAbort = () => {
713
+ clearTimeout(timeout);
714
+ void iterator.return?.(undefined);
715
+ reject(new Error("AI SDK stream aborted"));
716
+ };
717
+ signal.addEventListener("abort", onAbort, { once: true });
718
+ iterator.next().then((next) => {
719
+ clearTimeout(timeout);
720
+ signal.removeEventListener("abort", onAbort);
721
+ resolve(next);
722
+ }, (err) => {
723
+ clearTimeout(timeout);
724
+ signal.removeEventListener("abort", onAbort);
725
+ reject(err);
726
+ });
727
+ });
728
+ }