@openclaw/google-meet 2026.5.7-beta.1 → 2026.5.9-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,121 @@ 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({
796
856
  providerConfig: resolved.providerConfig,
797
857
  onTranscript: (text) => {
798
858
  const trimmed = text.trim();
799
859
  if (!trimmed || stopped) return;
860
+ const turnId = ensureTalkTurn();
861
+ emitTalkEvent({
862
+ type: "input.audio.committed",
863
+ turnId,
864
+ final: true,
865
+ payload: { meetingSessionId: params.meetingSessionId }
866
+ });
867
+ emitTalkEvent({
868
+ type: "transcript.done",
869
+ turnId,
870
+ final: true,
871
+ payload: {
872
+ meetingSessionId: params.meetingSessionId,
873
+ text: trimmed,
874
+ role: "user"
875
+ }
876
+ });
800
877
  recordGoogleMeetRealtimeTranscript(transcript, "user", trimmed);
801
- params.logger.info(`[google-meet] agent user: ${trimmed}`);
878
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog("agent user", trimmed));
802
879
  if (isGoogleMeetLikelyAssistantEchoTranscript({
803
880
  transcript,
804
881
  text: trimmed
805
882
  })) {
806
- params.logger.info(`[google-meet] agent ignored assistant echo transcript: ${trimmed}`);
883
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog("agent ignored assistant echo transcript", trimmed));
807
884
  return;
808
885
  }
809
- enqueueAgentConsultForUserTranscript(trimmed);
886
+ agentTalkback?.enqueue(trimmed);
810
887
  },
811
888
  onError: (error) => {
812
889
  params.logger.warn(`[google-meet] agent transcription bridge failed: ${formatErrorMessage(error)}`);
890
+ emitTalkEvent({
891
+ type: "session.error",
892
+ final: true,
893
+ payload: {
894
+ meetingSessionId: params.meetingSessionId,
895
+ error: formatErrorMessage(error)
896
+ }
897
+ });
813
898
  stop();
814
899
  }
815
900
  });
901
+ emitTalkEvent({
902
+ type: "session.started",
903
+ payload: {
904
+ meetingSessionId: params.meetingSessionId,
905
+ provider: resolved.provider.id
906
+ }
907
+ });
816
908
  await sttSession.connect();
817
909
  realtimeReady = true;
910
+ emitTalkEvent({
911
+ type: "session.ready",
912
+ payload: { meetingSessionId: params.meetingSessionId }
913
+ });
818
914
  inputProcess.stdout?.on("data", (chunk) => {
819
915
  if (stopped) return;
820
916
  const audio = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
@@ -825,6 +921,14 @@ async function startCommandAgentAudioBridge(params) {
825
921
  }
826
922
  lastInputAt = (/* @__PURE__ */ new Date()).toISOString();
827
923
  lastInputBytes += audio.byteLength;
924
+ emitTalkEvent({
925
+ type: "input.audio.delta",
926
+ turnId: ensureTalkTurn(),
927
+ payload: {
928
+ meetingSessionId: params.meetingSessionId,
929
+ bytes: audio.byteLength
930
+ }
931
+ });
828
932
  sttSession?.sendAudio(convertGoogleMeetBridgeAudioForStt(audio, params.config));
829
933
  });
830
934
  return {
@@ -844,6 +948,7 @@ async function startCommandAgentAudioBridge(params) {
844
948
  lastOutputBytes,
845
949
  suppressedInputBytes,
846
950
  ...getGoogleMeetRealtimeTranscriptHealth(transcript),
951
+ recentTalkEvents: summarizeGoogleMeetTalkEvents(recentTalkEvents),
847
952
  bridgeClosed: stopped
848
953
  }),
849
954
  stop
@@ -879,7 +984,7 @@ async function startCommandRealtimeAudioBridge(params) {
879
984
  let lastOutputAtMs = 0;
880
985
  let lastOutputPlayableUntilMs = 0;
881
986
  let bargeInInputProcess;
882
- let agentConsultDebounceTimer;
987
+ let agentTalkback;
883
988
  const suppressInputForOutput = (audio) => {
884
989
  const suppression = extendGoogleMeetOutputEchoSuppression({
885
990
  audio,
@@ -912,10 +1017,7 @@ async function startCommandRealtimeAudioBridge(params) {
912
1017
  const stop = async () => {
913
1018
  if (stopped) return;
914
1019
  stopped = true;
915
- if (agentConsultDebounceTimer) {
916
- clearTimeout(agentConsultDebounceTimer);
917
- agentConsultDebounceTimer = void 0;
918
- }
1020
+ agentTalkback?.close();
919
1021
  try {
920
1022
  bridge?.close();
921
1023
  } catch (error) {
@@ -1028,62 +1130,60 @@ async function startCommandRealtimeAudioBridge(params) {
1028
1130
  }));
1029
1131
  const transcript = [];
1030
1132
  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?.();
1133
+ const talk = createTalkSessionController({
1134
+ sessionId: `google-meet:${params.meetingSessionId}:command-realtime`,
1135
+ mode: "realtime",
1136
+ transport: "gateway-relay",
1137
+ brain: strategy === "bidi" ? "direct-tools" : "agent-consult",
1138
+ provider: resolved.provider.id
1139
+ }, { onEvent: recordTalkObservabilityEvent });
1140
+ const recentTalkEvents = [];
1141
+ const rememberTalkEvent = (event) => {
1142
+ if (event) pushGoogleMeetTalkEvent(recentTalkEvents, event);
1045
1143
  };
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
- }
1144
+ const emitTalkEvent = (input) => {
1145
+ rememberTalkEvent(talk.emit(input));
1146
+ };
1147
+ const ensureTalkTurn = () => {
1148
+ const turn = talk.ensureTurn({ payload: { meetingSessionId: params.meetingSessionId } });
1149
+ if (turn.event) rememberTalkEvent(turn.event);
1150
+ return turn.turnId;
1086
1151
  };
1152
+ const finishOutputAudio = (reason) => {
1153
+ rememberTalkEvent(talk.finishOutputAudio({ payload: { reason } }));
1154
+ };
1155
+ const endTalkTurn = (reason = "completed") => {
1156
+ const ended = talk.endTurn({ payload: { reason } });
1157
+ if (ended.ok) rememberTalkEvent(ended.event);
1158
+ };
1159
+ emitTalkEvent({
1160
+ type: "session.started",
1161
+ payload: { meetingSessionId: params.meetingSessionId }
1162
+ });
1163
+ agentTalkback = createRealtimeVoiceAgentTalkbackQueue({
1164
+ debounceMs: 900,
1165
+ isStopped: () => stopped,
1166
+ logger: params.logger,
1167
+ logPrefix: "[google-meet] realtime agent",
1168
+ responseStyle: "Brief, natural spoken answer for a live meeting.",
1169
+ fallbackText: "I hit an error while checking that. Please try again.",
1170
+ consult: ({ question, responseStyle }) => consultOpenClawAgentForGoogleMeet({
1171
+ config: params.config,
1172
+ fullConfig: params.fullConfig,
1173
+ runtime: params.runtime,
1174
+ logger: params.logger,
1175
+ meetingSessionId: params.meetingSessionId,
1176
+ requesterSessionKey: params.requesterSessionKey,
1177
+ args: {
1178
+ question,
1179
+ responseStyle
1180
+ },
1181
+ transcript
1182
+ }),
1183
+ deliver: (text) => {
1184
+ bridge?.sendUserMessage(buildGoogleMeetSpeakExactUserMessage(text));
1185
+ }
1186
+ });
1087
1187
  bridge = createRealtimeVoiceBridgeSession({
1088
1188
  provider: resolved.provider,
1089
1189
  providerConfig: resolved.providerConfig,
@@ -1097,69 +1197,140 @@ async function startCommandRealtimeAudioBridge(params) {
1097
1197
  audioSink: {
1098
1198
  isOpen: () => !stopped,
1099
1199
  sendAudio: (audio) => {
1200
+ const turnId = ensureTalkTurn();
1201
+ rememberTalkEvent(talk.startOutputAudio({
1202
+ turnId,
1203
+ payload: { meetingSessionId: params.meetingSessionId }
1204
+ }).event);
1205
+ emitTalkEvent({
1206
+ type: "output.audio.delta",
1207
+ turnId,
1208
+ payload: { byteLength: audio.byteLength }
1209
+ });
1100
1210
  lastOutputAtMs = Date.now();
1101
1211
  lastOutputAt = (/* @__PURE__ */ new Date()).toISOString();
1102
1212
  lastOutputBytes += audio.byteLength;
1103
1213
  suppressInputForOutput(audio);
1104
1214
  writeOutputAudio(audio);
1105
1215
  },
1106
- clearAudio: clearOutputPlayback
1216
+ clearAudio: () => {
1217
+ clearOutputPlayback();
1218
+ finishOutputAudio("clear");
1219
+ }
1107
1220
  },
1108
1221
  onTranscript: (role, text, isFinal) => {
1222
+ const turnId = ensureTalkTurn();
1223
+ emitTalkEvent({
1224
+ type: role === "assistant" ? isFinal ? "output.text.done" : "output.text.delta" : isFinal ? "transcript.done" : "transcript.delta",
1225
+ turnId,
1226
+ payload: role === "assistant" ? { text } : {
1227
+ role,
1228
+ text
1229
+ },
1230
+ final: isFinal
1231
+ });
1232
+ if (role === "user" && isFinal) emitTalkEvent({
1233
+ type: "input.audio.committed",
1234
+ turnId,
1235
+ payload: { meetingSessionId: params.meetingSessionId },
1236
+ final: true
1237
+ });
1109
1238
  if (isFinal) {
1110
1239
  recordGoogleMeetRealtimeTranscript(transcript, role, text);
1111
- params.logger.info(`[google-meet] realtime ${role}: ${text}`);
1240
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog(`realtime ${role}`, text));
1112
1241
  if (role === "user" && strategy === "agent") {
1113
1242
  if (isGoogleMeetLikelyAssistantEchoTranscript({
1114
1243
  transcript,
1115
1244
  text
1116
1245
  })) {
1117
- params.logger.info(`[google-meet] realtime ignored assistant echo transcript: ${text}`);
1246
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog("realtime ignored assistant echo transcript", text));
1118
1247
  return;
1119
1248
  }
1120
- enqueueAgentConsultForUserTranscript(text);
1249
+ agentTalkback?.enqueue(text);
1121
1250
  }
1122
1251
  }
1123
1252
  },
1124
1253
  onEvent: (event) => {
1125
1254
  recordGoogleMeetRealtimeEvent(realtimeEvents, event);
1255
+ if (event.type === "input_audio_buffer.speech_started") ensureTalkTurn();
1256
+ else if (event.type === "input_audio_buffer.speech_stopped") {
1257
+ const turnId = talk.activeTurnId;
1258
+ if (!turnId) return;
1259
+ emitTalkEvent({
1260
+ type: "input.audio.committed",
1261
+ turnId,
1262
+ payload: {
1263
+ meetingSessionId: params.meetingSessionId,
1264
+ source: event.type
1265
+ },
1266
+ final: true
1267
+ });
1268
+ } else if (event.type === "response.done") {
1269
+ finishOutputAudio("response.done");
1270
+ endTalkTurn("response.done");
1271
+ } else if (event.type === "error") emitTalkEvent({
1272
+ type: "session.error",
1273
+ payload: { message: event.detail ?? "Realtime provider error" },
1274
+ final: true
1275
+ });
1126
1276
  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
1277
  const detail = event.detail ? ` ${event.detail}` : "";
1128
1278
  params.logger.info(`[google-meet] realtime ${event.direction}:${event.type}${detail}`);
1129
1279
  }
1130
1280
  },
1131
1281
  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({
1282
+ emitTalkEvent({
1283
+ type: "tool.call",
1284
+ turnId: ensureTalkTurn(),
1285
+ itemId: event.itemId,
1286
+ callId: event.callId,
1287
+ payload: {
1288
+ name: event.name,
1289
+ args: event.args
1290
+ }
1291
+ });
1292
+ const turnId = ensureTalkTurn();
1293
+ handleGoogleMeetRealtimeConsultToolCall({
1294
+ strategy,
1295
+ session,
1296
+ event,
1142
1297
  config: params.config,
1143
1298
  fullConfig: params.fullConfig,
1144
1299
  runtime: params.runtime,
1145
1300
  logger: params.logger,
1146
1301
  meetingSessionId: params.meetingSessionId,
1147
1302
  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) });
1303
+ transcript,
1304
+ onTalkEvent: (input) => emitTalkEvent({
1305
+ ...input,
1306
+ turnId: input.turnId ?? turnId
1307
+ })
1154
1308
  });
1155
1309
  },
1156
- onError: fail("realtime voice bridge"),
1310
+ onError: (error) => {
1311
+ emitTalkEvent({
1312
+ type: "session.error",
1313
+ payload: { message: formatErrorMessage(error) },
1314
+ final: true
1315
+ });
1316
+ fail("realtime voice bridge")(error);
1317
+ },
1157
1318
  onClose: (reason) => {
1158
1319
  realtimeReady = false;
1320
+ finishOutputAudio(reason);
1321
+ emitTalkEvent({
1322
+ type: "session.closed",
1323
+ payload: { reason },
1324
+ final: true
1325
+ });
1159
1326
  if (reason === "error") stop();
1160
1327
  },
1161
1328
  onReady: () => {
1162
1329
  realtimeReady = true;
1330
+ emitTalkEvent({
1331
+ type: "session.ready",
1332
+ payload: { meetingSessionId: params.meetingSessionId }
1333
+ });
1163
1334
  }
1164
1335
  });
1165
1336
  startHumanBargeInMonitor();
@@ -1173,6 +1344,11 @@ async function startCommandRealtimeAudioBridge(params) {
1173
1344
  }
1174
1345
  lastInputAt = (/* @__PURE__ */ new Date()).toISOString();
1175
1346
  lastInputBytes += audio.byteLength;
1347
+ emitTalkEvent({
1348
+ type: "input.audio.delta",
1349
+ turnId: ensureTalkTurn(),
1350
+ payload: { byteLength: audio.byteLength }
1351
+ });
1176
1352
  bridge?.sendAudio(Buffer.from(audio));
1177
1353
  }
1178
1354
  });
@@ -1197,6 +1373,7 @@ async function startCommandRealtimeAudioBridge(params) {
1197
1373
  suppressedInputBytes,
1198
1374
  ...getGoogleMeetRealtimeTranscriptHealth(transcript),
1199
1375
  ...getGoogleMeetRealtimeEventHealth(realtimeEvents),
1376
+ recentTalkEvents: summarizeGoogleMeetTalkEvents(recentTalkEvents),
1200
1377
  lastClearAt,
1201
1378
  clearCount,
1202
1379
  bridgeClosed: stopped
@@ -1244,17 +1421,12 @@ async function startNodeAgentAudioBridge(params) {
1244
1421
  audioFormat: params.config.chrome.audioFormat
1245
1422
  }));
1246
1423
  const transcript = [];
1247
- let agentConsultActive = false;
1248
- let pendingAgentQuestion;
1249
- let agentConsultDebounceTimer;
1424
+ let agentTalkback;
1250
1425
  let ttsQueue = Promise.resolve();
1251
1426
  const stop = async () => {
1252
1427
  if (stopped) return;
1253
1428
  stopped = true;
1254
- if (agentConsultDebounceTimer) {
1255
- clearTimeout(agentConsultDebounceTimer);
1256
- agentConsultDebounceTimer = void 0;
1257
- }
1429
+ agentTalkback?.close();
1258
1430
  try {
1259
1431
  sttSession?.close();
1260
1432
  } catch (error) {
@@ -1303,7 +1475,7 @@ async function startNodeAgentAudioBridge(params) {
1303
1475
  ttsQueue = ttsQueue.then(async () => {
1304
1476
  if (stopped) return;
1305
1477
  recordGoogleMeetRealtimeTranscript(transcript, "assistant", normalized);
1306
- params.logger.info(`[google-meet] node agent assistant: ${normalized}`);
1478
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog("node agent assistant", normalized));
1307
1479
  const result = await params.runtime.tts.textToSpeechTelephony({
1308
1480
  text: normalized,
1309
1481
  cfg: params.fullConfig
@@ -1315,74 +1487,43 @@ async function startNodeAgentAudioBridge(params) {
1315
1487
  params.logger.warn(`[google-meet] node agent TTS failed: ${formatErrorMessage(error)}`);
1316
1488
  });
1317
1489
  };
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
- };
1490
+ agentTalkback = createRealtimeVoiceAgentTalkbackQueue({
1491
+ debounceMs: 900,
1492
+ isStopped: () => stopped,
1493
+ logger: params.logger,
1494
+ logPrefix: "[google-meet] node agent",
1495
+ responseStyle: "Brief, natural spoken answer for a live meeting.",
1496
+ fallbackText: "I hit an error while checking that. Please try again.",
1497
+ consult: ({ question, responseStyle }) => consultOpenClawAgentForGoogleMeet({
1498
+ config: params.config,
1499
+ fullConfig: params.fullConfig,
1500
+ runtime: params.runtime,
1501
+ logger: params.logger,
1502
+ meetingSessionId: params.meetingSessionId,
1503
+ requesterSessionKey: params.requesterSessionKey,
1504
+ args: {
1505
+ question,
1506
+ responseStyle
1507
+ },
1508
+ transcript
1509
+ }),
1510
+ deliver: enqueueSpeakText
1511
+ });
1371
1512
  sttSession = resolved.provider.createSession({
1372
1513
  providerConfig: resolved.providerConfig,
1373
1514
  onTranscript: (text) => {
1374
1515
  const trimmed = text.trim();
1375
1516
  if (!trimmed || stopped) return;
1376
1517
  recordGoogleMeetRealtimeTranscript(transcript, "user", trimmed);
1377
- params.logger.info(`[google-meet] node agent user: ${trimmed}`);
1518
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog("node agent user", trimmed));
1378
1519
  if (isGoogleMeetLikelyAssistantEchoTranscript({
1379
1520
  transcript,
1380
1521
  text: trimmed
1381
1522
  })) {
1382
- params.logger.info(`[google-meet] node agent ignored assistant echo transcript: ${trimmed}`);
1523
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog("node agent ignored assistant echo transcript", trimmed));
1383
1524
  return;
1384
1525
  }
1385
- enqueueAgentConsultForUserTranscript(trimmed);
1526
+ agentTalkback?.enqueue(trimmed);
1386
1527
  },
1387
1528
  onError: (error) => {
1388
1529
  params.logger.warn(`[google-meet] node agent transcription bridge failed: ${formatErrorMessage(error)}`);
@@ -1482,6 +1623,49 @@ async function startNodeRealtimeAudioBridge(params) {
1482
1623
  const transcript = [];
1483
1624
  const realtimeEvents = [];
1484
1625
  const strategy = params.config.realtime.strategy;
1626
+ const talk = createTalkSessionController({
1627
+ sessionId: `google-meet:${params.meetingSessionId}:${params.bridgeId}:node-realtime`,
1628
+ mode: "realtime",
1629
+ transport: "gateway-relay",
1630
+ brain: strategy === "bidi" ? "direct-tools" : "agent-consult",
1631
+ provider: resolved.provider.id
1632
+ }, { onEvent: recordTalkObservabilityEvent });
1633
+ const recentTalkEvents = [];
1634
+ const rememberTalkEvent = (event) => {
1635
+ if (event) pushGoogleMeetTalkEvent(recentTalkEvents, event);
1636
+ };
1637
+ const emitTalkEvent = (input) => {
1638
+ rememberTalkEvent(talk.emit(input));
1639
+ };
1640
+ const ensureTalkTurn = () => {
1641
+ const turn = talk.ensureTurn({ payload: {
1642
+ bridgeId: params.bridgeId,
1643
+ meetingSessionId: params.meetingSessionId
1644
+ } });
1645
+ if (turn.event) rememberTalkEvent(turn.event);
1646
+ return turn.turnId;
1647
+ };
1648
+ const finishOutputAudio = (reason) => {
1649
+ rememberTalkEvent(talk.finishOutputAudio({ payload: {
1650
+ bridgeId: params.bridgeId,
1651
+ reason
1652
+ } }));
1653
+ };
1654
+ const endTalkTurn = (reason = "completed") => {
1655
+ const ended = talk.endTurn({ payload: {
1656
+ bridgeId: params.bridgeId,
1657
+ reason
1658
+ } });
1659
+ if (ended.ok) rememberTalkEvent(ended.event);
1660
+ };
1661
+ emitTalkEvent({
1662
+ type: "session.started",
1663
+ payload: {
1664
+ bridgeId: params.bridgeId,
1665
+ meetingSessionId: params.meetingSessionId,
1666
+ nodeId: params.nodeId
1667
+ }
1668
+ });
1485
1669
  params.logger.info(formatGoogleMeetRealtimeVoiceModelLog({
1486
1670
  strategy,
1487
1671
  provider: resolved.provider,
@@ -1489,70 +1673,35 @@ async function startNodeRealtimeAudioBridge(params) {
1489
1673
  fallbackModel: params.config.realtime.model,
1490
1674
  audioFormat: params.config.chrome.audioFormat
1491
1675
  }));
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);
1676
+ let agentTalkback;
1677
+ agentTalkback = createRealtimeVoiceAgentTalkbackQueue({
1678
+ debounceMs: 900,
1679
+ isStopped: () => stopped,
1680
+ logger: params.logger,
1681
+ logPrefix: "[google-meet] node realtime agent",
1682
+ responseStyle: "Brief, natural spoken answer for a live meeting.",
1683
+ fallbackText: "I hit an error while checking that. Please try again.",
1684
+ consult: ({ question, responseStyle }) => consultOpenClawAgentForGoogleMeet({
1685
+ config: params.config,
1686
+ fullConfig: params.fullConfig,
1687
+ runtime: params.runtime,
1688
+ logger: params.logger,
1689
+ meetingSessionId: params.meetingSessionId,
1690
+ requesterSessionKey: params.requesterSessionKey,
1691
+ args: {
1692
+ question,
1693
+ responseStyle
1694
+ },
1695
+ transcript
1696
+ }),
1697
+ deliver: (text) => {
1698
+ bridge?.sendUserMessage(buildGoogleMeetSpeakExactUserMessage(text));
1547
1699
  }
1548
- };
1700
+ });
1549
1701
  const stop = async () => {
1550
1702
  if (stopped) return;
1551
1703
  stopped = true;
1552
- if (agentConsultDebounceTimer) {
1553
- clearTimeout(agentConsultDebounceTimer);
1554
- agentConsultDebounceTimer = void 0;
1555
- }
1704
+ agentTalkback?.close();
1556
1705
  try {
1557
1706
  bridge?.close();
1558
1707
  } catch (error) {
@@ -1585,6 +1734,16 @@ async function startNodeRealtimeAudioBridge(params) {
1585
1734
  audioSink: {
1586
1735
  isOpen: () => !stopped,
1587
1736
  sendAudio: (audio) => {
1737
+ const turnId = ensureTalkTurn();
1738
+ rememberTalkEvent(talk.startOutputAudio({
1739
+ turnId,
1740
+ payload: { bridgeId: params.bridgeId }
1741
+ }).event);
1742
+ emitTalkEvent({
1743
+ type: "output.audio.delta",
1744
+ turnId,
1745
+ payload: { byteLength: audio.byteLength }
1746
+ });
1588
1747
  const suppression = extendGoogleMeetOutputEchoSuppression({
1589
1748
  audio,
1590
1749
  audioFormat: params.config.chrome.audioFormat,
@@ -1613,6 +1772,7 @@ async function startNodeRealtimeAudioBridge(params) {
1613
1772
  clearAudio: () => {
1614
1773
  lastClearAt = (/* @__PURE__ */ new Date()).toISOString();
1615
1774
  clearCount += 1;
1775
+ finishOutputAudio("clear");
1616
1776
  suppressInputUntil = 0;
1617
1777
  lastOutputPlayableUntilMs = 0;
1618
1778
  params.runtime.nodes.invoke({
@@ -1630,63 +1790,119 @@ async function startNodeRealtimeAudioBridge(params) {
1630
1790
  }
1631
1791
  },
1632
1792
  onTranscript: (role, text, isFinal) => {
1793
+ const turnId = ensureTalkTurn();
1794
+ emitTalkEvent({
1795
+ type: role === "assistant" ? isFinal ? "output.text.done" : "output.text.delta" : isFinal ? "transcript.done" : "transcript.delta",
1796
+ turnId,
1797
+ payload: role === "assistant" ? { text } : {
1798
+ role,
1799
+ text
1800
+ },
1801
+ final: isFinal
1802
+ });
1803
+ if (role === "user" && isFinal) emitTalkEvent({
1804
+ type: "input.audio.committed",
1805
+ turnId,
1806
+ payload: { bridgeId: params.bridgeId },
1807
+ final: true
1808
+ });
1633
1809
  if (isFinal) {
1634
1810
  recordGoogleMeetRealtimeTranscript(transcript, role, text);
1635
- params.logger.info(`[google-meet] node realtime ${role}: ${text}`);
1811
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog(`node realtime ${role}`, text));
1636
1812
  if (role === "user" && strategy === "agent") {
1637
1813
  if (isGoogleMeetLikelyAssistantEchoTranscript({
1638
1814
  transcript,
1639
1815
  text
1640
1816
  })) {
1641
- params.logger.info(`[google-meet] node realtime ignored assistant echo transcript: ${text}`);
1817
+ params.logger.info(formatGoogleMeetTranscriptSummaryLog("node realtime ignored assistant echo transcript", text));
1642
1818
  return;
1643
1819
  }
1644
- enqueueAgentConsultForUserTranscript(text);
1820
+ agentTalkback?.enqueue(text);
1645
1821
  }
1646
1822
  }
1647
1823
  },
1648
1824
  onEvent: (event) => {
1649
1825
  recordGoogleMeetRealtimeEvent(realtimeEvents, event);
1826
+ if (event.type === "input_audio_buffer.speech_started") ensureTalkTurn();
1827
+ else if (event.type === "input_audio_buffer.speech_stopped") {
1828
+ const turnId = talk.activeTurnId;
1829
+ if (!turnId) return;
1830
+ emitTalkEvent({
1831
+ type: "input.audio.committed",
1832
+ turnId,
1833
+ payload: {
1834
+ bridgeId: params.bridgeId,
1835
+ source: event.type
1836
+ },
1837
+ final: true
1838
+ });
1839
+ } else if (event.type === "response.done") {
1840
+ finishOutputAudio("response.done");
1841
+ endTalkTurn("response.done");
1842
+ } else if (event.type === "error") emitTalkEvent({
1843
+ type: "session.error",
1844
+ payload: { message: event.detail ?? "Realtime provider error" },
1845
+ final: true
1846
+ });
1650
1847
  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
1848
  const detail = event.detail ? ` ${event.detail}` : "";
1652
1849
  params.logger.info(`[google-meet] node realtime ${event.direction}:${event.type}${detail}`);
1653
1850
  }
1654
1851
  },
1655
1852
  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({
1853
+ emitTalkEvent({
1854
+ type: "tool.call",
1855
+ turnId: ensureTalkTurn(),
1856
+ itemId: event.itemId,
1857
+ callId: event.callId,
1858
+ payload: {
1859
+ name: event.name,
1860
+ args: event.args
1861
+ }
1862
+ });
1863
+ const turnId = ensureTalkTurn();
1864
+ handleGoogleMeetRealtimeConsultToolCall({
1865
+ strategy,
1866
+ session,
1867
+ event,
1666
1868
  config: params.config,
1667
1869
  fullConfig: params.fullConfig,
1668
1870
  runtime: params.runtime,
1669
1871
  logger: params.logger,
1670
1872
  meetingSessionId: params.meetingSessionId,
1671
1873
  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) });
1874
+ transcript,
1875
+ onTalkEvent: (input) => emitTalkEvent({
1876
+ ...input,
1877
+ turnId: input.turnId ?? turnId
1878
+ })
1678
1879
  });
1679
1880
  },
1680
1881
  onError: (error) => {
1681
1882
  params.logger.warn(`[google-meet] node realtime voice bridge failed: ${formatErrorMessage(error)}`);
1883
+ emitTalkEvent({
1884
+ type: "session.error",
1885
+ payload: { message: formatErrorMessage(error) },
1886
+ final: true
1887
+ });
1682
1888
  stop();
1683
1889
  },
1684
1890
  onClose: (reason) => {
1685
1891
  realtimeReady = false;
1892
+ finishOutputAudio(reason);
1893
+ emitTalkEvent({
1894
+ type: "session.closed",
1895
+ payload: { reason },
1896
+ final: true
1897
+ });
1686
1898
  if (reason === "error") stop();
1687
1899
  },
1688
1900
  onReady: () => {
1689
1901
  realtimeReady = true;
1902
+ emitTalkEvent({
1903
+ type: "session.ready",
1904
+ payload: { bridgeId: params.bridgeId }
1905
+ });
1690
1906
  }
1691
1907
  });
1692
1908
  await bridge.connect();
@@ -1717,6 +1933,11 @@ async function startNodeRealtimeAudioBridge(params) {
1717
1933
  }
1718
1934
  lastInputAt = (/* @__PURE__ */ new Date()).toISOString();
1719
1935
  lastInputBytes += audio.byteLength;
1936
+ emitTalkEvent({
1937
+ type: "input.audio.delta",
1938
+ turnId: ensureTalkTurn(),
1939
+ payload: { byteLength: audio.byteLength }
1940
+ });
1720
1941
  bridge?.sendAudio(audio);
1721
1942
  }
1722
1943
  if (result.closed === true) await stop();
@@ -1754,6 +1975,7 @@ async function startNodeRealtimeAudioBridge(params) {
1754
1975
  suppressedInputBytes,
1755
1976
  ...getGoogleMeetRealtimeTranscriptHealth(transcript),
1756
1977
  ...getGoogleMeetRealtimeEventHealth(realtimeEvents),
1978
+ recentTalkEvents: summarizeGoogleMeetTalkEvents(recentTalkEvents),
1757
1979
  consecutiveInputErrors,
1758
1980
  lastInputError,
1759
1981
  clearCount,
@@ -2289,8 +2511,9 @@ async function openMeetWithBrowserRequest(params) {
2289
2511
  };
2290
2512
  break;
2291
2513
  }
2292
- if (Date.now() <= deadline) await new Promise((resolve) => setTimeout(resolve, 750));
2293
- } while (Date.now() <= deadline);
2514
+ const remainingWaitMs = deadline - Date.now();
2515
+ if (remainingWaitMs > 0) await new Promise((resolve) => setTimeout(resolve, Math.min(750, remainingWaitMs)));
2516
+ } while (Date.now() < deadline);
2294
2517
  return {
2295
2518
  launched: true,
2296
2519
  browser
@@ -3034,12 +3257,14 @@ function buildMeetDtmfSequence(params) {
3034
3257
  if (!/^[0-9]+#?$/.test(compactPin)) throw new Error("pin may only contain digits and an optional trailing #");
3035
3258
  return compactPin.endsWith("#") ? compactPin : `${compactPin}#`;
3036
3259
  }
3260
+ function prefixDtmfWait(sequence, delayMs) {
3261
+ if (!sequence || delayMs <= 0) return sequence;
3262
+ const waitCount = Math.ceil(delayMs / 500);
3263
+ if (waitCount <= 0) return sequence;
3264
+ return `${"w".repeat(waitCount)}${sequence}`;
3265
+ }
3037
3266
  //#endregion
3038
3267
  //#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
3268
  async function createConnectedGatewayClient(config) {
3044
3269
  let client;
3045
3270
  await new Promise((resolve, reject) => {
@@ -3080,42 +3305,44 @@ async function createConnectedGatewayClient(config) {
3080
3305
  });
3081
3306
  return client;
3082
3307
  }
3308
+ function isVoiceCallMissingError(error) {
3309
+ const message = formatErrorMessage(error).toLowerCase();
3310
+ return message.includes("call not found") || message.includes("call is not active");
3311
+ }
3083
3312
  async function joinMeetViaVoiceCallGateway(params) {
3084
3313
  let client;
3085
3314
  try {
3086
3315
  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"})`);
3316
+ params.logger?.info(`[google-meet] Delegating Twilio join to Voice Call (dtmf=${params.dtmfSequence ? "pre-connect" : "none"}, intro=${params.message ? "delayed" : "none"})`);
3088
3317
  const start = await client.request("voicecall.start", {
3089
3318
  to: params.dialInNumber,
3090
- mode: "conversation"
3319
+ mode: "conversation",
3320
+ ...params.dtmfSequence ? { dtmfSequence: params.dtmfSequence } : {},
3321
+ ...params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {},
3322
+ ...params.sessionKey ? { sessionKey: params.sessionKey } : {}
3091
3323
  }, { timeoutMs: params.config.voiceCall.requestTimeoutMs });
3092
3324
  if (!start.callId) throw new Error(start.error || "voicecall.start did not return callId");
3093
3325
  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
- }
3326
+ const dtmfSent = Boolean(params.dtmfSequence);
3327
+ if (dtmfSent) params.logger?.info(`[google-meet] Meet DTMF queued before realtime connect: callId=${start.callId} digits=${params.dtmfSequence?.length ?? 0}`);
3107
3328
  let introSent = false;
3108
3329
  if (params.message) {
3109
3330
  const delayMs = params.dtmfSequence ? params.config.voiceCall.postDtmfSpeechDelayMs : 0;
3110
3331
  if (delayMs > 0) {
3111
3332
  params.logger?.info(`[google-meet] Waiting ${delayMs}ms after Meet DTMF before speaking intro for callId=${start.callId}`);
3112
- await sleep$1(delayMs);
3333
+ await sleep(delayMs);
3334
+ }
3335
+ let spoken;
3336
+ try {
3337
+ spoken = await client.request("voicecall.speak", {
3338
+ callId: start.callId,
3339
+ allowTwimlFallback: false,
3340
+ message: params.message
3341
+ }, { timeoutMs: params.config.voiceCall.requestTimeoutMs });
3342
+ } catch (err) {
3343
+ params.logger?.warn?.(`[google-meet] Skipped intro speech because realtime bridge was not ready: ${formatErrorMessage(err)}`);
3344
+ spoken = { success: false };
3113
3345
  }
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
3346
  if (spoken.success === false) params.logger?.warn?.(`[google-meet] Skipped intro speech because realtime bridge was not ready: ${spoken.error || "voicecall.speak failed"}`);
3120
3347
  else {
3121
3348
  introSent = true;
@@ -3135,7 +3362,20 @@ async function endMeetVoiceCallGatewayCall(params) {
3135
3362
  let client;
3136
3363
  try {
3137
3364
  client = await createConnectedGatewayClient(params.config);
3138
- await client.request("voicecall.end", { callId: params.callId }, { timeoutMs: params.config.voiceCall.requestTimeoutMs });
3365
+ try {
3366
+ await client.request("voicecall.end", { callId: params.callId }, { timeoutMs: params.config.voiceCall.requestTimeoutMs });
3367
+ } catch (err) {
3368
+ if (!isVoiceCallMissingError(err)) throw err;
3369
+ }
3370
+ } finally {
3371
+ await client?.stopAndWait({ timeoutMs: 1e3 });
3372
+ }
3373
+ }
3374
+ async function getMeetVoiceCallGatewayCall(params) {
3375
+ let client;
3376
+ try {
3377
+ client = await createConnectedGatewayClient(params.config);
3378
+ return await client.request("voicecall.status", { callId: params.callId }, { timeoutMs: params.config.voiceCall.requestTimeoutMs });
3139
3379
  } finally {
3140
3380
  await client?.stopAndWait({ timeoutMs: 1e3 });
3141
3381
  }
@@ -3158,6 +3398,9 @@ async function speakMeetViaVoiceCallGateway(params) {
3158
3398
  function nowIso() {
3159
3399
  return (/* @__PURE__ */ new Date()).toISOString();
3160
3400
  }
3401
+ function buildTwilioVoiceCallSessionKey(meetingSessionId) {
3402
+ return `voice:google-meet:${meetingSessionId}`;
3403
+ }
3161
3404
  function normalizeMeetUrl(input) {
3162
3405
  const raw = normalizeOptionalString(input);
3163
3406
  if (!raw) throw new Error("url required");
@@ -3200,12 +3443,12 @@ function resolveProbeTimeoutMs(input, fallback) {
3200
3443
  if (!Number.isFinite(input) || input <= 0) throw new Error("timeoutMs must be a positive number");
3201
3444
  return Math.min(Math.trunc(input), 12e4);
3202
3445
  }
3203
- function sleep(ms) {
3204
- return new Promise((resolve) => setTimeout(resolve, ms));
3205
- }
3206
3446
  function isManagedChromeBrowserSession(session) {
3207
3447
  return Boolean((session.transport === "chrome" || session.transport === "chrome-node") && session.chrome && session.chrome.launched);
3208
3448
  }
3449
+ function noteSession(session, note) {
3450
+ session.notes = [...session.notes.filter((item) => item !== note), note];
3451
+ }
3209
3452
  function evaluateSpeechReadiness(session) {
3210
3453
  if (!isGoogleMeetTalkBackMode$1(session.mode) || !session.chrome) return { ready: true };
3211
3454
  if (!isManagedChromeBrowserSession(session)) {
@@ -3378,15 +3621,20 @@ var GoogleMeetRuntime = class {
3378
3621
  const url = normalizeMeetUrl(request.url);
3379
3622
  const transport = resolveTransport(request.transport, this.params.config);
3380
3623
  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);
3624
+ let reusable = this.list().find((session) => session.state === "active" && isSameMeetUrlForReuse(session.url, url) && session.transport === transport && session.mode === mode);
3625
+ if (reusable?.transport === "twilio") {
3626
+ await this.#refreshTwilioVoiceCallStatus(reusable);
3627
+ if (reusable.state !== "active") reusable = void 0;
3628
+ }
3382
3629
  const speechInstructions = request.message ?? this.params.config.realtime.introMessage;
3383
3630
  if (reusable) {
3384
3631
  await this.#refreshBrowserHealthForChromeSession(reusable);
3385
- reusable.notes = [...reusable.notes.filter((note) => note !== "Reused existing active Meet session."), "Reused existing active Meet session."];
3632
+ noteSession(reusable, "Reused existing active Meet session.");
3386
3633
  reusable.updatedAt = nowIso();
3634
+ const spoken = isGoogleMeetTalkBackMode$1(mode) && speechInstructions ? await this.#speakWhenReady(reusable, speechInstructions) : false;
3387
3635
  return {
3388
3636
  session: reusable,
3389
- spoken: isGoogleMeetTalkBackMode$1(mode) && speechInstructions ? await this.#speakWhenReady(reusable, speechInstructions) : false
3637
+ spoken
3390
3638
  };
3391
3639
  }
3392
3640
  const createdAt = nowIso();
@@ -3444,15 +3692,18 @@ var GoogleMeetRuntime = class {
3444
3692
  } else {
3445
3693
  const dialInNumber = normalizeDialInNumber(request.dialInNumber ?? this.params.config.twilio.defaultDialInNumber);
3446
3694
  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({
3695
+ const rawDtmfSequence = buildMeetDtmfSequence({
3448
3696
  pin: request.pin ?? this.params.config.twilio.defaultPin,
3449
3697
  dtmfSequence: request.dtmfSequence ?? this.params.config.twilio.defaultDtmfSequence
3450
3698
  });
3699
+ const dtmfSequence = request.dtmfSequence || this.params.config.twilio.defaultDtmfSequence ? rawDtmfSequence : prefixDtmfWait(rawDtmfSequence, this.params.config.voiceCall.dtmfDelayMs);
3451
3700
  const voiceCallResult = this.params.config.voiceCall.enabled ? await joinMeetViaVoiceCallGateway({
3452
3701
  config: this.params.config,
3453
3702
  dialInNumber,
3454
3703
  dtmfSequence,
3455
3704
  logger: this.params.logger,
3705
+ ...request.requesterSessionKey ? { requesterSessionKey: request.requesterSessionKey } : {},
3706
+ sessionKey: buildTwilioVoiceCallSessionKey(session.id),
3456
3707
  message: isGoogleMeetTalkBackMode$1(mode) ? request.message ?? this.params.config.voiceCall.introMessage ?? this.params.config.realtime.introMessage : void 0
3457
3708
  }) : void 0;
3458
3709
  delegatedTwilioSpoken = Boolean(voiceCallResult?.introSent);
@@ -3470,7 +3721,7 @@ var GoogleMeetRuntime = class {
3470
3721
  callId: voiceCallResult.callId
3471
3722
  });
3472
3723
  });
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.");
3724
+ 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
3725
  }
3475
3726
  } catch (err) {
3476
3727
  this.params.logger.warn(`[google-meet] join failed: ${formatErrorMessage(err)}`);
@@ -3490,7 +3741,12 @@ var GoogleMeetRuntime = class {
3490
3741
  this.#sessionStops.delete(sessionId);
3491
3742
  this.#sessionSpeakers.delete(sessionId);
3492
3743
  this.#sessionHealth.delete(sessionId);
3493
- await stop();
3744
+ try {
3745
+ await stop();
3746
+ } finally {
3747
+ session.state = "ended";
3748
+ session.updatedAt = nowIso();
3749
+ }
3494
3750
  }
3495
3751
  session.state = "ended";
3496
3752
  session.updatedAt = nowIso();
@@ -3506,11 +3762,21 @@ var GoogleMeetRuntime = class {
3506
3762
  spoken: false
3507
3763
  };
3508
3764
  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
- });
3765
+ try {
3766
+ await speakMeetViaVoiceCallGateway({
3767
+ config: this.params.config,
3768
+ callId: session.twilio.voiceCallId,
3769
+ message: instructions || this.params.config.voiceCall.introMessage || this.params.config.realtime.introMessage || ""
3770
+ });
3771
+ } catch (err) {
3772
+ if (!isVoiceCallMissingError(err)) throw err;
3773
+ this.#markTwilioSessionEnded(session, "Voice Call is no longer active.");
3774
+ return {
3775
+ found: true,
3776
+ spoken: false,
3777
+ session
3778
+ };
3779
+ }
3514
3780
  session.twilio.introSent = true;
3515
3781
  session.updatedAt = nowIso();
3516
3782
  return {
@@ -3553,7 +3819,7 @@ var GoogleMeetRuntime = class {
3553
3819
  const waitMs = Math.min(Math.max(0, this.params.config.chrome.waitForInCallMs), Math.max(0, this.params.config.chrome.joinTimeoutMs));
3554
3820
  const deadline = Date.now() + waitMs;
3555
3821
  while (Date.now() < deadline) {
3556
- await sleep(250);
3822
+ await sleep(Math.min(250, Math.max(0, deadline - Date.now())));
3557
3823
  result = await this.speak(session.id, instructions);
3558
3824
  if (result.spoken) return true;
3559
3825
  const health = result.session?.chrome?.health;
@@ -3672,6 +3938,34 @@ var GoogleMeetRuntime = class {
3672
3938
  });
3673
3939
  return;
3674
3940
  }
3941
+ if (session.transport === "twilio") {
3942
+ await this.#refreshTwilioVoiceCallStatus(session);
3943
+ return;
3944
+ }
3945
+ this.#refreshSpeechReadiness(session);
3946
+ }
3947
+ #markTwilioSessionEnded(session, reason) {
3948
+ session.state = "ended";
3949
+ session.updatedAt = nowIso();
3950
+ this.#sessionStops.delete(session.id);
3951
+ this.#sessionSpeakers.delete(session.id);
3952
+ this.#sessionHealth.delete(session.id);
3953
+ noteSession(session, reason);
3954
+ }
3955
+ async #refreshTwilioVoiceCallStatus(session) {
3956
+ const callId = session.twilio?.voiceCallId;
3957
+ if (!callId || session.state !== "active") {
3958
+ this.#refreshSpeechReadiness(session);
3959
+ return;
3960
+ }
3961
+ try {
3962
+ if ((await getMeetVoiceCallGatewayCall({
3963
+ config: this.params.config,
3964
+ callId
3965
+ })).found === false) this.#markTwilioSessionEnded(session, "Voice Call is no longer active.");
3966
+ } catch (error) {
3967
+ this.params.logger.debug?.(`[google-meet] voice-call status refresh ignored: ${formatErrorMessage(error)}`);
3968
+ }
3675
3969
  this.#refreshSpeechReadiness(session);
3676
3970
  }
3677
3971
  async #refreshBrowserHealthForChromeSession(session, options = {}) {
@@ -3897,13 +4191,13 @@ const googleMeetConfigSchema = {
3897
4191
  advanced: true
3898
4192
  },
3899
4193
  "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.",
4194
+ label: "DTMF Wait Before PIN (ms)",
4195
+ 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
4196
  advanced: true
3903
4197
  },
3904
4198
  "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.",
4199
+ label: "Post-DTMF Speech Delay (ms)",
4200
+ help: "Delay before requesting the realtime intro greeting after Voice Call starts the Twilio leg.",
3907
4201
  advanced: true
3908
4202
  },
3909
4203
  "voiceCall.introMessage": {
@@ -4141,10 +4435,10 @@ async function callGoogleMeetGatewayFromTool(params) {
4141
4435
  }
4142
4436
  }
4143
4437
  async function createMeetFromParams(params) {
4144
- return (await import("./create-mHH3keLH.js")).createMeetFromParams(params);
4438
+ return (await import("./create-C01ezOIH.js")).createMeetFromParams(params);
4145
4439
  }
4146
4440
  async function createAndJoinMeetFromParams(params) {
4147
- return (await import("./create-mHH3keLH.js")).createAndJoinMeetFromParams(params);
4441
+ return (await import("./create-C01ezOIH.js")).createAndJoinMeetFromParams(params);
4148
4442
  }
4149
4443
  async function resolveGoogleMeetTokenFromParams(config, raw) {
4150
4444
  const { resolveGoogleMeetAccessToken } = await import("./oauth-BJwzuzT-.js");
@@ -4236,7 +4530,7 @@ async function exportGoogleMeetBundleFromParams(config, raw) {
4236
4530
  lateAfterMinutes: resolved.lateAfterMinutes,
4237
4531
  earlyBeforeMinutes: resolved.earlyBeforeMinutes
4238
4532
  })]);
4239
- const { buildGoogleMeetExportManifest, googleMeetExportFileNames, writeMeetExportBundle } = await import("./cli-CP1gp7Wl.js");
4533
+ const { buildGoogleMeetExportManifest, googleMeetExportFileNames, writeMeetExportBundle } = await import("./cli-Dgpp3OTQ.js");
4240
4534
  const calendarId = normalizeOptionalString(raw.calendarId);
4241
4535
  const request = {
4242
4536
  ...resolved.meeting ? { meeting: resolved.meeting } : {},
@@ -4652,7 +4946,7 @@ var google_meet_default = definePluginEntry({
4652
4946
  handle: handleGoogleMeetNodeHostCommand
4653
4947
  });
4654
4948
  api.registerCli(async ({ program }) => {
4655
- const { registerGoogleMeetCli } = await import("./cli-CP1gp7Wl.js");
4949
+ const { registerGoogleMeetCli } = await import("./cli-Dgpp3OTQ.js");
4656
4950
  registerGoogleMeetCli({
4657
4951
  program,
4658
4952
  config,