@openclaw/google-meet 2026.5.7 → 2026.5.10-beta.1

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 CHANGED
@@ -1,17 +1,18 @@
1
- import { a as isSameMeetUrlForReuse, c as resolveChromeNode, f as endGoogleMeetActiveConference, g as fetchLatestGoogleMeetConferenceRecord, h as fetchGoogleMeetSpace, i as callBrowserProxyOnNode, l as resolveChromeNodeInfo, m as fetchGoogleMeetAttendance, n as isGoogleMeetBrowserManualActionError, o as normalizeMeetUrlForReuse, p as fetchGoogleMeetArtifacts, r as asBrowserTabs, s as readBrowserTab, t as createMeetWithBrowserProxyOnNode, u as buildGoogleMeetPreflightReport } from "./chrome-create-B0wV2zaj.js";
2
- import { n as findGoogleMeetCalendarEvent, r as listGoogleMeetCalendarEvents, t as buildGoogleMeetCalendarDayWindow } from "./calendar-6EQiwLUb.js";
1
+ import { a as isSameMeetUrlForReuse, c as resolveChromeNode, f as endGoogleMeetActiveConference, g as fetchLatestGoogleMeetConferenceRecord, h as fetchGoogleMeetSpace, i as callBrowserProxyOnNode, l as resolveChromeNodeInfo, m as fetchGoogleMeetAttendance, n as isGoogleMeetBrowserManualActionError, o as normalizeMeetUrlForReuse, p as fetchGoogleMeetArtifacts, r as asBrowserTabs, s as readBrowserTab, t as createMeetWithBrowserProxyOnNode, u as buildGoogleMeetPreflightReport } from "./chrome-create-D0XLX4Ed.js";
2
+ import { n as findGoogleMeetCalendarEvent, r as listGoogleMeetCalendarEvents, t as buildGoogleMeetCalendarDayWindow } from "./calendar-DXHh6IyF.js";
3
3
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
4
4
  import { ErrorCodes, GatewayClient, callGatewayFromCli, errorShape, startGatewayClientWhenEventLoopReady } from "openclaw/plugin-sdk/gateway-runtime";
5
5
  import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
6
6
  import { normalizeOptionalLowercaseString, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
7
7
  import { Type } from "typebox";
8
8
  import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
9
- import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ, REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, buildRealtimeVoiceAgentConsultWorkingResponse, consultRealtimeVoiceAgent, convertPcmToMulaw8k, createRealtimeVoiceBridgeSession, mulawToPcm, resamplePcm, resolveConfiguredRealtimeVoiceProvider, resolveRealtimeVoiceAgentConsultToolPolicy, resolveRealtimeVoiceAgentConsultTools, resolveRealtimeVoiceAgentConsultToolsAllow } from "openclaw/plugin-sdk/realtime-voice";
9
+ import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ, REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, buildRealtimeVoiceAgentConsultWorkingResponse, consultRealtimeVoiceAgent, convertPcmToMulaw8k, createRealtimeVoiceAgentTalkbackQueue, createRealtimeVoiceBridgeSession, createTalkSessionController, extendRealtimeVoiceOutputEchoSuppression, getRealtimeVoiceBridgeEventHealth, getRealtimeVoiceTranscriptHealth, isLikelyRealtimeVoiceAssistantEchoTranscript, mulawToPcm, recordRealtimeVoiceBridgeEvent, recordRealtimeVoiceTranscript, recordTalkObservabilityEvent, resamplePcm, resolveConfiguredRealtimeVoiceProvider, resolveRealtimeVoiceAgentConsultToolPolicy, resolveRealtimeVoiceAgentConsultTools, resolveRealtimeVoiceAgentConsultToolsAllow } from "openclaw/plugin-sdk/realtime-voice";
10
10
  import { spawn, spawnSync } from "node:child_process";
11
11
  import { randomUUID } from "node:crypto";
12
12
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
13
13
  import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
14
14
  import { getRealtimeTranscriptionProvider, listRealtimeTranscriptionProviders } from "openclaw/plugin-sdk/realtime-transcription";
15
+ import { sleep } from "openclaw/plugin-sdk/runtime-env";
15
16
  import fs from "node:fs";
16
17
  import os from "node:os";
17
18
  import path from "node:path";
@@ -131,7 +132,7 @@ const DEFAULT_GOOGLE_MEET_CONFIG = {
131
132
  voiceCall: {
132
133
  enabled: true,
133
134
  requestTimeoutMs: 3e4,
134
- dtmfDelayMs: 2500,
135
+ dtmfDelayMs: 12e3,
135
136
  postDtmfSpeechDelayMs: 5e3
136
137
  },
137
138
  realtime: {
@@ -381,45 +382,89 @@ async function consultOpenClawAgentForGoogleMeet(params) {
381
382
  extraSystemPrompt: GOOGLE_MEET_CONSULT_SYSTEM_PROMPT
382
383
  });
383
384
  }
385
+ function handleGoogleMeetRealtimeConsultToolCall(params) {
386
+ const callId = params.event.callId || params.event.itemId;
387
+ if (params.strategy !== "bidi") {
388
+ params.onTalkEvent?.({
389
+ type: "tool.error",
390
+ callId,
391
+ payload: {
392
+ name: params.event.name,
393
+ error: `Tool "${params.event.name}" is only available in bidi realtime strategy`
394
+ },
395
+ final: true
396
+ });
397
+ params.session.submitToolResult(callId, { error: `Tool "${params.event.name}" is only available in bidi realtime strategy` });
398
+ return;
399
+ }
400
+ if (params.event.name !== GOOGLE_MEET_AGENT_CONSULT_TOOL_NAME) {
401
+ params.onTalkEvent?.({
402
+ type: "tool.error",
403
+ callId,
404
+ payload: {
405
+ name: params.event.name,
406
+ error: `Tool "${params.event.name}" not available`
407
+ },
408
+ final: true
409
+ });
410
+ params.session.submitToolResult(callId, { error: `Tool "${params.event.name}" not available` });
411
+ return;
412
+ }
413
+ params.onTalkEvent?.({
414
+ type: "tool.progress",
415
+ callId,
416
+ payload: {
417
+ name: params.event.name,
418
+ status: "working"
419
+ }
420
+ });
421
+ submitGoogleMeetConsultWorkingResponse(params.session, callId);
422
+ consultOpenClawAgentForGoogleMeet({
423
+ config: params.config,
424
+ fullConfig: params.fullConfig,
425
+ runtime: params.runtime,
426
+ logger: params.logger,
427
+ meetingSessionId: params.meetingSessionId,
428
+ requesterSessionKey: params.requesterSessionKey,
429
+ args: params.event.args,
430
+ transcript: params.transcript
431
+ }).then((result) => {
432
+ params.onTalkEvent?.({
433
+ type: "tool.result",
434
+ callId,
435
+ payload: {
436
+ name: params.event.name,
437
+ result
438
+ },
439
+ final: true
440
+ });
441
+ params.session.submitToolResult(callId, result);
442
+ }).catch((error) => {
443
+ params.onTalkEvent?.({
444
+ type: "tool.error",
445
+ callId,
446
+ payload: {
447
+ name: params.event.name,
448
+ error: formatErrorMessage(error)
449
+ },
450
+ final: true
451
+ });
452
+ params.session.submitToolResult(callId, { error: formatErrorMessage(error) });
453
+ });
454
+ }
384
455
  //#endregion
385
456
  //#region extensions/google-meet/src/realtime.ts
386
- function recordGoogleMeetRealtimeTranscript(transcript, role, text) {
387
- const entry = {
388
- at: (/* @__PURE__ */ new Date()).toISOString(),
389
- role,
390
- text
391
- };
392
- transcript.push(entry);
393
- if (transcript.length > 40) transcript.splice(0, transcript.length - 40);
394
- return entry;
395
- }
457
+ const recordGoogleMeetRealtimeTranscript = recordRealtimeVoiceTranscript;
396
458
  function getGoogleMeetRealtimeTranscriptHealth(transcript) {
397
- const last = transcript.at(-1);
398
- return {
399
- realtimeTranscriptLines: transcript.length,
400
- lastRealtimeTranscriptAt: last?.at,
401
- lastRealtimeTranscriptRole: last?.role,
402
- lastRealtimeTranscriptText: last?.text,
403
- recentRealtimeTranscript: transcript.slice(-5)
404
- };
459
+ return getRealtimeVoiceTranscriptHealth(transcript);
405
460
  }
406
461
  const GOOGLE_MEET_OUTPUT_ECHO_SUPPRESSION_TAIL_MS = 3e3;
462
+ const GOOGLE_MEET_TRANSCRIPT_ECHO_LOOKBACK_MS = 45e3;
407
463
  function recordGoogleMeetRealtimeEvent(events, event) {
408
- if (event.direction === "client" && event.type === "input_audio_buffer.append") return;
409
- events.push({
410
- at: (/* @__PURE__ */ new Date()).toISOString(),
411
- ...event
412
- });
413
- if (events.length > 40) events.splice(0, events.length - 40);
464
+ recordRealtimeVoiceBridgeEvent(events, event);
414
465
  }
415
466
  function getGoogleMeetRealtimeEventHealth(events) {
416
- const last = events.at(-1);
417
- return {
418
- lastRealtimeEventAt: last?.at,
419
- lastRealtimeEventType: last ? `${last.direction}:${last.type}` : void 0,
420
- lastRealtimeEventDetail: last?.detail,
421
- recentRealtimeEvents: events.slice(-10)
422
- };
467
+ return getRealtimeVoiceBridgeEventHealth(events);
423
468
  }
424
469
  function splitCommand$1(argv) {
425
470
  const [command, ...args] = argv;
@@ -444,40 +489,19 @@ function readPcm16Stats(audio) {
444
489
  peak
445
490
  };
446
491
  }
447
- function normalizeTranscriptForEchoMatch(text) {
448
- return text.toLowerCase().replace(/['’]/g, "").replace(/[^a-z0-9]+/g, " ").trim().split(/\s+/).filter((token) => token.length > 1);
449
- }
450
- function hasMeaningfulEchoOverlap(userTokens, assistantTokens) {
451
- if (userTokens.length < 4 || assistantTokens.length < 4) return false;
452
- const uniqueUserTokens = [...new Set(userTokens)];
453
- if (uniqueUserTokens.length < 4) return false;
454
- const assistantTokenSet = new Set(assistantTokens);
455
- return uniqueUserTokens.filter((token) => assistantTokenSet.has(token)).length / uniqueUserTokens.length >= .58;
456
- }
457
492
  function isGoogleMeetLikelyAssistantEchoTranscript(params) {
458
- const userTokens = normalizeTranscriptForEchoMatch(params.text);
459
- if (userTokens.length < 4) return false;
460
- const nowMs = params.nowMs ?? Date.now();
461
- const recentAssistantText = params.transcript.filter((entry) => {
462
- if (entry.role !== "assistant") return false;
463
- const at = Date.parse(entry.at);
464
- return Number.isFinite(at) && nowMs - at <= 45e3;
465
- }).slice(-6).map((entry) => entry.text).join(" ");
466
- if (!recentAssistantText.trim()) return false;
467
- const userNormalized = userTokens.join(" ");
468
- const assistantTokens = normalizeTranscriptForEchoMatch(recentAssistantText);
469
- const assistantNormalized = assistantTokens.join(" ");
470
- return userNormalized.length >= 18 && assistantNormalized.includes(userNormalized) || assistantNormalized.length >= 18 && userNormalized.includes(assistantNormalized) || hasMeaningfulEchoOverlap(userTokens, assistantTokens);
493
+ return isLikelyRealtimeVoiceAssistantEchoTranscript({
494
+ ...params,
495
+ lookbackMs: GOOGLE_MEET_TRANSCRIPT_ECHO_LOOKBACK_MS
496
+ });
471
497
  }
472
498
  function extendGoogleMeetOutputEchoSuppression(params) {
473
499
  const bytesPerMs = params.audioFormat === "g711-ulaw-8khz" ? 8 : 48;
474
- const durationMs = Math.ceil(params.audio.byteLength / bytesPerMs);
475
- const playbackEndMs = Math.max(params.nowMs, params.lastOutputPlayableUntilMs) + durationMs;
476
- return {
477
- durationMs,
478
- lastOutputPlayableUntilMs: playbackEndMs,
479
- suppressInputUntilMs: Math.max(params.suppressInputUntilMs, playbackEndMs + GOOGLE_MEET_OUTPUT_ECHO_SUPPRESSION_TAIL_MS)
480
- };
500
+ return extendRealtimeVoiceOutputEchoSuppression({
501
+ ...params,
502
+ bytesPerMs,
503
+ tailMs: GOOGLE_MEET_OUTPUT_ECHO_SUPPRESSION_TAIL_MS
504
+ });
481
505
  }
482
506
  function resolveGoogleMeetRealtimeAudioFormat(config) {
483
507
  return config.chrome.audioFormat === "g711-ulaw-8khz" ? REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ : REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ;
@@ -598,6 +622,9 @@ function formatGoogleMeetAgentTtsResultLog(prefix, result) {
598
622
  ...result.fallbackFrom ? [`fallbackFrom=${formatLogValue(result.fallbackFrom)}`] : []
599
623
  ].join(" ");
600
624
  }
625
+ function formatGoogleMeetTranscriptSummaryLog(prefix, text) {
626
+ return `[google-meet] ${prefix}: chars=${text.length}`;
627
+ }
601
628
  function normalizeGoogleMeetTtsPromptText$1(text) {
602
629
  const trimmed = text?.trim();
603
630
  if (!trimmed) return;
@@ -605,6 +632,21 @@ function normalizeGoogleMeetTtsPromptText$1(text) {
605
632
  if (sayExactly) return sayExactly.replace(/^["']|["']$/g, "").trim() || trimmed;
606
633
  return trimmed;
607
634
  }
635
+ function pushGoogleMeetTalkEvent(events, event, maxEntries = 40) {
636
+ events.push(event);
637
+ if (events.length > maxEntries) events.splice(0, events.length - maxEntries);
638
+ }
639
+ function summarizeGoogleMeetTalkEvents(events) {
640
+ return events.slice(-20).map((event) => ({
641
+ id: event.id,
642
+ type: event.type,
643
+ sessionId: event.sessionId,
644
+ turnId: event.turnId,
645
+ seq: event.seq,
646
+ timestamp: event.timestamp,
647
+ final: event.final
648
+ }));
649
+ }
608
650
  async function startCommandAgentAudioBridge(params) {
609
651
  const input = splitCommand$1(params.inputCommand);
610
652
  const output = splitCommand$1(params.outputCommand);
@@ -630,9 +672,7 @@ async function startCommandAgentAudioBridge(params) {
630
672
  let lastSuppressedInputAt;
631
673
  let suppressInputUntil = 0;
632
674
  let lastOutputPlayableUntilMs = 0;
633
- let agentConsultActive = false;
634
- let pendingAgentQuestion;
635
- let agentConsultDebounceTimer;
675
+ let agentTalkback;
636
676
  let ttsQueue = Promise.resolve();
637
677
  const transcript = [];
638
678
  const resolved = resolveGoogleMeetRealtimeTranscriptionProvider({
@@ -640,6 +680,25 @@ async function startCommandAgentAudioBridge(params) {
640
680
  fullConfig: params.fullConfig,
641
681
  providers: params.providers
642
682
  });
683
+ const talk = createTalkSessionController({
684
+ sessionId: `google-meet:${params.meetingSessionId}:agent`,
685
+ mode: "stt-tts",
686
+ transport: "gateway-relay",
687
+ brain: "agent-consult",
688
+ provider: resolved.provider.id,
689
+ turnIdPrefix: `google-meet:${params.meetingSessionId}:turn`
690
+ }, { onEvent: recordTalkObservabilityEvent });
691
+ const recentTalkEvents = [];
692
+ const emitTalkEvent = (input) => pushGoogleMeetTalkEvent(recentTalkEvents, talk.emit(input));
693
+ const ensureTalkTurn = () => {
694
+ const turn = talk.ensureTurn({ payload: { meetingSessionId: params.meetingSessionId } });
695
+ if (turn.event) pushGoogleMeetTalkEvent(recentTalkEvents, turn.event);
696
+ return turn.turnId;
697
+ };
698
+ const endTalkTurn = () => {
699
+ const ended = talk.endTurn({ payload: { meetingSessionId: params.meetingSessionId } });
700
+ if (ended.ok) pushGoogleMeetTalkEvent(recentTalkEvents, ended.event);
701
+ };
643
702
  params.logger.info(formatGoogleMeetAgentAudioModelLog({
644
703
  provider: resolved.provider,
645
704
  providerConfig: resolved.providerConfig,
@@ -666,15 +725,17 @@ async function startCommandAgentAudioBridge(params) {
666
725
  const stop = async () => {
667
726
  if (stopped) return;
668
727
  stopped = true;
669
- if (agentConsultDebounceTimer) {
670
- clearTimeout(agentConsultDebounceTimer);
671
- agentConsultDebounceTimer = void 0;
672
- }
728
+ agentTalkback?.close();
673
729
  try {
674
730
  sttSession?.close();
675
731
  } catch (error) {
676
732
  params.logger.debug?.(`[google-meet] agent transcription bridge close ignored: ${formatErrorMessage(error)}`);
677
733
  }
734
+ emitTalkEvent({
735
+ type: "session.closed",
736
+ final: true,
737
+ payload: { meetingSessionId: params.meetingSessionId }
738
+ });
678
739
  terminateProcess(inputProcess);
679
740
  terminateProcess(outputProcess);
680
741
  };
@@ -715,6 +776,14 @@ async function startCommandAgentAudioBridge(params) {
715
776
  lastOutputPlayableUntilMs = suppression.lastOutputPlayableUntilMs;
716
777
  lastOutputAt = (/* @__PURE__ */ new Date()).toISOString();
717
778
  lastOutputBytes += audio.byteLength;
779
+ emitTalkEvent({
780
+ type: "output.audio.delta",
781
+ turnId: ensureTalkTurn(),
782
+ payload: {
783
+ meetingSessionId: params.meetingSessionId,
784
+ bytes: audio.byteLength
785
+ }
786
+ });
718
787
  try {
719
788
  outputProcess.stdin?.write(audio);
720
789
  } catch (error) {
@@ -727,94 +796,122 @@ async function startCommandAgentAudioBridge(params) {
727
796
  ttsQueue = ttsQueue.then(async () => {
728
797
  if (stopped) return;
729
798
  recordGoogleMeetRealtimeTranscript(transcript, "assistant", normalized);
730
- params.logger.info(`[google-meet] agent assistant: ${normalized}`);
799
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog("agent assistant", normalized));
800
+ const turnId = ensureTalkTurn();
801
+ emitTalkEvent({
802
+ type: "output.text.done",
803
+ turnId,
804
+ final: true,
805
+ payload: {
806
+ meetingSessionId: params.meetingSessionId,
807
+ text: normalized
808
+ }
809
+ });
731
810
  const result = await params.runtime.tts.textToSpeechTelephony({
732
811
  text: normalized,
733
812
  cfg: params.fullConfig
734
813
  });
735
814
  if (!result.success || !result.audioBuffer || !result.sampleRate) throw new Error(result.error ?? "TTS conversion failed");
736
815
  params.logger.info(formatGoogleMeetAgentTtsResultLog("agent", result));
816
+ emitTalkEvent({
817
+ type: "output.audio.started",
818
+ turnId,
819
+ payload: { meetingSessionId: params.meetingSessionId }
820
+ });
737
821
  writeOutputAudio(convertGoogleMeetTtsAudioForBridge(result.audioBuffer, result.sampleRate, params.config, result.outputFormat));
822
+ emitTalkEvent({
823
+ type: "output.audio.done",
824
+ turnId,
825
+ final: true,
826
+ payload: { meetingSessionId: params.meetingSessionId }
827
+ });
828
+ endTalkTurn();
738
829
  }).catch((error) => {
739
830
  params.logger.warn(`[google-meet] agent TTS failed: ${formatErrorMessage(error)}`);
740
831
  });
741
832
  };
742
- const runAgentConsultForUserTranscript = async (question) => {
743
- const trimmed = question.trim();
744
- if (!trimmed || stopped) return;
745
- if (agentConsultActive) {
746
- pendingAgentQuestion = trimmed;
747
- return;
748
- }
749
- agentConsultActive = true;
750
- let nextQuestion = trimmed;
751
- try {
752
- while (nextQuestion) {
753
- if (stopped) return;
754
- const currentQuestion = nextQuestion;
755
- pendingAgentQuestion = void 0;
756
- params.logger.info(`[google-meet] agent consult: ${currentQuestion}`);
757
- enqueueSpeakText((await consultOpenClawAgentForGoogleMeet({
758
- config: params.config,
759
- fullConfig: params.fullConfig,
760
- runtime: params.runtime,
761
- logger: params.logger,
762
- meetingSessionId: params.meetingSessionId,
763
- requesterSessionKey: params.requesterSessionKey,
764
- args: {
765
- question: currentQuestion,
766
- responseStyle: "Brief, natural spoken answer for a live meeting."
767
- },
768
- transcript
769
- })).text);
770
- nextQuestion = pendingAgentQuestion;
771
- }
772
- } catch (error) {
773
- params.logger.warn(`[google-meet] agent consult failed: ${formatErrorMessage(error)}`);
774
- enqueueSpeakText("I hit an error while checking that. Please try again.");
775
- } finally {
776
- agentConsultActive = false;
777
- const queuedQuestion = pendingAgentQuestion;
778
- pendingAgentQuestion = void 0;
779
- if (queuedQuestion && !stopped) runAgentConsultForUserTranscript(queuedQuestion);
780
- }
781
- };
782
- const enqueueAgentConsultForUserTranscript = (question) => {
783
- const trimmed = question.trim();
784
- if (!trimmed || stopped) return;
785
- pendingAgentQuestion = pendingAgentQuestion ? `${pendingAgentQuestion}\n${trimmed}` : trimmed;
786
- if (agentConsultDebounceTimer) clearTimeout(agentConsultDebounceTimer);
787
- agentConsultDebounceTimer = setTimeout(() => {
788
- agentConsultDebounceTimer = void 0;
789
- const queuedQuestion = pendingAgentQuestion;
790
- pendingAgentQuestion = void 0;
791
- if (queuedQuestion && !stopped) runAgentConsultForUserTranscript(queuedQuestion);
792
- }, 900);
793
- agentConsultDebounceTimer.unref?.();
794
- };
833
+ agentTalkback = createRealtimeVoiceAgentTalkbackQueue({
834
+ debounceMs: 900,
835
+ isStopped: () => stopped,
836
+ logger: params.logger,
837
+ logPrefix: "[google-meet] agent",
838
+ responseStyle: "Brief, natural spoken answer for a live meeting.",
839
+ fallbackText: "I hit an error while checking that. Please try again.",
840
+ consult: ({ question, responseStyle }) => consultOpenClawAgentForGoogleMeet({
841
+ config: params.config,
842
+ fullConfig: params.fullConfig,
843
+ runtime: params.runtime,
844
+ logger: params.logger,
845
+ meetingSessionId: params.meetingSessionId,
846
+ requesterSessionKey: params.requesterSessionKey,
847
+ args: {
848
+ question,
849
+ responseStyle
850
+ },
851
+ transcript
852
+ }),
853
+ deliver: enqueueSpeakText
854
+ });
795
855
  sttSession = resolved.provider.createSession({
856
+ cfg: params.fullConfig,
796
857
  providerConfig: resolved.providerConfig,
797
858
  onTranscript: (text) => {
798
859
  const trimmed = text.trim();
799
860
  if (!trimmed || stopped) return;
861
+ const turnId = ensureTalkTurn();
862
+ emitTalkEvent({
863
+ type: "input.audio.committed",
864
+ turnId,
865
+ final: true,
866
+ payload: { meetingSessionId: params.meetingSessionId }
867
+ });
868
+ emitTalkEvent({
869
+ type: "transcript.done",
870
+ turnId,
871
+ final: true,
872
+ payload: {
873
+ meetingSessionId: params.meetingSessionId,
874
+ text: trimmed,
875
+ role: "user"
876
+ }
877
+ });
800
878
  recordGoogleMeetRealtimeTranscript(transcript, "user", trimmed);
801
- params.logger.info(`[google-meet] agent user: ${trimmed}`);
879
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog("agent user", trimmed));
802
880
  if (isGoogleMeetLikelyAssistantEchoTranscript({
803
881
  transcript,
804
882
  text: trimmed
805
883
  })) {
806
- params.logger.info(`[google-meet] agent ignored assistant echo transcript: ${trimmed}`);
884
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog("agent ignored assistant echo transcript", trimmed));
807
885
  return;
808
886
  }
809
- enqueueAgentConsultForUserTranscript(trimmed);
887
+ agentTalkback?.enqueue(trimmed);
810
888
  },
811
889
  onError: (error) => {
812
890
  params.logger.warn(`[google-meet] agent transcription bridge failed: ${formatErrorMessage(error)}`);
891
+ emitTalkEvent({
892
+ type: "session.error",
893
+ final: true,
894
+ payload: {
895
+ meetingSessionId: params.meetingSessionId,
896
+ error: formatErrorMessage(error)
897
+ }
898
+ });
813
899
  stop();
814
900
  }
815
901
  });
902
+ emitTalkEvent({
903
+ type: "session.started",
904
+ payload: {
905
+ meetingSessionId: params.meetingSessionId,
906
+ provider: resolved.provider.id
907
+ }
908
+ });
816
909
  await sttSession.connect();
817
910
  realtimeReady = true;
911
+ emitTalkEvent({
912
+ type: "session.ready",
913
+ payload: { meetingSessionId: params.meetingSessionId }
914
+ });
818
915
  inputProcess.stdout?.on("data", (chunk) => {
819
916
  if (stopped) return;
820
917
  const audio = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
@@ -825,6 +922,14 @@ async function startCommandAgentAudioBridge(params) {
825
922
  }
826
923
  lastInputAt = (/* @__PURE__ */ new Date()).toISOString();
827
924
  lastInputBytes += audio.byteLength;
925
+ emitTalkEvent({
926
+ type: "input.audio.delta",
927
+ turnId: ensureTalkTurn(),
928
+ payload: {
929
+ meetingSessionId: params.meetingSessionId,
930
+ bytes: audio.byteLength
931
+ }
932
+ });
828
933
  sttSession?.sendAudio(convertGoogleMeetBridgeAudioForStt(audio, params.config));
829
934
  });
830
935
  return {
@@ -844,6 +949,7 @@ async function startCommandAgentAudioBridge(params) {
844
949
  lastOutputBytes,
845
950
  suppressedInputBytes,
846
951
  ...getGoogleMeetRealtimeTranscriptHealth(transcript),
952
+ recentTalkEvents: summarizeGoogleMeetTalkEvents(recentTalkEvents),
847
953
  bridgeClosed: stopped
848
954
  }),
849
955
  stop
@@ -879,7 +985,7 @@ async function startCommandRealtimeAudioBridge(params) {
879
985
  let lastOutputAtMs = 0;
880
986
  let lastOutputPlayableUntilMs = 0;
881
987
  let bargeInInputProcess;
882
- let agentConsultDebounceTimer;
988
+ let agentTalkback;
883
989
  const suppressInputForOutput = (audio) => {
884
990
  const suppression = extendGoogleMeetOutputEchoSuppression({
885
991
  audio,
@@ -912,10 +1018,7 @@ async function startCommandRealtimeAudioBridge(params) {
912
1018
  const stop = async () => {
913
1019
  if (stopped) return;
914
1020
  stopped = true;
915
- if (agentConsultDebounceTimer) {
916
- clearTimeout(agentConsultDebounceTimer);
917
- agentConsultDebounceTimer = void 0;
918
- }
1021
+ agentTalkback?.close();
919
1022
  try {
920
1023
  bridge?.close();
921
1024
  } catch (error) {
@@ -1028,64 +1131,63 @@ async function startCommandRealtimeAudioBridge(params) {
1028
1131
  }));
1029
1132
  const transcript = [];
1030
1133
  const realtimeEvents = [];
1031
- let agentConsultActive = false;
1032
- let pendingAgentQuestion;
1033
- const enqueueAgentConsultForUserTranscript = (question) => {
1034
- const trimmed = question.trim();
1035
- if (!trimmed || stopped) return;
1036
- pendingAgentQuestion = pendingAgentQuestion ? `${pendingAgentQuestion}\n${trimmed}` : trimmed;
1037
- if (agentConsultDebounceTimer) clearTimeout(agentConsultDebounceTimer);
1038
- agentConsultDebounceTimer = setTimeout(() => {
1039
- agentConsultDebounceTimer = void 0;
1040
- const queuedQuestion = pendingAgentQuestion;
1041
- pendingAgentQuestion = void 0;
1042
- if (queuedQuestion && !stopped) runAgentConsultForUserTranscript(queuedQuestion);
1043
- }, 900);
1044
- agentConsultDebounceTimer.unref?.();
1134
+ const talk = createTalkSessionController({
1135
+ sessionId: `google-meet:${params.meetingSessionId}:command-realtime`,
1136
+ mode: "realtime",
1137
+ transport: "gateway-relay",
1138
+ brain: strategy === "bidi" ? "direct-tools" : "agent-consult",
1139
+ provider: resolved.provider.id
1140
+ }, { onEvent: recordTalkObservabilityEvent });
1141
+ const recentTalkEvents = [];
1142
+ const rememberTalkEvent = (event) => {
1143
+ if (event) pushGoogleMeetTalkEvent(recentTalkEvents, event);
1045
1144
  };
1046
- const runAgentConsultForUserTranscript = async (question) => {
1047
- const trimmed = question.trim();
1048
- if (!trimmed || stopped) return;
1049
- if (agentConsultActive) {
1050
- pendingAgentQuestion = trimmed;
1051
- return;
1052
- }
1053
- agentConsultActive = true;
1054
- let nextQuestion = trimmed;
1055
- try {
1056
- while (nextQuestion) {
1057
- if (stopped) return;
1058
- const currentQuestion = nextQuestion;
1059
- pendingAgentQuestion = void 0;
1060
- params.logger.info(`[google-meet] realtime agent consult: ${currentQuestion}`);
1061
- const result = await consultOpenClawAgentForGoogleMeet({
1062
- config: params.config,
1063
- fullConfig: params.fullConfig,
1064
- runtime: params.runtime,
1065
- logger: params.logger,
1066
- meetingSessionId: params.meetingSessionId,
1067
- requesterSessionKey: params.requesterSessionKey,
1068
- args: {
1069
- question: currentQuestion,
1070
- responseStyle: "Brief, natural spoken answer for a live meeting."
1071
- },
1072
- transcript
1073
- });
1074
- if (!stopped && result.text.trim()) bridge?.sendUserMessage(buildGoogleMeetSpeakExactUserMessage(result.text.trim()));
1075
- nextQuestion = pendingAgentQuestion;
1076
- }
1077
- } catch (error) {
1078
- params.logger.warn(`[google-meet] realtime agent consult failed: ${formatErrorMessage(error)}`);
1079
- if (!stopped) bridge?.sendUserMessage(buildGoogleMeetSpeakExactUserMessage("I hit an error while checking that. Please try again."));
1080
- } finally {
1081
- agentConsultActive = false;
1082
- const queuedQuestion = pendingAgentQuestion;
1083
- pendingAgentQuestion = void 0;
1084
- if (queuedQuestion && !stopped) runAgentConsultForUserTranscript(queuedQuestion);
1085
- }
1145
+ const emitTalkEvent = (input) => {
1146
+ rememberTalkEvent(talk.emit(input));
1147
+ };
1148
+ const ensureTalkTurn = () => {
1149
+ const turn = talk.ensureTurn({ payload: { meetingSessionId: params.meetingSessionId } });
1150
+ if (turn.event) rememberTalkEvent(turn.event);
1151
+ return turn.turnId;
1152
+ };
1153
+ const finishOutputAudio = (reason) => {
1154
+ rememberTalkEvent(talk.finishOutputAudio({ payload: { reason } }));
1155
+ };
1156
+ const endTalkTurn = (reason = "completed") => {
1157
+ const ended = talk.endTurn({ payload: { reason } });
1158
+ if (ended.ok) rememberTalkEvent(ended.event);
1086
1159
  };
1160
+ emitTalkEvent({
1161
+ type: "session.started",
1162
+ payload: { meetingSessionId: params.meetingSessionId }
1163
+ });
1164
+ agentTalkback = createRealtimeVoiceAgentTalkbackQueue({
1165
+ debounceMs: 900,
1166
+ isStopped: () => stopped,
1167
+ logger: params.logger,
1168
+ logPrefix: "[google-meet] realtime agent",
1169
+ responseStyle: "Brief, natural spoken answer for a live meeting.",
1170
+ fallbackText: "I hit an error while checking that. Please try again.",
1171
+ consult: ({ question, responseStyle }) => consultOpenClawAgentForGoogleMeet({
1172
+ config: params.config,
1173
+ fullConfig: params.fullConfig,
1174
+ runtime: params.runtime,
1175
+ logger: params.logger,
1176
+ meetingSessionId: params.meetingSessionId,
1177
+ requesterSessionKey: params.requesterSessionKey,
1178
+ args: {
1179
+ question,
1180
+ responseStyle
1181
+ },
1182
+ transcript
1183
+ }),
1184
+ deliver: (text) => {
1185
+ bridge?.sendUserMessage(buildGoogleMeetSpeakExactUserMessage(text));
1186
+ }
1187
+ });
1087
1188
  bridge = createRealtimeVoiceBridgeSession({
1088
1189
  provider: resolved.provider,
1190
+ cfg: params.fullConfig,
1089
1191
  providerConfig: resolved.providerConfig,
1090
1192
  audioFormat: resolveGoogleMeetRealtimeAudioFormat(params.config),
1091
1193
  instructions: params.config.realtime.instructions,
@@ -1097,69 +1199,140 @@ async function startCommandRealtimeAudioBridge(params) {
1097
1199
  audioSink: {
1098
1200
  isOpen: () => !stopped,
1099
1201
  sendAudio: (audio) => {
1202
+ const turnId = ensureTalkTurn();
1203
+ rememberTalkEvent(talk.startOutputAudio({
1204
+ turnId,
1205
+ payload: { meetingSessionId: params.meetingSessionId }
1206
+ }).event);
1207
+ emitTalkEvent({
1208
+ type: "output.audio.delta",
1209
+ turnId,
1210
+ payload: { byteLength: audio.byteLength }
1211
+ });
1100
1212
  lastOutputAtMs = Date.now();
1101
1213
  lastOutputAt = (/* @__PURE__ */ new Date()).toISOString();
1102
1214
  lastOutputBytes += audio.byteLength;
1103
1215
  suppressInputForOutput(audio);
1104
1216
  writeOutputAudio(audio);
1105
1217
  },
1106
- clearAudio: clearOutputPlayback
1218
+ clearAudio: () => {
1219
+ clearOutputPlayback();
1220
+ finishOutputAudio("clear");
1221
+ }
1107
1222
  },
1108
1223
  onTranscript: (role, text, isFinal) => {
1224
+ const turnId = ensureTalkTurn();
1225
+ emitTalkEvent({
1226
+ type: role === "assistant" ? isFinal ? "output.text.done" : "output.text.delta" : isFinal ? "transcript.done" : "transcript.delta",
1227
+ turnId,
1228
+ payload: role === "assistant" ? { text } : {
1229
+ role,
1230
+ text
1231
+ },
1232
+ final: isFinal
1233
+ });
1234
+ if (role === "user" && isFinal) emitTalkEvent({
1235
+ type: "input.audio.committed",
1236
+ turnId,
1237
+ payload: { meetingSessionId: params.meetingSessionId },
1238
+ final: true
1239
+ });
1109
1240
  if (isFinal) {
1110
1241
  recordGoogleMeetRealtimeTranscript(transcript, role, text);
1111
- params.logger.info(`[google-meet] realtime ${role}: ${text}`);
1242
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog(`realtime ${role}`, text));
1112
1243
  if (role === "user" && strategy === "agent") {
1113
1244
  if (isGoogleMeetLikelyAssistantEchoTranscript({
1114
1245
  transcript,
1115
1246
  text
1116
1247
  })) {
1117
- params.logger.info(`[google-meet] realtime ignored assistant echo transcript: ${text}`);
1248
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog("realtime ignored assistant echo transcript", text));
1118
1249
  return;
1119
1250
  }
1120
- enqueueAgentConsultForUserTranscript(text);
1251
+ agentTalkback?.enqueue(text);
1121
1252
  }
1122
1253
  }
1123
1254
  },
1124
1255
  onEvent: (event) => {
1125
1256
  recordGoogleMeetRealtimeEvent(realtimeEvents, event);
1257
+ if (event.type === "input_audio_buffer.speech_started") ensureTalkTurn();
1258
+ else if (event.type === "input_audio_buffer.speech_stopped") {
1259
+ const turnId = talk.activeTurnId;
1260
+ if (!turnId) return;
1261
+ emitTalkEvent({
1262
+ type: "input.audio.committed",
1263
+ turnId,
1264
+ payload: {
1265
+ meetingSessionId: params.meetingSessionId,
1266
+ source: event.type
1267
+ },
1268
+ final: true
1269
+ });
1270
+ } else if (event.type === "response.done") {
1271
+ finishOutputAudio("response.done");
1272
+ endTalkTurn("response.done");
1273
+ } else if (event.type === "error") emitTalkEvent({
1274
+ type: "session.error",
1275
+ payload: { message: event.detail ?? "Realtime provider error" },
1276
+ final: true
1277
+ });
1126
1278
  if (event.type === "error" || event.type === "response.done" || event.type === "input_audio_buffer.speech_started" || event.type === "input_audio_buffer.speech_stopped" || event.type === "conversation.item.input_audio_transcription.completed" || event.type === "conversation.item.input_audio_transcription.failed") {
1127
1279
  const detail = event.detail ? ` ${event.detail}` : "";
1128
1280
  params.logger.info(`[google-meet] realtime ${event.direction}:${event.type}${detail}`);
1129
1281
  }
1130
1282
  },
1131
1283
  onToolCall: (event, session) => {
1132
- if (strategy !== "bidi") {
1133
- session.submitToolResult(event.callId || event.itemId, { error: `Tool "${event.name}" is only available in bidi realtime strategy` });
1134
- return;
1135
- }
1136
- if (event.name !== GOOGLE_MEET_AGENT_CONSULT_TOOL_NAME) {
1137
- session.submitToolResult(event.callId || event.itemId, { error: `Tool "${event.name}" not available` });
1138
- return;
1139
- }
1140
- submitGoogleMeetConsultWorkingResponse(session, event.callId || event.itemId);
1141
- consultOpenClawAgentForGoogleMeet({
1284
+ emitTalkEvent({
1285
+ type: "tool.call",
1286
+ turnId: ensureTalkTurn(),
1287
+ itemId: event.itemId,
1288
+ callId: event.callId,
1289
+ payload: {
1290
+ name: event.name,
1291
+ args: event.args
1292
+ }
1293
+ });
1294
+ const turnId = ensureTalkTurn();
1295
+ handleGoogleMeetRealtimeConsultToolCall({
1296
+ strategy,
1297
+ session,
1298
+ event,
1142
1299
  config: params.config,
1143
1300
  fullConfig: params.fullConfig,
1144
1301
  runtime: params.runtime,
1145
1302
  logger: params.logger,
1146
1303
  meetingSessionId: params.meetingSessionId,
1147
1304
  requesterSessionKey: params.requesterSessionKey,
1148
- args: event.args,
1149
- transcript
1150
- }).then((result) => {
1151
- session.submitToolResult(event.callId || event.itemId, result);
1152
- }).catch((error) => {
1153
- session.submitToolResult(event.callId || event.itemId, { error: formatErrorMessage(error) });
1305
+ transcript,
1306
+ onTalkEvent: (input) => emitTalkEvent({
1307
+ ...input,
1308
+ turnId: input.turnId ?? turnId
1309
+ })
1310
+ });
1311
+ },
1312
+ onError: (error) => {
1313
+ emitTalkEvent({
1314
+ type: "session.error",
1315
+ payload: { message: formatErrorMessage(error) },
1316
+ final: true
1154
1317
  });
1318
+ fail("realtime voice bridge")(error);
1155
1319
  },
1156
- onError: fail("realtime voice bridge"),
1157
1320
  onClose: (reason) => {
1158
1321
  realtimeReady = false;
1322
+ finishOutputAudio(reason);
1323
+ emitTalkEvent({
1324
+ type: "session.closed",
1325
+ payload: { reason },
1326
+ final: true
1327
+ });
1159
1328
  if (reason === "error") stop();
1160
1329
  },
1161
1330
  onReady: () => {
1162
1331
  realtimeReady = true;
1332
+ emitTalkEvent({
1333
+ type: "session.ready",
1334
+ payload: { meetingSessionId: params.meetingSessionId }
1335
+ });
1163
1336
  }
1164
1337
  });
1165
1338
  startHumanBargeInMonitor();
@@ -1173,6 +1346,11 @@ async function startCommandRealtimeAudioBridge(params) {
1173
1346
  }
1174
1347
  lastInputAt = (/* @__PURE__ */ new Date()).toISOString();
1175
1348
  lastInputBytes += audio.byteLength;
1349
+ emitTalkEvent({
1350
+ type: "input.audio.delta",
1351
+ turnId: ensureTalkTurn(),
1352
+ payload: { byteLength: audio.byteLength }
1353
+ });
1176
1354
  bridge?.sendAudio(Buffer.from(audio));
1177
1355
  }
1178
1356
  });
@@ -1197,6 +1375,7 @@ async function startCommandRealtimeAudioBridge(params) {
1197
1375
  suppressedInputBytes,
1198
1376
  ...getGoogleMeetRealtimeTranscriptHealth(transcript),
1199
1377
  ...getGoogleMeetRealtimeEventHealth(realtimeEvents),
1378
+ recentTalkEvents: summarizeGoogleMeetTalkEvents(recentTalkEvents),
1200
1379
  lastClearAt,
1201
1380
  clearCount,
1202
1381
  bridgeClosed: stopped
@@ -1244,17 +1423,12 @@ async function startNodeAgentAudioBridge(params) {
1244
1423
  audioFormat: params.config.chrome.audioFormat
1245
1424
  }));
1246
1425
  const transcript = [];
1247
- let agentConsultActive = false;
1248
- let pendingAgentQuestion;
1249
- let agentConsultDebounceTimer;
1426
+ let agentTalkback;
1250
1427
  let ttsQueue = Promise.resolve();
1251
1428
  const stop = async () => {
1252
1429
  if (stopped) return;
1253
1430
  stopped = true;
1254
- if (agentConsultDebounceTimer) {
1255
- clearTimeout(agentConsultDebounceTimer);
1256
- agentConsultDebounceTimer = void 0;
1257
- }
1431
+ agentTalkback?.close();
1258
1432
  try {
1259
1433
  sttSession?.close();
1260
1434
  } catch (error) {
@@ -1303,7 +1477,7 @@ async function startNodeAgentAudioBridge(params) {
1303
1477
  ttsQueue = ttsQueue.then(async () => {
1304
1478
  if (stopped) return;
1305
1479
  recordGoogleMeetRealtimeTranscript(transcript, "assistant", normalized);
1306
- params.logger.info(`[google-meet] node agent assistant: ${normalized}`);
1480
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog("node agent assistant", normalized));
1307
1481
  const result = await params.runtime.tts.textToSpeechTelephony({
1308
1482
  text: normalized,
1309
1483
  cfg: params.fullConfig
@@ -1315,74 +1489,44 @@ async function startNodeAgentAudioBridge(params) {
1315
1489
  params.logger.warn(`[google-meet] node agent TTS failed: ${formatErrorMessage(error)}`);
1316
1490
  });
1317
1491
  };
1318
- const runAgentConsultForUserTranscript = async (question) => {
1319
- const trimmed = question.trim();
1320
- if (!trimmed || stopped) return;
1321
- if (agentConsultActive) {
1322
- pendingAgentQuestion = trimmed;
1323
- return;
1324
- }
1325
- agentConsultActive = true;
1326
- let nextQuestion = trimmed;
1327
- try {
1328
- while (nextQuestion) {
1329
- if (stopped) return;
1330
- const currentQuestion = nextQuestion;
1331
- pendingAgentQuestion = void 0;
1332
- params.logger.info(`[google-meet] node agent consult: ${currentQuestion}`);
1333
- enqueueSpeakText((await consultOpenClawAgentForGoogleMeet({
1334
- config: params.config,
1335
- fullConfig: params.fullConfig,
1336
- runtime: params.runtime,
1337
- logger: params.logger,
1338
- meetingSessionId: params.meetingSessionId,
1339
- requesterSessionKey: params.requesterSessionKey,
1340
- args: {
1341
- question: currentQuestion,
1342
- responseStyle: "Brief, natural spoken answer for a live meeting."
1343
- },
1344
- transcript
1345
- })).text);
1346
- nextQuestion = pendingAgentQuestion;
1347
- }
1348
- } catch (error) {
1349
- params.logger.warn(`[google-meet] node agent consult failed: ${formatErrorMessage(error)}`);
1350
- enqueueSpeakText("I hit an error while checking that. Please try again.");
1351
- } finally {
1352
- agentConsultActive = false;
1353
- const queuedQuestion = pendingAgentQuestion;
1354
- pendingAgentQuestion = void 0;
1355
- if (queuedQuestion && !stopped) runAgentConsultForUserTranscript(queuedQuestion);
1356
- }
1357
- };
1358
- const enqueueAgentConsultForUserTranscript = (question) => {
1359
- const trimmed = question.trim();
1360
- if (!trimmed || stopped) return;
1361
- pendingAgentQuestion = pendingAgentQuestion ? `${pendingAgentQuestion}\n${trimmed}` : trimmed;
1362
- if (agentConsultDebounceTimer) clearTimeout(agentConsultDebounceTimer);
1363
- agentConsultDebounceTimer = setTimeout(() => {
1364
- agentConsultDebounceTimer = void 0;
1365
- const queuedQuestion = pendingAgentQuestion;
1366
- pendingAgentQuestion = void 0;
1367
- if (queuedQuestion && !stopped) runAgentConsultForUserTranscript(queuedQuestion);
1368
- }, 900);
1369
- agentConsultDebounceTimer.unref?.();
1370
- };
1492
+ agentTalkback = createRealtimeVoiceAgentTalkbackQueue({
1493
+ debounceMs: 900,
1494
+ isStopped: () => stopped,
1495
+ logger: params.logger,
1496
+ logPrefix: "[google-meet] node agent",
1497
+ responseStyle: "Brief, natural spoken answer for a live meeting.",
1498
+ fallbackText: "I hit an error while checking that. Please try again.",
1499
+ consult: ({ question, responseStyle }) => consultOpenClawAgentForGoogleMeet({
1500
+ config: params.config,
1501
+ fullConfig: params.fullConfig,
1502
+ runtime: params.runtime,
1503
+ logger: params.logger,
1504
+ meetingSessionId: params.meetingSessionId,
1505
+ requesterSessionKey: params.requesterSessionKey,
1506
+ args: {
1507
+ question,
1508
+ responseStyle
1509
+ },
1510
+ transcript
1511
+ }),
1512
+ deliver: enqueueSpeakText
1513
+ });
1371
1514
  sttSession = resolved.provider.createSession({
1515
+ cfg: params.fullConfig,
1372
1516
  providerConfig: resolved.providerConfig,
1373
1517
  onTranscript: (text) => {
1374
1518
  const trimmed = text.trim();
1375
1519
  if (!trimmed || stopped) return;
1376
1520
  recordGoogleMeetRealtimeTranscript(transcript, "user", trimmed);
1377
- params.logger.info(`[google-meet] node agent user: ${trimmed}`);
1521
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog("node agent user", trimmed));
1378
1522
  if (isGoogleMeetLikelyAssistantEchoTranscript({
1379
1523
  transcript,
1380
1524
  text: trimmed
1381
1525
  })) {
1382
- params.logger.info(`[google-meet] node agent ignored assistant echo transcript: ${trimmed}`);
1526
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog("node agent ignored assistant echo transcript", trimmed));
1383
1527
  return;
1384
1528
  }
1385
- enqueueAgentConsultForUserTranscript(trimmed);
1529
+ agentTalkback?.enqueue(trimmed);
1386
1530
  },
1387
1531
  onError: (error) => {
1388
1532
  params.logger.warn(`[google-meet] node agent transcription bridge failed: ${formatErrorMessage(error)}`);
@@ -1482,6 +1626,49 @@ async function startNodeRealtimeAudioBridge(params) {
1482
1626
  const transcript = [];
1483
1627
  const realtimeEvents = [];
1484
1628
  const strategy = params.config.realtime.strategy;
1629
+ const talk = createTalkSessionController({
1630
+ sessionId: `google-meet:${params.meetingSessionId}:${params.bridgeId}:node-realtime`,
1631
+ mode: "realtime",
1632
+ transport: "gateway-relay",
1633
+ brain: strategy === "bidi" ? "direct-tools" : "agent-consult",
1634
+ provider: resolved.provider.id
1635
+ }, { onEvent: recordTalkObservabilityEvent });
1636
+ const recentTalkEvents = [];
1637
+ const rememberTalkEvent = (event) => {
1638
+ if (event) pushGoogleMeetTalkEvent(recentTalkEvents, event);
1639
+ };
1640
+ const emitTalkEvent = (input) => {
1641
+ rememberTalkEvent(talk.emit(input));
1642
+ };
1643
+ const ensureTalkTurn = () => {
1644
+ const turn = talk.ensureTurn({ payload: {
1645
+ bridgeId: params.bridgeId,
1646
+ meetingSessionId: params.meetingSessionId
1647
+ } });
1648
+ if (turn.event) rememberTalkEvent(turn.event);
1649
+ return turn.turnId;
1650
+ };
1651
+ const finishOutputAudio = (reason) => {
1652
+ rememberTalkEvent(talk.finishOutputAudio({ payload: {
1653
+ bridgeId: params.bridgeId,
1654
+ reason
1655
+ } }));
1656
+ };
1657
+ const endTalkTurn = (reason = "completed") => {
1658
+ const ended = talk.endTurn({ payload: {
1659
+ bridgeId: params.bridgeId,
1660
+ reason
1661
+ } });
1662
+ if (ended.ok) rememberTalkEvent(ended.event);
1663
+ };
1664
+ emitTalkEvent({
1665
+ type: "session.started",
1666
+ payload: {
1667
+ bridgeId: params.bridgeId,
1668
+ meetingSessionId: params.meetingSessionId,
1669
+ nodeId: params.nodeId
1670
+ }
1671
+ });
1485
1672
  params.logger.info(formatGoogleMeetRealtimeVoiceModelLog({
1486
1673
  strategy,
1487
1674
  provider: resolved.provider,
@@ -1489,70 +1676,35 @@ async function startNodeRealtimeAudioBridge(params) {
1489
1676
  fallbackModel: params.config.realtime.model,
1490
1677
  audioFormat: params.config.chrome.audioFormat
1491
1678
  }));
1492
- let agentConsultActive = false;
1493
- let pendingAgentQuestion;
1494
- let agentConsultDebounceTimer;
1495
- const enqueueAgentConsultForUserTranscript = (question) => {
1496
- const trimmed = question.trim();
1497
- if (!trimmed || stopped) return;
1498
- pendingAgentQuestion = pendingAgentQuestion ? `${pendingAgentQuestion}\n${trimmed}` : trimmed;
1499
- if (agentConsultDebounceTimer) clearTimeout(agentConsultDebounceTimer);
1500
- agentConsultDebounceTimer = setTimeout(() => {
1501
- agentConsultDebounceTimer = void 0;
1502
- const queuedQuestion = pendingAgentQuestion;
1503
- pendingAgentQuestion = void 0;
1504
- if (queuedQuestion && !stopped) runAgentConsultForUserTranscript(queuedQuestion);
1505
- }, 900);
1506
- agentConsultDebounceTimer.unref?.();
1507
- };
1508
- const runAgentConsultForUserTranscript = async (question) => {
1509
- const trimmed = question.trim();
1510
- if (!trimmed || stopped) return;
1511
- if (agentConsultActive) {
1512
- pendingAgentQuestion = trimmed;
1513
- return;
1514
- }
1515
- agentConsultActive = true;
1516
- let nextQuestion = trimmed;
1517
- try {
1518
- while (nextQuestion) {
1519
- if (stopped) return;
1520
- const currentQuestion = nextQuestion;
1521
- pendingAgentQuestion = void 0;
1522
- params.logger.info(`[google-meet] node realtime agent consult: ${currentQuestion}`);
1523
- const result = await consultOpenClawAgentForGoogleMeet({
1524
- config: params.config,
1525
- fullConfig: params.fullConfig,
1526
- runtime: params.runtime,
1527
- logger: params.logger,
1528
- meetingSessionId: params.meetingSessionId,
1529
- requesterSessionKey: params.requesterSessionKey,
1530
- args: {
1531
- question: currentQuestion,
1532
- responseStyle: "Brief, natural spoken answer for a live meeting."
1533
- },
1534
- transcript
1535
- });
1536
- if (!stopped && result.text.trim()) bridge?.sendUserMessage(buildGoogleMeetSpeakExactUserMessage(result.text.trim()));
1537
- nextQuestion = pendingAgentQuestion;
1538
- }
1539
- } catch (error) {
1540
- params.logger.warn(`[google-meet] node realtime agent consult failed: ${formatErrorMessage(error)}`);
1541
- if (!stopped) bridge?.sendUserMessage(buildGoogleMeetSpeakExactUserMessage("I hit an error while checking that. Please try again."));
1542
- } finally {
1543
- agentConsultActive = false;
1544
- const queuedQuestion = pendingAgentQuestion;
1545
- pendingAgentQuestion = void 0;
1546
- if (queuedQuestion && !stopped) runAgentConsultForUserTranscript(queuedQuestion);
1679
+ let agentTalkback;
1680
+ agentTalkback = createRealtimeVoiceAgentTalkbackQueue({
1681
+ debounceMs: 900,
1682
+ isStopped: () => stopped,
1683
+ logger: params.logger,
1684
+ logPrefix: "[google-meet] node realtime agent",
1685
+ responseStyle: "Brief, natural spoken answer for a live meeting.",
1686
+ fallbackText: "I hit an error while checking that. Please try again.",
1687
+ consult: ({ question, responseStyle }) => consultOpenClawAgentForGoogleMeet({
1688
+ config: params.config,
1689
+ fullConfig: params.fullConfig,
1690
+ runtime: params.runtime,
1691
+ logger: params.logger,
1692
+ meetingSessionId: params.meetingSessionId,
1693
+ requesterSessionKey: params.requesterSessionKey,
1694
+ args: {
1695
+ question,
1696
+ responseStyle
1697
+ },
1698
+ transcript
1699
+ }),
1700
+ deliver: (text) => {
1701
+ bridge?.sendUserMessage(buildGoogleMeetSpeakExactUserMessage(text));
1547
1702
  }
1548
- };
1703
+ });
1549
1704
  const stop = async () => {
1550
1705
  if (stopped) return;
1551
1706
  stopped = true;
1552
- if (agentConsultDebounceTimer) {
1553
- clearTimeout(agentConsultDebounceTimer);
1554
- agentConsultDebounceTimer = void 0;
1555
- }
1707
+ agentTalkback?.close();
1556
1708
  try {
1557
1709
  bridge?.close();
1558
1710
  } catch (error) {
@@ -1574,6 +1726,7 @@ async function startNodeRealtimeAudioBridge(params) {
1574
1726
  };
1575
1727
  bridge = createRealtimeVoiceBridgeSession({
1576
1728
  provider: resolved.provider,
1729
+ cfg: params.fullConfig,
1577
1730
  providerConfig: resolved.providerConfig,
1578
1731
  audioFormat: resolveGoogleMeetRealtimeAudioFormat(params.config),
1579
1732
  instructions: params.config.realtime.instructions,
@@ -1585,6 +1738,16 @@ async function startNodeRealtimeAudioBridge(params) {
1585
1738
  audioSink: {
1586
1739
  isOpen: () => !stopped,
1587
1740
  sendAudio: (audio) => {
1741
+ const turnId = ensureTalkTurn();
1742
+ rememberTalkEvent(talk.startOutputAudio({
1743
+ turnId,
1744
+ payload: { bridgeId: params.bridgeId }
1745
+ }).event);
1746
+ emitTalkEvent({
1747
+ type: "output.audio.delta",
1748
+ turnId,
1749
+ payload: { byteLength: audio.byteLength }
1750
+ });
1588
1751
  const suppression = extendGoogleMeetOutputEchoSuppression({
1589
1752
  audio,
1590
1753
  audioFormat: params.config.chrome.audioFormat,
@@ -1613,6 +1776,7 @@ async function startNodeRealtimeAudioBridge(params) {
1613
1776
  clearAudio: () => {
1614
1777
  lastClearAt = (/* @__PURE__ */ new Date()).toISOString();
1615
1778
  clearCount += 1;
1779
+ finishOutputAudio("clear");
1616
1780
  suppressInputUntil = 0;
1617
1781
  lastOutputPlayableUntilMs = 0;
1618
1782
  params.runtime.nodes.invoke({
@@ -1630,63 +1794,119 @@ async function startNodeRealtimeAudioBridge(params) {
1630
1794
  }
1631
1795
  },
1632
1796
  onTranscript: (role, text, isFinal) => {
1797
+ const turnId = ensureTalkTurn();
1798
+ emitTalkEvent({
1799
+ type: role === "assistant" ? isFinal ? "output.text.done" : "output.text.delta" : isFinal ? "transcript.done" : "transcript.delta",
1800
+ turnId,
1801
+ payload: role === "assistant" ? { text } : {
1802
+ role,
1803
+ text
1804
+ },
1805
+ final: isFinal
1806
+ });
1807
+ if (role === "user" && isFinal) emitTalkEvent({
1808
+ type: "input.audio.committed",
1809
+ turnId,
1810
+ payload: { bridgeId: params.bridgeId },
1811
+ final: true
1812
+ });
1633
1813
  if (isFinal) {
1634
1814
  recordGoogleMeetRealtimeTranscript(transcript, role, text);
1635
- params.logger.info(`[google-meet] node realtime ${role}: ${text}`);
1815
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog(`node realtime ${role}`, text));
1636
1816
  if (role === "user" && strategy === "agent") {
1637
1817
  if (isGoogleMeetLikelyAssistantEchoTranscript({
1638
1818
  transcript,
1639
1819
  text
1640
1820
  })) {
1641
- params.logger.info(`[google-meet] node realtime ignored assistant echo transcript: ${text}`);
1821
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog("node realtime ignored assistant echo transcript", text));
1642
1822
  return;
1643
1823
  }
1644
- enqueueAgentConsultForUserTranscript(text);
1824
+ agentTalkback?.enqueue(text);
1645
1825
  }
1646
1826
  }
1647
1827
  },
1648
1828
  onEvent: (event) => {
1649
1829
  recordGoogleMeetRealtimeEvent(realtimeEvents, event);
1830
+ if (event.type === "input_audio_buffer.speech_started") ensureTalkTurn();
1831
+ else if (event.type === "input_audio_buffer.speech_stopped") {
1832
+ const turnId = talk.activeTurnId;
1833
+ if (!turnId) return;
1834
+ emitTalkEvent({
1835
+ type: "input.audio.committed",
1836
+ turnId,
1837
+ payload: {
1838
+ bridgeId: params.bridgeId,
1839
+ source: event.type
1840
+ },
1841
+ final: true
1842
+ });
1843
+ } else if (event.type === "response.done") {
1844
+ finishOutputAudio("response.done");
1845
+ endTalkTurn("response.done");
1846
+ } else if (event.type === "error") emitTalkEvent({
1847
+ type: "session.error",
1848
+ payload: { message: event.detail ?? "Realtime provider error" },
1849
+ final: true
1850
+ });
1650
1851
  if (event.type === "error" || event.type === "response.done" || event.type === "input_audio_buffer.speech_started" || event.type === "input_audio_buffer.speech_stopped" || event.type === "conversation.item.input_audio_transcription.completed" || event.type === "conversation.item.input_audio_transcription.failed") {
1651
1852
  const detail = event.detail ? ` ${event.detail}` : "";
1652
1853
  params.logger.info(`[google-meet] node realtime ${event.direction}:${event.type}${detail}`);
1653
1854
  }
1654
1855
  },
1655
1856
  onToolCall: (event, session) => {
1656
- if (strategy !== "bidi") {
1657
- session.submitToolResult(event.callId || event.itemId, { error: `Tool "${event.name}" is only available in bidi realtime strategy` });
1658
- return;
1659
- }
1660
- if (event.name !== GOOGLE_MEET_AGENT_CONSULT_TOOL_NAME) {
1661
- session.submitToolResult(event.callId || event.itemId, { error: `Tool "${event.name}" not available` });
1662
- return;
1663
- }
1664
- submitGoogleMeetConsultWorkingResponse(session, event.callId || event.itemId);
1665
- consultOpenClawAgentForGoogleMeet({
1857
+ emitTalkEvent({
1858
+ type: "tool.call",
1859
+ turnId: ensureTalkTurn(),
1860
+ itemId: event.itemId,
1861
+ callId: event.callId,
1862
+ payload: {
1863
+ name: event.name,
1864
+ args: event.args
1865
+ }
1866
+ });
1867
+ const turnId = ensureTalkTurn();
1868
+ handleGoogleMeetRealtimeConsultToolCall({
1869
+ strategy,
1870
+ session,
1871
+ event,
1666
1872
  config: params.config,
1667
1873
  fullConfig: params.fullConfig,
1668
1874
  runtime: params.runtime,
1669
1875
  logger: params.logger,
1670
1876
  meetingSessionId: params.meetingSessionId,
1671
1877
  requesterSessionKey: params.requesterSessionKey,
1672
- args: event.args,
1673
- transcript
1674
- }).then((result) => {
1675
- session.submitToolResult(event.callId || event.itemId, result);
1676
- }).catch((error) => {
1677
- session.submitToolResult(event.callId || event.itemId, { error: formatErrorMessage(error) });
1878
+ transcript,
1879
+ onTalkEvent: (input) => emitTalkEvent({
1880
+ ...input,
1881
+ turnId: input.turnId ?? turnId
1882
+ })
1678
1883
  });
1679
1884
  },
1680
1885
  onError: (error) => {
1681
1886
  params.logger.warn(`[google-meet] node realtime voice bridge failed: ${formatErrorMessage(error)}`);
1887
+ emitTalkEvent({
1888
+ type: "session.error",
1889
+ payload: { message: formatErrorMessage(error) },
1890
+ final: true
1891
+ });
1682
1892
  stop();
1683
1893
  },
1684
1894
  onClose: (reason) => {
1685
1895
  realtimeReady = false;
1896
+ finishOutputAudio(reason);
1897
+ emitTalkEvent({
1898
+ type: "session.closed",
1899
+ payload: { reason },
1900
+ final: true
1901
+ });
1686
1902
  if (reason === "error") stop();
1687
1903
  },
1688
1904
  onReady: () => {
1689
1905
  realtimeReady = true;
1906
+ emitTalkEvent({
1907
+ type: "session.ready",
1908
+ payload: { bridgeId: params.bridgeId }
1909
+ });
1690
1910
  }
1691
1911
  });
1692
1912
  await bridge.connect();
@@ -1717,6 +1937,11 @@ async function startNodeRealtimeAudioBridge(params) {
1717
1937
  }
1718
1938
  lastInputAt = (/* @__PURE__ */ new Date()).toISOString();
1719
1939
  lastInputBytes += audio.byteLength;
1940
+ emitTalkEvent({
1941
+ type: "input.audio.delta",
1942
+ turnId: ensureTalkTurn(),
1943
+ payload: { byteLength: audio.byteLength }
1944
+ });
1720
1945
  bridge?.sendAudio(audio);
1721
1946
  }
1722
1947
  if (result.closed === true) await stop();
@@ -1754,6 +1979,7 @@ async function startNodeRealtimeAudioBridge(params) {
1754
1979
  suppressedInputBytes,
1755
1980
  ...getGoogleMeetRealtimeTranscriptHealth(transcript),
1756
1981
  ...getGoogleMeetRealtimeEventHealth(realtimeEvents),
1982
+ recentTalkEvents: summarizeGoogleMeetTalkEvents(recentTalkEvents),
1757
1983
  consecutiveInputErrors,
1758
1984
  lastInputError,
1759
1985
  clearCount,
@@ -2289,8 +2515,9 @@ async function openMeetWithBrowserRequest(params) {
2289
2515
  };
2290
2516
  break;
2291
2517
  }
2292
- if (Date.now() <= deadline) await new Promise((resolve) => setTimeout(resolve, 750));
2293
- } while (Date.now() <= deadline);
2518
+ const remainingWaitMs = deadline - Date.now();
2519
+ if (remainingWaitMs > 0) await new Promise((resolve) => setTimeout(resolve, Math.min(750, remainingWaitMs)));
2520
+ } while (Date.now() < deadline);
2294
2521
  return {
2295
2522
  launched: true,
2296
2523
  browser
@@ -3034,12 +3261,14 @@ function buildMeetDtmfSequence(params) {
3034
3261
  if (!/^[0-9]+#?$/.test(compactPin)) throw new Error("pin may only contain digits and an optional trailing #");
3035
3262
  return compactPin.endsWith("#") ? compactPin : `${compactPin}#`;
3036
3263
  }
3264
+ function prefixDtmfWait(sequence, delayMs) {
3265
+ if (!sequence || delayMs <= 0) return sequence;
3266
+ const waitCount = Math.ceil(delayMs / 500);
3267
+ if (waitCount <= 0) return sequence;
3268
+ return `${"w".repeat(waitCount)}${sequence}`;
3269
+ }
3037
3270
  //#endregion
3038
3271
  //#region extensions/google-meet/src/voice-call-gateway.ts
3039
- function sleep$1(ms) {
3040
- if (ms <= 0) return Promise.resolve();
3041
- return new Promise((resolve) => setTimeout(resolve, ms));
3042
- }
3043
3272
  async function createConnectedGatewayClient(config) {
3044
3273
  let client;
3045
3274
  await new Promise((resolve, reject) => {
@@ -3080,42 +3309,44 @@ async function createConnectedGatewayClient(config) {
3080
3309
  });
3081
3310
  return client;
3082
3311
  }
3312
+ function isVoiceCallMissingError(error) {
3313
+ const message = formatErrorMessage(error).toLowerCase();
3314
+ return message.includes("call not found") || message.includes("call is not active");
3315
+ }
3083
3316
  async function joinMeetViaVoiceCallGateway(params) {
3084
3317
  let client;
3085
3318
  try {
3086
3319
  client = await createConnectedGatewayClient(params.config);
3087
- params.logger?.info(`[google-meet] Delegating Twilio join to Voice Call (dtmf=${params.dtmfSequence ? "post-connect" : "none"}, intro=${params.message ? "delayed" : "none"})`);
3320
+ params.logger?.info(`[google-meet] Delegating Twilio join to Voice Call (dtmf=${params.dtmfSequence ? "pre-connect" : "none"}, intro=${params.message ? "delayed" : "none"})`);
3088
3321
  const start = await client.request("voicecall.start", {
3089
3322
  to: params.dialInNumber,
3090
- mode: "conversation"
3323
+ mode: "conversation",
3324
+ ...params.dtmfSequence ? { dtmfSequence: params.dtmfSequence } : {},
3325
+ ...params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {},
3326
+ ...params.sessionKey ? { sessionKey: params.sessionKey } : {}
3091
3327
  }, { timeoutMs: params.config.voiceCall.requestTimeoutMs });
3092
3328
  if (!start.callId) throw new Error(start.error || "voicecall.start did not return callId");
3093
3329
  params.logger?.info(`[google-meet] Voice Call Twilio phone leg started: callId=${start.callId}`);
3094
- let dtmfSent = false;
3095
- if (params.dtmfSequence) {
3096
- const delayMs = params.config.voiceCall.dtmfDelayMs;
3097
- params.logger?.info(`[google-meet] Waiting ${delayMs}ms before sending Meet DTMF for callId=${start.callId}`);
3098
- await sleep$1(delayMs);
3099
- const dtmf = await client.request("voicecall.dtmf", {
3100
- callId: start.callId,
3101
- digits: params.dtmfSequence
3102
- }, { timeoutMs: params.config.voiceCall.requestTimeoutMs });
3103
- if (dtmf.success === false) throw new Error(dtmf.error || "voicecall.dtmf failed");
3104
- dtmfSent = true;
3105
- params.logger?.info(`[google-meet] Meet DTMF sent after phone leg connected: callId=${start.callId} digits=${params.dtmfSequence.length}`);
3106
- }
3330
+ const dtmfSent = Boolean(params.dtmfSequence);
3331
+ if (dtmfSent) params.logger?.info(`[google-meet] Meet DTMF queued before realtime connect: callId=${start.callId} digits=${params.dtmfSequence?.length ?? 0}`);
3107
3332
  let introSent = false;
3108
3333
  if (params.message) {
3109
3334
  const delayMs = params.dtmfSequence ? params.config.voiceCall.postDtmfSpeechDelayMs : 0;
3110
3335
  if (delayMs > 0) {
3111
3336
  params.logger?.info(`[google-meet] Waiting ${delayMs}ms after Meet DTMF before speaking intro for callId=${start.callId}`);
3112
- await sleep$1(delayMs);
3337
+ await sleep(delayMs);
3338
+ }
3339
+ let spoken;
3340
+ try {
3341
+ spoken = await client.request("voicecall.speak", {
3342
+ callId: start.callId,
3343
+ allowTwimlFallback: false,
3344
+ message: params.message
3345
+ }, { timeoutMs: params.config.voiceCall.requestTimeoutMs });
3346
+ } catch (err) {
3347
+ params.logger?.warn?.(`[google-meet] Skipped intro speech because realtime bridge was not ready: ${formatErrorMessage(err)}`);
3348
+ spoken = { success: false };
3113
3349
  }
3114
- const spoken = await client.request("voicecall.speak", {
3115
- callId: start.callId,
3116
- allowTwimlFallback: false,
3117
- message: params.message
3118
- }, { timeoutMs: params.config.voiceCall.requestTimeoutMs });
3119
3350
  if (spoken.success === false) params.logger?.warn?.(`[google-meet] Skipped intro speech because realtime bridge was not ready: ${spoken.error || "voicecall.speak failed"}`);
3120
3351
  else {
3121
3352
  introSent = true;
@@ -3135,7 +3366,20 @@ async function endMeetVoiceCallGatewayCall(params) {
3135
3366
  let client;
3136
3367
  try {
3137
3368
  client = await createConnectedGatewayClient(params.config);
3138
- await client.request("voicecall.end", { callId: params.callId }, { timeoutMs: params.config.voiceCall.requestTimeoutMs });
3369
+ try {
3370
+ await client.request("voicecall.end", { callId: params.callId }, { timeoutMs: params.config.voiceCall.requestTimeoutMs });
3371
+ } catch (err) {
3372
+ if (!isVoiceCallMissingError(err)) throw err;
3373
+ }
3374
+ } finally {
3375
+ await client?.stopAndWait({ timeoutMs: 1e3 });
3376
+ }
3377
+ }
3378
+ async function getMeetVoiceCallGatewayCall(params) {
3379
+ let client;
3380
+ try {
3381
+ client = await createConnectedGatewayClient(params.config);
3382
+ return await client.request("voicecall.status", { callId: params.callId }, { timeoutMs: params.config.voiceCall.requestTimeoutMs });
3139
3383
  } finally {
3140
3384
  await client?.stopAndWait({ timeoutMs: 1e3 });
3141
3385
  }
@@ -3158,6 +3402,9 @@ async function speakMeetViaVoiceCallGateway(params) {
3158
3402
  function nowIso() {
3159
3403
  return (/* @__PURE__ */ new Date()).toISOString();
3160
3404
  }
3405
+ function buildTwilioVoiceCallSessionKey(meetingSessionId) {
3406
+ return `voice:google-meet:${meetingSessionId}`;
3407
+ }
3161
3408
  function normalizeMeetUrl(input) {
3162
3409
  const raw = normalizeOptionalString(input);
3163
3410
  if (!raw) throw new Error("url required");
@@ -3200,12 +3447,12 @@ function resolveProbeTimeoutMs(input, fallback) {
3200
3447
  if (!Number.isFinite(input) || input <= 0) throw new Error("timeoutMs must be a positive number");
3201
3448
  return Math.min(Math.trunc(input), 12e4);
3202
3449
  }
3203
- function sleep(ms) {
3204
- return new Promise((resolve) => setTimeout(resolve, ms));
3205
- }
3206
3450
  function isManagedChromeBrowserSession(session) {
3207
3451
  return Boolean((session.transport === "chrome" || session.transport === "chrome-node") && session.chrome && session.chrome.launched);
3208
3452
  }
3453
+ function noteSession(session, note) {
3454
+ session.notes = [...session.notes.filter((item) => item !== note), note];
3455
+ }
3209
3456
  function evaluateSpeechReadiness(session) {
3210
3457
  if (!isGoogleMeetTalkBackMode$1(session.mode) || !session.chrome) return { ready: true };
3211
3458
  if (!isManagedChromeBrowserSession(session)) {
@@ -3378,15 +3625,20 @@ var GoogleMeetRuntime = class {
3378
3625
  const url = normalizeMeetUrl(request.url);
3379
3626
  const transport = resolveTransport(request.transport, this.params.config);
3380
3627
  const mode = resolveMode(request.mode, this.params.config);
3381
- const reusable = this.list().find((session) => session.state === "active" && isSameMeetUrlForReuse(session.url, url) && session.transport === transport && session.mode === mode);
3628
+ let reusable = this.list().find((session) => session.state === "active" && isSameMeetUrlForReuse(session.url, url) && session.transport === transport && session.mode === mode);
3629
+ if (reusable?.transport === "twilio") {
3630
+ await this.#refreshTwilioVoiceCallStatus(reusable);
3631
+ if (reusable.state !== "active") reusable = void 0;
3632
+ }
3382
3633
  const speechInstructions = request.message ?? this.params.config.realtime.introMessage;
3383
3634
  if (reusable) {
3384
3635
  await this.#refreshBrowserHealthForChromeSession(reusable);
3385
- reusable.notes = [...reusable.notes.filter((note) => note !== "Reused existing active Meet session."), "Reused existing active Meet session."];
3636
+ noteSession(reusable, "Reused existing active Meet session.");
3386
3637
  reusable.updatedAt = nowIso();
3638
+ const spoken = isGoogleMeetTalkBackMode$1(mode) && speechInstructions ? await this.#speakWhenReady(reusable, speechInstructions) : false;
3387
3639
  return {
3388
3640
  session: reusable,
3389
- spoken: isGoogleMeetTalkBackMode$1(mode) && speechInstructions ? await this.#speakWhenReady(reusable, speechInstructions) : false
3641
+ spoken
3390
3642
  };
3391
3643
  }
3392
3644
  const createdAt = nowIso();
@@ -3444,15 +3696,18 @@ var GoogleMeetRuntime = class {
3444
3696
  } else {
3445
3697
  const dialInNumber = normalizeDialInNumber(request.dialInNumber ?? this.params.config.twilio.defaultDialInNumber);
3446
3698
  if (!dialInNumber) throw new Error("Twilio transport requires a Meet dial-in phone number. Google Meet URLs do not include dial-in details; pass dialInNumber with optional pin/dtmfSequence, configure twilio.defaultDialInNumber, or use chrome/chrome-node transport.");
3447
- const dtmfSequence = buildMeetDtmfSequence({
3699
+ const rawDtmfSequence = buildMeetDtmfSequence({
3448
3700
  pin: request.pin ?? this.params.config.twilio.defaultPin,
3449
3701
  dtmfSequence: request.dtmfSequence ?? this.params.config.twilio.defaultDtmfSequence
3450
3702
  });
3703
+ const dtmfSequence = request.dtmfSequence || this.params.config.twilio.defaultDtmfSequence ? rawDtmfSequence : prefixDtmfWait(rawDtmfSequence, this.params.config.voiceCall.dtmfDelayMs);
3451
3704
  const voiceCallResult = this.params.config.voiceCall.enabled ? await joinMeetViaVoiceCallGateway({
3452
3705
  config: this.params.config,
3453
3706
  dialInNumber,
3454
3707
  dtmfSequence,
3455
3708
  logger: this.params.logger,
3709
+ ...request.requesterSessionKey ? { requesterSessionKey: request.requesterSessionKey } : {},
3710
+ sessionKey: buildTwilioVoiceCallSessionKey(session.id),
3456
3711
  message: isGoogleMeetTalkBackMode$1(mode) ? request.message ?? this.params.config.voiceCall.introMessage ?? this.params.config.realtime.introMessage : void 0
3457
3712
  }) : void 0;
3458
3713
  delegatedTwilioSpoken = Boolean(voiceCallResult?.introSent);
@@ -3470,7 +3725,7 @@ var GoogleMeetRuntime = class {
3470
3725
  callId: voiceCallResult.callId
3471
3726
  });
3472
3727
  });
3473
- session.notes.push(this.params.config.voiceCall.enabled ? dtmfSequence ? "Twilio transport delegated the phone leg to the voice-call plugin, then sent configured DTMF after connect before speaking." : "Twilio transport delegated the call to the voice-call plugin without configured DTMF." : "Twilio transport is an explicit dial plan; voice-call delegation is disabled.");
3728
+ session.notes.push(this.params.config.voiceCall.enabled ? dtmfSequence ? "Twilio transport delegated the phone leg to the voice-call plugin, then queued configured DTMF before realtime connect." : "Twilio transport delegated the call to the voice-call plugin without configured DTMF." : "Twilio transport is an explicit dial plan; voice-call delegation is disabled.");
3474
3729
  }
3475
3730
  } catch (err) {
3476
3731
  this.params.logger.warn(`[google-meet] join failed: ${formatErrorMessage(err)}`);
@@ -3490,7 +3745,12 @@ var GoogleMeetRuntime = class {
3490
3745
  this.#sessionStops.delete(sessionId);
3491
3746
  this.#sessionSpeakers.delete(sessionId);
3492
3747
  this.#sessionHealth.delete(sessionId);
3493
- await stop();
3748
+ try {
3749
+ await stop();
3750
+ } finally {
3751
+ session.state = "ended";
3752
+ session.updatedAt = nowIso();
3753
+ }
3494
3754
  }
3495
3755
  session.state = "ended";
3496
3756
  session.updatedAt = nowIso();
@@ -3506,11 +3766,21 @@ var GoogleMeetRuntime = class {
3506
3766
  spoken: false
3507
3767
  };
3508
3768
  if (session.transport === "twilio" && session.twilio?.voiceCallId) {
3509
- await speakMeetViaVoiceCallGateway({
3510
- config: this.params.config,
3511
- callId: session.twilio.voiceCallId,
3512
- message: instructions || this.params.config.voiceCall.introMessage || this.params.config.realtime.introMessage || ""
3513
- });
3769
+ try {
3770
+ await speakMeetViaVoiceCallGateway({
3771
+ config: this.params.config,
3772
+ callId: session.twilio.voiceCallId,
3773
+ message: instructions || this.params.config.voiceCall.introMessage || this.params.config.realtime.introMessage || ""
3774
+ });
3775
+ } catch (err) {
3776
+ if (!isVoiceCallMissingError(err)) throw err;
3777
+ this.#markTwilioSessionEnded(session, "Voice Call is no longer active.");
3778
+ return {
3779
+ found: true,
3780
+ spoken: false,
3781
+ session
3782
+ };
3783
+ }
3514
3784
  session.twilio.introSent = true;
3515
3785
  session.updatedAt = nowIso();
3516
3786
  return {
@@ -3553,7 +3823,7 @@ var GoogleMeetRuntime = class {
3553
3823
  const waitMs = Math.min(Math.max(0, this.params.config.chrome.waitForInCallMs), Math.max(0, this.params.config.chrome.joinTimeoutMs));
3554
3824
  const deadline = Date.now() + waitMs;
3555
3825
  while (Date.now() < deadline) {
3556
- await sleep(250);
3826
+ await sleep(Math.min(250, Math.max(0, deadline - Date.now())));
3557
3827
  result = await this.speak(session.id, instructions);
3558
3828
  if (result.spoken) return true;
3559
3829
  const health = result.session?.chrome?.health;
@@ -3565,6 +3835,8 @@ var GoogleMeetRuntime = class {
3565
3835
  }
3566
3836
  async testSpeech(request) {
3567
3837
  if (request.mode === "transcribe") throw new Error("test_speech requires mode: agent or bidi; use join mode: transcribe for observe-only sessions.");
3838
+ const requestedMode = request.mode ? resolveMode(request.mode, this.params.config) : void 0;
3839
+ const mode = requestedMode && isGoogleMeetTalkBackMode$1(requestedMode) ? requestedMode : isGoogleMeetTalkBackMode$1(this.params.config.defaultMode) ? this.params.config.defaultMode : "agent";
3568
3840
  const url = normalizeMeetUrl(request.url);
3569
3841
  const transport = resolveTransport(request.transport, this.params.config);
3570
3842
  const beforeSessions = this.list();
@@ -3574,7 +3846,7 @@ var GoogleMeetRuntime = class {
3574
3846
  ...request,
3575
3847
  transport,
3576
3848
  url,
3577
- mode: "agent",
3849
+ mode,
3578
3850
  message: request.message ?? "Say exactly: Google Meet speech test complete."
3579
3851
  });
3580
3852
  let health = result.session.chrome?.health;
@@ -3662,16 +3934,40 @@ var GoogleMeetRuntime = class {
3662
3934
  }
3663
3935
  async #refreshStatusHealthForSession(session) {
3664
3936
  if (session.transport === "chrome" || session.transport === "chrome-node") {
3665
- if (session.chrome?.health?.manualActionRequired) {
3666
- this.#refreshSpeechReadiness(session);
3667
- return;
3668
- }
3669
3937
  await this.#refreshBrowserHealthForChromeSession(session, {
3670
3938
  force: true,
3671
3939
  readOnly: true
3672
3940
  });
3673
3941
  return;
3674
3942
  }
3943
+ if (session.transport === "twilio") {
3944
+ await this.#refreshTwilioVoiceCallStatus(session);
3945
+ return;
3946
+ }
3947
+ this.#refreshSpeechReadiness(session);
3948
+ }
3949
+ #markTwilioSessionEnded(session, reason) {
3950
+ session.state = "ended";
3951
+ session.updatedAt = nowIso();
3952
+ this.#sessionStops.delete(session.id);
3953
+ this.#sessionSpeakers.delete(session.id);
3954
+ this.#sessionHealth.delete(session.id);
3955
+ noteSession(session, reason);
3956
+ }
3957
+ async #refreshTwilioVoiceCallStatus(session) {
3958
+ const callId = session.twilio?.voiceCallId;
3959
+ if (!callId || session.state !== "active") {
3960
+ this.#refreshSpeechReadiness(session);
3961
+ return;
3962
+ }
3963
+ try {
3964
+ if ((await getMeetVoiceCallGatewayCall({
3965
+ config: this.params.config,
3966
+ callId
3967
+ })).found === false) this.#markTwilioSessionEnded(session, "Voice Call is no longer active.");
3968
+ } catch (error) {
3969
+ this.params.logger.debug?.(`[google-meet] voice-call status refresh ignored: ${formatErrorMessage(error)}`);
3970
+ }
3675
3971
  this.#refreshSpeechReadiness(session);
3676
3972
  }
3677
3973
  async #refreshBrowserHealthForChromeSession(session, options = {}) {
@@ -3897,13 +4193,13 @@ const googleMeetConfigSchema = {
3897
4193
  advanced: true
3898
4194
  },
3899
4195
  "voiceCall.dtmfDelayMs": {
3900
- label: "Legacy DTMF Delay (ms)",
3901
- help: "Compatibility setting from the old post-connect DTMF flow. Twilio Meet joins now play DTMF before realtime connect.",
4196
+ label: "DTMF Wait Before PIN (ms)",
4197
+ help: "Leading Twilio wait time before playing a PIN-derived Meet DTMF sequence. Increase it if Meet asks for the PIN after DTMF was sent.",
3902
4198
  advanced: true
3903
4199
  },
3904
4200
  "voiceCall.postDtmfSpeechDelayMs": {
3905
- label: "Legacy Post-DTMF Speech Delay (ms)",
3906
- help: "Compatibility setting from the old delayed-speech flow. Twilio Meet joins now carry the intro as the initial Voice Call message.",
4201
+ label: "Post-DTMF Speech Delay (ms)",
4202
+ help: "Delay before requesting the realtime intro greeting after Voice Call starts the Twilio leg.",
3907
4203
  advanced: true
3908
4204
  },
3909
4205
  "voiceCall.introMessage": {
@@ -4141,10 +4437,10 @@ async function callGoogleMeetGatewayFromTool(params) {
4141
4437
  }
4142
4438
  }
4143
4439
  async function createMeetFromParams(params) {
4144
- return (await import("./create-mHH3keLH.js")).createMeetFromParams(params);
4440
+ return (await import("./create-C01ezOIH.js")).createMeetFromParams(params);
4145
4441
  }
4146
4442
  async function createAndJoinMeetFromParams(params) {
4147
- return (await import("./create-mHH3keLH.js")).createAndJoinMeetFromParams(params);
4443
+ return (await import("./create-C01ezOIH.js")).createAndJoinMeetFromParams(params);
4148
4444
  }
4149
4445
  async function resolveGoogleMeetTokenFromParams(config, raw) {
4150
4446
  const { resolveGoogleMeetAccessToken } = await import("./oauth-BJwzuzT-.js");
@@ -4236,7 +4532,7 @@ async function exportGoogleMeetBundleFromParams(config, raw) {
4236
4532
  lateAfterMinutes: resolved.lateAfterMinutes,
4237
4533
  earlyBeforeMinutes: resolved.earlyBeforeMinutes
4238
4534
  })]);
4239
- const { buildGoogleMeetExportManifest, googleMeetExportFileNames, writeMeetExportBundle } = await import("./cli-CP1gp7Wl.js");
4535
+ const { buildGoogleMeetExportManifest, googleMeetExportFileNames, writeMeetExportBundle } = await import("./cli-Dgpp3OTQ.js");
4240
4536
  const calendarId = normalizeOptionalString(raw.calendarId);
4241
4537
  const request = {
4242
4538
  ...resolved.meeting ? { meeting: resolved.meeting } : {},
@@ -4652,7 +4948,7 @@ var google_meet_default = definePluginEntry({
4652
4948
  handle: handleGoogleMeetNodeHostCommand
4653
4949
  });
4654
4950
  api.registerCli(async ({ program }) => {
4655
- const { registerGoogleMeetCli } = await import("./cli-CP1gp7Wl.js");
4951
+ const { registerGoogleMeetCli } = await import("./cli-Dgpp3OTQ.js");
4656
4952
  registerGoogleMeetCli({
4657
4953
  program,
4658
4954
  config,