botium-connector-voip 0.0.23 → 0.0.25

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.
@@ -49,6 +49,7 @@ const Capabilities = {
49
49
  VOIP_STT_TIMEOUT: 'VOIP_STT_TIMEOUT',
50
50
  VOIP_STT_MESSAGE_HANDLING: 'VOIP_STT_MESSAGE_HANDLING',
51
51
  VOIP_STT_MESSAGE_HANDLING_TIMEOUT: 'VOIP_STT_MESSAGE_HANDLING_TIMEOUT',
52
+ VOIP_STT_MESSAGE_HANDLING_TIMEOUT_SUBSEQUENT: 'VOIP_STT_MESSAGE_HANDLING_TIMEOUT_SUBSEQUENT',
52
53
  VOIP_STT_MESSAGE_HANDLING_DELIMITER: 'VOIP_STT_MESSAGE_HANDLING_DELIMITER',
53
54
  VOIP_STT_MESSAGE_HANDLING_PUNCTUATION: 'VOIP_STT_MESSAGE_HANDLING_PUNCTUATION',
54
55
  VOIP_TTS_URL: 'VOIP_TTS_URL',
@@ -87,7 +88,8 @@ const Capabilities = {
87
88
  VOIP_SILENCE_DURATION_TIMEOUT_START: 'VOIP_SILENCE_DURATION_TIMEOUT_START',
88
89
  VOIP_STT_CONFIDENCE_THRESHOLD: 'VOIP_STT_CONFIDENCE_THRESHOLD',
89
90
  VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER',
90
- VOIP_USER_INPUT_PREFER_VOICE: 'VOIP_USER_INPUT_PREFER_VOICE'
91
+ VOIP_USER_INPUT_PREFER_VOICE: 'VOIP_USER_INPUT_PREFER_VOICE',
92
+ VOIP_EMIT_SPECULATIVE_TEXT: 'VOIP_EMIT_SPECULATIVE_TEXT'
91
93
  };
92
94
  const Defaults = {
93
95
  VOIP_STT_METHOD: 'POST',
@@ -139,6 +141,7 @@ class BotiumConnectorVoip {
139
141
  // For debugging latency between incoming STT (bot says) and outgoing audio (sendAudio)
140
142
  this._lastBotSaysQueuedAt = null;
141
143
  this._lastBotSaysText = null;
144
+ this._speculativeTurnToken = 0;
142
145
  }
143
146
  async Validate() {
144
147
  debug$3('Validate called');
@@ -189,8 +192,20 @@ class BotiumConnectorVoip {
189
192
  this.ttsCache.clear();
190
193
  }
191
194
  const sendBotMsg = botMsg => {
192
- this._lastBotSaysQueuedAt = Date.now();
195
+ const queuedAt = Date.now();
196
+ this._lastBotSaysQueuedAt = queuedAt;
193
197
  this._lastBotSaysText = botMsg && botMsg.messageText ? String(botMsg.messageText) : null;
198
+ // Stamp the wall-clock instant at which the connector released the bot
199
+ // utterance to botium-core's queue. Paired with `_receivedAtMs` (last
200
+ // STT final frame) this gives the true "join silence" the connector
201
+ // imposed, independent of how long the coach later takes to pick the
202
+ // message up with WaitBotSays().
203
+ if (botMsg && botMsg.sourceData) {
204
+ const head = Array.isArray(botMsg.sourceData) ? botMsg.sourceData[0] : botMsg.sourceData;
205
+ if (head && typeof head === 'object' && !('flushedAtMs' in head)) {
206
+ head.flushedAtMs = queuedAt;
207
+ }
208
+ }
194
209
  setTimeout(() => this.queueBotSays(botMsg), 0);
195
210
  };
196
211
  const joinBotMsg = (botMsgs, joinLastPrevMsg) => {
@@ -211,6 +226,161 @@ class BotiumConnectorVoip {
211
226
  }
212
227
  return botMsg;
213
228
  };
229
+
230
+ // Returns true when a partial represents a NEW utterance rather than a tail/echo
231
+ // of buffered text. Used by PSST partial handler to extend the silence timer.
232
+ // Heuristic: substring check, then word-overlap fallback (<70% overlap = new).
233
+ const partialLooksLikeNewUtterance = (partialText, botMsgs) => {
234
+ const normalize = s => String(s || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
235
+ const partial = normalize(partialText);
236
+ if (!partial) return false;
237
+ const buffered = normalize((botMsgs || []).map(m => m && m.messageText || '').join(' '));
238
+ if (!buffered) return true;
239
+ if (buffered.includes(partial)) return false;
240
+ const partialWords = partial.split(' ').filter(Boolean);
241
+ if (partialWords.length === 0) return false;
242
+ const bufferedWords = new Set(buffered.split(' ').filter(Boolean));
243
+ const overlap = partialWords.filter(w => bufferedWords.has(w)).length;
244
+ return overlap / partialWords.length < 0.7;
245
+ };
246
+
247
+ // Arm (or re-arm) the JOIN/PSST silence timer that flushes buffered STT
248
+ // chunks once the bot has been silent for `joinTimeoutMs`. No-op outside
249
+ // JOIN/PSST/CONCAT modes (other modes emit finals immediately).
250
+ const armJoinSilenceTimer = () => {
251
+ if (!this.botMsgs || this.botMsgs.length === 0) return;
252
+ const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
253
+ const joinLogicHook = this._getJoinLogicHook(this.convoStep);
254
+ const isJoinMethod = sttHandling === 'JOIN' || sttHandling === 'PSST' || sttHandling === 'CONCAT' || !lodash__default["default"].isNil(joinLogicHook);
255
+ if (!isJoinMethod) return;
256
+ const isPsst = sttHandling === 'PSST';
257
+ const toJoinTimeoutMs = ms => {
258
+ const parsed = parseInt(ms, 10);
259
+ if (!lodash__default["default"].isFinite(parsed) || parsed <= 0) return null;
260
+ return isPsst ? Math.max(0, parsed - 500) : parsed;
261
+ };
262
+ let joinTimeoutMs = toJoinTimeoutMs(lodash__default["default"].get(joinLogicHook, 'args[0]'));
263
+ if (!lodash__default["default"].isFinite(joinTimeoutMs) || joinTimeoutMs <= 0) {
264
+ joinTimeoutMs = toJoinTimeoutMs(this._getEffectiveMessageHandlingTimeout());
265
+ }
266
+ if (this.silenceTimeout) {
267
+ clearTimeout(this.silenceTimeout);
268
+ this.silenceTimeout = null;
269
+ }
270
+ const bufferedAtArm = this.botMsgs.length;
271
+ const armedAt = Date.now();
272
+ _info('psst_timer_armed', {
273
+ sessionId: this.sessionId,
274
+ joinTimeoutMs: joinTimeoutMs || 0,
275
+ bufferedChunks: bufferedAtArm,
276
+ stopCalled: !!this.stopCalled
277
+ });
278
+ // Emit the authoritative join timeout so downstream consumers (e.g.
279
+ // SpeculationBuffer) can size their quiet threshold relative to it.
280
+ if (this.eventEmitter && lodash__default["default"].isFinite(joinTimeoutMs) && joinTimeoutMs > 0) {
281
+ try {
282
+ this.eventEmitter.emit('voip.psstTimerArmed', {
283
+ sessionId: this.sessionId,
284
+ joinTimeoutMs,
285
+ bufferedChunks: bufferedAtArm,
286
+ armedAt
287
+ });
288
+ } catch (emitErr) {
289
+ // Never block the silence timer on listener errors.
290
+ debug$3(`voip.psstTimerArmed emission failed: ${emitErr && emitErr.message}`);
291
+ }
292
+ }
293
+ this.silenceTimeout = setTimeout(() => {
294
+ const fireDelay = Date.now() - armedAt;
295
+ if (this.botMsgs.length > 0) {
296
+ _info('psst_timer_fired', {
297
+ sessionId: this.sessionId,
298
+ bufferedChunks: this.botMsgs.length,
299
+ actualDelayMs: fireDelay,
300
+ scheduledDelayMs: joinTimeoutMs || 0,
301
+ outcome: 'emit'
302
+ });
303
+ debug$3('Silence Duration Timeout (JOIN/PSST):', joinTimeoutMs, 'ms');
304
+ sendBotMsg(joinBotMsg(this.botMsgs, this.joinLastPrevMsg));
305
+ this.firstMsg = false;
306
+ this.joinLastPrevMsg = this.botMsgs[this.botMsgs.length - 1];
307
+ this.botMsgs = [];
308
+ // Reset partial-driven extension budget for next cycle.
309
+ this.psstRearmCount = 0;
310
+ this.psstFirstRearmAt = null;
311
+ } else {
312
+ _info('psst_timer_fired', {
313
+ sessionId: this.sessionId,
314
+ bufferedChunks: 0,
315
+ actualDelayMs: fireDelay,
316
+ scheduledDelayMs: joinTimeoutMs || 0,
317
+ outcome: 'noop_empty_buffer'
318
+ });
319
+ }
320
+ }, joinTimeoutMs || 0);
321
+ };
322
+
323
+ // Flush buffered STT chunks on teardown so a late final is not lost when
324
+ // the PSST silence timer is cleared by Stop(). Falls back to the cached
325
+ // interim transcript (`lastPartialBotMsg`) when STT never delivered a
326
+ // final for the closing utterance. Must be called from every terminal
327
+ // path before `this.end` is flipped.
328
+ const flushPendingBotMsgs = reason => {
329
+ if (this.silenceTimeout) {
330
+ _info('psst_timer_cleared', {
331
+ sessionId: this.sessionId,
332
+ reason: `flush:${reason}`,
333
+ bufferedChunks: this.botMsgs && this.botMsgs.length || 0
334
+ });
335
+ clearTimeout(this.silenceTimeout);
336
+ this.silenceTimeout = null;
337
+ }
338
+ if (this.botMsgs && this.botMsgs.length > 0) {
339
+ const chunkCount = this.botMsgs.length;
340
+ _info('stt_buffer_flushed_on_end', {
341
+ sessionId: this.sessionId,
342
+ reason,
343
+ outcome: 'flushed',
344
+ chunks: chunkCount
345
+ });
346
+ debug$3(`Flushing ${chunkCount} buffered STT chunk(s) on ${reason}`);
347
+ sendBotMsg(joinBotMsg(this.botMsgs, this.joinLastPrevMsg));
348
+ this.firstMsg = false;
349
+ this.joinLastPrevMsg = this.botMsgs[this.botMsgs.length - 1];
350
+ this.botMsgs = [];
351
+ this.lastPartialBotMsg = null;
352
+ this.psstRearmCount = 0;
353
+ this.psstFirstRearmAt = null;
354
+ return;
355
+ }
356
+ if (this.lastPartialBotMsg && typeof this.lastPartialBotMsg.messageText === 'string' && this.lastPartialBotMsg.messageText.trim().length > 0) {
357
+ const recovered = this.lastPartialBotMsg;
358
+ const textLen = recovered.messageText.length;
359
+ const preview = recovered.messageText.trim().substring(0, 80);
360
+ _info('stt_partial_recovery_on_end', {
361
+ sessionId: this.sessionId,
362
+ reason,
363
+ outcome: 'partial_recovery',
364
+ messageLength: textLen,
365
+ message: preview
366
+ });
367
+ debug$3(`Recovering last STT partial on ${reason} (no final arrived): "${preview}"`);
368
+ sendBotMsg(recovered);
369
+ this.firstMsg = false;
370
+ this.joinLastPrevMsg = recovered;
371
+ this.lastPartialBotMsg = null;
372
+ return;
373
+ }
374
+ _info('stt_flush_noop', {
375
+ sessionId: this.sessionId,
376
+ reason,
377
+ outcome: 'noop',
378
+ sttPartialCount: this.sttPartialCount || 0
379
+ });
380
+ debug$3(`flushPendingBotMsgs(${reason}): nothing buffered, no partial available (sttPartialCount=${this.sttPartialCount || 0})`);
381
+ };
382
+ // Expose on the instance so Stop() (outside this closure) can call it as a final safety net.
383
+ this._flushPendingBotMsgs = flushPendingBotMsgs;
214
384
  const splitBotMsgs = botMsgs => {
215
385
  const splitSentences = s => s.match(new RegExp(`[^${this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_PUNCTUATION]}]+[${this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_PUNCTUATION]}]+`, 'g'));
216
386
  const botMsgsFinal = [];
@@ -236,21 +406,31 @@ class BotiumConnectorVoip {
236
406
  let httpInitRetries = 0;
237
407
  const connectHttp = async retryIndex => {
238
408
  retryIndex = retryIndex || 0;
409
+ let initCallUrl = '';
239
410
  try {
240
411
  const workerUrl = this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_URL : this.caps[Capabilities.VOIP_WORKER_URL].replace('wss', 'https').replace('ws', 'http');
241
412
  const baseUrl = workerUrl.endsWith('/') ? workerUrl.slice(0, -1) : workerUrl;
413
+ initCallUrl = `${baseUrl}/initCall`;
414
+ const postPayload = {
415
+ API_KEY: this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_APIKEY : this.caps[Capabilities.VOIP_WORKER_APIKEY]
416
+ };
417
+ const payloadForLog = {
418
+ ...postPayload,
419
+ API_KEY: postPayload.API_KEY != null && postPayload.API_KEY !== '' ? '[REDACTED]' : postPayload.API_KEY
420
+ };
421
+ debug$3(`HTTP initCall: POST ${initCallUrl} payload=${JSON.stringify(payloadForLog)}`);
242
422
  const res = await axios__default["default"]({
243
423
  method: 'post',
244
- data: {
245
- API_KEY: this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_APIKEY : this.caps[Capabilities.VOIP_WORKER_APIKEY]
246
- },
247
- url: `${baseUrl}/initCall`
424
+ data: postPayload,
425
+ url: initCallUrl
248
426
  });
249
427
  if (res) {
250
428
  data = res.data;
251
429
  headers = res.headers;
430
+ debug$3(`HTTP initCall: response status=${res.status} data=${this._getAxiosShortenedOutput(res.data)}`);
252
431
  }
253
432
  } catch (err) {
433
+ debug$3(`HTTP initCall: failed ${initCallUrl} — ${this._getAxiosErrOutput(err)}`);
254
434
  debug$3(`Retry ${retryIndex} / ${this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]}: Connecting to VOIP Worker failed: ${err.message || 'Not reachable'}`);
255
435
  if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
256
436
  throw new Error(`Connecting to VOIP Worker failed: ${err.message || 'Not reachable'}`);
@@ -275,7 +455,15 @@ class BotiumConnectorVoip {
275
455
  const connectWs = retryIndex => {
276
456
  retryIndex = retryIndex || 0;
277
457
  return new Promise((resolve, reject) => {
278
- if ('set-cookie' in headers) {
458
+ const useCookie = headers && 'set-cookie' in headers;
459
+ const connectPayload = useCookie ? {
460
+ withCookie: true,
461
+ cookieSize: headers['set-cookie'] && String(headers['set-cookie']).length || 0
462
+ } : {
463
+ withCookie: false
464
+ };
465
+ debug$3(`WebSocket connect: url=${wsEndpoint} attempt=${retryIndex + 1} max=${this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES] + 1} ` + `options=${JSON.stringify(connectPayload)}`);
466
+ if (useCookie) {
279
467
  this.ws = new ws__default["default"](wsEndpoint, {
280
468
  headers: {
281
469
  Cookie: headers['set-cookie']
@@ -285,9 +473,11 @@ class BotiumConnectorVoip {
285
473
  this.ws = new ws__default["default"](wsEndpoint);
286
474
  }
287
475
  this.ws.on('open', () => {
476
+ debug$3(`WebSocket connect: result=open url=${wsEndpoint} attempt=${retryIndex + 1} ` + `readyState=${this.ws && this.ws.readyState}`);
288
477
  resolve(retryIndex);
289
478
  });
290
- this.ws.on('error', () => {
479
+ this.ws.on('error', err => {
480
+ debug$3(`WebSocket connect: result=error url=${wsEndpoint} attempt=${retryIndex + 1} ` + `err=${err && err.message || err || 'unknown'}`);
291
481
  if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
292
482
  reject(new Error(`Websocket connection failed to ${wsEndpoint}`));
293
483
  }
@@ -320,9 +510,9 @@ class BotiumConnectorVoip {
320
510
  if (joinLogicHook && joinLogicHook.args && joinLogicHook.args.length > 0) {
321
511
  silenceMs = parseInt(joinLogicHook.args[0], 10);
322
512
  }
323
- // Fallback to global timeout if no per-step hook is set
513
+ // Fallback to effective timeout (subsequent if past first flush, else global)
324
514
  if (!lodash__default["default"].isFinite(silenceMs) || silenceMs <= 0) {
325
- silenceMs = parseInt(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT], 10);
515
+ silenceMs = parseInt(this._getEffectiveMessageHandlingTimeout(), 10);
326
516
  }
327
517
  // PSST: treat like JOIN, but subtract 500ms from timeout
328
518
  if (lodash__default["default"].isFinite(silenceMs) && silenceMs > 0) {
@@ -344,9 +534,15 @@ class BotiumConnectorVoip {
344
534
  this.silence = null;
345
535
  this.msgCount = 0;
346
536
  this.sttPartialCount = 0;
537
+ // Last interim transcript, used by `flushPendingBotMsgs` when no final arrives.
538
+ this.lastPartialBotMsg = null;
347
539
  this.firstMsg = true;
348
540
  this.firstSttInfoReceived = false;
349
541
  this.silenceTimeout = null;
542
+ // PSST re-arm tracking: new-utterance partials reset the silence timer,
543
+ // bounded by MAX_EXTENSION_MS to prevent infinite stranding.
544
+ this.psstRearmCount = 0;
545
+ this.psstFirstRearmAt = null;
350
546
  this.wsOpened = true;
351
547
  debug$3(`Websocket connection to ${wsEndpoint} opened.`);
352
548
  const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
@@ -403,12 +599,45 @@ class BotiumConnectorVoip {
403
599
  }
404
600
  });
405
601
  this.ws.on('message', async data => {
406
- const parsedData = JSON.parse(data);
407
- // Allow fullRecord delivery even if Stop() was already called.
408
- // Otherwise the recording can be dropped if the worker streams it late (common on hangup).
602
+ // Drop non-JSON gateway frames (rare, but crash the process without this guard).
603
+ let parsedData;
604
+ try {
605
+ parsedData = JSON.parse(data);
606
+ // Earliest local observation of the frame. Consumed downstream
607
+ // as the "bot finished speaking" anchor for `recordingEpochMs`
608
+ // (propagates via `sourceData = parsedData` reference).
609
+ parsedData._receivedAtMs = Date.now();
610
+ } catch (parseErr) {
611
+ const rawString = (() => {
612
+ try {
613
+ if (Buffer.isBuffer(data)) return data.toString('utf8');
614
+ if (typeof data === 'string') return data;
615
+ if (data && typeof data.toString === 'function') return data.toString();
616
+ return '<non-stringable>';
617
+ } catch (_) {
618
+ return '<unreadable>';
619
+ }
620
+ })();
621
+ const preview = rawString.length > 200 ? `${rawString.substring(0, 200)}...` : rawString;
622
+ debug$3(`${this.sessionId} - Ignoring non-JSON WS frame from gateway: ${preview}`);
623
+ if (typeof _info === 'function') {
624
+ try {
625
+ _info('ws_non_json_frame', {
626
+ sessionId: this.sessionId,
627
+ preview,
628
+ byteLength: rawString.length,
629
+ parseError: parseErr && parseErr.message ? parseErr.message : String(parseErr)
630
+ });
631
+ } catch (_infoErr) {/* structured logger is best-effort */}
632
+ }
633
+ return;
634
+ }
635
+ // After Stop(), still accept late-arriving recording frames
636
+ // (fullRecord*) and hard errors so `full_record.wav` is delivered
637
+ // on early-completion hangups. Post-Stop STT frames remain blocked.
409
638
  if (this.stopCalled) {
410
- const allowedTypes = ['fullRecord', 'fullRecordStart', 'fullRecordChunk', 'fullRecordEnd', 'error'];
411
- if (!parsedData || !parsedData.type || !allowedTypes.includes(parsedData.type)) {
639
+ const allowedPostStopTypes = ['fullRecord', 'fullRecordStart', 'fullRecordChunk', 'fullRecordEnd', 'error'];
640
+ if (!parsedData || !allowedPostStopTypes.includes(parsedData.type)) {
412
641
  debug$3(`${this.sessionId} - Stop already called, ignoring incoming message`);
413
642
  return;
414
643
  }
@@ -517,6 +746,7 @@ class BotiumConnectorVoip {
517
746
  connectDurationSec: parsedData.connectDuration || null,
518
747
  sttPartialCount: this.sttPartialCount
519
748
  });
749
+ flushPendingBotMsgs('callinfo_disconnected');
520
750
  const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_STT_BODY));
521
751
  if (parsedData.connectDuration && parsedData.connectDuration > 0) {
522
752
  this.eventEmitter.emit('CONSUMPTION_METADATA', this, {
@@ -529,6 +759,7 @@ class BotiumConnectorVoip {
529
759
  }
530
760
  }
531
761
  if (parsedData && parsedData.type === 'error') {
762
+ flushPendingBotMsgs('error');
532
763
  this.end = true;
533
764
  reject(new Error(`Error: ${parsedData.message}`));
534
765
  sendBotMsg(new Error(`Error: ${parsedData.message}`));
@@ -553,11 +784,15 @@ class BotiumConnectorVoip {
553
784
  source: 'fullRecordEnd',
554
785
  base64Len
555
786
  });
787
+ // Flush before `this.end = true` so the buffered final STT is not
788
+ // dropped when Stop() clears the PSST silence timer on teardown.
789
+ flushPendingBotMsgs('fullRecordEnd');
556
790
  this.end = true;
557
791
  }
558
792
  if (parsedData && parsedData.type === 'silence') {
559
793
  if (lodash__default["default"].isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
560
794
  if (lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep)) && parsedData.data.silence.length > 0) {
795
+ flushPendingBotMsgs('silence_exceeded');
561
796
  this.end = true;
562
797
  sendBotMsg(new Error(`Silence Duration of ${parsedData.data.silence[0][2].toFixed(2)}s exceeded General Silence Duration Timeout of ${this.caps[Capabilities.VOIP_SILENCE_DURATION_TIMEOUT] / 1000}s`));
563
798
  }
@@ -572,12 +807,69 @@ class BotiumConnectorVoip {
572
807
  source: 'fullRecord',
573
808
  base64Len: (base64 || '').length
574
809
  });
810
+ flushPendingBotMsgs('fullRecord');
575
811
  this.end = true;
576
812
  }
577
813
  if (parsedData && parsedData.data && parsedData.data.final === false) {
578
814
  this.sttPartialCount++;
815
+ const partialText = parsedData.data.message;
816
+ if (typeof partialText === 'string' && partialText.trim().length > 0) {
817
+ this.lastPartialBotMsg = {
818
+ messageText: partialText,
819
+ sourceData: Object.assign({}, parsedData, {
820
+ partialRecovery: true
821
+ })
822
+ };
823
+ }
824
+ const partialPreview = typeof partialText === 'string' ? partialText.trim().substring(0, 60) : '';
825
+ _info('stt_partial_received', {
826
+ sessionId: this.sessionId,
827
+ partialIndex: this.sttPartialCount,
828
+ preview: partialPreview,
829
+ bufferedChunks: this.botMsgs && this.botMsgs.length || 0,
830
+ timerActive: !!this.silenceTimeout
831
+ });
832
+ // Emit liveness keep-alive so waiters can distinguish "bot silent"
833
+ // from "bot streaming partials between finals".
834
+ if (this.caps[Capabilities.VOIP_EMIT_SPECULATIVE_TEXT] && this.eventEmitter && typeof partialText === 'string' && partialText.trim().length > 0) {
835
+ this.eventEmitter.emit('voip.botActivity', {
836
+ sessionId: this.sessionId,
837
+ kind: 'partial',
838
+ partialIndex: this.sttPartialCount,
839
+ bufferedChunks: this.botMsgs && this.botMsgs.length || 0
840
+ });
841
+ }
842
+ // PSST partial timer logic:
843
+ // Empty buffer → clear timer (final will arm fresh).
844
+ // Buffered finals + new-utterance partial → re-arm within cap.
845
+ // Tail/echo or cap-exceeded partials → ignore (legacy path).
579
846
  if (this.silenceTimeout) {
580
- clearTimeout(this.silenceTimeout);
847
+ if (!this.botMsgs || this.botMsgs.length === 0) {
848
+ clearTimeout(this.silenceTimeout);
849
+ this.silenceTimeout = null;
850
+ } else {
851
+ const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
852
+ const isPsstMode = sttHandling === 'PSST';
853
+ if (isPsstMode && typeof partialText === 'string' && partialText.trim().length > 0) {
854
+ const MAX_EXTENSION_MS = 60000;
855
+ const now = Date.now();
856
+ const withinCap = this.psstFirstRearmAt == null || now - this.psstFirstRearmAt < MAX_EXTENSION_MS;
857
+ if (withinCap && partialLooksLikeNewUtterance(partialText, this.botMsgs)) {
858
+ if (this.psstFirstRearmAt == null) this.psstFirstRearmAt = now;
859
+ this.psstRearmCount++;
860
+ _info('psst_timer_extended', {
861
+ sessionId: this.sessionId,
862
+ rearmCount: this.psstRearmCount,
863
+ msSinceFirstRearm: now - this.psstFirstRearmAt,
864
+ bufferedChunks: this.botMsgs.length,
865
+ reason: 'new_utterance_partial',
866
+ partialPreview
867
+ });
868
+ // armJoinSilenceTimer() handles clearing the existing timeout.
869
+ armJoinSilenceTimer();
870
+ }
871
+ }
872
+ }
581
873
  }
582
874
  if (lodash__default["default"].isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
583
875
  if (!this.firstSttInfoReceived && lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep)) && this.caps[Capabilities.VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE] && parsedData.data.start && parsedData.data.start * 1000 > this.caps[Capabilities.VOIP_SILENCE_DURATION_TIMEOUT_START]) {
@@ -605,7 +897,7 @@ class BotiumConnectorVoip {
605
897
  matched = true;
606
898
  }
607
899
  } else if (lodash__default["default"].isNil(joinLogicHook) && isJoinMethod) {
608
- const joinTimeoutMs = toJoinTimeoutMs(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT]);
900
+ const joinTimeoutMs = toJoinTimeoutMs(this._getEffectiveMessageHandlingTimeout());
609
901
  if (lodash__default["default"].isFinite(joinTimeoutMs) && joinTimeoutMs > 0 && silenceDuration > joinTimeoutMs / 1000) {
610
902
  matched = true;
611
903
  }
@@ -625,6 +917,8 @@ class BotiumConnectorVoip {
625
917
  const msgText = parsedData.data.message || '';
626
918
  const msgLen = typeof msgText === 'string' ? msgText.length : 0;
627
919
  const msgPreview = typeof msgText === 'string' ? msgText.trim().substring(0, 80) : '';
920
+ // A final supersedes the cached interim; clear to avoid duplicate tail emission.
921
+ this.lastPartialBotMsg = null;
628
922
  debug$3(`Message: ${parsedData.data.message} / Confidence Score: ${this._getConfidenceScore(parsedData)} (Threshold: ${confidenceThreshold})`);
629
923
  _info('stt_final', {
630
924
  sessionId: this.sessionId,
@@ -702,27 +996,30 @@ class BotiumConnectorVoip {
702
996
  this.prevData = parsedData;
703
997
  if (successfulConfidenceScore) {
704
998
  this.botMsgs.push(botMsg);
705
- const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
706
- const isPsst = sttHandling === 'PSST';
707
- const toJoinTimeoutMs = ms => {
708
- const parsed = parseInt(ms, 10);
709
- if (!lodash__default["default"].isFinite(parsed) || parsed <= 0) return null;
710
- return isPsst ? Math.max(0, parsed - 500) : parsed;
711
- };
712
- const joinLogicHook = this._getJoinLogicHook(this.convoStep);
713
- let joinTimeoutMs = toJoinTimeoutMs(lodash__default["default"].get(joinLogicHook, 'args[0]'));
714
- if (!lodash__default["default"].isFinite(joinTimeoutMs) || joinTimeoutMs <= 0) {
715
- joinTimeoutMs = toJoinTimeoutMs(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT]);
999
+ if (this.caps[Capabilities.VOIP_EMIT_SPECULATIVE_TEXT] && this.eventEmitter) {
1000
+ const tentative = joinBotMsg(this.botMsgs, this.joinLastPrevMsg);
1001
+ this._speculativeTurnToken++;
1002
+ // speech{Start,End}Sec are audio-clock offsets (seconds since
1003
+ // call connect) taken from the STT worker, so downstream
1004
+ // consumers can align with the recording playhead directly.
1005
+ const firstChunkStart = lodash__default["default"].get(this.botMsgs, '[0].sourceData.data.start', null);
1006
+ const thisFrameStart = lodash__default["default"].get(parsedData, 'data.start', null);
1007
+ const thisFrameEnd = lodash__default["default"].get(parsedData, 'data.end', null);
1008
+ const speechStartSec = lodash__default["default"].isFinite(firstChunkStart) ? firstChunkStart : lodash__default["default"].isFinite(thisFrameStart) ? thisFrameStart : null;
1009
+ const speechEndSec = lodash__default["default"].isFinite(thisFrameEnd) ? thisFrameEnd : null;
1010
+ this.eventEmitter.emit('voip.speculativeBotText', {
1011
+ sessionId: this.sessionId,
1012
+ messageText: tentative.messageText,
1013
+ turnToken: this._speculativeTurnToken,
1014
+ speechStartSec,
1015
+ speechEndSec
1016
+ });
716
1017
  }
717
- this.silenceTimeout = setTimeout(() => {
718
- if (this.botMsgs.length > 0) {
719
- debug$3('Silence Duration Timeout (JOIN/PSST):', joinTimeoutMs, 'ms');
720
- sendBotMsg(joinBotMsg(this.botMsgs, this.joinLastPrevMsg));
721
- this.firstMsg = false;
722
- this.joinLastPrevMsg = this.botMsgs[this.botMsgs.length - 1];
723
- this.botMsgs = [];
724
- }
725
- }, joinTimeoutMs || 0);
1018
+
1019
+ // New final arrived — reset extension budget for next pause.
1020
+ this.psstRearmCount = 0;
1021
+ this.psstFirstRearmAt = null;
1022
+ armJoinSilenceTimer();
726
1023
  }
727
1024
  }
728
1025
  }
@@ -775,6 +1072,22 @@ class BotiumConnectorVoip {
775
1072
  const preferVoice = !!preferVoiceCapRaw;
776
1073
  const skipTtsForMixedInput = preferVoice && hasText && hasVoiceMedia;
777
1074
  debug$3(`UserSays routing: hasText=${hasText} hasVoiceMedia=${hasVoiceMedia} preferVoice=${preferVoice} preferVoiceRaw=${JSON.stringify(preferVoiceCapRaw)} skipTtsForMixedInput=${skipTtsForMixedInput}`);
1075
+
1076
+ // Stamp `msg.voipAgent` at the moment bytes leave the WebSocket so
1077
+ // the coach can place the agent turn on the recording timeline.
1078
+ // `requestedDurationMs` is the best estimate of on-wire playback
1079
+ // length (DTMF tones × digits, TTS synth output, parsed media duration).
1080
+ const stampAgentWire = (wireKind, requestedDurationMs, extras = {}) => {
1081
+ msg.voipAgent = {
1082
+ wireSentAtMs: Date.now(),
1083
+ inputType,
1084
+ wireKind,
1085
+ requestedDurationMs: Math.max(0, Math.round(requestedDurationMs || 0)),
1086
+ ...extras
1087
+ };
1088
+ };
1089
+ // Twilio default: 100 ms tone + 100 ms gap per digit. Drives agent-bar width only.
1090
+ const DTMF_MS_PER_DIGIT = 200;
778
1091
  if (msg && msg.buttons && msg.buttons.length > 0) {
779
1092
  const digits = sanitizeDtmfDigits(msg.buttons[0].payload);
780
1093
  if (!digits) {
@@ -787,6 +1100,9 @@ class BotiumConnectorVoip {
787
1100
  digits,
788
1101
  sessionId: this.sessionId
789
1102
  });
1103
+ stampAgentWire('dtmf', digits.length * DTMF_MS_PER_DIGIT, {
1104
+ digitCount: digits.length
1105
+ });
790
1106
  this.ws.send(request);
791
1107
  } else if (msg && msg.messageText) {
792
1108
  // Check for DTMF tag in messageText: <DTMF>1234</DTMF>
@@ -808,6 +1124,9 @@ class BotiumConnectorVoip {
808
1124
  digits,
809
1125
  sessionId: this.sessionId
810
1126
  });
1127
+ stampAgentWire('dtmf', digits.length * DTMF_MS_PER_DIGIT, {
1128
+ digitCount: digits.length
1129
+ });
811
1130
  this.ws.send(request);
812
1131
  return resolve();
813
1132
  }
@@ -822,8 +1141,11 @@ class BotiumConnectorVoip {
822
1141
  if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
823
1142
  msg.sourceData = ttsRequest;
824
1143
  let ttsResult = null;
1144
+ const ttsStartedAt = Date.now();
1145
+ let ttsSynthMs = 0;
825
1146
  try {
826
1147
  ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
1148
+ ttsSynthMs = Date.now() - ttsStartedAt;
827
1149
  } catch (err) {
828
1150
  return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
829
1151
  }
@@ -855,6 +1177,10 @@ class BotiumConnectorVoip {
855
1177
  mimeType: 'audio/wav',
856
1178
  base64: b64Buffer
857
1179
  });
1180
+ stampAgentWire('tts', (duration || 0) * 1000, {
1181
+ ttsSynthMs,
1182
+ textLength: msg.messageText ? msg.messageText.length : 0
1183
+ });
858
1184
  this.ws.send(request);
859
1185
  } else {
860
1186
  return reject(new Error('TTS failed, response is empty'));
@@ -876,6 +1202,10 @@ class BotiumConnectorVoip {
876
1202
  const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
877
1203
  debug$3(`Latency (bot says -> sendAudio/MEDIA): ${latencyMs} ms (last bot: ${botText})`);
878
1204
  }
1205
+ // Stamp now; `requestedDurationMs` is backfilled once media metadata is parsed.
1206
+ stampAgentWire('media', 0, {
1207
+ mediaUri: msg.media[0].mediaUri || null
1208
+ });
879
1209
  this.ws.send(request);
880
1210
  msg.attachments.push({
881
1211
  name: msg.media[0].mediaUri,
@@ -887,6 +1217,9 @@ class BotiumConnectorVoip {
887
1217
  if (metadata && metadata.format && metadata.format.duration) {
888
1218
  debug$3('Audio duration of user audio:', metadata.format.duration, 'seconds');
889
1219
  duration = Math.round(metadata.format.duration);
1220
+ if (msg.voipAgent) {
1221
+ msg.voipAgent.requestedDurationMs = Math.max(0, Math.round((duration || 0) * 1000));
1222
+ }
890
1223
  } else {
891
1224
  reject(new Error('Could not determine audio duration from metadata'));
892
1225
  }
@@ -894,7 +1227,11 @@ class BotiumConnectorVoip {
894
1227
  reject(new Error(`Getting audio duration failed: ${err.message}`));
895
1228
  }
896
1229
  }
897
- setTimeout(resolve, duration * 1000);
1230
+ const requestedDurationMs = Math.max(0, Math.round((duration || 0) * 1000));
1231
+ if (requestedDurationMs <= 0) {
1232
+ return resolve();
1233
+ }
1234
+ setTimeout(resolve, requestedDurationMs);
898
1235
  }, 0);
899
1236
  });
900
1237
  }
@@ -1100,6 +1437,16 @@ class BotiumConnectorVoip {
1100
1437
  _extractApiKey(body) {
1101
1438
  return lodash__default["default"].get(body, 'polly.credentials.accessKeyId') || lodash__default["default"].get(body, 'google.credentials.client_email') || lodash__default["default"].get(body, 'ibm.credentials.apikey') || lodash__default["default"].get(body, 'awstranscribe.credentials.accessKeyId') || lodash__default["default"].get(body, 'azure.credentials.subscriptionKey') || null;
1102
1439
  }
1440
+
1441
+ // After the first flush, use the shorter SUBSEQUENT timeout if configured.
1442
+ // Falls back to the global timeout — fully backward-compatible.
1443
+ _getEffectiveMessageHandlingTimeout() {
1444
+ if (!this.firstMsg) {
1445
+ const sub = parseInt(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT_SUBSEQUENT], 10);
1446
+ if (lodash__default["default"].isFinite(sub) && sub > 0) return sub;
1447
+ }
1448
+ return this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT];
1449
+ }
1103
1450
  _getJoinLogicHook(convoStep) {
1104
1451
  if (lodash__default["default"].isNil(convoStep)) return null;
1105
1452
  if (lodash__default["default"].isNil(convoStep.logicHooks)) return null;