openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
6,980 lines • 274 kB
JavaScript
import { c as normalizeOptionalLowercaseString, h as readNonEmptyStringPreservingWhitespace, l as normalizeOptionalString, m as readNonBlankString } from "../string-coerce-1VuiLaaR.mjs";
import { P as resolvePositiveTimerTimeoutMs, a as addTimerTimeoutGraceMs, f as asSafeIntegerInRange, u as asPositiveFiniteNumber } from "../number-coercion-CuVhdCBs.mjs";
import { t as coerceErrorMessage } from "../error-coercion-CvJK4lmH.mjs";
import { t as createDeferredCore } from "../deferred-D0La5CRk.mjs";
import { r as createLazyRuntimeModule } from "../lazy-runtime-CJrzFzqU.mjs";
import { a as asOptionalRecord, o as asRecord } from "../record-coerce-TIsquoVR.mjs";
import { s as normalizeOptionalTrimmedStringList, y as uniqueStrings } from "../string-normalization-CECOqf64.mjs";
import { r as truncateUtf16Safe } from "../utf16-slice-D_ngcYKd.mjs";
import { h as sleep } from "../utils-DgZn2dLu.mjs";
import { s as raceWithTimeout } from "../src-D1BHGNTj.mjs";
import { t as formatErrorMessage } from "../errors-O33D01XW.mjs";
import { Fn as object, Jn as string, Lt as boolean, Nt as array, Pn as number, Sn as looseObject, or as union, sr as unknown } from "../schemas-BOYIvvln.mjs";
import { a as PluginStateStoreError } from "../openclaw-state-worker-error-Cmtk9G4_.mjs";
import { n as truncateUtf8Suffix } from "../utf8-truncate-_hf7tp13.mjs";
import { t as KeyedAsyncQueue } from "../keyed-async-queue-CTreGrmR.mjs";
import { n as normalizeAgentId } from "../agent-id-D1-Q6vR4.mjs";
import { g as resolveDefaultAgentId } from "../agent-scope-config-CDYpzA0r.mjs";
import { M as parseAgentSessionKey } from "../session-key-C09ow4h0.mjs";
import { n as callGatewayFromCli } from "../gateway-rpc-0aouJxQt.mjs";
import { r as onDecodedOutput } from "../decoded-output-QlGZ46Aj.mjs";
import "../plugin-state-store.kernel-DUORZ5yM.mjs";
import { t as ErrorCodes } from "../gateway-error-details-DIefe7Zy.mjs";
import { f as errorShape } from "../error-codes-Dh56LniQ.mjs";
import { t as canonicalizeBase64 } from "../base64-CU53EX1R.mjs";
import { t as jsonResult } from "../tool-results-BCM3fdVS.mjs";
import { c as readNonNegativeIntegerParam, u as readPositiveIntegerParam } from "../common-Dyxcwsy8.mjs";
import { t as resolveTranscriptsConfig } from "../config-B42acf6V.mjs";
import { n as getRealtimeTranscriptionProvider, r as listRealtimeTranscriptionProviders } from "../provider-registry-BfJbDyoZ.mjs";
import { S as resolveRealtimeVoiceBargeIn, d as resolveConfiguredRealtimeVoiceProvider, t as createRealtimeVoiceSessionHarness, y as consultRealtimeVoiceAgent } from "../realtime-session-harness--UjfIPHf.mjs";
import { A as REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, D as resolveRealtimeVoiceAgentConsultTools, E as resolveRealtimeVoiceAgentConsultToolPolicy, O as resolveRealtimeVoiceAgentConsultToolsAllow, k as REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ, x as buildRealtimeVoiceAgentConsultWorkingResponse } from "../agent-run-control-shared-HQwazRP4.mjs";
import { a as convertPcmToMulaw8k, i as readPcm16AudioStats, l as resamplePcm, n as createSpeechThresholdGate, r as isRealtimeVoiceAudioAudible, s as mulawToPcm } from "../audio-energy-DXFUFDyy.mjs";
import { a as createMeetingStatusCallSource, i as createMeetingStatusPreludeSource, n as createMeetingPageScripts } from "../page-script-source-BCpP1rNj.mjs";
import { a as snapshotMeetingObservation, i as normalizeMeetingObservationProvenance, o as snapshotMeetingTranscript, r as meetingCaptionParticipationSources, s as meetingCaptionSourceSchema, t as MeetingSessionTranscriptStore } from "../session-transcript-store-FjmK_UEB.mjs";
import { t as createMeetingTranscriptSourceProvider } from "../transcripts-bridge-BhkdwJIv.mjs";
import { t as registerMeetingPluginCli } from "../plugin-cli-B8jd8x_k.mjs";
import { spawn, spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { Readable } from "node:stream";
import { Type } from "typebox";
//#region src/meeting-bot/realtime-audio-format.ts
function resolveMeetingRealtimeAudioFormat(audioFormat) {
return audioFormat === "g711-ulaw-8khz" ? REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ : REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ;
}
function convertMeetingBridgeAudioForStt(audio, audioFormat) {
if (audioFormat === "g711-ulaw-8khz") return audio;
return convertPcmToMulaw8k(audio, 24e3);
}
function convertMeetingTtsAudioForBridge(audio, sampleRate, audioFormat, outputFormat, platformName = "meeting platform") {
const sourceFormat = sourceTelephonyTtsFormat(outputFormat, platformName);
if (audioFormat === "g711-ulaw-8khz" && sourceFormat === "mulaw" && sampleRate === 8e3) return audio;
const pcm = decodeMeetingTelephonyTtsAudio(audio, sourceFormat);
return audioFormat === "g711-ulaw-8khz" ? convertPcmToMulaw8k(pcm, sampleRate) : resamplePcm(pcm, sampleRate, 24e3);
}
function sourceTelephonyTtsFormat(outputFormat, platformName) {
const normalized = outputFormat?.trim().toLowerCase().replaceAll("_", "-") ?? "";
if (!normalized || normalized === "pcm" || normalized.startsWith("pcm-") || normalized.includes("pcm16") || normalized.includes("16bit-mono-pcm")) return "pcm";
if (normalized.includes("mu-law") || normalized.includes("mulaw") || normalized.includes("ulaw")) return "mulaw";
if (normalized.includes("a-law") || normalized.includes("alaw")) return "alaw";
throw new Error(`Unsupported telephony TTS output format for ${platformName}: ${outputFormat}`);
}
function decodeMeetingTelephonyTtsAudio(audio, sourceFormat) {
switch (sourceFormat) {
case "pcm": return audio;
case "mulaw": return mulawToPcm(audio);
case "alaw": return alawToPcm(audio);
}
throw new Error("Unsupported telephony TTS output format for meeting platform");
}
function alawToPcm(alaw) {
const pcm = Buffer.alloc(alaw.length * 2);
for (let index = 0; index < alaw.length; index += 1) pcm.writeInt16LE(alawByteToLinear(alaw[index] ?? 0), index * 2);
return pcm;
}
function alawByteToLinear(value) {
const aLaw = value ^ 85;
const sign = aLaw & 128;
const exponent = (aLaw & 112) >> 4;
const mantissa = aLaw & 15;
const sample = exponent === 0 ? (mantissa << 4) + 8 : (mantissa << 4) + 264 << exponent - 1;
return sign ? sample : -sample;
}
//#endregion
//#region src/meeting-bot/realtime-engine-support.ts
const MEETING_REALTIME_CANCELLATION_RACE_DETAIL = "Cancellation failed: no active response found";
function meetingOutputBytesPerMs(audioFormat) {
return audioFormat === "g711-ulaw-8khz" ? 8 : 48;
}
function resolveMeetingRealtimeProvider(params) {
const providerId = params.config.realtime.voiceProvider ?? params.config.realtime.provider;
return resolveConfiguredRealtimeVoiceProvider({
configuredProviderId: providerId,
providerConfigs: params.config.realtime.providers,
cfg: params.fullConfig,
agentId: params.config.realtime.agentId,
surface: "gateway-relay",
useProviderDefaultModel: true,
providers: params.providers,
defaultModel: params.config.realtime.model,
noRegisteredProviderMessage: "No configured realtime voice provider registered"
});
}
function resolveMeetingRealtimeTranscriptionProvider(params) {
const providers = params.providers ?? listRealtimeTranscriptionProviders(params.fullConfig);
if (providers.length === 0) throw new Error("No configured realtime transcription provider registered");
const providerId = params.config.realtime.transcriptionProvider ?? params.config.realtime.provider;
const provider = (providerId ? params.providers?.find((entry) => entry.id === providerId || entry.aliases?.includes(providerId)) ?? getRealtimeTranscriptionProvider(providerId, params.fullConfig) : void 0) ?? providers[0];
if (!provider) throw new Error("No configured realtime transcription provider registered");
const rawConfig = providerId ? params.config.realtime.providers[providerId] ?? params.config.realtime.providers[provider.id] ?? {} : params.config.realtime.providers[provider.id] ?? {};
const providerConfig = provider.resolveConfig ? provider.resolveConfig({
cfg: params.fullConfig,
rawConfig
}) : rawConfig;
if (!provider.isConfigured({
cfg: params.fullConfig,
providerConfig
})) throw new Error(`Realtime transcription provider "${provider.id}" is not configured`);
return {
provider,
providerConfig
};
}
function buildMeetingSpeakExactUserMessage(text) {
return ["Speak this exact OpenClaw answer to the meeting, without adding, removing, or rephrasing words.", `Answer: ${JSON.stringify(text)}`].join("\n");
}
function formatLogValue(value) {
return (value ? truncateUtf16Safe(value.replace(/\s+/g, "_"), 180) : void 0) || "unknown";
}
function resolveProviderModelForLog(params) {
return normalizeOptionalString(params.providerConfig.model) ?? normalizeOptionalString(params.providerConfig.modelId) ?? normalizeOptionalString(params.fallbackModel) ?? normalizeOptionalString(params.provider.defaultModel) ?? "provider-default";
}
function formatMeetingRealtimeVoiceModelLog(params) {
return [
`${params.logScope} realtime voice bridge starting: strategy=${formatLogValue(params.strategy)}`,
`provider=${formatLogValue(params.provider.id)}`,
`model=${formatLogValue(resolveProviderModelForLog(params))}`,
`audioFormat=${formatLogValue(params.audioFormat)}`
].join(" ");
}
function formatMeetingAgentAudioModelLog(params) {
return [
`${params.logScope} agent audio bridge starting: transcriptionProvider=${formatLogValue(params.provider.id)}`,
`transcriptionModel=${formatLogValue(resolveProviderModelForLog(params))}`,
"tts=telephony",
`audioFormat=${formatLogValue(params.audioFormat)}`
].join(" ");
}
function formatMeetingAgentTtsResultLog(logScope, prefix, result) {
return [
`${logScope} ${prefix} TTS: provider=${formatLogValue(result.provider)}`,
`model=${formatLogValue(result.providerModel)}`,
`voice=${formatLogValue(result.providerVoice)}`,
`outputFormat=${formatLogValue(result.outputFormat)}`,
`sampleRate=${result.sampleRate ?? "unknown"}`,
...result.fallbackFrom ? [`fallbackFrom=${formatLogValue(result.fallbackFrom)}`] : []
].join(" ");
}
function formatMeetingTranscriptSummaryLog(logScope, prefix, text) {
return `${logScope} ${prefix}: chars=${text.length}`;
}
function normalizeMeetingTtsPromptText(text) {
const trimmed = text?.trim();
if (!trimmed) return;
const sayExactly = trimmed.match(/^say exactly:\s*(?<text>.+)$/is)?.groups?.text?.trim();
if (sayExactly) return sayExactly.replace(/^["']|["']$/g, "").trim() || trimmed;
return trimmed;
}
function createMeetingRealtimeLifecycleHandlers(params) {
const onEvent = (event) => {
if (event.direction === "server" && event.type === "session.created") params.lifecycle.continuityResetActive = false;
if (event.direction === "client" && event.type === "session.continuity.reset") {
if (params.lifecycle.continuityResetActive) return;
params.lifecycle.continuityResetActive = true;
params.lifecycle.realtimeReady = false;
params.outputOwner.reset();
params.lifecycle.outputGenerationActive = false;
params.resetToolContinuity(event.type);
const turnId = params.harness.talk.activeTurnId;
params.invalidateOutputPlayback();
params.harness.flushOutput(params.clearOutputPlayback);
params.harness.finishOutputAudio(event.type);
if (turnId) params.harness.talk.cancelTurn({
turnId,
payload: {
...params.outputTalkPayload,
reason: event.type
}
});
return;
}
params.outputOwner.noteEvent(event);
if (event.type === "input_audio_buffer.speech_started") params.harness.ensureTurn();
else if (event.type === "input_audio_buffer.speech_stopped") {
const turnId = params.harness.talk.activeTurnId;
if (!turnId) return;
params.harness.emit({
type: "input.audio.committed",
turnId,
payload: {
...params.outputTalkPayload,
source: event.type
},
final: true
});
} else if (event.type === "error" && event.detail === MEETING_REALTIME_CANCELLATION_RACE_DETAIL) {
if (params.outputOwner.clearBlocked()) {
params.lifecycle.outputGenerationActive = false;
params.harness.finishOutputAudio(event.type);
}
} else if (event.type === "error") params.harness.emit({
type: "session.error",
payload: { message: event.detail ?? "Realtime provider error" },
final: true
});
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") {
const detail = event.detail ? ` ${event.detail}` : "";
params.logger.info(`${params.logScope} ${params.realtimeLogScope} ${event.direction}:${event.type}${detail}`);
}
};
const onResponseDone = (outcome) => {
if (!params.outputOwner.terminal(outcome.responseId)) return;
params.lifecycle.outputGenerationActive = false;
if (outcome.status === "failed" || outcome.status === "incomplete") params.logger.warn(`${params.logScope} ${params.realtimeLogScope} response ${outcome.status}: ${outcome.message}`);
};
return {
onEvent,
onResponseDone
};
}
//#endregion
//#region src/meeting-bot/realtime-output-owner.ts
const STALE_RESPONSE_LIMIT = 16;
const AUDIO_DELTA_EVENTS = /* @__PURE__ */ new Set([
"conversation.output_audio.delta",
"response.audio.delta",
"response.output_audio.delta"
]);
const OUTPUT_MAX_PENDING_MS = 2e3;
const OUTPUT_MAX_WRITE_MS = 500;
const OUTPUT_MAX_PENDING_FRAMES = 256;
/** Serializes native playback writes and fences queued audio across interruption. */
function createMeetingRealtimeOutputQueue(params) {
let stopped = false;
let generation = 0;
let writePhase = "idle";
let clearPending = 0;
let clearAfterActive = false;
let pendingBytes = 0;
let pendingFrames = 0;
let pendingAudibleFrames = 0;
let playableUntilMs = 0;
let audibleUntilMs = 0;
let clearCount = 0;
let lastClearAt;
let clearTail = Promise.resolve();
const queue = [];
const maxPendingBytes = params.bytesPerMs * OUTPUT_MAX_PENDING_MS;
const maxWriteBytes = params.bytesPerMs * OUTPUT_MAX_WRITE_MS;
const reset = () => {
generation += 1;
queue.length = 0;
pendingBytes = 0;
pendingFrames = 0;
pendingAudibleFrames = 0;
playableUntilMs = 0;
audibleUntilMs = 0;
};
const clear = () => {
if (stopped) return;
clearCount += 1;
lastClearAt = (/* @__PURE__ */ new Date()).toISOString();
clearPending += 1;
clearTail = clearTail.then(async () => {
if (!stopped) await params.transport.clearOutput();
}).catch((error) => params.onFailure("audio output clear", error)).finally(() => {
clearPending -= 1;
pump();
});
};
const pump = () => {
if (stopped || writePhase !== "idle" || clearPending > 0) return;
const next = queue.shift();
if (!next) return;
const batch = [next];
let batchBytes = next.audio.byteLength;
let batchFrames = 1;
let batchAudibleFrames = Number(next.audible);
while (batchBytes < maxWriteBytes) {
const queued = queue[0];
if (!queued || queued.beginsOutput || queued.audio.byteLength > maxWriteBytes - batchBytes) break;
queue.shift();
batch.push(queued);
batchBytes += queued.audio.byteLength;
batchFrames += 1;
batchAudibleFrames += Number(queued.audible);
}
const audio = batch.length === 1 ? next.audio : Buffer.concat(batch.map((entry) => entry.audio), batchBytes);
writePhase = "scheduled";
Promise.resolve().then(async () => {
if (stopped || next.generation !== generation) return;
if (next.beginsOutput) params.transport.beginOutput?.();
writePhase = "writing";
await params.transport.writeOutput(audio);
if (!stopped && next.generation === generation) {
playableUntilMs = Math.max(Date.now(), playableUntilMs);
for (const entry of batch) {
playableUntilMs += entry.audio.byteLength / params.bytesPerMs;
if (entry.audible) audibleUntilMs = playableUntilMs;
}
}
}).catch((error) => {
if (!stopped && next.generation === generation) params.onFailure("audio output", error);
}).finally(() => {
writePhase = "idle";
if (next.generation === generation) {
pendingBytes -= batchBytes;
pendingFrames -= batchFrames;
pendingAudibleFrames -= batchAudibleFrames;
}
if (clearAfterActive && !stopped) {
clearAfterActive = false;
clear();
return;
}
pump();
});
};
return {
enqueue(audio, audible, beginsOutput) {
if (stopped || audio.byteLength > maxPendingBytes - pendingBytes || pendingFrames >= OUTPUT_MAX_PENDING_FRAMES) return false;
pendingBytes += audio.byteLength;
pendingFrames += 1;
pendingAudibleFrames += Number(audible);
queue.push({
audio,
audible,
beginsOutput,
generation
});
pump();
return true;
},
invalidate() {
clearAfterActive ||= writePhase === "writing";
reset();
},
clear,
stop() {
stopped = true;
clearAfterActive = false;
reset();
},
pending: () => ({
pendingBytes,
pendingFrames
}),
hasUnplayedAudibleAudio: () => pendingAudibleFrames > 0 || Date.now() < audibleUntilMs,
getHealth: () => ({
clearCount,
lastClearAt
})
};
}
function createMeetingRealtimeOutputOwner() {
let nextResponseId;
let announcedResponseId;
let currentResponseId;
let blocked;
const staleResponseIds = /* @__PURE__ */ new Set();
const rememberStale = (responseId) => {
staleResponseIds.delete(responseId);
staleResponseIds.add(responseId);
while (staleResponseIds.size > STALE_RESPONSE_LIMIT) {
const oldest = staleResponseIds.values().next().value;
if (!oldest) break;
staleResponseIds.delete(oldest);
}
};
return {
accept(responseId) {
if (responseId && staleResponseIds.has(responseId)) return false;
if (blocked) {
if (!blocked.responseId || !responseId || responseId === blocked.responseId) return false;
blocked = void 0;
}
if (responseId) currentResponseId = responseId;
return true;
},
block() {
if (blocked) return {
blocked: false,
token: blocked.token
};
const token = Symbol("meeting-realtime-output-blocked");
const responseId = currentResponseId ?? announcedResponseId;
blocked = {
...responseId ? { responseId } : {},
token
};
if (responseId) rememberStale(responseId);
nextResponseId = void 0;
return {
blocked: true,
token
};
},
clearBlocked() {
if (!blocked) return false;
blocked = void 0;
return true;
},
isBlockedBy(token) {
return blocked?.token === token;
},
noteEvent(event) {
if (event.direction === "server" && event.type === "response.created" && event.responseId) {
announcedResponseId = event.responseId;
nextResponseId = void 0;
return;
}
nextResponseId = event.direction === "server" && AUDIO_DELTA_EVENTS.has(event.type) ? event.responseId ?? announcedResponseId : void 0;
},
providerClear() {
if (blocked) {
if (!blocked.responseId) blocked = void 0;
return false;
}
const responseId = currentResponseId ?? announcedResponseId;
if (responseId) {
blocked = {
responseId,
token: Symbol("meeting-realtime-output-blocked")
};
rememberStale(responseId);
}
nextResponseId = void 0;
return true;
},
reset() {
nextResponseId = void 0;
announcedResponseId = void 0;
currentResponseId = void 0;
blocked = void 0;
staleResponseIds.clear();
},
takeNextResponseId() {
const responseId = nextResponseId ?? announcedResponseId;
nextResponseId = void 0;
return responseId;
},
terminal(responseId) {
if (!responseId || !blocked?.responseId || blocked.responseId === responseId) blocked = void 0;
if (!responseId || announcedResponseId === responseId) announcedResponseId = void 0;
if (responseId && currentResponseId && currentResponseId !== responseId) return false;
currentResponseId = void 0;
return true;
}
};
}
//#endregion
//#region src/meeting-bot/realtime-tool-continuity.ts
const meetingRealtimeToolAbortSignals = /* @__PURE__ */ new WeakMap();
function readMeetingRealtimeToolAbortSignal(session) {
return meetingRealtimeToolAbortSignals.get(session);
}
function createMeetingRealtimeToolContinuity(handleToolCall) {
let epoch = 0;
const activeControllers = /* @__PURE__ */ new Set();
const reset = (reason) => {
epoch += 1;
for (const controller of activeControllers) controller.abort(reason);
activeControllers.clear();
};
const runConsult = async (request, consult) => {
const controller = new AbortController();
activeControllers.add(controller);
const signal = request.signal ? AbortSignal.any([controller.signal, request.signal]) : controller.signal;
try {
signal.throwIfAborted();
const result = await consult({
...request,
signal
});
signal.throwIfAborted();
return result;
} finally {
activeControllers.delete(controller);
}
};
const run = (params) => {
const callEpoch = epoch;
const controller = new AbortController();
activeControllers.add(controller);
const isActive = () => !controller.signal.aborted && callEpoch === epoch;
const turnId = params.harness.ensureTurn();
params.harness.emit({
type: "tool.call",
turnId,
itemId: params.call.event.itemId,
callId: params.call.event.callId,
payload: {
name: params.call.event.name,
args: params.call.event.args
}
});
const guardedSession = Object.create(params.session);
meetingRealtimeToolAbortSignals.set(guardedSession, controller.signal);
guardedSession.submitToolResult = (callId, result, options) => {
if (!isActive()) return;
return params.session.submitToolResult(callId, result, options);
};
return handleToolCall({
...params.call,
session: guardedSession,
onTalkEvent: (event) => {
if (isActive()) params.harness.emit({
...event,
turnId: event.turnId ?? turnId
});
}
}).catch((error) => {
if (isActive()) throw error;
}).finally(() => {
meetingRealtimeToolAbortSignals.delete(guardedSession);
activeControllers.delete(controller);
});
};
return {
reset,
run,
runConsult
};
}
const MEETING_OUTPUT_ECHO_SUPPRESSION_TAIL_MS = 3e3;
const MEETING_TRANSCRIPT_ECHO_LOOKBACK_MS = 45e3;
async function startMeetingRealtimeEngine(params) {
let stopped = false;
let stopPromise;
let bridgeClosed = false;
let transportStopped = false;
let transportDisposed = false;
let bridge;
const lifecycle = {
realtimeReady: false,
outputGenerationActive: false,
continuityResetActive: false
};
const outputOwner = createMeetingRealtimeOutputOwner();
const toolContinuity = createMeetingRealtimeToolContinuity(params.handleToolCall);
const realtimeLogScope = params.logPrefix ? `${params.logPrefix} realtime` : "realtime";
const audioFormat = resolveMeetingRealtimeAudioFormat(params.config.chrome.audioFormat);
const outputQueue = createMeetingRealtimeOutputQueue({
transport: params.transport,
bytesPerMs: meetingOutputBytesPerMs(params.config.chrome.audioFormat),
onFailure: (source, error) => {
params.logger.warn(`${params.platform.logScope} ${realtimeLogScope} ${source} failed: ${formatErrorMessage(error)}`);
stopAfterFailure(source);
}
});
const stop = async () => {
if (!stopped) {
stopped = true;
outputOwner.reset();
outputQueue.stop();
lifecycle.outputGenerationActive = false;
toolContinuity.reset("meeting realtime stopped");
harness.talkback?.close();
harness.forcedConsults.clear();
}
if (stopPromise) {
await stopPromise;
return;
}
const cleanup = Promise.resolve().then(async () => {
if (!bridgeClosed) try {
await bridge?.close();
} catch (error) {
params.logger.debug?.(`${params.platform.logScope} ${realtimeLogScope}${params.logPrefix ? "" : " voice"} bridge close ignored: ${formatErrorMessage(error)}`);
} finally {
bridgeClosed = true;
harness.close();
}
let cleanupError;
if (!transportStopped) try {
await params.transport.stop();
transportStopped = true;
} catch (error) {
cleanupError = error;
}
if (!transportDisposed) try {
await params.transport.dispose();
transportDisposed = true;
} catch (error) {
cleanupError ??= error;
}
if (cleanupError) throw cleanupError instanceof Error ? cleanupError : new Error("Meeting realtime transport cleanup failed", { cause: cleanupError });
});
stopPromise = cleanup;
try {
await cleanup;
} finally {
if (stopPromise === cleanup) stopPromise = void 0;
}
};
const stopAfterFailure = (source) => {
stop().catch((error) => {
params.logger.warn(`${params.platform.logScope} ${realtimeLogScope} ${source} cleanup failed: ${formatErrorMessage(error)}`);
});
};
const invalidateOutputPlayback = () => {
outputQueue.invalidate();
lifecycle.outputGenerationActive = false;
};
const invalidateAndClearOutputPlayback = () => {
blockOutput();
outputQueue.clear();
};
const blockOutput = () => {
const result = outputOwner.block();
invalidateOutputPlayback();
return result;
};
const handleOutputBackpressure = () => {
const { pendingBytes, pendingFrames } = outputQueue.pending();
const block = bridge?.bridge.outputAudioMode === "continuous" ? void 0 : blockOutput();
if (block && !block.blocked) return;
params.logger.warn(`${params.platform.logScope} ${realtimeLogScope} audio output backpressured: pendingBytes=${pendingBytes} pendingFrames=${pendingFrames}`);
if (!block) invalidateOutputPlayback();
harness.flushOutput(outputQueue.clear);
harness.finishOutputAudio("output-backpressure");
if (!block) return;
queueMicrotask(() => {
if (stopped || !outputOwner.isBlockedBy(block.token)) return;
harness.handleBargeIn({
audioPlaybackActive: true,
force: true
}, () => {});
});
};
const startHumanBargeInMonitor = () => {
if (!params.transport.startBargeInMonitor || !resolveRealtimeVoiceBargeIn({
configuredBargeIn: void 0,
interruptResponseOnInputAudio: void 0,
capabilities: resolved.capabilities,
outputAudioMode: bridge?.bridge.outputAudioMode
})) return;
params.transport.startBargeInMonitor(() => {
if (stopped || !harness.outputActivity.isInterruptible()) return false;
const now = Date.now();
const playbackActive = harness.isOutputPlaybackWindowActive();
const lastOutputAudioAt = harness.outputActivity.snapshot().lastAudioAt;
if (!playbackActive && (lastOutputAudioAt === void 0 || now - lastOutputAudioAt > 1e3)) return false;
harness.handleBargeIn({ audioPlaybackActive: true }, invalidateAndClearOutputPlayback);
return true;
});
};
const resolved = resolveMeetingRealtimeProvider({
config: params.config,
fullConfig: params.fullConfig,
providers: params.providers
});
const strategy = params.config.realtime.strategy;
params.logger.info(formatMeetingRealtimeVoiceModelLog({
logScope: params.platform.logScope,
strategy,
provider: resolved.provider,
providerConfig: resolved.providerConfig,
fallbackModel: params.config.realtime.model,
audioFormat: params.config.chrome.audioFormat
}));
const meetingTalkPayload = params.talkContext ? {
bridgeId: params.talkContext.bridgeId,
meetingSessionId: params.meetingSessionId
} : { meetingSessionId: params.meetingSessionId };
const outputTalkPayload = params.talkContext ? { bridgeId: params.talkContext.bridgeId } : { meetingSessionId: params.meetingSessionId };
const reasonTalkPayload = (reason) => params.talkContext ? {
bridgeId: params.talkContext.bridgeId,
reason
} : { reason };
const harness = createRealtimeVoiceSessionHarness({
talk: {
sessionId: params.talkSessionId ?? `${params.platform.sessionIdPrefix}:${params.meetingSessionId}:command-realtime`,
mode: "realtime",
transport: "gateway-relay",
brain: strategy === "bidi" && !resolved.capabilities?.handlesAgentConsult ? "direct-tools" : "agent-consult",
provider: resolved.provider.id
},
talkPayloads: {
turnStarted: () => meetingTalkPayload,
turnEnded: reasonTalkPayload,
inputAudioDelta: (audio) => ({ byteLength: audio.byteLength }),
outputAudioStarted: () => outputTalkPayload,
outputAudioDelta: (audio) => ({ byteLength: audio.byteLength }),
outputAudioDone: reasonTalkPayload
},
echoSuppression: params.transport.inputAudioIsolated ? void 0 : {
bytesPerMs: meetingOutputBytesPerMs(params.config.chrome.audioFormat),
tailMs: MEETING_OUTPUT_ECHO_SUPPRESSION_TAIL_MS,
transcriptLookbackMs: MEETING_TRANSCRIPT_ECHO_LOOKBACK_MS
},
talkback: {
debounceMs: 900,
logger: params.logger,
logPrefix: `${params.platform.logScope} ${realtimeLogScope} agent`,
responseStyle: "Brief, natural spoken answer for a live meeting.",
fallbackText: "I hit an error while checking that. Please try again.",
consult: ({ question, responseStyle, signal }) => params.consultAgent({
meetingSessionId: params.meetingSessionId,
requesterSessionKey: params.requesterSessionKey,
args: {
question,
responseStyle
},
transcript: harness.transcript,
abortSignal: signal
}),
deliver: (text) => {
bridge?.sendUserMessage(buildMeetingSpeakExactUserMessage(text));
}
}
});
harness.emit({
type: "session.started",
payload: params.talkContext ? {
...meetingTalkPayload,
nodeId: params.talkContext.nodeId
} : meetingTalkPayload
});
params.transport.onFatal(() => {
stopAfterFailure("audio transport");
});
if (stopped) throw new Error(`${params.platform.displayName} audio transport failed before realtime provider setup`);
const lifecycleHandlers = createMeetingRealtimeLifecycleHandlers({
clearOutputPlayback: outputQueue.clear,
lifecycle,
harness,
invalidateOutputPlayback,
logger: params.logger,
logScope: params.platform.logScope,
outputOwner,
outputTalkPayload,
realtimeLogScope,
resetToolContinuity: (reason) => toolContinuity.reset(reason)
});
try {
const requireIsolatedInput = () => {
if (!params.transport.inputAudioIsolated) throw new Error(`${params.platform.displayName} native live voice requires isolated meeting audio input. Remove chrome.audioInputCommand to use managed browser capture, which must be available before connecting.`);
};
if (resolved.capabilities?.handlesInputAudioBargeIn === true && resolved.capabilities.supportsBargeIn === false) requireIsolatedInput();
bridge = harness.createBridge({
provider: resolved.provider,
capabilities: resolved.capabilities,
cfg: params.fullConfig,
agentId: params.config.realtime.agentId,
providerConfig: resolved.providerConfig,
audioFormat,
instructions: params.config.realtime.instructions,
initialGreetingInstructions: params.config.realtime.introMessage,
autoRespondToAudio: strategy === "bidi",
triggerGreetingOnReady: false,
markStrategy: "ack-immediately",
tools: strategy === "bidi" && !resolved.capabilities?.handlesAgentConsult ? params.tools : [],
...resolved.capabilities?.handlesAgentConsult ? { runAgentConsult: (request) => {
if (stopped) throw new Error("Meeting realtime session is closed");
return toolContinuity.runConsult(request, ({ prompt, signal }) => {
if (params.config.realtime.toolPolicy === "none") throw new Error("Agent delegation is disabled by the meeting tool policy");
return params.consultAgent({
meetingSessionId: params.meetingSessionId,
requesterSessionKey: params.requesterSessionKey,
args: { question: prompt },
transcript: harness.transcript,
abortSignal: signal
});
});
} } : {},
audioSink: {
isOpen: () => !stopped,
sendAudio: (audio) => {
const responseId = outputOwner.takeNextResponseId();
const continuous = bridge?.bridge.outputAudioMode === "continuous";
const audible = !continuous || isRealtimeVoiceAudioAudible(audio, audioFormat);
if (!audible && !outputQueue.hasUnplayedAudibleAudio()) {
if (lifecycle.outputGenerationActive) {
lifecycle.outputGenerationActive = false;
harness.finishOutputAudio("silence");
}
return;
}
if (stopped || !continuous && !outputOwner.accept(responseId)) return;
if (!outputQueue.enqueue(audio, audible, !lifecycle.outputGenerationActive)) {
handleOutputBackpressure();
return;
}
lifecycle.outputGenerationActive = true;
harness.outputActivity.markPlaybackStarted();
harness.recordOutputAudio(audio);
},
clearAudio: () => {
const continuous = bridge?.bridge.outputAudioMode === "continuous";
if (!continuous && !outputOwner.providerClear()) return;
if (continuous) outputOwner.reset();
invalidateOutputPlayback();
harness.flushOutput(outputQueue.clear);
harness.finishOutputAudio("clear");
}
},
onTranscript: (role, text, isFinal) => {
const turnId = harness.ensureTurn();
const eventType = role === "assistant" ? isFinal ? "output.text.done" : "output.text.delta" : isFinal ? "transcript.done" : "transcript.delta";
const payload = role === "assistant" ? { text } : {
role,
text
};
harness.emit({
type: eventType,
turnId,
payload,
final: isFinal
});
if (role === "user" && isFinal) harness.emit({
type: "input.audio.committed",
turnId,
payload: outputTalkPayload,
final: true
});
if (!isFinal) return;
params.logger.info(formatMeetingTranscriptSummaryLog(params.platform.logScope, `${realtimeLogScope} ${role}`, text));
if (role !== "user" || strategy !== "agent") return;
if (harness.isLikelyAssistantEchoTranscript(text)) {
params.logger.info(formatMeetingTranscriptSummaryLog(params.platform.logScope, `${realtimeLogScope} ignored assistant echo transcript`, text));
return;
}
if (!stopped) harness.talkback?.enqueue(text);
},
onEvent: lifecycleHandlers.onEvent,
onResponseDone: lifecycleHandlers.onResponseDone,
onToolCall: (event, session) => {
if (stopped) return Promise.resolve();
return toolContinuity.run({
session,
call: {
strategy,
event,
meetingSessionId: params.meetingSessionId,
requesterSessionKey: params.requesterSessionKey,
transcript: harness.transcript
},
harness
});
},
onError: (error) => {
harness.emit({
type: "session.error",
payload: { message: formatErrorMessage(error) },
final: true
});
params.logger.warn(`${params.platform.logScope} ${realtimeLogScope} voice bridge failed: ${formatErrorMessage(error)}`);
},
onClose: (reason) => {
lifecycle.outputGenerationActive = false;
lifecycle.realtimeReady = false;
harness.finishOutputAudio(reason);
harness.emit({
type: "session.closed",
payload: { reason },
final: true
});
stopAfterFailure("voice bridge close");
},
onReady: () => {
lifecycle.realtimeReady = true;
lifecycle.continuityResetActive = false;
harness.emit({
type: "session.ready",
payload: outputTalkPayload
});
}
});
if (bridge.bridge.outputAudioMode === "continuous") requireIsolatedInput();
startHumanBargeInMonitor();
params.transport.startInput((audio) => {
if (stopped || audio.byteLength === 0) return;
if (!harness.recordInputAudio(audio)) return;
bridge?.sendAudio(audio);
});
await bridge.connect();
if (stopped) throw new Error(`${params.platform.displayName} audio transport stopped during realtime provider setup`);
} catch (error) {
try {
await stop();
} catch (cleanupError) {
params.logger.debug?.(`${params.platform.logScope} ${realtimeLogScope} failed-start cleanup ignored: ${formatErrorMessage(cleanupError)}`);
try {
await stop();
} catch (retryError) {
params.logger.debug?.(`${params.platform.logScope} ${realtimeLogScope} failed-start cleanup retry ignored: ${formatErrorMessage(retryError)}`);
}
}
throw error;
}
return {
providerId: resolved.provider.id,
speak: (instructions) => {
bridge?.triggerGreeting(instructions);
},
getHealth: () => ({
...harness.getHealth({
providerConnected: bridge?.bridge.isConnected() ?? false,
realtimeReady: lifecycle.realtimeReady
}),
...bridge?.bridge.outputAudioMode === "continuous" ? { audioOutputActive: outputQueue.hasUnplayedAudibleAudio() } : {},
...params.transport.getHealth?.(),
...outputQueue.getHealth(),
bridgeClosed
}),
stop
};
}
//#endregion
//#region src/meeting-bot/realtime-agent-engine.ts
async function startMeetingAgentRealtimeEngine(params) {
let stopped = false;
let stopPromise;
let sttSession = null;
let realtimeReady = false;
let ttsQueue = Promise.resolve();
const agentLogScope = params.logPrefix ? `${params.logPrefix} agent` : "agent";
const resolved = resolveMeetingRealtimeTranscriptionProvider({
config: params.config,
fullConfig: params.fullConfig,
providers: params.providers
});
params.logger.info(formatMeetingAgentAudioModelLog({
logScope: params.platform.logScope,
provider: resolved.provider,
providerConfig: resolved.providerConfig,
audioFormat: params.config.chrome.audioFormat
}));
const stop = async () => {
if (stopped) {
await stopPromise;
return;
}
stopped = true;
stopPromise = (async () => {
harness.close();
try {
sttSession?.close();
} catch (error) {
params.logger.debug?.(`${params.platform.logScope} ${agentLogScope} transcription bridge close ignored: ${formatErrorMessage(error)}`);
}
harness.finishOutputAudio("stopped");
harness.endTurn("stopped");
harness.emit({
type: "session.closed",
final: true,
payload: { meetingSessionId: params.meetingSessionId }
});
try {
await params.transport.stop();
} finally {
await params.transport.dispose();
}
})();
await stopPromise;
};
const stopAfterFailure = (source) => {
stop().catch((error) => {
params.logger.warn(`${params.platform.logScope} ${agentLogScope} ${source} cleanup failed: ${formatErrorMessage(error)}`);
});
};
const writeOutputAudio = async (audio) => {
params.transport.beginOutput?.();
harness.outputActivity.markPlaybackStarted();
harness.recordOutputAudio(audio);
await params.transport.writeOutput(audio);
};
const enqueueSpeakText = (text) => {
const normalized = normalizeMeetingTtsPromptText(text);
if (!normalized || stopped) return;
ttsQueue = ttsQueue.then(async () => {
if (stopped) return;
harness.recordTranscript("assistant", normalized);
params.logger.info(formatMeetingTranscriptSummaryLog(params.platform.logScope, `${agentLogScope} assistant`, normalized));
const turnId = harness.ensureTurn();
harness.emit({
type: "output.text.done",
turnId,
final: true,
payload: {
meetingSessionId: params.meetingSessionId,
text: normalized
}
});
const result = await params.runtime.tts.textToSpeechTelephony({
text: normalized,
cfg: params.fullConfig
});
if (stopped) return;
if (!result.success || !result.audioBuffer || !result.sampleRate) throw new Error(result.error ?? "TTS conversion failed");
params.logger.info(formatMeetingAgentTtsResultLog(params.platform.logScope, agentLogScope, result));
await writeOutputAudio(convertMeetingTtsAudioForBridge(result.audioBuffer, result.sampleRate, params.config.chrome.audioFormat, result.outputFormat, params.platform.displayName));
if (stopped) return;
harness.finishOutputAudio("completed");
harness.endTurn();
}).catch((error) => {
if (stopped) return;
harness.finishOutputAudio("failed");
harness.endTurn("failed");
params.logger.warn(`${params.platform.logScope} ${agentLogScope} TTS failed: ${formatErrorMessage(error)}`);
});
};
const harness = createRealtimeVoiceSessionHarness({
talk: {
sessionId: `${params.platform.sessionIdPrefix}:${params.meetingSessionId}:agent`,
mode: "stt-tts",
transport: "gateway-relay",
brain: "agent-consult",
provider: resolved.provider.id,
turnIdPrefix: `${params.platform.sessionIdPrefix}:${params.meetingSessionId}:turn`
},
talkPayloads: {
turnStarted: () => ({ meetingSessionId: params.meetingSessionId }),
turnEnded: () => ({ meetingSessionId: params.meetingSessionId }),
inputAudioDelta: (audio) => ({
meetingSessionId: params.meetingSessionId,
bytes: audio.byteLength
}),
outputAudioStarted: () => ({ meetingSessionId: params.meetingSessionId }),
outputAudioDelta: (audio) => ({
meetingSessionId: params.meetingSessionId,
bytes: audio.byteLength
}),
outputAudioDone: () => ({ meetingSessionId: params.meetingSessionId })
},
echoSuppression: params.transport.inputAudioIsolated ? void 0 : {
bytesPerMs: meetingOutputBytesPerMs(params.config.chrome.audioFormat),
tailMs: MEETING_OUTPUT_ECHO_SUPPRESSION_TAIL_MS,
transcriptLookbackMs: MEETING_TRANSCRIPT_ECHO_LOOKBACK_MS
},
talkback: {
debounceMs: 900,
logger: params.logger,
logPrefix: `${params.platform.logScope} ${agentLogScope}`,
responseStyle: "Brief, natural spoken answer for a live meeting.",
fallbackText: "I hit an error while checking that. Please try again.",
consult: ({ question, responseStyle, signal }) => params.consultAgent({
meetingSessionId: params.meetingSessionId,
requesterSessionKey: params.requesterSessionKey,
args: {
question,
responseStyle
},
transcript: harness.transcript,
abortSignal: signal
}),
deliver: enqueueSpeakText
}
});
params.transport.onFatal(() => {
stopAfterFailure("audio transport");
});
if (stopped) throw new Error(`${params.platform.displayName} audio transport failed before transcription provider setup`);
try {
sttSession = resolved.provider.createSession({
cfg: params.fullConfig,
providerConfig: resolved.providerConfig,
onTranscript: (text) => {
const trimmed = text.trim();
if (!trimmed || stopped) return;
const turnId = harness.ensureTurn();
harness.emit({
type: "input.audio.committed",
turnId,
final: true,
payload: { meetingSessionId: params.meetingSessionId }
});
harness.emit({
type: "transcript.done",
turnId,
final: true,
payload: {
meetingSessionId: params.meetingSessionId,
text: trimmed,
role: "user"
}
});
harness.recordTranscript("user", trimmed);
params.logger.info(formatMeetingTranscriptSummaryLog(params.platform.logScope, `${agentLogScope} user`, trimmed));
if (harness.isLikelyAssistantEchoTranscript(trimmed)) {
params.logger.info(formatMeetingTranscriptSummaryLog(params.platform.logScope, `${agentLogScope} ignored assistant echo transcript`, trimmed));
return;
}
harness.talkback?.enqueue(trimmed);
},
onError: (error) => {
params.logger.warn(`${params.platform.logScope} ${agentLogScope} transcription bridge failed: ${formatErrorMessage(error)}`);
harness.emit({
type: "session.error",
final: true,
payload: {
meetingSessionId: params.meetingSessionId,
error: formatErrorMessage(error)
}
});
stopAfterFailure("transcription bridge");
}
});
harness.emit({
type: "session.started",
payload: {
meetingSessionId: params.meetingSessionId,
provider: resolved.provider.id
}
});
params.transport.startInput((audio) => {
if (stopped || !realtimeReady || audio.byteLength === 0) return;
if (!harness.recordInputAudio(audio)) return;
sttSession?.sendAudio(convertMeetingBridgeAudioForStt(audio, params.config.chrome.audioFormat));
});
await sttSession.connect();
} catch (error) {
try {
await stop();
} catch (cleanupError) {
params.logger.debug?.(`${params.platform.logScope} ${agentLogScope} failed-start cleanup ignored: ${formatErrorMessage(cleanupError)}`);
}
throw error;
}
if (stopped) throw new Error(`${params.platform.displayName} audio transport stopped during transcription provider setup`);
realtimeReady = true;
harness.emit({
type: "session.ready",
payload: { meetingSessionId: params.meetingSessionId }
});
return {
providerId: resolved.provider.id,
speak: enqueueSpeakText,
getHealth: () => ({
...harness.getHealth({
providerConnected: sttSession?.isConnected() ?? false,
realtimeReady
}),
...params.transport.getHealth?.(),
bridgeClosed: stopped
}),
stop
};
}
//#endregion
//#region src/meeting-bot/bridge-process.ts
function writeMeetingOutputChunk(waiters, process, stdin, audio) {
return new Promise((resolve, reject) => {
let settled = false;
const finish = (error) => {
if (settled) return;
settled = true;
waiters.delete(waiter);
if (error) reject(error);
else resolve();
};
const waiter = {
process,
release: () => finish()
};
waiters.add(waiter);
try {
stdin.write(audio, (error) => finish(error ?? void 0));
} catch (error) {
finish(error instanceof Error ? error : new Error(formatErrorMessage(error)));
return;
}
if (stdin.destroyed || stdin.writableEnded) finish(/* @__PURE__ */ new Error("audio output stream is closed"));
});
}
function hasExited(proc) {
return proc.exitCode !== null || proc.signalCode !== null;
}
function waitForExit(proc, timeoutMs) {
if (hasExited(proc)) return Promise.resolve(true);
return new Promise((resolve) => {
let settled = false;
const finish = (exited) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
proc.off("exit", onExit);
resolve(exited);
};
const onExit = () => finish(true);
const timeout = setTimeout(() => finish(hasExited(proc)), timeoutMs);
timeout.unref?.();
proc.once("exit", onExit);
if (hasExited(proc)) finish(true);
});
}
/** Settles after the bridge exits or the bounded force-kill sequence finishes. */
async function terminateMeetingBridgeProcess(proc, options) {
if (!proc || hasExited(proc)) return;
const initialSignal = options.initialSignal ?? "SIGTERM";
try {
if (!proc.kill(initialSignal)) return;
} catch {
return;
}
const forceKillWaitMs = options.forceKillWaitMs ?? 1e3;
if (initialSignal === "SIGKILL") {
await waitForExit(proc, forceKillWaitMs);
return;
}
if (await waitForExit(proc, options.graceMs)) return;
try {
if (!proc.kill("SIGKILL")) return;
} catch {
return;
}
await waitForExit(proc, forceKillWaitMs);
}
//#endregion
//#region src/meeting-bot/command-argv.ts
function splitCommandArgv(argv, commandLabel) {
const [command, ...args] = argv;
if (!command) throw new Error(`${commandLabel} must not be empty`);
return {
command,
args
};
}
//#endregion
//#region src/meeting-bot/output-loopback-verifier.ts
const OUTPUT_LOOPBACK_OBSERVATION_WINDOW_MS = 5e3;
const OUTPUT_LOOPBACK_REFERENCE_MS = 500;
const OUTPUT_LOOPBACK_INPUT_HISTORY_MS = 1e4;
const OUTPUT_LOOPBACK_FINGERPRINT_POINTS = 64;
const OUTPUT_LOOPBACK_MIN_FINGERPRINT_POINTS = 16;
const OUTPUT_LOOPBACK_FULL_CORRELATION_THRESHOLD = .9;
const OUTPUT_LOOPBACK_SHORT_CORRELATION_THRESHOLD = .98;
const OUTPUT_LOOPBACK_RMS_THRESHOLD = 8;
const OUTPUT_LOOPBACK_PEAK_THRESHOLD = 32;
function decodeMeetingAudio(audio, audioFormat) {
return audioFormat === "g711-ulaw-8khz" ? mulawToPcm(audio) : audio;
}
function hasSignal(stats) {
return stats.rms >= OUTPUT_LOOPBACK_RMS_THRESHOLD || stats.peak >= OUTPUT_LOOPBACK_PEAK_THRESHOLD;
}
function createOutputFingerprint(pcm, fullReferenceBytes) {
const totalSamples = Math.floor(pcm.byteLength * .5);
let firstSignalSample = 0;
while (firstSignalSample < totalSamples && Math.abs(pcm.readInt16LE(firstSignalSample * 2)) < OUTPUT_LOOPBACK_PEAK_THRESHOLD) firstSignalSample += 1;
let lastSignalSample = totalSamples - 1;
while (lastSignalSample >= firstSignalSample && Math.abs(pcm.readInt16LE(lastSignalSample * 2)) < OUTPUT_LOOPBACK_PEAK_THRESHOLD) lastSignalSample -= 1;
const activePcm = pcm.subarray(firstSignalSample * 2, (lastSignalSample + 1) * 2);
const sampleCount = Math.floor(activePcm.byteLength * .5);
const pointCount = Math.min(OUTPUT_LOOPBACK_FINGERPRINT_POINTS, Math.floor(sampleCount * .25));
if (pointCount < OUTPUT_LOOPBACK_MIN_FINGERPRINT_POINTS) return;
const inversePointCount = pointCount ** -1;
const selected = [];
for (let point = 0; point < pointCount; point += 1) {
const start = Math.floor(point * sampleCount * inversePointCount);
const end = Math.max(start + 1, Math.floor((point + 1) * sampleCount * inversePointCount));
let selectedIndex = start;
let selectedSample = activePcm.readInt16LE(start * 2);
for (let index = start + 1; index < end; index += 1) {
const sample = activePcm.readInt16LE(index * 2);
if (Math.abs(sample) > Math.abs(selectedSample)) {
selectedIndex = index;
selectedSample = sample;
}
}
selected.push({
index: selectedIndex,
sample: selectedSample
});
}
const baseIndex = selected[0]?.index ?? 0;
const samples = selected.map((entry) => entry.sample);
const inverseSamples = samples.length ** -1;
const referenceMean = samples.reduce((sum, sample) => sum + sample, 0) * inverseSamples;
const referenceVariance = samples.reduce((sum, sample) => {
const delta = sample - referenceMean;
return sum + delta * delta;
}, 0);
if (referenceVariance === 0) return;
const offsets = selected.map((entry) => entry.index - baseIndex);
return {
offsets,
referenceMean,
referenceVariance,
samples,
spanSamples: (offsets.at(-1) ?? 0) + 1,
threshold: activePcm.byteLength < fullReferenceBytes ? OUTPUT_LOOPBACK_SHORT_CORRELATION_THRESHOLD : OUTPUT_LOOPBACK_FULL_CORRELATION_THRESHOLD
};
}
function fingerprintCorrelation(pcm, startSample, fingerprint) {
let inputSum = 0;
for (const offset of fingerprint.offsets) inputSum += pcm.readInt16LE((startSample + offset) * 2);
const inputMean = inputSum * fingerprint.offsets.length ** -1;
let covariance = 0;
let inputVariance = 0;
for (let index = 0; index < fingerprint.offsets.length; index += 1) {
const referenceDelta = (fingerprint.samples[index] ?? 0) - fingerprint.referenceMean;
const inputDelta = pcm.readInt16LE((startSample + (fingerprint.offsets[index] ?? 0)) * 2) - inputMean;
covariance += referenceDelta * inputDelta;
inputVariance += inputDelta * inputDelta;
}
if (inputVariance === 0) return 0;
return Math.abs(covariance * (fingerprint.referenceVariance * inputVariance) ** -.5);
}
/** Correlates sink audio with the same waveform returning on the microphone capture path. */
function createMeetingOutputLoopbackVerifier(options) {
const now = options.now ?? Date.now;
const sampleRate = options.audioFormat === "g711-ulaw-8khz" ? 8e3 : 24e3;
const fullReferenceBytes = sampleRate * OUTPUT_LOOPBACK_REFERENCE_MS * .001 * 2;
const maxInputHistoryBytes = sampleRate * OUTPUT_LOOPBACK_INPUT_HISTORY_MS * .001 * 2;
let generationStarted = false;
let generationVerified = false;
let outputGeneration = 0;
let verifiedOutputGeneration;
let inputPcm = Buffer.alloc(0);
let nextInputStartSample = 0;
let outputFingerprint;
let pendingOutputPcm = Buffer.alloc(0);
let outputObservationDeadlineMs = Number.NEGATIVE_INFINITY;
let outputQueuedUntilMs = Number.NEGATIVE_INFINITY;
let outputLoopbackSignalBytes = 0;
let lastOutputLoopbackAt;
let lastOutputLoopbackCorrelation;
let lastOutputLoopbackPeak;
let lastOutputLoopbackRms;
const clearCorrelationScratch = () => {
inputPcm = Buffer.alloc(0);
nextInputStartSample = 0;
outputFingerprint = void 0;
pendingOutputPcm = Buffer.alloc(0);
};
const resetGeneration = (started) => {
generationStarted = started;
generationVerified = false;
if (started) outputGeneration += 1;
clearCorrelationScratch();
outputObservationDeadlineMs = Number.NEGATIVE_INFINITY;
outputQueuedUntilMs = Number.NEGATIVE_INFINITY;
};
const refreshFingerprint = (fingerprint) => {
outputFingerprint = fingerprint;
const inputSampleCount = Math.floor(inputPcm.byteLength * .5);
const rescanTailSamples = sampleRate * OUTPUT_LOOPBACK_REFERENCE_MS * .001;
nextInputStartSample = Math.max(0, inputSampleCount - rescanTailSamples);
};
const consumePendingOutput = () => {
const pendingBytes = pendingOutputPcm.byteLength;
for (let end = pendingBytes; end >= fullReferenceBytes; end -= fullReferenceBytes) {
const fingerprint = createOutputFingerprint(pendingOutputPcm.subarray(end - fullReferenceBytes, end), fullReferenceBytes);
if (fingerprint) {
pendingOutputPcm = Buffer.alloc(0);
refreshFingerprint(fingerprint);
return;
}
}
const residualBytes = pendingBytes % fullReferenceBytes;
if (residualBytes > 0) {
const fingerprint = createOutputFingerprint(pendingOutputPcm.subarray(0, residualBytes), fullReferenceBytes);
if (fingerprint) {
pendingOutputPcm = Buffer.alloc(0);
refreshFingerprint(fingerprint);
return;
}
}
pendingOutputPcm = pendingBytes > fullReferenceBytes ? pendingOutputPcm.subarray(pendingBytes - fullReferenceBytes) : pendingOutputPcm;
};
return {
beginOutput() {
resetGeneration(true);
},
cancelOutput() {
resetGeneration(false);
},
recordInput(audio) {
const capturedAtMs = now();
if (!generationStarted || generationVerified || audio.byteLength === 0 || capturedAtMs > outputObservationDeadlineMs) return;
const decoded = decodeMeetingAudio(audio, options.audioFormat);
const chunkStats = readPcm16AudioStats(decoded);
const combinedInput = inputPcm.byteLength > 0 ? Buffer.concat([inputPcm, decoded]) : decoded;
const droppedInputBytes = Math.max(0, combinedInput.byteLength - maxInputHistoryBytes);
inputPcm = droppedInputBytes > 0 ? combinedInput.subarray(droppedInputBytes) : combinedInput;
if (droppedInputBytes > 0) nextInputStartSample = Math.max(0, nextInputStartSample - Math.floor(droppedInputBytes * .5));
const fingerprint = outputFingerprint;
if (!fingerprint || !hasSignal(chunkStats)) return;
const lastStartSample = Math.floor(inputPcm.byteLength * .5) - fingerprint.spanSamples;
for (let startSample = nextInputStartSample; startSample <= lastStartSample; startSample += 1) {
const matchedCorrelation = fingerprintCorrelation(inputPcm, startSample, fingerprint);
if (matchedCorrelation < fingerprint.threshold) continue;
const matchedPcm = inputPcm.subarray(startSample * 2, (startSample + fingerprint.spanSamples) * 2);
const matchedStats = readPcm16AudioStats(matchedPcm);
if (!hasSignal(matchedStats)) continue;
generationVerified = true;
verifiedOutputGeneration = outputGeneration;
outputLoopbackSignalBytes += audio.byteLength;
lastOutputLoopbackAt = new Date(capturedAtMs).toISOString();
lastOutputLoopbackCorrelation = matchedCorrelation;
lastOutputLoopbackPeak = matchedStats.peak;
lastOutputLoopbackRms = matchedStats.rms;
clearCorrelationScratch();
return;
}
nextInputStartSample = Math.max(0, lastStartSample + 1);
},
recordOutput(audio) {
const outputAtMs = now();
if (!generationStarted || outputObservationDeadlineMs !== Number.NEGATIVE_INFINITY && outputAtMs > outputObservationDeadlineMs) resetGeneration(true);
if (audio.byteLength === 0) return;
const durationMs = (options.audioFormat === "g711-ulaw-8khz" ? audio.byteLength : audio.byteLength * .5) * sampleRate ** -1 * 1e3;
outputQueuedUntilMs = Math.max(outputAtMs, outputQueuedUntilMs) + durationMs;
outputObservationDeadlineMs = Math.max(outputObservationDeadlineMs, outputQueuedUntilMs + OUTPUT_LOOPBACK_OBSERVATION_WINDOW_MS);
if (generationVerified) return;
const decoded = decodeMeetingAudio(audio, options.audioFormat);
pendingOutputPcm = Buffer.concat([pendingOutputPcm, decoded]);
if (!outputFingerprint || pendingOutputPcm.byteLength >= fullReferenceBytes) consumePendingOutput();
},
getHealth() {
return {
lastOutputLoopbackAt,
lastOutputLoopbackCorrelation,
lastOutputLoopbackPeak,
lastOutputLoopbackRms,
outputLoopbackSignalBytes,
outputGeneration,
verifiedOutputGeneration
};
}
};
}
//#endregion
//#region src/meeting-bot/realtime-local-audio-transport.ts
const LOCAL_BRIDGE_TERMINATION_GRACE_MS = 1e3;
const STDERR_LINE_TRUNCATED_PREFIX = "[stderr line truncated] ";
const MAX_STDERR_CHUNK_BYTES = 8192;
function attachStderrLineLogger(params) {
if (!params.stderr) return;
if (!params.logger.debug) {
params.stderr.on("data", () => {});
return;
}
const debug = (message) => params.logger.debug?.(message);
if (!(params.stderr instanceof Readable)) {
params.stderr.on("data", (chunk) => {
debug(`${params.prefix}: ${String(chunk).trim()}`);
});
return;
}
onDecodedOutput(params.stderr, (chunk) => {
const trimmed = chunk.trim();
if (!trimmed) return;
const truncated = Buffer.byteLength(trimmed, "utf8") > MAX_STDERR_CHUNK_BYTES;
const value = truncated ? truncateUtf8Suffix(trimmed, MAX_STDERR_CHUNK_BYTES) : trimmed;
debug(`${params.prefix}: ${truncated ? STDERR_LINE_TRUNCATED_PREFIX : ""}${value}`);
});
}
function createLocalMeetingRealtimeAudioTransport(params) {
const input = splitCommandArgv(params.inputCommand, "audio bridge command");
const output = splitCommandArgv(params.outputCommand, "audio bridge command");
const spawnFn = params.spawn ?? ((command, args, options) => spawn(command, args, options));
const spawnOutputProcess = () => spawnFn(output.command, output.args, { stdio: [
"pipe",
"ignore",
"pipe"
] });
let outputProcess = spawnOutputProcess();
let inputProcess;
try {
inputProcess = spawnFn(input.command, input.args, { stdio: [
"ignore",
"pipe",
"pipe"
] });
} catch (error) {
outputProcess.on("error", () => {});
terminateMeetingBridgeProcess(outputProcess, { graceMs: LOCAL_BRIDGE_TERMINATION_GRACE_MS });
throw error;
}
let bargeInInputProcess;
let stopped = false;
let inputStarted = false;
let fatalSignaled = false;
let fatalHandler;
let stopPromise;
const retiredOutputStops = /* @__PURE__ */ new Set();
const outputWriteWaiters = /* @__PURE__ */ new Set();
const outputLoopbackVerifier = createMeetingOutputLoopbackVerifier({ audioFormat: params.audioFormat ?? "pcm16-24khz" });
const signalFatal = () => {
if (!fatalSignaled) {
fatalSignaled = true;
stop().catch((error) => {
params.logger.warn(`${params.logScope} failed audio transport cleanup: ${formatErrorMessage(error)}`);
});
fatalHandler?.();
}
};
const fail = (label) => (error) => {
params.logger.warn(`${params.logScope} ${label} failed: ${formatErrorMessage(error)}`);
signalFatal();
};
const attachOutputProcessHandlers = (proc) => {
const onOutputError = (error) => {
if (proc === outputProcess) fail("audio output command")(error);
};
proc.on("error", onOutputError);
proc.stdin?.on?.("error", onOutputError);
proc.on("exit", (code, signal) => {
if (proc === outputProcess && !stopped) {
params.logger.warn(`${params.logScope} audio output command exited (${code ?? signal ?? "done"})`);
signalFatal();
}
});
attachStderrLineLogger({
stderr: proc.stderr,
logger: params.logger,
prefix: `${params.logScope} audio output`
});
proc.stderr?.on("error", (error) => {
if (proc === outputProcess) fail("audio output command stderr")(error);
});
};
const releaseOutputWriteWaiters = (proc) => {
for (const waiter of outputWriteWaiters) if (!proc || waiter.process === proc) waiter.release();
};
const stop = () => {
stopPromise ??= (async () => {
stopped = true;
outputLoopbackVerifier.cancelOutput();
releaseOutputWriteWaiters();
await Promise.all([
terminateMeetingBridgeProcess(inputProcess, { graceMs: LOCAL_BRIDGE_TERMINATION_GRACE_MS }),
terminateMeetingBridgeProcess(outputProcess, { graceMs: LOCAL_BRIDGE_TERMINATION_GRACE_MS }),
terminateMeetingBridgeProcess(bargeInInputProcess, { graceMs: LOCAL_BRIDGE_TERMINATION_GRACE_MS }),
...retiredOutputStops
]);
})();
return stopPromise;
};
attachOutputProcessHandlers(outputProcess);
inputProcess.on("error", fail("audio input command"));
inputProcess.on("exit", (code, signal) => {
if (!stopped) {
params.logger.warn(`${params.logScope} audio input command exited (${code ?? signal ?? "done"})`);
signalFatal();
}
});
attachStderrLineLogger({
stderr: inputProcess.stderr,
logger: params.logger,
prefix: `${params.logScope} audio input`
});
inputProcess.stdout?.on("error", fail("audio input command stdout"));
inputProcess.stderr?.on("error", fail("audio input command stderr"));
const transport = {
onFatal: (handler) => {
fatalHandler = handler;
if (fatalSignaled) handler();
},
startInput: (onAudio) => {
if (inputStarted) throw new Error("audio input transport already started");
inputStarted = true;
inputProcess.stdout?.on("data", (chunk) => {
if (!stopped) {
const audio = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
outputLoopbackVerifier.recordInput(audio);
onAudio(audio);
}
});
},
beginOutput: () => outputLoopbackVerifier.beginOutput(),
stop,
writeOutput: async (audio) => {
if (stopped) return;
const proc = outputProcess;
const stdin = proc.stdin;
if (!stdin) return;
outputLoopbackVerifier.recordOutput(audio);
try {
await writeMeetingOutputChunk(outputWriteWaiters, proc, stdin, audio);
} catch (error) {
if (stopped || proc !== outputProcess || fatalSignaled) return;
fail("audio output command")(error instanceof Error ? error : new Error(formatErrorMessage(error)));
}
},
clearOutput: async () => {
if (stopped) return;
outputLoopbackVerifier.cancelOutput();
const previousOutput = outputProcess;
outputProcess = spawnOutputProcess();
attachOutputProcessHandlers(outputProcess);
releaseOutputWriteWaiters(previousOutput);
params.logger.debug?.(`${params.logScope} cleared realtime audio output buffer by restarting playback command`);
const retiredOutputStop = terminateMeetingBridgeProcess(previousOutput, {
graceMs: LOCAL_BRIDGE_TERMINATION_GRACE_MS,
initialSignal: "SIGKILL"
});
retiredOutputStops.add(retiredOutputStop);
retiredOutputStop.finally(() => {
retiredOutputStops.delete(retiredOutputStop);
});
},
dispose: async () => {
await transport.stop();
},
getHealth: () => outputLoopbackVerifier.getHealth()
};
if (!params.bargeInInputCommand) return transport;
return {
...transport,
startBargeInMonitor: (onBargeIn) => {
if (bargeInInputProcess || stopped) return;
const command = splitCommandArgv(params.bargeInInputCommand ?? [], "audio bridge command");
const bargeInGate = createSpeechThresholdGate({
rmsThreshold: params.bargeInRmsThreshold,
peakThreshold: params.bargeInPeakThreshold,
cooldownMs: params.bargeInCooldownMs
});
bargeInInputProcess = spawnFn(command.command, command.args, { stdio: [
"ignore",
"pipe",
"pipe"
] });
bargeInInputProcess.stdout?.on("data", (chunk) => {
const audio = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (stopped) return;
const stats = readPcm16AudioStats(audio);
if (!bargeInGate.accept(stats, {
nowMs: Date.now(),
onTrigger: () => onBargeIn(audio)
})) return;
params.logger.debug?.(`${params.logScope} human barge-in detected by local input (rms=${Math.round(stats.rms)}, peak=${stats.peak})`);
});
bargeInInputProcess.stdout?.on("error", (error) => {
params.logger.warn(`${params.logScope} human barge-in input stdout failed: ${formatErrorMessage(error)}`);
});
attachStderrLineLogger({
stderr: bargeInInputProcess.stderr,
logger: params.logger,
prefix: `${params.logScope} barge-in input`
});
bargeInInputProcess.stderr?.on("error", (error) => {
params.logger.warn(`${params.logScope} human barge-in input stderr failed: ${formatErrorMessage(error)}`);
});
bargeInInputProcess.on("error", (error) => {
params.logger.warn(`${params.logScope} human barge-in input failed: ${formatErrorMessage(error)}`);
});
bargeInInputProcess.on("exit", (code, signal) => {
if (!stopped) params.logger.debug?.(`${params.logScope} human barge-in input exited (${code ?? signal ?? "done"})`);
});
}
};
}
//#endregion
//#region src/meeting-bot/audio-base64.ts
function decodeMeetingAudioBase64(base64, action) {
const canonicalBase64 = canonicalizeBase64(base64);
if (!canonicalBase64) throw new Error(`${action} base64 must be a valid audio payload`);
return Buffer.from(canonicalBase64, "base64");
}
function isMeetingAudioBase64(base64) {
return canonicalizeBase64(base64) !== void 0;
}
//#endregion
//#region src/meeting-bot/realtime-node-audio-transport.ts
const NODE_OUTPUT_GENERATION_CAPABILITY = Symbol.for("openclaw.internal.meeting-node-output-generation.v1");
function createNodeMeetingRealtimeAudioTransport(params) {
let stopped = false;
let inputStarted = false;
let consecutiveInputErrors = 0;
let lastInputError;
let fatalSignaled = false;
let fatalHandler;
let outputGeneration = 0;
let outputGenerationSupported = false;
let legacyOutputTail = Promise.resolve();
const outputLoopbackVerifier = createMeetingOutputLoopbackVerifier({ audioFormat: params.audioFormat ?? "pcm16-24khz" });
const invoke = (action, payload = {}, timeoutMs = 5e3) => params.runtime.nodes.invoke({
nodeId: params.nodeId,
command: params.commandName,
params: {
action,
bridgeId: params.bridgeId,
...payload
},
timeoutMs
});
const runOutputCommand = (task) => {
if (outputGenerationSupported) return task();
const result = legacyOutputTail.then(task, task);
legacyOutputTail = result.then(() => void 0, () => void 0);
return result;
};
const signalFatal = () => {
if (!fatalSignaled) {
fatalSignaled = true;
fatalHandler?.();
}
};
const transport = {
onFatal: (handler) => {
fatalHandler = handler;
if (fatalSignaled) handler();
},
startInput: (onAudio) => {
if (inputStarted) throw new Error("audio input transport already started");
inputStarted = true;
(async () => {
for (;;) {
if (stopped) break;
try {
const raw = await invoke("pullAudio", { timeoutMs: 250 }, 2e3);
if (stopped) break;
const rawRecord = asOptionalRecord(raw);
const result = asOptionalRecord(rawRecord?.payload ?? raw) ?? {};
const base64 = readNonBlankString(result.base64);
if (base64) {
const audio = decodeMeetingAudioBase64(base64, "pullAudio");
outputLoopbackVerifier.recordInput(audio);
onAudio(audio);
}
consecutiveInputErrors = 0;
lastInputError = void 0;
if (result.closed === true) {
signalFatal();
break;
}
} catch (error) {
if (stopped) break;
const message = formatErrorMessage(error);
consecutiveInputErrors += 1;
lastInputError = message;
params.logger.warn(`${params.logScope} ${params.logPrefix} audio input failed (${consecutiveInputErrors}/5): ${message}`);
if (consecutiveInputErrors >= 5 || /unknown bridgeId|bridge is not open/i.test(message)) {
signalFatal();
break;
}
await sleep(250);
}
}
})();
},
beginOutput: () => outputLoopbackVerifier.beginOutput(),
stop: async () => {
if (stopped) return;
stopped = true;
outputLoopbackVerifier.cancelOutput();
try {
await invoke("stop");
} catch (error) {
params.logger.debug?.(`${params.logScope} node audio bridge stop ignored: ${formatErrorMessage(error)}`);
}
},
writeOutput: async (audio) => {
if (stopped) return;
const generation = outputGeneration;
outputLoopbackVerifier.recordOutput(audio);
try {
await runOutputCommand(async () => {
if (stopped) return;
await invoke("pushAudio", {
base64: audio.toString("base64"),
...outputGenerationSupported ? { outputGeneration: generation } : {}
});
});
} catch (error) {
if (!stopped && generation === outputGeneration) outputLoopbackVerifier.cancelOutput();
throw error;
}
},
clearOutput: async () => {
if (stopped) return;
outputGeneration += 1;
outputLoopbackVerifier.cancelOutput();
await runOutputCommand(async () => {
if (stopped) return;
await invoke("clearAudio", outputGenerationSupported ? { outputGeneration } : {});
});
},
dispose: async () => {
await transport.stop();
},
getHealth: () => ({
consecutiveInputErrors,
lastInputError,
...outputLoopbackVerifier.getHealth()
})
};
Object.defineProperty(transport, NODE_OUTPUT_GENERATION_CAPABILITY, {
get() {
return outputGenerationSupported;
},
set(value) {
outputGenerationSupported = value === true;
}
});
return transport;
}
//#endregion
//#region src/meeting-bot/participation.ts
const SOURCE_LIFETIME_MS = 12e4;
const MAX_SOURCES = 1024;
function fingerprint(value) {
const canonical = (input) => {
if (Array.isArray(input)) return input.map(canonical);
if (input && typeof input === "object") return Object.fromEntries(Object.entries(input).toSorted(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)]));
return input;
};
return createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex");
}
/** The live session owns authority; persisted claims only prevent replay of that authority. */
var MeetingParticipation = class {
#closed = /* @__PURE__ */ new Set();
#orders = /* @__PURE__ */ new Map();
#identities = /* @__PURE__ */ new Map();
#sources = /* @__PURE__ */ new Map();
#epochs = /* @__PURE__ */ new Map();
constructor(options) {
this.options = options;
}
close(sessionId) {
this.#closed.add(sessionId);
this.#sources.delete(sessionId);
this.#identities.delete(sessionId);
this.#epochs.delete(sessionId);
}
observeEpoch(sessionId, kind, observedEpoch) {
if (this.#closed.has(sessionId) || !this.options.current(sessionId) || !observedEpoch) return false;
let epochs = this.#epochs.get(sessionId);
if (!epochs) this.#epochs.set(sessionId, epochs = /* @__PURE__ */ new Map());
const epoch = epochs.get(kind);
if (epoch?.seen.has(observedEpoch) && epoch.current !== observedEpoch) return false;
if (epoch) {
epoch.current = observedEpoch;
epoch.seen.add(observedEpoch);
} else epochs.set(kind, {
current: observedEpoch,
seen: /* @__PURE__ */ new Set([observedEpoch])
});
const sources = this.#sources.get(sessionId);
for (const [key, source] of sources ?? []) if (source.kind === kind && source.epoch !== observedEpoch) sources?.delete(key);
return true;
}
observe(sessionId, source) {
const current = this.#closed.has(sessionId) ? void 0 : this.options.current(sessionId);
if (!current) return;
if (!source.id || !this.observeEpoch(sessionId, source.kind, source.epoch)) return;
let sources = this.#sources.get(sessionId);
if (!sources) this.#sources.set(sessionId, sources = /* @__PURE__ */ new Map());
for (const [key, existing] of sources) if (Date.now() - existing.observedAt > SOURCE_LIFETIME_MS) sources.delete(key);
const key = fingerprint([
source.kind,
source.epoch,
source.id
]);
const previous = sources.get(key);
let identities = this.#identities.get(sessionId);
if (!identities) this.#identities.set(sessionId, identities = /* @__PURE__ */ new Map());
let identity = identities.get(key);
if (!identity) {
if (identities.size >= 1e4) return;
const order = (this.#orders.get(sessionId) ?? 0) + 1;
this.#orders.set(sessionId, order);
identities.set(key, identity = {
order,
observedAt: Date.now(),
assertOwnerCurrent: current.assertCurrent.bind(current),
seenRevisions: /* @__PURE__ */ new Set()
});
}
identity.ownEcho ||= source.ownEcho;
if (identity.ownEcho) {
sources.delete(key);
return;
}
if (identity.seenRevisions.has(source.revision) && identity.revision !== source.revision) return;
identity.revision = source.revision;
identity.seenRevisions.add(source.revision);
if (identity.seenRevisions.size > 128) {
sources.delete(key);
return;
}
try {
identity.assertOwnerCurrent();
} catch {
sources.delete(key);
return;
}
if (Date.now() - identity.observedAt > SOURCE_LIFETIME_MS) return;
if (!source.finalized || !source.revision || !source.text?.trim() || source.text.length > 16384) {
sources.delete(key);
return;
}
if (previous?.revision === source.revision && previous.text === source.text) return previous.sourceId;
let evictionKey;
if (!previous && sources.size >= MAX_SOURCES) {
let oldestOrder = identity.order;
for (const [liveKey, liveSource] of sources) if (liveSource.order < oldestOrder) {
evictionKey = liveKey;
oldestOrder = liveSource.order;
}
if (evictionKey === void 0) return;
}
const entry = {
...source,
sourceId: randomUUID(),
order: identity.order,
...identity.sourceId ? { replacesSourceId: identity.sourceId } : {},
observedAt: identity.observedAt,
assertOwnerCurrent: identity.assertOwnerCurrent
};
identity.sourceId = entry.sourceId;
sources.set(key, entry);
if (evictionKey !== void 0) sources.delete(evictionKey);
return entry.sourceId;
}
context(sessionId) {
const current = this.#closed.has(sessionId) ? void 0 : this.options.current(sessionId);
return {
sessionId,
active: Boolean(current),
sourceOrder: current ? this.#orders.get(sessionId) ?? 0 : 0,
capabilities: current ? [...this.options.capabilities(current.session)] : [],
sources: current ? [...this.#sources.get(sessionId)?.values() ?? []].filter((source) => this.#isSourceCurrent(source)).map(({ observedAt: _at, assertOwnerCurrent: _assert, ...source }) => source) : []
};
}
inspect(sessionId, sourceId) {
const current = this.#closed.has(sessionId) ? void 0 : this.options.current(sessionId);
const source = this.#findSource(sessionId, sourceId);
if (!current || !source) return;
const { observedAt: _at, assertOwnerCurrent: _assert, ...snapshot } = source;
return {
source: snapshot,
assertCurrent: () => {
if (this.#closed.has(sessionId) || this.#findSource(sessionId, sourceId) !== source) throw new Error("The participation source is no longer current.");
current.assertCurrent();
}
};
}
async execute(sessionId, input) {
const request = structuredClone(input);
const result = (status, message) => ({
requestId: request.requestId,
status,
message
});
const validToken = (value) => typeof value === "string" && value.trim().length > 0 && Buffer.byteLength(value, "utf8") <= 128;
if (!validToken(sessionId) || !validToken(request.requestId) || !request.action || !validToken(request.action.type) || request.sourceId !== void 0 && !validToken(request.sourceId) || request.correctionOf !== void 0 && !validToken(request.correctionOf)) return result("rejected", "Session, request, action, and source identifiers must be non-empty strings of at most 128 UTF-8 bytes.");
const store = this.options.store;
const key = `${sessionId}:request:${request.requestId}`;
const digest = fingerprint(request);
const replay = (prior) => {
if (prior?.fingerprint !== digest) return result("rejected", "This requestId already identifies a different action.");
return {
...prior.result ?? result("uncertain", "This action was already claimed; its result is unknown. Do not retry it."),
replayed: true
};
};
const prior = await store.lookup(key);
if (prior) return replay(prior);
const current = this.#closed.has(sessionId) ? void 0 : this.options.current(sessionId);
if (!current) return result("rejected", "The meeting session is no longer active.");
const source = request.sourceId ? this.#findSource(sessionId, request.sourceId) : void 0;
let capabilityAdmitted = false;
const assertCurrent = () => {
if (this.#closed.has(sessionId)) throw new Error("The meeting session is closing.");
current.assertCurrent();
if (capabilityAdmitted && !this.options.capabilities(current.session).includes(request.action.type)) throw new Error("The participation capability is no longer available.");
if (request.sourceId && (!source || this.#findSource(sessionId, request.sourceId) !== source)) throw new Error("The participation source is stale, corrected, expired, or an own echo.");
};
const attempt = {
kind: "meeting-participation-attempt",
sessionId,
requestId: request.requestId,
fingerprint: digest,
...request.sourceId === void 0 ? {} : { sourceId: request.sourceId },
actionType: request.action.type,
...request.correctionOf === void 0 ? {} : { correctionOf: request.correctionOf }
};
if (!await this.#claim(key, attempt)) return replay(await store.lookup(key));
const finish = async (outcome) => {
try {
await store.register(key, {
...attempt,
result: outcome
});
} catch {
return result("uncertain", "The action result could not be recorded. Its claim remains; do not retry it.");
}
return outcome;
};
try {
assertCurrent();
if (request.correctionOf) {
const original = await store.lookup(`${sessionId}:request:${request.correctionOf}`);
assertCurrent();
if (!original || original.correctionOf || original.result?.correctionOf !== request.correctionOf || original.sourceId !== request.sourceId || original.actionType !== request.action.type) return await finish(result("rejected", "The correction must retain a correctable request's source and action type."));
const correctionClaimed = await this.#claim(`${sessionId}:correction:${request.correctionOf}`, attempt);
assertCurrent();
if (!correctionClaimed) return await finish(result("rejected", "This request already used its one correction opportunity."));
}
if (!this.options.capabilities(current.session).includes(request.action.type)) return await finish(result("unsupported", "This meeting does not support that participation action."));
capabilityAdmitted = true;
if (source) {
const sourceKey = `${sessionId}:source:${fingerprint([
source.kind,
source.epoch,
source.id,
request.action.type
])}`;
const sourceClaimed = await this.#claim(sourceKey, attempt);
assertCurrent();
if (!sourceClaimed) {
const original = await store.lookup(sourceKey);
assertCurrent();
if (!request.correctionOf || original?.requestId !== request.correctionOf || original.sourceId !== request.sourceId) return await finish(result("rejected", "This source already authorized this action type; it cannot authorize another attempt."));
}
}
const validation = this.options.validateAction(request.action);
if (validation) return await finish({
...result("rejected", validation),
...!request.correctionOf ? { correctionOf: request.requestId } : {}
});
assertCurrent();
} catch (error) {
return await finish(result("rejected", coerceErrorMessage(error)));
}
let outcome;
try {
assertCurrent();
const { correctable, ...effect } = await this.options.execute(current.session, request, assertCurrent);
assertCurrent();
outcome = {
status: effect.status,
...effect.message === void 0 ? {} : { message: effect.message },
...effect.observed === void 0 ? {} : { observed: effect.observed },
requestId: request.requestId,
...effect.status === "rejected" && correctable === true && !request.correctionOf ? { correctionOf: request.requestId } : {}
};
} catch (error) {
outcome = result("uncertain", `The action outcome is unknown: ${coerceErrorMessage(error)}. Do not retry it.`);
}
return await finish(outcome);
}
async #claim(key, attempt) {
try {
return await this.options.store.registerIfAbsent(key, attempt);
} catch (error) {
if (!(error instanceof PluginStateStoreError) || error.code !== "PLUGIN_STATE_LIMIT_EXCEEDED") throw error;
const entries = await this.options.store.entries();
let removed = 0;
for (const entry of entries.toSorted((a, b) => a.createdAt - b.createdAt)) {
const value = entry.value;
if (!value || value.kind !== "meeting-participation-attempt" || typeof value.sessionId !== "string" || !value.sessionId || typeof value.requestId !== "string") continue;
if (!(entry.key === `${value.sessionId}:request:${value.requestId}` || value.correctionOf && entry.key === `${value.sessionId}:correction:${value.correctionOf}` || entry.key.startsWith(`${value.sessionId}:source:`) && /^[a-f0-9]{64}$/.test(entry.key.slice(`${value.sessionId}:source:`.length)))) continue;
if (!this.#closed.has(value.sessionId) && this.options.current(value.sessionId)) continue;
if (await this.options.store.delete(entry.key)) removed++;
if (removed >= 1e3) break;
}
return await this.options.store.registerIfAbsent(key, attempt);
}
}
#findSource(sessionId, sourceId) {
for (const source of this.#sources.get(sessionId)?.values() ?? []) if (source.sourceId === sourceId && this.#isSourceCurrent(source)) return source;
}
#isSourceCurrent(source) {
if (Date.now() - source.observedAt > SOURCE_LIFETIME_MS) return false;
try {
source.assertOwnerCurrent();
return true;
} catch {
return false;
}
}
};
//#endregion
//#region src/meeting-bot/session-cleanup-tracker.ts
var MeetingSessionCleanupTracker = class {
#states = /* @__PURE__ */ new Map();
#state(sessionId) {
let state = this.#states.get(sessionId);
if (!state) {
state = { stops: /* @__PURE__ */ new Set() };
this.#states.set(sessionId, state);
}
return state;
}
prepareRuntime(sessionId, prepare) {
const state = this.#state(sessionId);
if (state.setup) return state.setup;
const setup = Promise.resolve().then(prepare).finally(() => {
if (state.setup === setup) state.setup = void 0;
});
state.setup = setup;
return setup;
}
addStop(sessionId, stop) {
this.#state(sessionId).stops.add(stop);
}
hasRuntime(sessionId) {
const state = this.#states.get(sessionId);
return Boolean(state?.setup || state?.stops.size);
}
retainFailedJoin(sessionId) {
this.#state(sessionId).unpublished = true;
}
stopRuntime(sessionId) {
const state = this.#states.get(sessionId);
if (!state) return Promise.resolve();
if (state.stopping) return state.stopping;
const stopping = Promise.resolve().then(async () => {
await state.setup?.catch(() => void 0);
const failures = (await Promise.allSettled([...state.stops].map(async (stop) => {
await stop();
state.stops.delete(stop);
}))).flatMap((result) => result.status === "rejected" ? [result.reason] : []);
if (failures.length === 1) throw failures[0];
if (failures.length > 1) throw new AggregateError(failures, "Meeting runtime cleanup failed");
}).finally(() => {
if (state.stopping === stopping) state.stopping = void 0;
});
state.stopping = stopping;
return stopping;
}
begin(sessionId, browserLeft) {
const state = this.#state(sessionId);
if (state.cleanup) return false;
state.cleanup = {
browserLeft,
browserSettled: false,
stopSettled: false
};
return true;
}
isPending(sessionId) {
return this.#states.get(sessionId)?.cleanup !== void 0;
}
async cleanup(params) {
const state = this.#states.get(params.sessionId)?.cleanup;
if (!state) throw new Error("Missing cleanup state for meeting session " + params.sessionId);
let cleanupError;
if (!state.stopSettled) try {
await this.stopRuntime(params.sessionId);
state.stopSettled = true;
} catch (error) {
cleanupError = error;
}
if (!state.browserSettled) try {
if (params.keepBrowserTab) state.browserSettled = true;
else {
state.browserLeft = await params.releaseBrowser();
state.browserSettled = state.browserLeft !== false || !params.hasBrowserTab();
}
} catch (error) {
cleanupError ??= error;
}
const unpublished = this.#states.get(params.sessionId)?.unpublished === true;
const complete = this.#completeIfSettled(params.sessionId, state);
if (cleanupError) throw cleanupError instanceof Error ? cleanupError : new Error("Meeting session cleanup failed", { cause: cleanupError });
return {
browserLeft: state.browserLeft,
complete,
unpublished
};
}
async retryBrowserAfterFailedJoin(params) {
const state = this.#states.get(params.sessionId)?.cleanup;
if (!state) return {
browserLeft: params.browserLeft,
complete: true,
incomplete: false
};
if (!params.hasBrowserTab()) state.browserSettled = true;
else if (!state.browserSettled) try {
state.browserLeft = await params.releaseBrowser();
state.browserSettled = state.browserLeft !== false;
} catch (error) {
return {
browserLeft: state.browserLeft,
complete: false,
error,
incomplete: params.hasBrowserTab()
};
}
return {
browserLeft: state.browserLeft,
complete: this.#completeIfSettled(params.sessionId, state),
incomplete: params.hasBrowserTab()
};
}
async rollbackFailedJoin(params) {
try {
await params.leave();
} catch (error) {
params.warn(`replacement cleanup failed: ${params.formatError(error)}`);
try {
await params.leave();
} catch (retryError) {
params.warn(`replacement cleanup retry failed: ${params.formatError(retryError)}`);
}
}
const retry = await this.retryBrowserAfterFailedJoin(params);
params.onBrowserResult(retry.browserLeft);
if (retry.error) params.warn(`replacement browser cleanup retry failed: ${params.formatError(retry.error)}`);
if (retry.complete) params.onComplete();
if (retry.incomplete) params.warn("replacement browser cleanup incomplete after failed join");
}
#completeIfSettled(sessionId, state) {
if (!state.stopSettled || !state.browserSettled || this.hasRuntime(sessionId)) return false;
this.#states.delete(sessionId);
return true;
}
};
//#endregion
//#region src/meeting-bot/session-durable-transcripts.ts
const STOP_RETRY_DELAY_MS = 1e3;
const STOP_RETRY_MAX_DELAY_MS = 6e4;
var MeetingSessionDurableTranscripts = class {
#bridge;
#enabled;
#policyTransition = Promise.resolve();
#stopRetries = /* @__PURE__ */ new Map();
constructor(options) {
this.options = options;
this.#enabled = resolveTranscriptsConfig(options.config?.config).enabled;
}
reconcilePolicy(enabled) {
this.#enabled = enabled;
const reconcile = async () => {
const bridge = await this.#getBridge();
if (!bridge) return;
await Promise.all(this.options.listSessions().map(async (session) => {
if (session.state !== "active" || !this.options.isBrowserSession(session)) return;
if (!enabled) await bridge.stop(session, async () => this.options.transcriptStore.captureNotes(session));
else if (this.#enabled) {
await bridge.stop(session, async () => this.options.transcriptStore.flushPending(session));
this.#stopRetries.delete(session.id);
await this.options.transcriptStore.captureNotes(session);
if (this.#enabled && session.state === "active") await bridge.start(session, async () => this.options.transcriptStore.captureNotes(session));
}
}));
};
const transition = this.#policyTransition.then(reconcile);
this.#policyTransition = transition.catch(() => {});
return transition;
}
async ingest(session, lines) {
await (await this.#getBridge())?.ingest(session, lines);
}
async start(session) {
await this.#policyTransition;
if (!this.#enabled || !this.options.isBrowserSession(session)) return;
await (await this.#getBridge())?.start(session, async () => await this.options.transcriptStore.captureNotes(session));
}
async stop(session, options) {
const finalCapture = async () => await this.options.transcriptStore.captureNotes(session, { finalize: true });
const bridge = await this.#getBridge();
if (bridge) try {
if (await bridge.stop(session, finalCapture)) {
this.#stopRetries.delete(session.id);
return true;
}
} catch (error) {
this.options.logger.warn(`${this.options.logScope} durable transcript finalization queued for retry: ${this.options.formatError(error)}`);
this.#scheduleStopRetry(session, bridge);
return false;
}
if (options.allowFallback && this.options.isTranscribeSession(session)) await finalCapture().catch((error) => {
this.options.logger.debug?.(`${this.options.logScope} final transcript snapshot ignored: ${this.options.formatError(error)}`);
});
return true;
}
async startSource(request) {
const bridge = await this.#getBridge();
if (!bridge?.enabled) return {
ok: false,
error: "meeting transcripts are disabled"
};
const session = this.#findSourceSession(request.session.source);
if (!session) return {
ok: false,
error: "No active meeting session matches the transcript source."
};
if (request.session.source.agentId !== session.agentId) return {
ok: false,
error: "meeting transcript source belongs to another agent"
};
return await bridge.attach(session, request);
}
async stopSource(request) {
const bridge = await this.#getBridge();
return bridge ? await bridge.detach(request) : {
ok: true,
sessionId: request.sessionId,
stoppedAt: (/* @__PURE__ */ new Date()).toISOString()
};
}
#findSourceSession(source) {
const agentId = source.agentId?.trim();
const meetingUrl = source.meetingUrl?.trim();
const sessionId = source.channelId?.trim();
if (!agentId || !meetingUrl && !sessionId) return;
return this.options.listSessions().filter((session) => session.state === "active").toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)).find((session) => session.agentId === agentId && (!sessionId || session.id === sessionId) && (!meetingUrl || this.options.sameMeetingUrl(session.url, meetingUrl)));
}
async #getBridge() {
if (!this.options.config) return;
this.#bridge ??= import("../transcripts-bridge.runtime-K6P5o0Zi.mjs").then(({ createMeetingDurableTranscriptBridge }) => createMeetingDurableTranscriptBridge({
isEnabled: () => this.#enabled,
logger: this.options.logger,
options: this.options.config
})).catch((error) => {
this.options.logger.warn(`${this.options.logScope} durable transcripts unavailable: ${this.options.formatError(error)}`);
});
return await this.#bridge;
}
#scheduleStopRetry(session, bridge) {
if (this.#stopRetries.has(session.id)) return;
const token = Symbol(session.id);
this.#stopRetries.set(session.id, {
attempt: 1,
token
});
const retry = () => {
const attempt = this.#stopRetries.get(session.id)?.attempt ?? 1;
const delayMs = Math.min(STOP_RETRY_DELAY_MS * 2 ** Math.min(attempt - 1, 16), STOP_RETRY_MAX_DELAY_MS);
setTimeout(() => void run(), delayMs).unref?.();
};
const run = async () => {
await this.#policyTransition;
const state = this.#stopRetries.get(session.id);
if (!state || state.token !== token) return;
try {
if (!await bridge.stop(session, async () => await this.options.transcriptStore.flushPending(session))) throw new Error("durable transcript capture is no longer active");
if (this.#stopRetries.get(session.id)?.token !== token) return;
this.options.transcriptStore.retire(session.id);
this.#stopRetries.delete(session.id);
} catch {
if (this.#stopRetries.get(session.id)?.token !== token) return;
this.#stopRetries.set(session.id, {
attempt: state.attempt + 1,
token
});
retry();
}
};
retry();
}
};
//#endregion
//#region src/meeting-bot/session-runtime-browser-tabs.ts
function inheritMeetingBrowserTabOwnership(sessions, options, params) {
if (!params.tab) return;
return [...sessions].some((session) => {
const browser = options.getBrowser(session);
const browserTab = browser?.tab;
return session.transport === params.transport && options.sameMeetingUrl(session.url, params.meetingUrl) && browser?.nodeId === params.nodeId && browserTab?.targetId === params.tab?.targetId && browserTab?.openedByPlugin === true;
}) ? {
...params.tab,
openedByPlugin: true
} : params.tab;
}
async function settleMeetingRetainedBrowserTabs(options, retained, adopted) {
let settled = true;
for (let index = 0; index < retained.length;) {
const retainedTab = retained[index];
if (!retainedTab) break;
const { session, tab } = retainedTab;
const browser = options.getBrowser(session);
if (adopted?.transport === session.transport && adopted.nodeId === browser?.nodeId && adopted.tab.targetId === tab.targetId) {
options.setBrowserTab(session, void 0);
retained.splice(index, 1);
continue;
}
if (await options.releaseBrowserTab(session) === false) {
settled = false;
index += 1;
continue;
}
retained.splice(index, 1);
}
return settled;
}
async function settleMeetingRetainedBrowserTabsAfterFailure(options, retained) {
for (let attempt = 0; attempt < 2 && retained.length > 0; attempt += 1) try {
if (await settleMeetingRetainedBrowserTabs(options, retained)) return;
} catch (error) {
options.logger.warn(`${options.logScope} retained browser cleanup failed: ${options.formatError(error)}`);
}
if (retained.length > 0) options.logger.warn(`${options.logScope} retained browser cleanup incomplete after failed join`);
}
//#endregion
//#region src/meeting-bot/session-speech-readiness.ts
function evaluateMeetingSpeechReadiness(params) {
const { browser, speech } = params;
if (!params.talkBack || !browser) return { ready: true };
const health = params.managedBrowser ? browser.health : void 0;
if (health?.manualAction) return {
ready: false,
reason: health.manualAction.reason,
message: health.manualAction.message
};
if (health?.inCall === true) {
if (health.micMuted !== false) {
const muted = health.micMuted === true;
return {
ready: false,
reason: muted ? speech.microphoneMutedReason : speech.browserUnverifiedReason,
message: muted ? speech.microphoneMuted : speech.browserUnverified
};
}
} else if (health?.inCall === false) return {
ready: false,
reason: speech.notInCallReason,
message: speech.notInCall
};
else if (params.managedBrowser) return {
ready: false,
reason: speech.browserUnverifiedReason,
message: speech.browserUnverified
};
return browser.hasAudioBridge ? { ready: true } : {
ready: false,
reason: speech.audioBridgeUnavailableReason,
message: speech.audioBridgeUnavailable
};
}
//#endregion
//#region src/meeting-bot/session-runtime.ts
const nowIso$1 = () => (/* @__PURE__ */ new Date()).toISOString();
/** Shared lifecycle owner; platform strategies perform transport-specific I/O only. */
var MeetingSessionRuntime = class {
#sessions = /* @__PURE__ */ new Map();
#participation;
#sessionLeaves = /* @__PURE__ */ new Map();
#sessionCleanup = new MeetingSessionCleanupTracker();
#meetingLock = new KeyedAsyncQueue();
#sessionSpeakers = /* @__PURE__ */ new Map();
#sessionHealth = /* @__PURE__ */ new Map();
#durableTranscripts;
#transcriptStore;
constructor(options) {
this.options = options;
if (options.participation) this.#participation = new MeetingParticipation({
...options.participation,
current: (sessionId) => {
const session = this.#sessions.get(sessionId);
if (!session) return;
const isCurrent = this.#captureSessionOwnership(session);
if (session.state !== "active") return;
return {
session,
assertCurrent: () => {
if (!isCurrent(sessionId)) throw new Error("The meeting session no longer owns this browser tab and route.");
}
};
}
});
this.#transcriptStore = new MeetingSessionTranscriptStore({
getSession: (sessionId) => this.#sessions.get(sessionId),
isBrowserSession: (session) => this.options.isBrowserTransport(session.transport),
isTranscribeSession: (session) => this.options.isTranscribeMode(session.mode),
hasBrowserTab: (session) => Boolean(this.options.getBrowser(session)?.tab),
capture: async (session, captureOptions) => {
const isCurrent = this.#captureSessionOwnership(session, { requireSameTab: this.#participation !== void 0 });
const snapshot = await this.options.captureTranscript(session, captureOptions);
if (!isCurrent(session.id)) throw new Error("The meeting session no longer owns the captured browser tab and route.");
return snapshot && snapshotMeetingTranscript(snapshot);
},
onSnapshot: (session, snapshot) => {
if (snapshot.epoch && this.#participation?.observeEpoch(session.id, "caption", snapshot.epoch) === false) return;
for (const source of meetingCaptionParticipationSources(snapshot)) this.observeParticipationSource(session.id, source);
},
onLines: async (session, lines) => await this.#durableTranscripts.ingest(session, lines)
});
this.#durableTranscripts = new MeetingSessionDurableTranscripts({
config: options.durableTranscripts,
formatError: (error) => options.formatError(error),
isBrowserSession: (session) => options.isBrowserTransport(session.transport),
isTranscribeSession: (session) => options.isTranscribeMode(session.mode),
listSessions: () => [...this.#sessions.values()],
logger: options.logger,
logScope: options.logScope,
sameMeetingUrl: (left, right) => options.sameMeetingUrl(left, right),
transcriptStore: this.#transcriptStore
});
}
list() {
this.refreshHealth();
return [...this.#sessions.values()].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
}
getSession(sessionId) {
return this.#sessions.get(sessionId);
}
async status(sessionId) {
this.refreshHealth(sessionId);
if (!sessionId) {
const sessions = [...this.#sessions.values()].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
await Promise.all(sessions.map((session) => this.options.refreshStatus(session)));
return {
found: true,
sessions
};
}
const session = this.#sessions.get(sessionId);
if (session) await this.options.refreshStatus(session);
return session ? {
found: true,
session
} : { found: false };
}
participationContext(sessionId) {
const context = this.#participation?.context(sessionId) ?? {
sessionId,
active: false,
sourceOrder: 0,
capabilities: [],
sources: []
};
return {
...context,
sources: context.sources.map(snapshotMeetingObservation)
};
}
observeParticipationEpoch(sessionId, kind, epoch) {
return this.#participation?.observeEpoch(sessionId, kind, epoch) ?? false;
}
observeParticipationSource(sessionId, source) {
return this.#participation?.observe(sessionId, snapshotMeetingObservation(source));
}
inspectParticipationSource(sessionId, sourceId) {
const inspected = this.#participation?.inspect(sessionId, sourceId);
return inspected && {
...inspected,
source: snapshotMeetingObservation(inspected.source)
};
}
async participate(sessionId, request) {
return this.#participation ? await this.#participation.execute(sessionId, request) : {
requestId: request.requestId,
status: "unsupported",
message: "This meeting platform does not support participation actions."
};
}
async transcript(sessionId, options = {}) {
return snapshotMeetingTranscript(await this.#transcriptStore.read(sessionId, options));
}
async startTranscriptSource(request) {
return await this.#durableTranscripts.startSource(request);
}
reconcileTranscriptPolicy(enabled) {
return this.#durableTranscripts.reconcilePolicy(enabled);
}
async stopTranscriptSource(request) {
return await this.#durableTranscripts.stopSource(request);
}
isReusableSession(session, resolved) {
return session.state === "active" && this.options.sameMeetingUrl(session.url, resolved.url) && session.transport === resolved.transport && session.mode === resolved.mode && session.agentId === resolved.agentId;
}
async join(request) {
const resolved = this.options.resolveJoin(request);
return await this.#meetingLock.enqueue(this.#meetingKey(resolved.transport, resolved.url), async () => await this.#joinUnlocked(request, resolved));
}
async leave(sessionId, options) {
const session = this.#sessions.get(sessionId);
if (!session) return { found: false };
this.#participation?.close(sessionId);
return await this.#meetingLock.enqueue(this.#meetingKey(session.transport, session.url), async () => await this.#leaveUnlocked(sessionId, options));
}
async speak(sessionId, instructions) {
const session = this.#sessions.get(sessionId);
if (!session) return {
found: false,
spoken: false
};
if (session.state !== "active") return {
found: true,
spoken: false,
session
};
const delegated = await this.options.speakViaTransport(session, instructions);
if (session.state !== "active") return {
found: true,
spoken: false,
session
};
if (delegated?.handled) return {
found: true,
spoken: delegated.spoken,
session
};
await this.refreshBrowserHealth(session);
if (session.state !== "active") return {
found: true,
spoken: false,
session
};
await this.#sessionCleanup.prepareRuntime(session.id, async () => {
if (session.state !== "active") return;
const handles = await this.options.ensureRealtimeBridge(session);
if (handles) this.#attachRuntimeHandles(session, handles);
});
if (session.state !== "active") {
await this.#sessionCleanup.stopRuntime(session.id);
return {
found: true,
spoken: false,
session
};
}
const speak = this.#sessionSpeakers.get(sessionId);
if (!speak || session.state !== "active") return {
found: true,
spoken: false,
session
};
const readiness = this.refreshSpeechReadiness(session);
if (!readiness.ready) {
const note = readiness.message ? `Realtime speech blocked: ${readiness.message}` : this.options.messages.speechBlockedFallback;
this.#noteSession(session, note);
session.updatedAt = nowIso$1();
return {
found: true,
spoken: false,
session
};
}
speak(instructions || this.options.defaultSpeechInstructions);
session.updatedAt = nowIso$1();
this.refreshHealth(sessionId);
return {
found: true,
spoken: true,
session
};
}
async speakWhenReady(session, instructions) {
let result = await this.speak(session.id, instructions);
if (result.spoken || !this.options.isBrowserTransport(session.transport)) return result.spoken;
const waitMs = Math.min(Math.max(0, this.options.waitForInCallMs), Math.max(0, this.options.joinTimeoutMs));
const deadline = Date.now() + waitMs;
while (Date.now() < deadline) {
await sleep(Math.min(250, Math.max(0, deadline - Date.now())));
result = await this.speak(session.id, instructions);
if (result.spoken) return true;
const health = this.options.getBrowser(result.session)?.health;
if (health?.manualAction || result.session?.state !== "active") return false;
const blocked = health?.speechBlockedReason;
if (blocked && !this.options.transientSpeechBlockedReasons.has(blocked)) return false;
}
return false;
}
hasHealthHandle(sessionId) {
return this.#sessionHealth.has(sessionId);
}
refreshHealth(sessionId) {
const ids = sessionId ? [sessionId] : [...this.#sessionHealth.keys()];
for (const id of ids) {
const session = this.#sessions.get(id);
const getHealth = this.#sessionHealth.get(id);
const browser = session ? this.options.getBrowser(session) : void 0;
if (!session || !browser || !getHealth) continue;
this.options.setBrowserHealth(session, {
...browser.health,
...getHealth()
});
this.refreshSpeechReadiness(session);
}
}
async refreshBrowserHealth(session, options = {}) {
if (!this.#isManagedBrowserSession(session)) {
this.refreshSpeechReadiness(session);
return;
}
if (!options.force && this.options.isTalkBackMode(session.mode) && this.#evaluateSpeechReadiness(session).ready) {
this.refreshSpeechReadiness(session);
return;
}
await this.options.refreshBrowserHealth(session, options);
this.refreshSpeechReadiness(session);
}
async refreshCaptionHealth(session) {
if (!this.options.isTranscribeMode(session.mode)) {
this.refreshSpeechReadiness(session);
return;
}
await this.refreshBrowserHealth(session);
}
refreshSpeechReadiness(session) {
const readiness = this.#evaluateSpeechReadiness(session);
if (readiness.ready) session.notes = session.notes.filter((note) => !note.startsWith("Realtime speech blocked:"));
const browser = this.options.getBrowser(session);
if (browser) this.options.setBrowserHealth(session, {
...browser.health,
speechReady: readiness.ready,
speechBlockedReason: readiness.reason,
speechBlockedMessage: readiness.message
});
return readiness;
}
markSessionEnded(session, reason) {
this.#participation?.close(session.id);
session.state = "ended";
session.updatedAt = nowIso$1();
this.#dropRuntimeHandles(session.id);
this.#noteSession(session, reason);
}
#captureSessionOwnership(session, { requireSameTab = true } = {}) {
const browser = this.options.getBrowser(session);
const targetId = browser?.tab?.targetId;
const nodeId = browser?.nodeId;
const { url, transport, state } = session;
return (sessionId) => {
const latest = this.options.getBrowser(session);
return this.#sessions.get(sessionId) === session && session.state === state && session.url === url && session.transport === transport && latest?.nodeId === nodeId && (!requireSameTab || latest?.tab?.targetId === targetId);
};
}
async #joinUnlocked(request, resolved) {
for (const session of this.list()) if (session.state === "ended" && session.transport === resolved.transport && this.options.sameMeetingUrl(session.url, resolved.url) && this.#sessionCleanup.hasRuntime(session.id)) await this.#leaveUnlocked(session.id);
const activeSessions = this.list().filter((session) => session.state === "active" && this.options.sameMeetingUrl(session.url, resolved.url) && session.transport === resolved.transport);
const retained = [];
if (this.options.isBrowserTransport(resolved.transport)) for (const session of activeSessions) {
if (this.isReusableSession(session, resolved)) continue;
const browser = this.options.getBrowser(session);
const tab = this.options.reuseExistingBrowserTab ? browser?.tab : void 0;
const keepBrowserParticipant = Boolean(tab) || browser?.launched === false;
if (tab) retained.push({
session,
tab
});
try {
if ((await this.#leaveUnlocked(session.id, keepBrowserParticipant ? { keepBrowserTab: true } : void 0)).browserLeft === false) throw new Error(this.options.messages.previousBrowserLeaveFailed);
} catch (error) {
await settleMeetingRetainedBrowserTabsAfterFailure(this.options, retained);
throw error;
}
this.#noteSession(session, this.options.messages.reassignedSessionNote);
}
let reusable = activeSessions.find((session) => this.isReusableSession(session, resolved));
if (reusable) {
const refreshResult = await this.options.refreshReusableSession(reusable, request, resolved);
if (reusable.state !== "active") {
await this.#leaveSession(reusable, { keepBrowserTab: refreshResult?.keepBrowserTab ?? true });
reusable = void 0;
}
}
const speechInstructions = this.options.resolveSpeechInstructions(request);
if (reusable) {
await this.#durableTranscripts.start(reusable);
await this.refreshBrowserHealth(reusable);
this.#noteSession(reusable, this.options.messages.reusedSessionNote);
reusable.updatedAt = nowIso$1();
const spoken = this.options.isTalkBackMode(resolved.mode) && speechInstructions ? await this.speakWhenReady(reusable, speechInstructions) : false;
return {
session: reusable,
spoken
};
}
const session = this.options.createSession({
request,
resolved,
createdAt: nowIso$1()
});
let delegatedSpoken;
try {
delegatedSpoken = (await this.options.joinTransport({
request,
session,
context: {
attachRuntimeHandles: (target, handles) => this.#attachRuntimeHandles(target, handles),
inheritedBrowserTab: (params) => inheritMeetingBrowserTabOwnership(this.#sessions.values(), this.options, params)
}
})).delegatedSpoken === true;
const browser = this.options.getBrowser(session);
if (!await settleMeetingRetainedBrowserTabs(this.options, retained, browser?.tab ? {
transport: session.transport,
nodeId: browser.nodeId,
tab: browser.tab
} : void 0)) throw new Error(this.options.messages.replacementBrowserLeaveFailed);
} catch (error) {
await this.#rollbackFailedJoinSession(session);
if (this.#sessionCleanup.isPending(session.id)) {
this.#sessionCleanup.retainFailedJoin(session.id);
this.#sessions.set(session.id, session);
this.#noteSession(session, "Meeting cleanup is pending; use leave to retry.");
}
await settleMeetingRetainedBrowserTabsAfterFailure(this.options, retained);
this.options.logger.warn(`${this.options.logScope} join failed: ${this.options.formatError(error)}`);
throw error;
}
this.#sessions.set(session.id, session);
await this.#durableTranscripts.start(session);
return {
session,
spoken: delegatedSpoken ? true : this.options.isTalkBackMode(resolved.mode) && speechInstructions ? await this.speakWhenReady(session, speechInstructions) : false
};
}
async #leaveUnlocked(sessionId, options) {
const inFlight = this.#sessionLeaves.get(sessionId);
if (inFlight) return await inFlight;
const session = this.#sessions.get(sessionId);
if (!session) return { found: false };
if (session.state === "ended" && !this.#sessionCleanup.isPending(sessionId) && !this.#sessionCleanup.hasRuntime(sessionId)) return {
found: true,
session,
...session.browserLeft === void 0 ? {} : { browserLeft: session.browserLeft }
};
const leave = this.#leaveSession(session, options);
this.#sessionLeaves.set(sessionId, leave);
try {
return await leave;
} finally {
if (this.#sessionLeaves.get(sessionId) === leave) this.#sessionLeaves.delete(sessionId);
}
}
async #leaveSession(session, options) {
this.#participation?.close(session.id);
const firstAttempt = this.#sessionCleanup.begin(session.id, session.browserLeft);
session.state = "ended";
session.updatedAt = nowIso$1();
this.#dropRuntimeHandles(session.id);
const transcribe = this.options.isTranscribeMode(session.mode);
let transcriptStopped = false;
if (transcribe) this.#transcriptStore.startFinalizing(session.id);
try {
transcriptStopped = await this.#durableTranscripts.stop(session, { allowFallback: firstAttempt });
const cleanup = await this.#sessionCleanup.cleanup({
sessionId: session.id,
keepBrowserTab: options?.keepBrowserTab === true,
hasBrowserTab: () => Boolean(this.options.getBrowser(session)?.tab),
releaseBrowser: async () => await this.options.releaseBrowserTab(session)
});
session.browserLeft = cleanup.browserLeft;
const browser = this.options.getBrowser(session);
if (cleanup.browserLeft === true && browser?.health) this.options.setBrowserHealth(session, {
...browser.health,
inCall: false,
micMuted: void 0,
manualAction: void 0,
speechReady: false,
speechBlockedReason: void 0,
speechBlockedMessage: void 0
});
if (cleanup.complete) {
this.#dropRuntimeHandles(session.id);
if (cleanup.unpublished) this.#sessions.delete(session.id);
}
return {
found: true,
session,
...cleanup.browserLeft === void 0 ? {} : { browserLeft: cleanup.browserLeft }
};
} finally {
if (transcriptStopped) this.#transcriptStore.retire(session.id);
if (transcribe) this.#transcriptStore.finishFinalizing(session.id);
}
}
#meetingKey(transport, url) {
return `${transport}:${this.options.normalizeMeetingUrlForReuse(url) ?? url}`;
}
async #rollbackFailedJoinSession(session) {
await this.#sessionCleanup.rollbackFailedJoin({
sessionId: session.id,
browserLeft: session.browserLeft,
leave: async () => await this.#leaveSession(session),
hasBrowserTab: () => Boolean(this.options.getBrowser(session)?.tab),
releaseBrowser: async () => await this.options.releaseBrowserTab(session),
formatError: (error) => this.options.formatError(error),
warn: (message) => this.options.logger.warn(`${this.options.logScope} ${message}`),
onBrowserResult: (left) => session.browserLeft = left,
onComplete: () => this.#dropRuntimeHandles(session.id)
});
}
#attachRuntimeHandles(session, handles) {
if (handles.stop) this.#sessionCleanup.addStop(session.id, handles.stop);
if (session.state !== "active") return;
if (handles.speak) this.#sessionSpeakers.set(session.id, handles.speak);
if (handles.getHealth) this.#sessionHealth.set(session.id, handles.getHealth);
}
#dropRuntimeHandles(sessionId) {
this.#sessionSpeakers.delete(sessionId);
this.#sessionHealth.delete(sessionId);
}
#isManagedBrowserSession(session) {
const browser = this.options.getBrowser(session);
return Boolean(this.options.isBrowserTransport(session.transport) && browser?.launched);
}
#evaluateSpeechReadiness(session) {
return evaluateMeetingSpeechReadiness({
browser: this.options.getBrowser(session),
managedBrowser: this.#isManagedBrowserSession(session),
speech: this.options.messages.speech,
talkBack: this.options.isTalkBackMode(session.mode)
});
}
#noteSession(session, note) {
session.notes = [...session.notes.filter((item) => item !== note), note];
}
};
//#endregion
//#region src/meeting-bot/session-factory.ts
function createMeetingSession(params) {
const { config, createdAt, platform, resolved } = params;
return {
id: `${platform.session.idPrefix}_${randomUUID()}`,
...resolved,
state: "active",
createdAt,
updatedAt: createdAt,
participantIdentity: platform.session.participantIdentity(resolved.transport),
realtime: {
enabled: resolved.mode === "agent" || resolved.mode === "bidi",
strategy: resolved.mode === "bidi" ? "bidi" : "agent",
provider: resolved.mode === "bidi" ? config.realtime.voiceProvider ?? config.realtime.provider : void 0,
model: resolved.mode === "bidi" ? config.realtime.model : void 0,
transcriptionProvider: resolved.mode === "agent" ? config.realtime.transcriptionProvider ?? config.realtime.provider : void 0,
toolPolicy: config.realtime.toolPolicy
},
notes: []
};
}
//#endregion
//#region src/meeting-bot/browser-act-lock.ts
const browserActLock = new KeyedAsyncQueue();
const BROWSER_ACT_TIMEOUT_MESSAGE = "Meeting browser operation timed out waiting for browser tab control.";
async function runMeetingBrowserAct(params) {
const waitMs = Math.floor(params.deadline - performance.now());
if (waitMs <= 0) throw new Error(BROWSER_ACT_TIMEOUT_MESSAGE);
const { promise: acquisition, resolve: markAcquired } = createDeferredCore();
const queued = browserActLock.enqueue(params.targetId, async () => {
const remainingMs = Math.floor(params.deadline - performance.now());
if (remainingMs <= 0) throw new Error(BROWSER_ACT_TIMEOUT_MESSAGE);
markAcquired();
return await params.operation(remainingMs);
});
queued.catch(() => void 0);
await raceWithTimeout(acquisition, waitMs, () => {
throw new Error(BROWSER_ACT_TIMEOUT_MESSAGE);
});
return await queued;
}
//#endregion
//#region src/meeting-bot/browser-navigation-errors.ts
function isMeetingBrowserTransientNavigationError(error) {
return /execution context was destroyed.*navigation|cannot find context with specified id/i.test(coerceErrorMessage(error));
}
//#endregion
//#region src/meeting-bot/browser-request.ts
function asMeetingBrowserTabs(result) {
const record = result && typeof result === "object" ? result : {};
return Array.isArray(record.tabs) ? record.tabs : [];
}
function readMeetingBrowserTab(result) {
return result && typeof result === "object" ? result : void 0;
}
function resolveBrowserGatewayTimeoutMs(timeoutMs) {
return addTimerTimeoutGraceMs(timeoutMs) ?? 1;
}
async function callLocalBrowserRequest(params) {
return await callGatewayFromCli("browser.request", {
json: true,
timeout: String(resolveBrowserGatewayTimeoutMs(params.timeoutMs))
}, {
method: params.method,
path: params.path,
body: params.body,
timeoutMs: params.timeoutMs
}, { progress: false });
}
async function resolveLocalMeetingBrowserRequest(runtime) {
if (!await runtime.gateway.isAvailable()) return callLocalBrowserRequest;
return async (params) => await runtime.gateway.request("browser.request", {
method: params.method,
path: params.path,
body: params.body,
timeoutMs: params.timeoutMs
}, {
timeoutMs: resolveBrowserGatewayTimeoutMs(params.timeoutMs),
scopes: ["operator.admin"]
});
}
//#endregion
//#region src/meeting-bot/browser-controller.ts
function mergeBrowserNotes(browser, notes) {
if (!browser || notes.length === 0) return browser;
return {
...browser,
notes: uniqueStrings([...browser.notes ?? [], ...notes])
};
}
function applyMeetingManualAction(browser, manual) {
return browser && manual ? {
...browser,
manualAction: {
reason: manual.reason,
message: manual.message
}
} : browser;
}
async function prepareMeetingBrowserTab(params) {
const plan = params.adapter.browser.permissions({
allowMicrophone: params.allowMicrophone,
meetingUrl: params.meetingUrl
});
if (!plan) return params.adapter.browser.permissionNotes({ allowMicrophone: params.allowMicrophone });
try {
const result = await params.callBrowser({
method: "POST",
path: "/permissions/grant",
body: {
origin: plan.origin,
permissions: plan.permissions,
optionalPermissions: plan.optionalPermissions,
targetId: params.targetId,
timeoutMs: Math.min(params.timeoutMs, 5e3)
},
timeoutMs: Math.min(params.timeoutMs, 5e3)
});
return params.adapter.browser.permissionNotes({
allowMicrophone: params.allowMicrophone,
result
});
} catch (error) {
return params.adapter.browser.permissionNotes({
allowMicrophone: params.allowMicrophone,
error
});
}
}
async function openMeetingWithBrowser(params) {
if (!params.config.launch) return { launched: false };
const timeoutMs = Math.max(1e3, params.config.joinTimeoutMs);
let targetId;
let tab;
let openSession = params.session;
let openedByPlugin = false;
if (params.config.reuseExistingTab) {
const matches = asMeetingBrowserTabs(await params.callBrowser({
method: "GET",
path: "/tabs",
timeoutMs: Math.min(timeoutMs, 5e3)
})).filter((candidate) => params.adapter.urls.isRecoverableTab(candidate, params.session.url));
const accountHint = params.adapter.urls.accountHint(params.session.url);
tab = matches.find((candidate) => params.adapter.urls.isPreferredJoinUrl(candidate.url) && (!accountHint || params.adapter.urls.accountHint(candidate.url) === accountHint));
if (!tab && !params.adapter.urls.accountHint(params.session.url)) {
const fallbackUrl = matches.find((candidate) => candidate.url)?.url;
if (fallbackUrl) openSession = {
...params.session,
url: fallbackUrl
};
}
targetId = tab?.targetId;
if (tab && targetId) await params.callBrowser({
method: "POST",
path: "/tabs/focus",
body: { targetId },
timeoutMs: Math.min(timeoutMs, 5e3)
});
}
if (!targetId) {
tab = readMeetingBrowserTab(await params.callBrowser({
method: "POST",
path: "/tabs/open",
body: { url: params.adapter.urls.buildJoinUrl(openSession) },
timeoutMs
}));
targetId = tab?.targetId;
openedByPlugin = Boolean(targetId);
}
if (!targetId) return {
launched: true,
browser: {
status: "browser-control",
notes: [`Browser proxy opened ${params.adapter.browserLabel} but did not return a targetId.`],
browserUrl: tab?.url,
browserTitle: tab?.title
}
};
const tabIdentity = {
targetId,
openedByPlugin
};
const allowMicrophone = params.adapter.browser.allowsMicrophone(params.session.mode);
const permissionNotes = await prepareMeetingBrowserTab({
adapter: params.adapter,
allowMicrophone,
callBrowser: params.callBrowser,
meetingUrl: params.session.url,
targetId,
timeoutMs
});
const deadline = performance.now() + Math.max(0, params.config.waitForInCallMs);
let browser = {
status: "browser-control",
browserUrl: tab?.url,
browserTitle: tab?.title,
notes: permissionNotes
};
let allowSessionAdoption = true;
do {
try {
const adoptSession = allowSessionAdoption;
allowSessionAdoption = false;
const actionTimeoutMs = Math.min(timeoutMs, 1e4);
const evaluated = await runMeetingBrowserAct({
deadline: performance.now() + actionTimeoutMs,
targetId,
operation: async (remainingMs) => await params.callBrowser({
method: "POST",
path: "/act",
body: {
kind: "evaluate",
targetId,
fn: params.adapter.browser.buildStatusJoinScript({
...params.session,
allowSessionAdoption: adoptSession,
autoJoin: params.config.autoJoin,
captureCaptions: params.session.captureCaptions ?? params.adapter.browser.captions.enabled(params.session.mode),
guestName: params.config.guestName,
waitForInCallMs: params.config.waitForInCallMs
})
},
timeoutMs: remainingMs
})
});
browser = mergeBrowserNotes(params.adapter.browser.parseStatus(evaluated) ?? browser, permissionNotes);
const manual = browser ? params.adapter.browser.classifyManualAction(browser) : void 0;
browser = applyMeetingManualAction(browser, manual);
const shouldRetry = browser ? params.adapter.browser.shouldRetryJoinStatus?.(browser) === true : false;
if (!shouldRetry && browser?.inCall === true && browser.manualAction === void 0 && (!allowMicrophone || browser.micMuted !== true)) return {
launched: true,
browser,
tab: tabIdentity
};
if (!shouldRetry && browser?.manualAction) return {
launched: true,
browser,
tab: tabIdentity
};
} catch (error) {
if (isMeetingBrowserTransientNavigationError(error) && performance.now() < deadline) browser = mergeBrowserNotes(browser, [`${params.adapter.browserLabel} navigated while joining; retrying browser inspection.`]);
else {
const manual = params.adapter.browser.browserControlUnavailable(error);
browser = {
...browser,
inCall: false,
manualAction: {
reason: manual.reason,
message: manual.message
},
notes: [...permissionNotes, `Browser control could not inspect or auto-join ${params.adapter.browserLabel}: ${coerceErrorMessage(error)}`]
};
break;
}
}
const remainingWaitMs = deadline - performance.now();
if (remainingWaitMs > 0) await sleep(Math.min(750, remainingWaitMs));
} while (performance.now() < deadline);
return {
launched: true,
browser,
tab: tabIdentity
};
}
function findRecoverableTab(params) {
const candidates = params.tabs.filter((tab) => params.adapter.urls.isRecoverableTab(tab, params.requestedMeetingUrl));
if (!params.requestedMeetingUrl) {
const meetingCandidates = candidates.filter((tab) => params.adapter.urls.normalizeForReuse(tab.url));
return meetingCandidates.find((tab) => params.adapter.urls.isPreferredJoinUrl(tab.url)) ?? meetingCandidates[0] ?? candidates[0];
}
const accountHint = params.adapter.urls.accountHint(params.requestedMeetingUrl);
const accountCandidates = accountHint ? candidates.filter((tab) => params.adapter.urls.accountHint(tab.url) === accountHint) : candidates;
return accountCandidates.find((tab) => params.adapter.urls.isPreferredJoinUrl(tab.url)) ?? accountCandidates[0];
}
async function inspectRecoverableTab(params) {
const allowMicrophone = params.adapter.browser.allowsMicrophone(params.mode);
const focusTimeoutMs = params.deadline === void 0 ? params.timeoutMs : Math.floor(params.deadline - performance.now());
if (focusTimeoutMs <= 0) throw new Error("Meeting browser recovery timed out.");
await params.callBrowser({
method: "POST",
path: "/tabs/focus",
body: { targetId: params.targetId },
timeoutMs: Math.min(focusTimeoutMs, 5e3)
});
const localeAction = params.adapter.urls.localeAction(params.tab);
if (localeAction) return {
found: true,
targetId: params.targetId,
tab: params.tab,
browser: {
status: "browser-control",
browserUrl: params.tab.url,
browserTitle: params.tab.title,
manualAction: {
reason: localeAction.reason,
message: localeAction.message
}
},
message: localeAction.message
};
const permissionNotes = params.readOnly ? [] : await prepareMeetingBrowserTab({
adapter: params.adapter,
allowMicrophone,
callBrowser: params.callBrowser,
meetingUrl: params.requestedMeetingUrl ?? params.tab.url ?? "",
targetId: params.targetId,
timeoutMs: params.deadline === void 0 ? params.timeoutMs : Math.max(1, Math.floor(params.deadline - performance.now()))
});
const navigationNotes = [];
const inspectionDeadline = params.deadline ?? performance.now() + Math.min(params.timeoutMs, 1e4);
let allowSessionAdoption = params.allowSessionAdoption ?? false;
let evaluated;
for (;;) try {
const adoptSession = allowSessionAdoption;
allowSessionAdoption = false;
evaluated = await runMeetingBrowserAct({
deadline: inspectionDeadline,
targetId: params.targetId,
operation: async (remainingMs) => await params.callBrowser({
method: "POST",
path: "/act",
body: {
kind: "evaluate",
targetId: params.targetId,
fn: params.adapter.browser.buildStatusJoinScript({
allowSessionAdoption: adoptSession,
meetingSessionId: params.meetingSessionId ?? "",
mode: params.mode,
url: params.requestedMeetingUrl ?? params.tab.url ?? "",
autoJoin: params.autoJoin ?? false,
captureCaptions: params.captureCaptions ?? params.adapter.browser.captions.enabled(params.mode),
guestName: params.config.guestName,
readOnly: params.readOnly,
waitForInCallMs: params.config.waitForInCallMs
})
},
timeoutMs: remainingMs
})
});
break;
} catch (error) {
const remainingMs = inspectionDeadline - performance.now();
if (!isMeetingBrowserTransientNavigationError(error) || remainingMs <= 0) throw error;
navigationNotes.push(`${params.adapter.browserLabel} navigated while recovering; retrying browser inspection.`);
await sleep(Math.min(250, remainingMs));
if (performance.now() >= inspectionDeadline) throw error;
}
const browser = mergeBrowserNotes(params.adapter.browser.parseStatus(evaluated) ?? {
status: "browser-control",
browserUrl: params.tab.url,
browserTitle: params.tab.title
}, [...permissionNotes, ...navigationNotes]);
const manual = browser ? params.adapter.browser.classifyManualAction(browser) : void 0;
const recoveredBrowser = applyMeetingManualAction(browser, manual);
const message = manual?.message ?? (recoveredBrowser?.inCall ? `Existing ${params.adapter.browserLabel} tab is in-call.` : `Existing ${params.adapter.browserLabel} tab focused.`);
return {
found: true,
targetId: params.targetId,
tab: params.tab,
browser: recoveredBrowser,
message
};
}
async function recoverMeetingBrowserTab(params) {
const configuredTimeoutMs = Math.max(1e3, params.config.joinTimeoutMs);
const timeoutMs = params.timeoutMs === void 0 ? configuredTimeoutMs : Math.max(1, Math.min(configuredTimeoutMs, params.timeoutMs));
const deadline = params.timeoutMs === void 0 ? void 0 : performance.now() + timeoutMs;
const tabs = asMeetingBrowserTabs(await params.callBrowser({
method: "GET",
path: "/tabs",
timeoutMs: deadline === void 0 ? Math.min(timeoutMs, 5e3) : Math.min(Math.max(1, Math.floor(deadline - performance.now())), 5e3)
}));
const trackedCandidate = params.trackedTargetId ? tabs.find((tab) => tab.targetId === params.trackedTargetId) : void 0;
const trackedUrlHasMeetingIdentity = Boolean(params.adapter.urls.normalizeForReuse(trackedCandidate?.url));
const trackedIdentityMatches = params.adapter.urls.isSameMeeting(params.trackedMeetingUrl, params.requestedMeetingUrl);
const trackedUrlMatches = params.adapter.urls.isSameMeeting(trackedCandidate?.url, params.requestedMeetingUrl);
const tab = (trackedCandidate && trackedIdentityMatches && (!trackedUrlHasMeetingIdentity || trackedUrlMatches) ? trackedCandidate : void 0) ?? findRecoverableTab({
adapter: params.adapter,
tabs,
requestedMeetingUrl: params.requestedMeetingUrl
});
const targetId = tab?.targetId;
if (!tab || !targetId) return {
found: false,
tab,
message: params.requestedMeetingUrl ? `No existing ${params.adapter.browserLabel} tab matched ${params.requestedMeetingUrl}.` : `No existing ${params.adapter.browserLabel} tab found ${params.locationLabel}.`
};
return await inspectRecoverableTab({
...params,
...deadline === void 0 ? {} : { deadline },
timeoutMs,
tab,
targetId
});
}
//#endregion
//#region src/meeting-bot/browser-session-control.ts
async function leaveMeetingInPage(params) {
const deadline = performance.now() + params.timeoutMs;
let clickedLeave = false;
let ownershipRetained = false;
do {
const remainingMs = Math.floor(deadline - performance.now());
if (remainingMs <= 0) throw new Error("Meeting browser leave timed out.");
const evaluated = await params.callBrowser({
method: "POST",
path: "/act",
body: {
kind: "evaluate",
targetId: params.targetId,
fn: params.adapter.browser.buildSessionLeaveScript?.({
leaveInitiated: clickedLeave,
meetingSessionId: params.meetingSessionId ?? "",
meetingUrl: params.meetingUrl
}) ?? params.adapter.browser.buildLeaveScript(params.meetingUrl)
},
timeoutMs: remainingMs
});
const step = params.adapter.browser.parseLeaveResult(evaluated);
clickedLeave ||= step.leaveAction === "leave";
if (step.sessionMatched === false) {
const stepOwnershipRetained = clickedLeave && step.sessionConflict !== true;
if (step.departed || !stepOwnershipRetained) return {
departed: stepOwnershipRetained ? step.departed : false,
clickedLeave,
ownershipRetained: stepOwnershipRetained,
sessionConflict: step.sessionConflict,
sessionMatched: false,
urlMatched: step.urlMatched
};
ownershipRetained = true;
}
if (step.departed || step.urlMatched !== true) return {
departed: step.departed,
clickedLeave,
...ownershipRetained && step.sessionConflict !== true ? { ownershipRetained: true } : {},
urlMatched: step.urlMatched
};
if (!step.leaveAction && !clickedLeave) return {
departed: false,
clickedLeave,
urlMatched: true
};
if (!step.leaveAction) await sleep(100);
} while (performance.now() < deadline);
return {
departed: false,
clickedLeave,
...ownershipRetained ? {
ownershipRetained: true,
sessionMatched: false
} : {},
urlMatched: true
};
}
async function leaveMeetingWithBrowser(params) {
if (!params.launch) return {
left: false,
note: "Browser leave skipped because chrome.launch is disabled."
};
const timeoutMs = Math.min(Math.max(1e3, params.timeoutMs), 5e3);
const { targetId, openedByPlugin } = params.tab;
try {
if (!asMeetingBrowserTabs(await params.callBrowser({
method: "GET",
path: "/tabs",
timeoutMs
})).find((entry) => entry.targetId === targetId)) return {
left: true,
note: `${params.adapter.browserLabel} tab is already closed.`
};
let leaveResult;
let tabClosed = false;
try {
const locked = await runMeetingBrowserAct({
deadline: performance.now() + timeoutMs,
targetId,
operation: async (remainingMs) => {
const operationDeadline = performance.now() + remainingMs;
const closeReserveMs = openedByPlugin ? Math.min(1e3, Math.max(250, Math.floor(remainingMs / 4))) : 0;
const result = await leaveMeetingInPage({
adapter: params.adapter,
callBrowser: params.callBrowser,
meetingSessionId: params.meetingSessionId,
meetingUrl: params.meetingUrl,
targetId,
timeoutMs: Math.max(1, remainingMs - closeReserveMs)
});
if (!(openedByPlugin && (result.urlMatched === true || result.departed) && (result.sessionMatched !== false || result.ownershipRetained === true))) return {
leaveResult: result,
tabClosed: false
};
const closeTimeoutMs = Math.floor(operationDeadline - performance.now());
if (closeTimeoutMs <= 0) throw new Error("Meeting browser leave timed out before the tab could close.");
await params.callBrowser({
method: "DELETE",
path: `/tabs/${targetId}`,
timeoutMs: closeTimeoutMs
});
return {
leaveResult: result,
tabClosed: true
};
}
});
leaveResult = locked.leaveResult;
tabClosed = locked.tabClosed;
} catch (error) {
return {
left: false,
note: `Browser control could not verify the ${params.adapter.browserLabel} tab before leaving: ${coerceErrorMessage(error)}`
};
}
if (leaveResult.urlMatched === false) return {
left: true,
note: `${params.adapter.browserLabel} tab moved away from this session; left its current page untouched.`
};
if (leaveResult.sessionMatched === false && leaveResult.ownershipRetained !== true) {
if (leaveResult.sessionConflict !== true) return {
left: false,
note: `Browser control could not verify that the ${params.adapter.browserLabel} tab still belongs to this OpenClaw meeting session.`
};
return {
left: true,
note: `${params.adapter.browserLabel} tab belongs to another OpenClaw meeting session; left its current call untouched.`
};
}
if (leaveResult.urlMatched !== true && !leaveResult.departed) return {
left: false,
note: "Browser control could not verify that the tracked tab still showed this meeting."
};
const { clickedLeave, departed } = leaveResult;
return {
left: openedByPlugin ? tabClosed : departed,
note: openedByPlugin ? clickedLeave ? `Clicked ${params.adapter.browserLabel}'s Leave call button and closed the ${params.adapter.browserLabel} tab.` : `Closed the ${params.adapter.browserLabel} tab to leave the meeting (Leave call button was not found).` : departed ? `Clicked ${params.adapter.browserLabel}'s Leave call button; kept the reused browser tab open.` : clickedLeave ? `Clicked ${params.adapter.browserLabel}'s Leave call button, but could not verify departure; leave it manually.` : `Could not find ${params.adapter.browserLabel}'s Leave call button in the reused browser tab; leave it manually.`
};
} catch (error) {
return {
left: false,
note: `Browser control could not leave the ${params.adapter.browserLabel} tab: ${coerceErrorMessage(error)}`
};
}
}
async function readMeetingTranscriptWithBrowser(params) {
const result = await runMeetingBrowserAct({
deadline: performance.now() + Math.max(1, params.timeoutMs),
targetId: params.tab.targetId,
operation: async (remainingMs) => await params.callBrowser({
method: "POST",
path: "/act",
body: {
kind: "evaluate",
targetId: params.tab.targetId,
fn: params.adapter.browser.captions.buildTranscriptScript({
finalize: params.finalize,
meetingSessionId: params.meetingSessionId,
meetingUrl: params.meetingUrl
})
},
timeoutMs: remainingMs
})
});
const snapshot = params.adapter.browser.captions.parseTranscript(result);
if (snapshot.urlMatched === false) throw new Error(`The tracked ${params.adapter.browserLabel} tab no longer shows this session's meeting URL.`);
if (snapshot.sessionMatched === false) throw new Error(`The tracked ${params.adapter.browserLabel} tab now belongs to another OpenClaw meeting session.`);
return {
droppedLines: snapshot.droppedLines,
...snapshot.epoch ? { epoch: snapshot.epoch } : {},
lines: snapshot.lines,
...snapshot.pendingLines ? { pendingLines: snapshot.pendingLines } : {}
};
}
//#endregion
//#region src/meeting-bot/browser-node.ts
function matchesRequestedNode(node, requested) {
return [
node.nodeId,
node.displayName,
node.remoteIp
].some((value) => value === requested);
}
function formatNodeLabel(node) {
const parts = [
node.displayName,
node.nodeId,
node.remoteIp
].filter(Boolean);
return parts.length > 0 ? parts.join(" / ") : "unknown node";
}
function describeNodeUsabilityIssues(node, adapter) {
const commands = Array.isArray(node.commands) ? node.commands : [];
const caps = Array.isArray(node.caps) ? node.caps : [];
const issues = [];
if (node.connected !== true) issues.push("offline");
if (!commands.includes(adapter.nodeCommandName)) issues.push(`missing ${adapter.nodeCommandName}`);
if (!commands.includes("browser.proxy") && !caps.includes("browser")) issues.push("missing browser.proxy/browser capability");
return issues;
}
async function listMeetingNodes(runtime, adapter, params) {
try {
return params ? await runtime.nodes.list(params) : await runtime.nodes.list();
} catch (error) {
throw new Error(`${adapter.displayName} node inventory unavailable`, { cause: error });
}
}
async function resolveMeetingBrowserNodeInfo(params) {
const requested = params.requestedNode?.trim();
if (requested) {
const matches = (await listMeetingNodes(params.runtime, params.adapter)).nodes.filter((node) => matchesRequestedNode(node, requested));
if (matches.length > 1) throw new Error(`Configured ${params.adapter.displayName} node ${requested} is ambiguous (${matches.length} matches). Pin ${params.adapter.nodeConfigPath} to a unique node id, display name, or remote IP.`);
const [node] = matches;
if (!node) throw new Error(`Configured ${params.adapter.displayName} node ${requested} was not found. Run \`openclaw nodes status\` and start or approve the Chrome node.`);
const issues = describeNodeUsabilityIssues(node, params.adapter);
if (issues.length === 0) return node;
throw new Error(`Configured ${params.adapter.displayName} node ${requested} is not usable (${formatNodeLabel(node)}): ${issues.join("; ")}. Start or reinstall \`openclaw node run\` on that Chrome host, approve pairing, and allow ${params.adapter.nodeCommandName} plus browser.proxy.`);
}
const nodes = (await listMeetingNodes(params.runtime, params.adapter, { connected: true })).nodes.filter((node) => describeNodeUsabilityIssues(node, params.adapter).length === 0);
const [node] = nodes;
if (!node) throw new Error(`No connected ${params.adapter.displayName}-capable node with browser proxy. Run \`openclaw node run\` on the Chrome host with browser proxy enabled, approve pairing, and allow ${params.adapter.nodeCommandName} plus browser.proxy.`);
if (nodes.length === 1) return node;
throw new Error(`Multiple ${params.adapter.displayName}-capable nodes connected. Set ${params.adapter.nodeConfigPath}.`);
}
async function resolveMeetingBrowserNode(params) {
const node = await resolveMeetingBrowserNodeInfo(params);
if (!node.nodeId) throw new Error(`${params.adapter.displayName} node did not include a node id.`);
return node.nodeId;
}
function unwrapNodeInvokePayload(raw, adapter) {
const record = raw && typeof raw === "object" ? raw : {};
if (typeof record.payloadJSON === "string" && record.payloadJSON.trim()) try {
return JSON.parse(record.payloadJSON);
} catch (error) {
throw new Error(`${adapter.displayName} browser proxy returned malformed payloadJSON.`, { cause: error });
}
if ("payload" in record) return record.payload;
return raw;
}
function parseBrowserProxyResult(raw, adapter) {
const payload = unwrapNodeInvokePayload(raw, adapter);
const proxy = payload && typeof payload === "object" ? payload : void 0;
if (!proxy || !("result" in proxy)) throw new Error(`${adapter.displayName} browser proxy returned an invalid result.`);
return proxy.result;
}
async function callMeetingBrowserProxyOnNode(params) {
return parseBrowserProxyResult(await params.runtime.nodes.invoke({
nodeId: params.nodeId,
command: "browser.proxy",
params: {
method: params.method,
path: params.path,
body: params.body,
timeoutMs: params.timeoutMs
},
timeoutMs: addTimerTimeoutGraceMs(params.timeoutMs) ?? 1,
scopes: ["operator.admin"]
}), params.adapter);
}
//#endregion
//#region src/meeting-bot/sox-audio-command.ts
function formatArgs(format) {
return [
"-t",
"raw",
"-r",
String(format.sampleRate),
"-c",
String(format.channels),
"-e",
format.encoding,
"-b",
String(format.bits),
...format.endian === "little" ? ["-L"] : format.endian === "big" ? ["-B"] : [],
"-"
];
}
function withBuffer(executable, bufferBytes, args) {
return [
executable,
"-q",
"--buffer",
String(bufferBytes),
...args
];
}
function buildMeetingSoxAudioCommands(params) {
const wire = formatArgs(params.format);
if (!params.device) return {
inputCommand: withBuffer(params.inputExecutable ?? "rec", params.bufferBytes, wire),
outputCommand: withBuffer(params.outputExecutable ?? "play", params.bufferBytes, wire)
};
const deviceType = params.deviceType ?? "coreaudio";
return {
inputCommand: withBuffer(params.inputExecutable ?? "sox", params.bufferBytes, [
"-t",
deviceType,
params.device,
...wire
]),
outputCommand: withBuffer(params.outputExecutable ?? "sox", params.bufferBytes, [
...wire,
"-t",
deviceType,
params.device
])
};
}
//#endregion
//#region src/meeting-bot/audio-backend.ts
const PIPEWIRE_SINK_NAME = "openclaw_meeting_audio";
const PIPEWIRE_MONITOR_NAME = `${PIPEWIRE_SINK_NAME}.monitor`;
const PIPEWIRE_SOURCE_NAME = PIPEWIRE_SINK_NAME;
const PIPEWIRE_MEETING_AUDIO_DEVICE_LABEL = "OpenClaw Meeting Audio";
const BLACKHOLE_MEETING_AUDIO_DEVICE_LABEL = "BlackHole 2ch";
function resolveMeetingAudioBackend(selection, platform = process.platform) {
if (selection && selection !== "auto") {
if (selection === "blackhole-2ch" && platform !== "darwin") throw new Error("BlackHole 2ch meeting audio requires macOS.");
if (selection === "pipewire-pulse" && platform !== "linux") throw new Error("PipeWire-Pulse meeting audio requires Linux.");
return selection;
}
if (platform === "darwin") return "blackhole-2ch";
if (platform === "linux") return "pipewire-pulse";
throw new Error(`Local Chrome meeting talk-back is unsupported on ${platform}; use transcribe mode or a macOS/Linux Chrome node.`);
}
function resolveMeetingAudioRuntimeForFormat(params) {
const format = params.format === "g711-ulaw-8khz" ? {
sampleRate: 8e3,
channels: 1,
encoding: "mu-law",
bits: 8
} : {
sampleRate: 24e3,
channels: 1,
encoding: "signed-integer",
bits: 16,
endian: "little"
};
const backend = resolveMeetingAudioBackend(params.backend, params.platform);
const bytesPerSecond = format.sampleRate * format.channels * Math.ceil(format.bits / 8);
const pulseLatencyMs = Math.max(1, Math.ceil(params.bufferBytes / bytesPerSecond * 1e3));
const defaults = backend === "blackhole-2ch" ? buildMeetingSoxAudioCommands({
bufferBytes: params.bufferBytes,
device: BLACKHOLE_MEETING_AUDIO_DEVICE_LABEL,
deviceType: "coreaudio",
format
}) : {
inputCommand: [
"parec",
"--raw",
`--device=${PIPEWIRE_SOURCE_NAME}`,
`--format=${format.encoding === "mu-law" ? "ulaw" : "s16le"}`,
`--rate=${format.sampleRate}`,
`--channels=${format.channels}`,
`--latency-msec=${pulseLatencyMs}`
],
outputCommand: [
"pacat",
"--raw",
"--playback",
`--device=${PIPEWIRE_SINK_NAME}`,
`--format=${format.encoding === "mu-law" ? "ulaw" : "s16le"}`,
`--rate=${format.sampleRate}`,
`--channels=${format.channels}`,
`--latency-msec=${pulseLatencyMs}`
]
};
return {
backend,
deviceLabel: backend === "blackhole-2ch" ? BLACKHOLE_MEETING_AUDIO_DEVICE_LABEL : PIPEWIRE_MEETING_AUDIO_DEVICE_LABEL,
inputCommand: params.inputCommand ? [...params.inputCommand] : defaults.inputCommand,
outputCommand: params.outputCommand ? [...params.outputCommand] : defaults.outputCommand
};
}
function commandOutput(result) {
return `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
}
function pulseListContains(output, name) {
return output.split(/\r?\n/u).some((line) => line.split(/\s+/u)[1] === name);
}
function assertCommandSucceeded(result, message) {
if (result.code === 0) return;
const detail = commandOutput(result).trim();
throw new Error(detail ? `${message}: ${detail}` : message);
}
async function ensureMeetingAudioBackend(params) {
if (params.backend === "blackhole-2ch") {
const result = await params.run(["/usr/sbin/system_profiler", "SPAudioDataType"], params.timeoutMs);
if (result.code !== 0 || !commandOutput(result).toLowerCase().includes("blackhole 2ch")) throw new Error("BlackHole 2ch audio device not found. Install BlackHole 2ch and SoX, then reboot.");
return;
}
assertCommandSucceeded(await params.run(["pactl", "info"], params.timeoutMs), "PipeWire-Pulse is unavailable. Start pipewire-pulse and install pulseaudio-utils");
const ensurePulseModule = async (kind, name, args) => {
if (pulseListContains((await params.run([
"pactl",
"list",
"short",
`${kind}s`
], params.timeoutMs)).stdout ?? "", name)) return;
const loaded = await params.run([
"pactl",
"load-module",
...args
], params.timeoutMs);
if (loaded.code !== 0) {
if (!pulseListContains((await params.run([
"pactl",
"list",
"short",
`${kind}s`
], params.timeoutMs)).stdout ?? "", name)) assertCommandSucceeded(loaded, `Could not create the OpenClaw PipeWire-Pulse ${kind}`);
}
};
await ensurePulseModule("sink", PIPEWIRE_SINK_NAME, [
"module-null-sink",
`sink_name=${PIPEWIRE_SINK_NAME}`,
"rate=48000",
"channels=2",
"channel_map=front-left,front-right",
`sink_properties='device.description="${PIPEWIRE_MEETING_AUDIO_DEVICE_LABEL}"'`
]);
await ensurePulseModule("source", PIPEWIRE_SOURCE_NAME, [
"module-remap-source",
`source_name=${PIPEWIRE_SOURCE_NAME}`,
`master=${PIPEWIRE_MONITOR_NAME}`,
"channels=2",
"master_channel_map=front-left,front-right",
"channel_map=front-left,front-right",
"remix=no",
`source_properties='device.description="${PIPEWIRE_MEETING_AUDIO_DEVICE_LABEL}"'`
]);
const sinks = await params.run([
"pactl",
"list",
"short",
"sinks"
], params.timeoutMs);
const sources = await params.run([
"pactl",
"list",
"short",
"sources"
], params.timeoutMs);
if (!pulseListContains(sinks.stdout ?? "", PIPEWIRE_SINK_NAME) || !pulseListContains(sources.stdout ?? "", PIPEWIRE_SOURCE_NAME)) throw new Error("OpenClaw PipeWire-Pulse sink or monitor source was not created.");
}
//#endregion
//#region src/meeting-bot/meeting-modes.ts
function isMeetingTalkBackMode(mode) {
return mode === "agent" || mode === "bidi";
}
function isMeetingRealtimeRouteReady(mode, health) {
return isMeetingTalkBackMode(mode) && health?.inCall === true && health.micMuted === false && health.audioInputRouted === true && health.audioOutputRouted === true && health.manualAction === void 0;
}
//#endregion
//#region src/meeting-bot/browser-adapter-options.ts
function createMeetingBrowserAdapterOptions(options) {
const { scripts, manualActionReasonPrefix: prefix } = options;
const transcriptDisplayName = options.transcriptDisplayName ?? options.displayName;
return {
browser: {
buildAudioCaptureScript: scripts.audioCapture,
allowsMicrophone: isMeetingTalkBackMode,
buildStatusJoinScript: (params) => scripts.status({
allowMicrophone: isMeetingTalkBackMode(params.mode),
allowSessionAdoption: params.allowSessionAdoption,
autoJoin: params.autoJoin,
captureCaptions: params.captureCaptions,
guestName: params.guestName,
meetingSessionId: params.meetingSessionId || void 0,
meetingUrl: params.url,
readOnly: params.readOnly,
waitForInCallMs: params.waitForInCallMs
}),
shouldRetryJoinStatus: (health) => health.inCall === true && (health.manualAction?.reason === `${prefix}-audio-choice-required` && health.audioInputRouted === true && health.audioOutputRouteRetryable === true || options.retryCaptions && health.manualAction === void 0 && health.captionCaptureRequested === true && health.captioning !== true),
browserControlUnavailable: () => ({
category: "browser-control-unavailable",
reason: "browser-control-unavailable",
message: options.unavailableMessage
}),
buildLeaveScript: (meetingUrl) => scripts.leave({
leaveInitiated: false,
meetingSessionId: "",
meetingUrl
}),
buildSessionLeaveScript: scripts.leave,
captions: {
enabled: () => true,
buildTranscriptScript: ({ finalize, meetingSessionId, meetingUrl }) => scripts.transcript(meetingUrl, meetingSessionId, finalize)
},
permissions: ({ allowMicrophone, meetingUrl }) => {
const origin = options.origin(meetingUrl);
return allowMicrophone && origin ? {
origin,
permissions: ["audioCapture"],
optionalPermissions: ["speakerSelection"]
} : void 0;
}
},
parsing: {
classifyManualActionReason: (reason) => {
if (reason === "browser-control-unavailable") return reason;
if (options.admissionReasons?.includes(reason)) return "admission-required";
return [
"login-required",
"admission-required",
"permission-required",
"audio-choice-required",
"session-conflict"
].find((category) => reason === `${prefix}-${category}`) ?? "custom";
},
displayName: options.displayName,
invalidTranscriptMessage: `${transcriptDisplayName} transcript payload is invalid.`,
malformedStatusMessage: `${transcriptDisplayName} browser status JSON is malformed.`,
malformedTranscriptMessage: `${transcriptDisplayName} transcript JSON is malformed.`,
...options.statusFields ? { statusFields: options.statusFields } : {}
}
};
}
//#endregion
//#region src/meeting-bot/plugin-config.ts
const DEFAULT_AUDIO_BUFFER_BYTES = 4096;
const DEFAULT_AUDIO_FORMAT = "pcm16-24khz";
const DEFAULT_MODE_HELP = "Agent consults OpenClaw, bidi uses direct realtime voice, and transcribe observes only.";
const CHROME_NODE_HELP = "Node id/name/IP that owns Chrome and the native virtual-audio backend.";
function resolveBoolean(value, fallback) {
return typeof value === "boolean" ? value : fallback;
}
function resolvePositiveNumber(value, fallback) {
return asPositiveFiniteNumber(value) ?? fallback;
}
function resolveMode(value) {
const normalized = normalizeOptionalLowercaseString(value);
return normalized === "agent" || normalized === "bidi" || normalized === "transcribe" ? normalized : "agent";
}
function resolveAudioFormat(value) {
const normalized = normalizeOptionalLowercaseString(value)?.replaceAll("_", "-");
return normalized === "g711-ulaw-8khz" ? normalized : DEFAULT_AUDIO_FORMAT;
}
function resolveAudioBackend(value) {
const normalized = normalizeOptionalLowercaseString(value)?.replaceAll("_", "-");
return normalized === "blackhole-2ch" || normalized === "pipewire-pulse" ? normalized : "auto";
}
function resolveProviders(value) {
const providers = {};
for (const [key, entry] of Object.entries(asRecord(value))) {
const id = normalizeOptionalLowercaseString(key);
if (id) providers[id] = asRecord(entry);
}
return providers;
}
function createMeetingPluginConfigSchema(options) {
const buildAudioCommands = (backend, format, bufferBytes) => {
const platform = backend === "blackhole-2ch" ? "darwin" : backend === "pipewire-pulse" ? "linux" : process.platform;
try {
return resolveMeetingAudioRuntimeForFormat({
backend,
bufferBytes,
format,
platform
});
} catch {
return resolveMeetingAudioRuntimeForFormat({
backend: "blackhole-2ch",
bufferBytes,
format,
platform: "darwin"
});
}
};
const defaultAudioRuntime = buildAudioCommands("auto", DEFAULT_AUDIO_FORMAT, DEFAULT_AUDIO_BUFFER_BYTES);
const defaults = {
enabled: true,
defaultMode: "agent",
chrome: {
audioBackend: "auto",
audioFormat: DEFAULT_AUDIO_FORMAT,
audioBufferBytes: DEFAULT_AUDIO_BUFFER_BYTES,
launch: true,
guestName: "OpenClaw Agent",
reuseExistingTab: true,
autoJoin: true,
joinTimeoutMs: 3e4,
waitForInCallMs: 6e4,
audioInputCommand: defaultAudioRuntime.inputCommand,
audioOutputCommand: defaultAudioRuntime.outputCommand,
bargeInRmsThreshold: 650,
bargeInPeakThreshold: 2500,
bargeInCooldownMs: 900
},
chromeNode: {},
realtime: {
strategy: "agent",
provider: "openai",
transcriptionProvider: "openai",
instructions: options.defaultRealtimeInstructions,
introMessage: "Say exactly: I'm here and listening.",
toolPolicy: "safe-read-only",
providers: {}
}
};
const resolveConfig = (input) => {
const raw = asRecord(input);
const chrome = asRecord(raw.chrome);
const chromeNode = asRecord(raw.chromeNode);
const realtime = asRecord(raw.realtime);
const audioFormat = resolveAudioFormat(chrome.audioFormat);
const audioBufferBytes = Math.max(17, Math.trunc(resolvePositiveNumber(chrome.audioBufferBytes, DEFAULT_AUDIO_BUFFER_BYTES)));
const audioBackend = resolveAudioBackend(chrome.audioBackend);
const generatedCommands = buildAudioCommands(audioBackend, audioFormat, audioBufferBytes);
const audioInputCommandOverride = normalizeOptionalTrimmedStringList(chrome.audioInputCommand);
const audioOutputCommandOverride = normalizeOptionalTrimmedStringList(chrome.audioOutputCommand);
const provider = normalizeOptionalString(realtime.provider) ?? defaults.realtime.provider;
return {
enabled: resolveBoolean(raw.enabled, defaults.enabled),
defaultMode: resolveMode(raw.defaultMode),
chrome: {
audioBackend,
audioFormat,
audioBufferBytes,
launch: resolveBoolean(chrome.launch, defaults.chrome.launch),
browserProfile: normalizeOptionalString(chrome.browserProfile),
guestName: normalizeOptionalString(chrome.guestName) ?? defaults.chrome.guestName,
reuseExistingTab: resolveBoolean(chrome.reuseExistingTab, defaults.chrome.reuseExistingTab),
autoJoin: resolveBoolean(chrome.autoJoin, defaults.chrome.autoJoin),
joinTimeoutMs: resolvePositiveTimerTimeoutMs(chrome.joinTimeoutMs, defaults.chrome.joinTimeoutMs),
waitForInCallMs: resolvePositiveTimerTimeoutMs(chrome.waitForInCallMs, defaults.chrome.waitForInCallMs),
audioInputCommand: audioInputCommandOverride ?? generatedCommands.inputCommand,
audioOutputCommand: audioOutputCommandOverride ?? generatedCommands.outputCommand,
audioInputCommandOverride,
audioOutputCommandOverride,
bargeInInputCommand: normalizeOptionalTrimmedStringList(chrome.bargeInInputCommand),
bargeInRmsThreshold: resolvePositiveNumber(chrome.bargeInRmsThreshold, defaults.chrome.bargeInRmsThreshold),
bargeInPeakThreshold: resolvePositiveNumber(chrome.bargeInPeakThreshold, defaults.chrome.bargeInPeakThreshold),
bargeInCooldownMs: resolvePositiveTimerTimeoutMs(chrome.bargeInCooldownMs, defaults.chrome.bargeInCooldownMs)
},
chromeNode: { node: normalizeOptionalString(chromeNode.node) },
realtime: {
strategy: normalizeOptionalLowercaseString(realtime.strategy) === "bidi" ? "bidi" : "agent",
provider,
transcriptionProvider: normalizeOptionalString(realtime.transcriptionProvider) ?? defaults.realtime.transcriptionProvider,
voiceProvider: normalizeOptionalString(realtime.voiceProvider),
model: normalizeOptionalString(realtime.model),
instructions: normalizeOptionalString(realtime.instructions) ?? defaults.realtime.instructions,
introMessage: typeof realtime.introMessage === "string" ? realtime.introMessage.trim() : defaults.realtime.introMessage,
agentId: normalizeOptionalString(realtime.agentId),
toolPolicy: resolveRealtimeVoiceAgentConsultToolPolicy(realtime.toolPolicy, defaults.realtime.toolPolicy),
providers: resolveProviders(realtime.providers)
}
};
};
return {
configSchema: {
parse: resolveConfig,
uiHints: {
defaultMode: {
label: "Default Mode",
help: DEFAULT_MODE_HELP
},
"chrome.audioBackend": {
label: "Chrome Audio Backend",
help: "Auto selects BlackHole 2ch on macOS or PipeWire-Pulse on Linux."
},
"chrome.browserProfile": {
label: "Chrome Profile",
advanced: true
},
"chrome.guestName": { label: "Guest Name" },
"chrome.waitForInCallMs": {
label: "Wait For In-Call (ms)",
advanced: true
},
"chrome.audioInputCommand": {
label: "Audio Input Command",
advanced: true
},
"chrome.audioOutputCommand": {
label: "Audio Output Command",
advanced: true
},
"chromeNode.node": {
label: "Chrome Node",
help: CHROME_NODE_HELP,
advanced: true
},
"realtime.transcriptionProvider": { label: "Realtime Transcription Provider" },
"realtime.voiceProvider": { label: "Bidi Voice Provider" },
"realtime.model": {
label: "Bidi Realtime Model",
advanced: true
},
"realtime.instructions": {
label: "Realtime Instructions",
advanced: true
},
"realtime.introMessage": { label: "Realtime Intro Message" },
"realtime.agentId": {
label: "Realtime Consult Agent",
advanced: true
},
"realtime.toolPolicy": {
label: "Realtime Tool Policy",
advanced: true
}
}
},
defaultAudioInputCommand: defaultAudioRuntime.inputCommand,
defaultAudioOutputCommand: defaultAudioRuntime.outputCommand,
resolveConfig,
resolveGatewayOperationTimeoutMs: (config) => options.resolveGatewayOperationTimeoutMs(config)
};
}
//#endregion
//#region src/meeting-bot/agent-consult.ts
function createMeetingRealtimeEngineBindings(params) {
const surface = {
id: params.platform.id,
provider: params.platform.id,
lane: params.platform.id,
...params.platform.agentConsult
};
return {
platform: {
displayName: params.platform.displayName,
logScope: params.platform.logScope,
sessionIdPrefix: params.platform.id
},
consultAgent: async (consult) => await consultMeetingAgent({
surface,
config: params.fullConfig,
runtime: params.runtime,
logger: params.logger,
agentId: params.config.realtime.agentId,
toolPolicy: params.config.realtime.toolPolicy,
...consult
}),
tools: resolveRealtimeVoiceAgentConsultTools(params.config.realtime.toolPolicy),
handleToolCall: async (call) => {
const abortSignal = readMeetingRealtimeToolAbortSignal(call.session);
await handleMeetingRealtimeConsultToolCall({
surface,
config: params.fullConfig,
runtime: params.runtime,
logger: params.logger,
agentId: params.config.realtime.agentId,
toolPolicy: params.config.realtime.toolPolicy,
abortSignal,
...call
});
}
};
}
async function submitMeetingConsultWorkingResponse(params) {
if (params.abortSignal?.aborted || !params.session.bridge.supportsToolResultContinuation) return;
await params.session.submitToolResult(params.callId, buildRealtimeVoiceAgentConsultWorkingResponse(params.label), { willContinue: true });
}
async function consultMeetingAgent(params) {
const agentId = params.agentId ? normalizeAgentId(params.agentId) : resolveDefaultAgentId(params.config);
const requesterSessionKey = normalizeOptionalString(params.requesterSessionKey) ?? `agent:${agentId}:main`;
const sessionKey = `agent:${agentId}:subagent:${params.surface.id}:${params.meetingSessionId}`;
return await consultRealtimeVoiceAgent({
cfg: params.config,
agentRuntime: params.runtime.agent,
logger: params.logger,
agentId,
sessionKey,
messageProvider: params.surface.provider,
lane: params.surface.lane,
runIdPrefix: `${params.surface.id}:${params.meetingSessionId}`,
spawnedBy: requesterSessionKey,
contextMode: "fork",
args: params.args,
transcript: params.transcript,
surface: params.surface.surface,
userLabel: params.surface.userLabel,
assistantLabel: params.surface.assistantLabel,
questionSourceLabel: params.surface.questionSourceLabel,
toolsAllow: resolveRealtimeVoiceAgentConsultToolsAllow(params.toolPolicy),
extraSystemPrompt: params.surface.extraSystemPrompt,
abortSignal: params.abortSignal
});
}
async function handleMeetingRealtimeConsultToolCall(params) {
const callId = params.event.callId || params.event.itemId;
if (params.abortSignal?.aborted) return;
const submitError = async (message) => {
await params.session.submitToolResult(callId, { error: message });
if (!params.abortSignal?.aborted) params.onTalkEvent({
type: "tool.error",
callId,
payload: {
name: params.event.name,
error: message
},
final: true
});
};
const unavailableToolError = params.strategy !== "bidi" ? `Tool "${params.event.name}" is only available in bidi realtime strategy` : params.event.name !== "openclaw_agent_consult" ? `Tool "${params.event.name}" not available` : void 0;
if (unavailableToolError) {
await submitError(unavailableToolError);
return;
}
await submitMeetingConsultWorkingResponse({
session: params.session,
abortSignal: params.abortSignal,
callId,
label: params.surface.workingResponseLabel
});
if (params.abortSignal?.aborted) return;
params.onTalkEvent({
type: "tool.progress",
callId,
payload: {
name: params.event.name,
status: "working"
}
});
let result;
try {
result = await consultMeetingAgent({
...params,
args: params.event.args
});
} catch (error) {
if (params.abortSignal?.aborted) return;
await submitError(formatErrorMessage(error));
return;
}
if (params.abortSignal?.aborted) return;
await params.session.submitToolResult(callId, result);
if (params.abortSignal?.aborted) return;
params.onTalkEvent({
type: "tool.result",
callId,
payload: {
name: params.event.name,
result
},
final: true
});
}
//#endregion
//#region src/meeting-bot/chrome-node-result.ts
/** Unwraps the node.invoke envelope while keeping result validation transport-generic. */
function parseMeetingChromeNodeResult(raw, invalidMessage) {
const value = raw && typeof raw === "object" && "payload" in raw ? raw.payload : raw;
if (!value || typeof value !== "object") throw new Error(invalidMessage);
return value;
}
//#endregion
//#region src/meeting-bot/realtime-browser-audio-transport.ts
async function createBrowserMeetingRealtimeAudioTransport(params) {
const { buildCaptureScript, targetId } = params;
if (params.hasConfiguredInputCommand || !buildCaptureScript) return params.nativeTransport;
if (!targetId) throw new Error("Meeting browser audio capture requires its tracked tab.");
const captureId = randomUUID();
let stopped = false;
let inputStarted = false;
let fatal = false;
let fatalHandler;
let stopPromise;
let timer;
const callCapture = (action) => runMeetingBrowserAct({
deadline: performance.now() + 5e3,
targetId,
operation: async (timeoutMs) => {
if (stopped && action !== "stop") return { closed: true };
const response = await params.callBrowser({
method: "POST",
path: "/act",
timeoutMs,
body: {
kind: "evaluate",
targetId,
fn: buildCaptureScript({
action,
captureId,
meetingSessionId: params.meetingSessionId,
meetingUrl: params.meetingUrl
})
}
});
const raw = asOptionalRecord(response)?.result;
const result = typeof raw === "string" ? asOptionalRecord(JSON.parse(raw)) : void 0;
if (!result || result.captureId !== captureId) throw new Error("Meeting browser returned an invalid audio capture response.");
return result;
}
});
const stop = () => {
stopPromise ??= (async () => {
stopped = true;
clearTimeout(timer);
await Promise.all([params.nativeTransport.stop(), callCapture("stop").catch((error) => {
params.logger.warn(`Meeting browser audio cleanup failed: ${formatErrorMessage(error)}`);
})]);
})();
return stopPromise;
};
const fail = (error) => {
if (stopped || fatal) return;
fatal = true;
if (error) params.logger.warn(`Meeting browser audio capture failed: ${formatErrorMessage(error)}`);
stop().catch((cleanupError) => {
params.logger.warn(`Meeting audio cleanup failed: ${formatErrorMessage(cleanupError)}`);
});
fatalHandler?.();
};
params.nativeTransport.onFatal(() => fail());
try {
const result = await callCapture("start");
if (stopped || result.isolated !== true) throw new Error("Meeting browser could not isolate remote playback from its virtual microphone.");
} catch (error) {
await stop();
throw error;
}
return {
inputAudioIsolated: true,
onFatal(handler) {
fatalHandler = handler;
if (fatal) handler();
},
startInput(onAudio) {
if (inputStarted) throw new Error("audio input transport already started");
inputStarted = true;
params.nativeTransport.startInput(() => {});
const pull = async () => {
if (stopped) return;
try {
const result = await callCapture("pull");
if (stopped) return;
if (result.closed === true || result.isolated !== true) {
fail(/* @__PURE__ */ new Error("Meeting browser audio capture lost its session."));
return;
}
if (typeof result.base64 === "string" && result.base64) {
if (result.base64.length > 65536) throw new Error("Meeting browser audio exceeded its input buffer.");
const pcm = decodeMeetingAudioBase64(result.base64, "meeting browser audio");
if (pcm.length % 2 !== 0) throw new Error("Meeting browser returned incomplete PCM samples.");
onAudio(params.audioFormat === "pcm16-24khz" ? pcm : convertPcmToMulaw8k(pcm, 24e3));
}
if (!stopped) timer = setTimeout(() => void pull(), 50);
} catch (error) {
fail(error);
}
};
pull();
},
beginOutput: () => params.nativeTransport.beginOutput?.(),
writeOutput: (audio) => stopped ? Promise.resolve() : params.nativeTransport.writeOutput(audio),
clearOutput: () => stopped ? Promise.resolve() : params.nativeTransport.clearOutput(),
...params.nativeTransport.startBargeInMonitor ? { startBargeInMonitor: (handler) => {
if (!stopped) params.nativeTransport.startBargeInMonitor?.(handler);
} } : {},
getHealth: () => params.nativeTransport.getHealth?.() ?? {},
stop,
dispose: stop
};
}
//#endregion
//#region src/meeting-bot/chrome-transport.ts
function createMeetingChromeTransportWithAudioPolicy(options, audioBridge) {
async function openOrRecoverMeeting(params) {
const captureCaptions = params.mode === "transcribe" || resolveTranscriptsConfig(params.fullConfig.transcripts).enabled;
if (params.config.chrome.launch) return await openMeetingWithBrowser({
adapter: options.platform,
callBrowser: params.callBrowser,
config: params.config.chrome,
session: {
captureCaptions,
meetingSessionId: params.meetingSessionId,
mode: params.mode,
url: params.url
}
});
const recovered = await recoverMeetingBrowserTab({
adapter: options.platform,
allowSessionAdoption: true,
autoJoin: params.config.chrome.autoJoin,
callBrowser: params.callBrowser,
captureCaptions,
config: params.config.chrome,
locationLabel: params.locationLabel,
meetingSessionId: params.meetingSessionId,
mode: params.mode,
requestedMeetingUrl: params.url,
trackedMeetingUrl: params.url,
trackedTargetId: params.trackedTargetId
});
return {
launched: false,
browser: recovered.browser,
tab: recovered.targetId ? {
targetId: recovered.targetId,
openedByPlugin: false
} : void 0
};
}
async function rollbackBrowserJoin(params) {
if (!params.tab) return;
if (options.startupFailurePolicy === "owned" && (!params.config.chrome.launch || !params.tab.openedByPlugin)) return;
const result = await leaveMeetingWithBrowser({
adapter: options.platform,
callBrowser: params.callBrowser,
launch: true,
meetingSessionId: params.meetingSessionId,
meetingUrl: params.url,
tab: params.tab,
timeoutMs: params.config.chrome.joinTimeoutMs
}).catch((error) => ({
left: false,
note: error instanceof Error ? error.message : String(error)
}));
if (!result.left) params.logger.warn(`${options.platform.logScope} browser rollback after realtime startup failure did not complete: ${result.note}`);
}
async function prepareAudioRuntime(params) {
const audio = resolveMeetingAudioRuntimeForFormat({
backend: params.config.chrome.audioBackend,
bufferBytes: params.config.chrome.audioBufferBytes,
format: params.config.chrome.audioFormat,
inputCommand: params.config.chrome.audioInputCommandOverride,
outputCommand: params.config.chrome.audioOutputCommandOverride
});
await ensureMeetingAudioBackend({
backend: audio.backend,
timeoutMs: params.timeoutMs,
run: async (argv, timeoutMs) => {
const result = await params.runtime.system.runCommandWithTimeout(argv, { timeoutMs });
return {
...result,
code: result.code ?? 1
};
}
});
return audio;
}
async function assertAudioDeviceAvailable(params) {
await prepareAudioRuntime(params);
}
async function startRealtimeEngine(params, transport, node) {
const bindings = options.runtime.createBindings({
platform: options.platform,
...params
});
const engineContext = {
meetingSessionId: params.meetingSessionId,
requesterSessionKey: params.requesterSessionKey,
...node ? { logPrefix: "node" } : {},
transport,
logger: params.logger
};
if (params.mode === "agent") return await options.runtime.startAgentRealtimeEngine({
config: params.config,
fullConfig: params.fullConfig,
runtime: params.runtime,
platform: bindings.platform,
...engineContext,
consultAgent: bindings.consultAgent
});
return await options.runtime.startRealtimeEngine({
config: {
...params.config,
realtime: {
...params.config.realtime,
strategy: "bidi"
}
},
fullConfig: params.fullConfig,
runtime: params.runtime,
...bindings,
...engineContext,
...node ? {
talkSessionId: `${options.platform.id}:${params.meetingSessionId}:${node.bridgeId}:node-realtime`,
talkContext: node
} : {}
});
}
async function startLocalAudioBridge(params) {
if (!options.isTalkBackMode(params.mode)) return;
if (audioBridge.external) {
if (params.config.chrome.audioBridgeCommand) {
if (params.mode === "agent") throw new Error("Chrome agent mode requires chrome.audioInputCommand and chrome.audioOutputCommand so OpenClaw can run STT and regular TTS directly.");
const bridge = await params.runtime.system.runCommandWithTimeout(params.config.chrome.audioBridgeCommand, { timeoutMs: params.config.chrome.joinTimeoutMs });
if (bridge.code !== 0) throw new Error(`failed to start Chrome audio bridge: ${bridge.stderr || bridge.stdout || bridge.code}`);
return audioBridge.external();
}
if (!params.config.chrome.audioInputCommand || !params.config.chrome.audioOutputCommand) throw new Error("Chrome talk-back mode requires chrome.audioInputCommand and chrome.audioOutputCommand, or chrome.audioBridgeCommand for an external bridge.");
}
let transport = options.runtime.createLocalAudioTransport({
inputCommand: params.audio.inputCommand,
outputCommand: params.audio.outputCommand,
audioFormat: params.config.chrome.audioFormat,
bargeInInputCommand: params.config.chrome.bargeInInputCommand,
bargeInRmsThreshold: params.config.chrome.bargeInRmsThreshold,
bargeInPeakThreshold: params.config.chrome.bargeInPeakThreshold,
bargeInCooldownMs: params.config.chrome.bargeInCooldownMs,
logger: params.logger,
logScope: options.platform.logScope
});
try {
transport = await createBrowserMeetingRealtimeAudioTransport({
...params,
nativeTransport: transport,
hasConfiguredInputCommand: params.config.chrome.audioInputCommandOverride !== void 0,
buildCaptureScript: options.platform.browser.buildAudioCaptureScript?.bind(options.platform.browser),
meetingUrl: params.url,
targetId: params.tab?.targetId,
audioFormat: params.config.chrome.audioFormat
});
const engine = await startRealtimeEngine(params, transport);
return audioBridge.local(engine, params.audio);
} catch (error) {
await transport.dispose().catch(() => {});
throw error;
}
}
async function launchInChrome(params) {
const audio = options.isTalkBackMode(params.mode) ? await prepareAudioRuntime({
runtime: params.runtime,
config: params.config,
timeoutMs: Math.min(params.config.chrome.joinTimeoutMs, 1e4)
}) : void 0;
if (audio && audioBridge.external && params.config.chrome.audioBridgeHealthCommand) {
const health = await params.runtime.system.runCommandWithTimeout(params.config.chrome.audioBridgeHealthCommand, { timeoutMs: params.config.chrome.joinTimeoutMs });
if (health.code !== 0) throw new Error(`Chrome audio bridge health check failed: ${health.stderr || health.stdout || health.code}`);
}
const callBrowser = await resolveLocalMeetingBrowserRequest(params.runtime);
const result = await openOrRecoverMeeting({
...params,
callBrowser,
locationLabel: "in local Chrome"
});
if (!options.isRealtimeRouteReady(params.mode, result.browser)) return {
...result,
audioBackend: audio?.backend
};
try {
return {
...result,
audioBackend: audio?.backend,
audioBridge: audio ? await startLocalAudioBridge({
...params,
audio,
callBrowser,
tab: result.tab
}) : void 0
};
} catch (error) {
if (!options.preserveTrackedBrowserOnEngineFailure || !params.trackedTargetId) await rollbackBrowserJoin({
...params,
callBrowser,
tab: result.tab
});
throw error;
}
}
const parseNodeResult = (raw) => parseMeetingChromeNodeResult(raw, `${options.meetingLabel} node returned an invalid start result.`);
async function resolveBrowserRequest(runtime, nodeId) {
return nodeId ? async (request) => await callMeetingBrowserProxyOnNode({
runtime,
nodeId,
adapter: options.browserNodeAdapter,
...request
}) : await resolveLocalMeetingBrowserRequest(runtime);
}
async function launchOnNode(params) {
const nodeId = await resolveMeetingBrowserNode({
adapter: options.browserNodeAdapter,
runtime: params.runtime,
requestedNode: params.config.chromeNode.node
});
try {
await params.runtime.nodes.invoke({
nodeId,
command: options.nodeCommandName,
params: {
action: "stopByUrl",
url: params.url,
mode: params.mode
},
timeoutMs: 5e3
});
} catch (error) {
params.logger.debug?.(`${options.platform.logScope} node bridge cleanup ignored: ${error instanceof Error ? error.message : String(error)}`);
}
const audioSetup = options.isTalkBackMode(params.mode) ? parseNodeResult(await params.runtime.nodes.invoke({
nodeId,
command: options.nodeCommandName,
params: {
action: "setup",
audioBackend: params.config.chrome.audioBackend,
audioFormat: params.config.chrome.audioFormat,
audioBufferBytes: params.config.chrome.audioBufferBytes,
...params.config.chrome.audioInputCommandOverride ? { audioInputCommand: params.config.chrome.audioInputCommandOverride } : {},
...params.config.chrome.audioOutputCommandOverride ? { audioOutputCommand: params.config.chrome.audioOutputCommandOverride } : {}
},
timeoutMs: 12e3
})) : void 0;
const callBrowser = await resolveBrowserRequest(params.runtime, nodeId);
const browser = await openOrRecoverMeeting({
...params,
callBrowser,
locationLabel: "on the selected Chrome node"
});
if (!options.isRealtimeRouteReady(params.mode, browser.browser)) return {
nodeId,
launched: browser.launched,
audioBackend: audioSetup?.audioBackend,
browser: browser.browser,
tab: browser.tab
};
let startedBridgeId;
let audioTransport;
try {
const raw = await params.runtime.nodes.invoke({
nodeId,
command: options.nodeCommandName,
params: {
action: "start",
url: params.url,
mode: params.mode,
launch: false,
browserProfile: params.config.chrome.browserProfile,
joinTimeoutMs: params.config.chrome.joinTimeoutMs,
...params.config.chrome.audioInputCommandOverride ? { audioInputCommand: params.config.chrome.audioInputCommandOverride } : {},
...params.config.chrome.audioOutputCommandOverride ? { audioOutputCommand: params.config.chrome.audioOutputCommandOverride } : {},
audioBackend: params.config.chrome.audioBackend,
audioFormat: params.config.chrome.audioFormat,
audioBufferBytes: params.config.chrome.audioBufferBytes,
...audioBridge.external ? {
audioBridgeCommand: params.config.chrome.audioBridgeCommand,
audioBridgeHealthCommand: params.config.chrome.audioBridgeHealthCommand
} : {}
},
timeoutMs: addTimerTimeoutGraceMs(params.config.chrome.joinTimeoutMs) ?? 1
});
const result = parseNodeResult(raw);
if (result.audioBridge?.type !== "node-command-pair") return {
nodeId,
launched: browser.launched || result.launched === true,
audioBackend: result.audioBackend ?? audioSetup?.audioBackend,
browser: browser.browser ?? result.browser,
tab: browser.tab,
...audioBridge.external && result.audioBridge?.type === "external-command" ? { audioBridge: audioBridge.external() } : {}
};
if (!result.bridgeId) throw new Error(`${options.meetingLabel} node did not return an audio bridge id.`);
startedBridgeId = result.bridgeId;
let transport = options.runtime.createNodeAudioTransport({
runtime: params.runtime,
nodeId,
bridgeId: result.bridgeId,
audioFormat: params.config.chrome.audioFormat,
logger: params.logger,
commandName: options.nodeCommandName,
logScope: options.platform.logScope,
logPrefix: params.mode === "agent" ? "node agent" : "node"
});
audioTransport = transport;
Reflect.set(transport, Symbol.for("openclaw.internal.meeting-node-output-generation.v1"), result.audioBridge.outputGeneration === true);
transport = await createBrowserMeetingRealtimeAudioTransport({
...params,
nativeTransport: transport,
hasConfiguredInputCommand: params.config.chrome.audioInputCommandOverride !== void 0,
callBrowser,
buildCaptureScript: options.platform.browser.buildAudioCaptureScript?.bind(options.platform.browser),
meetingUrl: params.url,
targetId: browser.tab?.targetId,
audioFormat: params.config.chrome.audioFormat
});
audioTransport = transport;
const engine = await startRealtimeEngine(params, transport, {
nodeId,
bridgeId: result.bridgeId
});
return {
nodeId,
launched: browser.launched || result.launched === true,
audioBackend: result.audioBackend ?? audioSetup?.audioBackend,
audioBridge: {
type: "node-command-pair",
nodeId,
bridgeId: result.bridgeId,
...engine
},
browser: browser.browser ?? result.browser,
tab: browser.tab
};
} catch (error) {
await audioTransport?.dispose().catch(() => {});
if (options.startupFailurePolicy === "owned") {
if (!audioTransport && startedBridgeId) await params.runtime.nodes.invoke({
nodeId,
command: options.nodeCommandName,
params: {
action: "stop",
bridgeId: startedBridgeId
},
timeoutMs: 5e3
}).catch(() => {});
} else await params.runtime.nodes.invoke({
nodeId,
command: options.nodeCommandName,
params: {
action: "stopByUrl",
url: params.url,
mode: params.mode
},
timeoutMs: 5e3
}).catch(() => {});
if (!options.preserveTrackedBrowserOnEngineFailure || !params.trackedTargetId) await rollbackBrowserJoin({
...params,
callBrowser,
tab: browser.tab
});
throw error;
}
}
async function recoverCurrentTab(params) {
const nodeId = params.transport === "chrome-node" ? params.nodeId ?? await resolveMeetingBrowserNode({
adapter: options.browserNodeAdapter,
runtime: params.runtime,
requestedNode: params.config.chromeNode.node
}) : void 0;
return {
transport: params.transport,
...nodeId ? { nodeId } : {},
...await recoverMeetingBrowserTab({
adapter: options.platform,
callBrowser: await resolveBrowserRequest(params.runtime, nodeId),
captureCaptions: params.mode === "transcribe" || resolveTranscriptsConfig(params.fullConfig?.transcripts).enabled,
config: params.config.chrome,
locationLabel: nodeId ? "on the selected Chrome node" : "in local Chrome",
meetingSessionId: params.meetingSessionId,
mode: params.mode,
readOnly: params.readOnly,
requestedMeetingUrl: params.url,
trackedMeetingUrl: params.trackedMeetingUrl,
trackedTargetId: params.trackedTargetId,
timeoutMs: params.timeoutMs
})
};
}
async function leaveInBrowser(params) {
return await leaveMeetingWithBrowser({
adapter: options.platform,
callBrowser: await resolveBrowserRequest(params.runtime, params.nodeId),
launch: params.config.chrome.launch || !params.tab.openedByPlugin,
meetingSessionId: params.meetingSessionId,
meetingUrl: params.meetingUrl,
tab: params.tab,
timeoutMs: params.config.chrome.joinTimeoutMs
});
}
async function readTranscript(params) {
return await readMeetingTranscriptWithBrowser({
adapter: options.platform,
callBrowser: await resolveBrowserRequest(params.runtime, params.nodeId),
finalize: params.finalize === true,
meetingUrl: params.meetingUrl,
meetingSessionId: params.meetingSessionId,
tab: params.tab,
timeoutMs: Math.min(Math.max(1e3, params.config.chrome.joinTimeoutMs), 1e4)
});
}
return {
assertAudioDeviceAvailable,
launchInChrome,
launchOnNode,
leaveInBrowser,
readTranscript,
recoverCurrentTab
};
}
function createMeetingChromeTransport(options) {
return createMeetingChromeTransportWithAudioPolicy(options, { local: (engine) => ({
type: "command-pair",
...engine
}) });
}
function createMeetingChromeTransportWithExternalAudio(options) {
return createMeetingChromeTransportWithAudioPolicy(options, {
local: (engine, audio) => ({
type: "command-pair",
inputCommand: audio.inputCommand,
outputCommand: audio.outputCommand,
...engine
}),
external: () => ({ type: "external-command" })
});
}
//#endregion
//#region src/meeting-bot/node-audio-config.ts
const DEFAULT_NODE_AUDIO_BUFFER_BYTES = 4096;
const DEFAULT_NODE_AUDIO_FORMAT = "pcm16-24khz";
function readMeetingNodeCommand(value) {
if (!Array.isArray(value)) return;
const result = value.filter((entry) => typeof entry === "string" && entry.length > 0);
return result.length > 0 ? result : void 0;
}
function readAudioBackend(value) {
if (value === void 0) return;
if (value === "auto" || value === "blackhole-2ch" || value === "pipewire-pulse") return value;
throw new Error("audioBackend must be auto, blackhole-2ch, or pipewire-pulse");
}
function readAudioFormat(value) {
if (value === void 0) return;
if (value === "pcm16-24khz" || value === "g711-ulaw-8khz") return value;
throw new Error("audioFormat must be pcm16-24khz or g711-ulaw-8khz");
}
function readAudioBufferBytes(value) {
if (value === void 0) return;
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) return value;
throw new Error("audioBufferBytes must be a positive safe integer");
}
/** Resolves untrusted node params before any backend or command process starts. */
async function prepareMeetingNodeAudio(params, timeoutMs, options) {
const defaults = options.defaultAudio;
const config = {
backend: readAudioBackend(params.audioBackend) ?? defaults?.backend ?? "auto",
bufferBytes: readAudioBufferBytes(params.audioBufferBytes) ?? defaults?.bufferBytes ?? DEFAULT_NODE_AUDIO_BUFFER_BYTES,
format: readAudioFormat(params.audioFormat) ?? defaults?.format ?? DEFAULT_NODE_AUDIO_FORMAT,
inputCommand: readMeetingNodeCommand(params.audioInputCommand),
outputCommand: readMeetingNodeCommand(params.audioOutputCommand),
bargeInInputCommand: readMeetingNodeCommand(params.bargeInInputCommand)
};
if (options.prepareAudio) return await options.prepareAudio(config, timeoutMs);
await options.assertAudioAvailable(timeoutMs);
return {
backend: "blackhole-2ch",
deviceLabel: "BlackHole 2ch",
inputCommand: config.inputCommand ?? [...options.defaultAudioInputCommand],
outputCommand: config.outputCommand ?? [...options.defaultAudioOutputCommand]
};
}
//#endregion
//#region src/meeting-bot/node-audio-pull-waiters.ts
/** Internal pull-wait ownership used by the node-host long poll. */
var MeetingNodeAudioPullWaiters = class {
#waiters = /* @__PURE__ */ new Set();
get size() {
return this.#waiters.size;
}
async wait(timeoutMs) {
const { promise: ready, resolve: wake } = createDeferredCore();
this.#waiters.add(wake);
try {
await raceWithTimeout(ready, timeoutMs, () => {});
} finally {
this.#waiters.delete(wake);
}
}
wake() {
const waiters = [...this.#waiters];
this.#waiters.clear();
for (const waiter of waiters) waiter();
}
};
//#endregion
//#region src/meeting-bot/node-host.ts
const NODE_BRIDGE_TERMINATION_GRACE_MS = 2e3;
const NODE_BRIDGE_INPUT_DRAIN_MS = 3e3;
const NODE_BRIDGE_TERMINAL_RETENTION_MS = 5e3;
const NODE_BRIDGE_MAX_QUEUED_INPUT_CHUNKS = 200;
const NODE_BRIDGE_MAX_QUEUED_INPUT_BYTES = 1048576;
function readOutputGeneration(value) {
if (value === void 0) return;
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return value;
throw new Error("outputGeneration must be a non-negative integer");
}
function runCommandWithTimeout(argv, timeoutMs) {
const { command, args } = splitCommandArgv(argv, "command");
const result = spawnSync(command, args, {
encoding: "utf8",
timeout: timeoutMs
});
const errorMessage = result.error ? formatErrorMessage(result.error) : "";
const stderr = errorMessage && result.stderr ? `${errorMessage}: ${result.stderr}` : errorMessage || result.stderr || (result.signal ? `terminated by ${result.signal}` : "");
return {
code: typeof result.status === "number" ? result.status : 1,
stdout: result.stdout ?? "",
stderr
};
}
function waitForInputDrain(stream, timeoutMs) {
if (!stream || stream.readableEnded || stream.destroyed) return Promise.resolve();
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
stream.off("end", finish);
stream.off("close", finish);
resolve();
};
const timeout = setTimeout(finish, timeoutMs);
timeout.unref?.();
stream.once("end", finish);
stream.once("close", finish);
if (stream.readableEnded || stream.destroyed) finish();
});
}
function createMeetingNodeHost(options) {
const sessions = /* @__PURE__ */ new Map();
const activeProcesses = /* @__PURE__ */ new Set();
const trackProcess = (child) => {
activeProcesses.add(child);
child.once("close", () => activeProcesses.delete(child));
return child;
};
const releaseOutputWriteWaiters = (session, output) => {
for (const waiter of session.outputWriteWaiters) if (!output || waiter.process === output) waiter.release();
};
const retireOutputProcess = (session, outputProcess) => {
const stopPromise = terminateMeetingBridgeProcess(outputProcess, { graceMs: NODE_BRIDGE_TERMINATION_GRACE_MS });
session.retiredOutputStops.add(stopPromise);
stopPromise.finally(() => {
session.retiredOutputStops.delete(stopPromise);
});
};
const deleteSession = (session) => {
if (session.terminalEvictionTimer) {
clearTimeout(session.terminalEvictionTimer);
session.terminalEvictionTimer = void 0;
}
session.chunks.length = 0;
session.queuedInputBytes = 0;
if (sessions.get(session.id) === session) sessions.delete(session.id);
};
const armTerminalEviction = (session) => {
if (session.terminalEvictionTimer) clearTimeout(session.terminalEvictionTimer);
session.terminalEvictionTimer = setTimeout(() => {
deleteSession(session);
}, NODE_BRIDGE_TERMINAL_RETENTION_MS);
session.terminalEvictionTimer.unref?.();
};
const stopSession = (session, stopOptions = {}) => {
session.stopping = true;
if (stopOptions.discardQueuedAudio) {
session.discardQueuedAudioOnStop = true;
if (session.terminalEvictionTimer) {
clearTimeout(session.terminalEvictionTimer);
session.terminalEvictionTimer = void 0;
}
session.chunks.length = 0;
session.queuedInputBytes = 0;
if (!session.closed) session.closed = true;
session.waiters.wake();
}
releaseOutputWriteWaiters(session);
if (session.stopPromise) return session.stopPromise;
const terminalReady = session.discardQueuedAudioOnStop ? Promise.resolve() : waitForInputDrain(session.input?.stdout, NODE_BRIDGE_INPUT_DRAIN_MS).then(() => {
if (session.discardQueuedAudioOnStop || session.closed) return;
session.closed = true;
session.waiters.wake();
});
session.stopPromise = Promise.all([
terminateMeetingBridgeProcess(session.input, { graceMs: NODE_BRIDGE_TERMINATION_GRACE_MS }),
terminateMeetingBridgeProcess(session.output, { graceMs: NODE_BRIDGE_TERMINATION_GRACE_MS }),
...session.retiredOutputStops,
terminalReady
]).then(() => {
if (session.discardQueuedAudioOnStop) {
deleteSession(session);
return;
}
armTerminalEviction(session);
});
return session.stopPromise;
};
const attachOutputProcessHandlers = (session, outputProcess) => {
const stopIfCurrent = () => {
if (session.output === outputProcess) stopSession(session);
};
outputProcess.on("exit", stopIfCurrent);
outputProcess.on("error", stopIfCurrent);
outputProcess.stdin?.on("error", stopIfCurrent);
outputProcess.stderr?.on("error", stopIfCurrent);
};
const startOutputProcess = (command) => trackProcess(spawn(command.command, command.args, { stdio: [
"pipe",
"ignore",
"pipe"
] }));
const startCommandPair = (params) => {
const input = splitCommandArgv(params.inputCommand, "audio command");
const output = splitCommandArgv(params.outputCommand, "audio command");
const session = {
id: `${options.bridgeIdPrefix}${randomUUID()}`,
url: params.url,
mode: params.mode,
outputCommand: output,
chunks: [],
queuedInputBytes: 0,
waiters: new MeetingNodeAudioPullWaiters(),
closed: false,
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
lastInputBytes: 0,
lastOutputBytes: 0,
clearCount: 0,
outputGeneration: 0,
outputWriteWaiters: /* @__PURE__ */ new Set(),
retiredOutputStops: /* @__PURE__ */ new Set(),
stopping: false,
discardQueuedAudioOnStop: false
};
const outputProcess = startOutputProcess(output);
let inputProcess;
try {
inputProcess = trackProcess(spawn(input.command, input.args, { stdio: [
"ignore",
"pipe",
"pipe"
] }));
} catch (error) {
outputProcess.on("error", () => {});
terminateMeetingBridgeProcess(outputProcess, { graceMs: NODE_BRIDGE_TERMINATION_GRACE_MS });
throw error;
}
session.input = inputProcess;
session.output = outputProcess;
inputProcess.stdout?.on("data", (chunk) => {
if (session.discardQueuedAudioOnStop || session.closed) return;
const audio = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
session.lastInputAt = (/* @__PURE__ */ new Date()).toISOString();
session.lastInputBytes += audio.byteLength;
const retainedAudio = Buffer.from(audio.subarray(Math.max(0, audio.byteLength - NODE_BRIDGE_MAX_QUEUED_INPUT_BYTES)));
session.chunks.push(retainedAudio);
session.queuedInputBytes += retainedAudio.byteLength;
while (session.chunks.length > NODE_BRIDGE_MAX_QUEUED_INPUT_CHUNKS || session.queuedInputBytes > NODE_BRIDGE_MAX_QUEUED_INPUT_BYTES) {
const evicted = session.chunks.shift();
if (evicted) session.queuedInputBytes -= evicted.byteLength;
}
if (!session.stopPromise) session.waiters.wake();
});
const stop = () => {
stopSession(session);
};
inputProcess.on("exit", stop);
inputProcess.on("error", stop);
inputProcess.stdout?.on("error", stop);
inputProcess.stderr?.on("error", stop);
attachOutputProcessHandlers(session, outputProcess);
sessions.set(session.id, session);
return session;
};
const pullAudio = async (params) => {
const bridgeId = readNonEmptyStringPreservingWhitespace(params.bridgeId);
if (!bridgeId) throw new Error("bridgeId required");
const session = sessions.get(bridgeId);
if (!session) throw new Error(`unknown bridgeId: ${bridgeId}`);
const timeoutMs = Math.min(asPositiveFiniteNumber(params.timeoutMs) ?? 250, 2e3);
if (session.chunks.length === 0 && !session.closed) await session.waiters.wait(timeoutMs);
const chunk = session.chunks.shift();
if (chunk) session.queuedInputBytes -= chunk.byteLength;
const closed = session.closed && session.chunks.length === 0;
if (closed) deleteSession(session);
else if (chunk && session.closed && session.terminalEvictionTimer) armTerminalEviction(session);
return {
bridgeId,
closed,
base64: chunk ? chunk.toString("base64") : void 0
};
};
const staleOutputResult = (session) => ({
bridgeId: session.id,
ok: true,
stale: true
});
const pushAudio = async (params) => {
const bridgeId = readNonEmptyStringPreservingWhitespace(params.bridgeId);
const base64 = readNonEmptyStringPreservingWhitespace(params.base64);
if (!bridgeId || !base64) throw new Error("bridgeId and base64 required");
const session = sessions.get(bridgeId);
if (!session || session.stopping || session.closed) throw new Error(`bridge is not open: ${bridgeId}`);
const requestedGeneration = readOutputGeneration(params.outputGeneration);
if (requestedGeneration !== void 0 && requestedGeneration !== session.outputGeneration) return staleOutputResult(session);
const output = session.output;
if (!output?.stdin) throw new Error(`bridge is not open: ${bridgeId}`);
const audio = decodeMeetingAudioBase64(base64, "pushAudio");
try {
await writeMeetingOutputChunk(session.outputWriteWaiters, output, output.stdin, audio);
} catch {
if (session.output !== output || requestedGeneration !== void 0 && requestedGeneration !== session.outputGeneration) return staleOutputResult(session);
stopSession(session);
throw new Error(`bridge is not open: ${bridgeId}`);
}
if (session.stopping || session.closed || session.output !== output || requestedGeneration !== void 0 && requestedGeneration !== session.outputGeneration) return staleOutputResult(session);
session.lastOutputAt = (/* @__PURE__ */ new Date()).toISOString();
session.lastOutputBytes += audio.byteLength;
return {
bridgeId,
ok: true
};
};
const clearAudio = (params) => {
const bridgeId = readNonEmptyStringPreservingWhitespace(params.bridgeId);
if (!bridgeId) throw new Error("bridgeId required");
const session = sessions.get(bridgeId);
if (!session || session.stopping || session.closed) throw new Error(`bridge is not open: ${bridgeId}`);
const requestedGeneration = readOutputGeneration(params.outputGeneration);
if (requestedGeneration !== void 0 && requestedGeneration <= session.outputGeneration) return staleOutputResult(session);
if (requestedGeneration === void 0 && session.outputGeneration >= Number.MAX_SAFE_INTEGER) throw new Error("outputGeneration exhausted");
const nextGeneration = requestedGeneration ?? session.outputGeneration + 1;
const previousOutput = session.output;
const outputProcess = startOutputProcess(session.outputCommand);
session.output = outputProcess;
session.outputGeneration = nextGeneration;
attachOutputProcessHandlers(session, outputProcess);
releaseOutputWriteWaiters(session, previousOutput);
session.clearCount += 1;
session.lastClearAt = (/* @__PURE__ */ new Date()).toISOString();
retireOutputProcess(session, previousOutput);
return {
bridgeId,
ok: true,
clearCount: session.clearCount
};
};
const startBrowser = async (params) => {
const url = options.normalizeUrl(params.url);
const timeoutMs = asPositiveFiniteNumber(params.joinTimeoutMs) ?? 3e4;
const mode = readNonEmptyStringPreservingWhitespace(params.mode);
let audioRuntime;
let bridgeId;
let audioBridge;
if (mode && options.talkBackModes.has(mode)) {
audioRuntime = await prepareMeetingNodeAudio(params, Math.min(timeoutMs, 1e4), options);
const healthCommand = readMeetingNodeCommand(params.audioBridgeHealthCommand);
if (healthCommand) {
const health = runCommandWithTimeout(healthCommand, timeoutMs);
if (health.code !== 0) throw new Error(`Chrome audio bridge health check failed: ${health.stderr || health.stdout || health.code}`);
}
const bridgeCommand = readMeetingNodeCommand(params.audioBridgeCommand);
if (bridgeCommand) {
if (mode === options.agentMode) throw new Error("Chrome agent mode requires audioInputCommand and audioOutputCommand so OpenClaw can run STT and regular TTS directly.");
const bridge = runCommandWithTimeout(bridgeCommand, timeoutMs);
if (bridge.code !== 0) throw new Error(`failed to start Chrome audio bridge: ${bridge.stderr || bridge.stdout || bridge.code}`);
audioBridge = { type: "external-command" };
} else {
bridgeId = startCommandPair({
inputCommand: audioRuntime.inputCommand,
outputCommand: audioRuntime.outputCommand,
url,
mode
}).id;
audioBridge = {
type: "node-command-pair",
outputGeneration: true
};
}
}
if (params.launch !== false) {
const argv = [
"open",
"-a",
options.browser.application,
url
];
const browserProfile = readNonEmptyStringPreservingWhitespace(params.browserProfile);
if (browserProfile) argv.push(...options.browser.buildProfileArgs(browserProfile));
try {
const result = runCommandWithTimeout(argv, timeoutMs);
if (result.code !== 0) throw new Error(`failed to launch Chrome for ${options.browserLabel}: ${result.stderr || result.stdout || result.code}`);
} catch (error) {
if (bridgeId) {
const session = sessions.get(bridgeId);
if (session) stopSession(session, { discardQueuedAudio: true });
}
throw error;
}
}
return {
launched: params.launch !== false,
audioBackend: audioRuntime?.backend,
audioDeviceLabel: audioRuntime?.deviceLabel,
bridgeId,
audioBridge,
browser: params.launch !== false ? {
status: options.browser.openedStatus,
browserUrl: url,
notes: options.browser.openedNotes
} : void 0
};
};
const bridgeStatus = (params) => {
const bridgeId = readNonEmptyStringPreservingWhitespace(params.bridgeId);
const session = bridgeId ? sessions.get(bridgeId) : void 0;
return { bridge: session ? {
bridgeId,
closed: session.stopping || session.closed,
createdAt: session.createdAt,
lastInputAt: session.lastInputAt,
lastOutputAt: session.lastOutputAt,
lastClearAt: session.lastClearAt,
lastInputBytes: session.lastInputBytes,
lastOutputBytes: session.lastOutputBytes,
clearCount: session.clearCount,
queuedInputChunks: session.chunks.length
} : bridgeId ? {
bridgeId,
closed: true
} : void 0 };
};
const summarizeSession = (session) => ({
bridgeId: session.id,
url: session.url,
mode: session.mode,
closed: session.closed,
createdAt: session.createdAt,
lastInputAt: session.lastInputAt,
lastOutputAt: session.lastOutputAt,
lastInputBytes: session.lastInputBytes,
lastOutputBytes: session.lastOutputBytes
});
const listSessions = (params) => {
const urlKey = options.normalizeMeetingKey(readNonEmptyStringPreservingWhitespace(params.url));
const mode = readNonEmptyStringPreservingWhitespace(params.mode);
return { bridges: [...sessions.values()].filter((session) => !session.stopping && !session.closed).filter((session) => !urlKey || options.normalizeMeetingKey(session.url) === urlKey).filter((session) => !mode || session.mode === mode).map(summarizeSession) };
};
const stopSessionsByUrl = async (params) => {
const urlKey = options.normalizeMeetingKey(readNonEmptyStringPreservingWhitespace(params.url));
if (!urlKey) throw new Error("url required");
const mode = readNonEmptyStringPreservingWhitespace(params.mode);
const exceptBridgeId = readNonEmptyStringPreservingWhitespace(params.exceptBridgeId);
let stopped = 0;
const stopping = [];
for (const [bridgeId, session] of sessions) {
if (exceptBridgeId && bridgeId === exceptBridgeId) continue;
if (options.normalizeMeetingKey(session.url) !== urlKey) continue;
if (mode && session.mode !== mode) continue;
const wasClosed = session.stopping || session.closed;
stopping.push(stopSession(session, { discardQueuedAudio: true }));
if (!wasClosed) stopped += 1;
}
await Promise.all(stopping);
return {
ok: true,
stopped
};
};
const stopBrowser = async (params) => {
const bridgeId = readNonEmptyStringPreservingWhitespace(params.bridgeId);
if (!bridgeId) return {
ok: true,
stopped: false
};
const session = sessions.get(bridgeId);
if (!session) return {
ok: true,
stopped: false
};
const wasStopped = session.stopping || session.closed;
await stopSession(session, { discardQueuedAudio: true });
return {
ok: true,
stopped: !wasStopped
};
};
return {
hasActiveWork: () => sessions.size > 0 || activeProcesses.size > 0,
async handleCommand(paramsJSON) {
let raw = {};
if (paramsJSON) try {
raw = JSON.parse(paramsJSON);
} catch {
throw new Error(`${options.displayName} node host received malformed params JSON.`);
}
const params = asOptionalRecord(raw) ?? {};
const action = readNonEmptyStringPreservingWhitespace(params.action);
let result;
switch (action) {
case "setup":
{
const audioRuntime = await prepareMeetingNodeAudio(params, 1e4, options);
result = {
ok: true,
audioBackend: audioRuntime.backend,
audioDeviceLabel: audioRuntime.deviceLabel
};
}
break;
case "start":
result = await startBrowser(params);
break;
case "status":
result = bridgeStatus(params);
break;
case "list":
result = listSessions(params);
break;
case "stopByUrl":
result = await stopSessionsByUrl(params);
break;
case "pullAudio":
result = await pullAudio(params);
break;
case "pushAudio":
result = await pushAudio(params);
break;
case "clearAudio":
result = clearAudio(params);
break;
case "stop":
result = await stopBrowser(params);
break;
default: throw new Error(`unsupported ${options.commandName} action`);
}
return JSON.stringify(result);
}
};
}
//#endregion
//#region src/meeting-bot/configured-node-host.ts
function isSpawnSyncTimeout(error) {
return error instanceof Error && "code" in error && error.code === "ETIMEDOUT";
}
function createMeetingConfiguredNodeHost(options) {
const runCommand = (argv, timeoutMs) => {
const [command, ...args] = argv;
if (!command) return {
code: 1,
stderr: "command must not be empty"
};
const result = spawnSync(command, args, {
encoding: "utf8",
timeout: timeoutMs
});
if (isSpawnSyncTimeout(result.error)) throw new Error(`${options.meetingLabel} audio prerequisite check timed out on the node.`);
const stderr = [
result.error ? coerceErrorMessage(result.error) : "",
result.stderr,
result.signal ? `terminated by ${result.signal}` : ""
].filter(Boolean).join(": ");
return {
code: typeof result.status === "number" ? result.status : 1,
stdout: result.stdout ?? "",
stderr
};
};
const probeCommand = (command, timeoutMs) => {
const result = spawnSync("/bin/sh", [
"-lc",
"command -v \"$1\" >/dev/null 2>&1",
"sh",
command
], {
encoding: "utf8",
timeout: timeoutMs
});
if (isSpawnSyncTimeout(result.error)) return "timed-out";
return result.status === 0 ? "found" : "missing";
};
const prepareAudio = async (config, timeoutMs) => {
const deadline = Date.now() + timeoutMs;
const commandTimeout = () => {
if (!options.sharePrerequisiteDeadline) return timeoutMs;
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) throw new Error(`${options.meetingLabel} audio prerequisite check timed out on the node.`);
return remainingMs;
};
const runtime = resolveMeetingAudioRuntimeForFormat({
backend: config.backend,
bufferBytes: config.bufferBytes,
format: config.format,
inputCommand: config.inputCommand,
outputCommand: config.outputCommand
});
await ensureMeetingAudioBackend({
backend: runtime.backend,
run: async (argv, requestedTimeoutMs) => {
return runCommand(argv, Math.min(requestedTimeoutMs, commandTimeout()));
},
timeoutMs
});
const commandNames = new Set([
runtime.inputCommand,
runtime.outputCommand,
config.bargeInInputCommand
].flatMap((command) => command?.[0] ? [command[0]] : []));
for (const command of commandNames) {
const probeResult = probeCommand(command, commandTimeout());
if (probeResult === "timed-out") throw new Error(`${options.meetingLabel} audio prerequisite check timed out on the node.`);
if (probeResult === "missing") throw new Error(`Configured audio command not found on the node: ${command}`);
}
return runtime;
};
const host = createMeetingNodeHost({
...options,
assertAudioAvailable: async (timeoutMs) => {
await prepareAudio({
backend: options.defaultAudio?.backend ?? "auto",
bufferBytes: options.defaultAudio?.bufferBytes ?? 4096,
format: options.defaultAudio?.format ?? "pcm16-24khz"
}, timeoutMs);
},
prepareAudio
});
return Object.assign(host.handleCommand, { hasActiveWork: host.hasActiveWork });
}
//#endregion
//#region src/meeting-bot/node-invoke-policy.ts
function copyCommand(command) {
return command && command.length > 0 ? [...command] : void 0;
}
function copyConfiguredAudio(target, start) {
if (start.audioBackend === "auto" || start.audioBackend === "blackhole-2ch" || start.audioBackend === "pipewire-pulse") target.audioBackend = start.audioBackend;
if (typeof start.audioBufferBytes === "number" && start.audioBufferBytes > 0) target.audioBufferBytes = start.audioBufferBytes;
if (start.audioFormat === "pcm16-24khz" || start.audioFormat === "g711-ulaw-8khz") target.audioFormat = start.audioFormat;
const hasCommandOverrideFields = "audioInputCommandOverride" in start || "audioOutputCommandOverride" in start;
for (const key of ["audioInputCommand", "audioOutputCommand"]) {
const command = copyCommand(start[`${key}Override`] ?? (hasCommandOverrideFields ? void 0 : start[key]));
if (command) target[key] = command;
}
for (const key of [
"bargeInInputCommand",
"audioBridgeCommand",
"audioBridgeHealthCommand"
]) {
const command = copyCommand(start[key]);
if (command) target[key] = command;
}
}
function denied(options, message) {
return {
ok: false,
code: options.deniedCode,
message
};
}
function approved(params) {
return {
approved: true,
params
};
}
function buildStartParams(params, options) {
let url;
try {
url = options.normalizeUrl(params.url);
} catch (error) {
return {
approved: false,
result: denied(options, error instanceof Error ? error.message : `${options.commandName} start requires url`)
};
}
const mode = readNonEmptyStringPreservingWhitespace(params.mode);
if (mode && !options.supportedModes.has(mode)) return {
approved: false,
result: denied(options, `${options.commandName} start mode is unsupported: ${mode}`)
};
const startParams = {
action: "start",
url,
launch: params.launch === false ? false : options.start.launch,
browserProfile: options.start.browserProfile,
joinTimeoutMs: options.start.joinTimeoutMs
};
if (mode) startParams.mode = mode;
copyConfiguredAudio(startParams, options.start);
return approved(startParams);
}
function denyMissing(options, action, field) {
return {
approved: false,
result: denied(options, `${options.commandName} ${action} requires ${field}`)
};
}
function buildForwardParams(params, options) {
const action = readNonEmptyStringPreservingWhitespace(params.action);
switch (action) {
case "setup": return approved({ action });
case "stop":
case "status": {
const bridgeId = readNonEmptyStringPreservingWhitespace(params.bridgeId);
return approved(bridgeId ? {
action,
bridgeId
} : { action });
}
case "list":
case "stopByUrl": {
const forwarded = { action };
const url = readNonEmptyStringPreservingWhitespace(params.url);
const mode = readNonEmptyStringPreservingWhitespace(params.mode);
const exceptBridgeId = action === "stopByUrl" ? readNonEmptyStringPreservingWhitespace(params.exceptBridgeId) : void 0;
if (action === "stopByUrl" && !url) return denyMissing(options, action, "url");
if (url) try {
forwarded.url = options.normalizeUrl(url);
} catch (error) {
return {
approved: false,
result: denied(options, error instanceof Error ? error.message : `${options.commandName} ${action} url`)
};
}
if (mode) forwarded.mode = mode;
if (exceptBridgeId) forwarded.exceptBridgeId = exceptBridgeId;
return approved(forwarded);
}
case "pullAudio": {
const forwarded = { action };
const bridgeId = readNonEmptyStringPreservingWhitespace(params.bridgeId);
const timeoutMs = asPositiveFiniteNumber(params.timeoutMs);
if (!bridgeId) return denyMissing(options, action, "bridgeId");
forwarded.bridgeId = bridgeId;
if (timeoutMs) forwarded.timeoutMs = timeoutMs;
return approved(forwarded);
}
case "pushAudio":
case "clearAudio": {
const forwarded = { action };
const bridgeId = readNonEmptyStringPreservingWhitespace(params.bridgeId);
const base64 = action === "pushAudio" ? readNonEmptyStringPreservingWhitespace(params.base64) : void 0;
if (!bridgeId) return denyMissing(options, action, "bridgeId");
if (action === "pushAudio") {
if (!base64) return denyMissing(options, action, "base64");
if (!isMeetingAudioBase64(base64)) return {
approved: false,
result: denied(options, "base64 must be a valid audio payload")
};
}
forwarded.bridgeId = bridgeId;
if (base64) forwarded.base64 = base64;
const outputGeneration = asSafeIntegerInRange(params.outputGeneration, { min: 0 });
if (params.outputGeneration !== void 0 && outputGeneration === void 0) return {
approved: false,
result: denied(options, "outputGeneration must be a non-negative safe integer")
};
if (outputGeneration !== void 0) forwarded.outputGeneration = outputGeneration;
return approved(forwarded);
}
default: return null;
}
}
function createMeetingBrowserNodeInvokePolicy(options) {
return {
commands: [options.commandName],
dangerous: true,
async handle(ctx) {
if (ctx.command !== options.commandName) return denied(options, `unsupported ${options.displayName} node command: ${ctx.command}`);
const params = asOptionalRecord(ctx.params) ?? {};
const action = readNonEmptyStringPreservingWhitespace(params.action);
if (action === "setup" && options.useConfiguredSetupCommands) {
const setupParams = { action };
copyConfiguredAudio(setupParams, options.start);
return await ctx.invokeNode({ params: setupParams });
}
const decision = action === "start" ? buildStartParams(params, options) : buildForwardParams(params, options) ?? {
approved: false,
result: denied(options, `unsupported ${options.commandName} action`)
};
if (!decision.approved) return decision.result;
return await ctx.invokeNode({ params: decision.params });
}
};
}
//#endregion
//#region src/meeting-bot/plugin-entry.ts
function readErrorDetails(error) {
return error && typeof error === "object" && "details" in error ? error.details : void 0;
}
function createMeetingPluginEntryOptions(options) {
const invalidRequest = (message) => {
throw options.invalidRequest(message);
};
const normalizeTransport = (value) => {
if (value === void 0) return;
if (value === "chrome" || value === "chrome-node") return value;
return invalidRequest("transport must be chrome or chrome-node");
};
const normalizeMode = (value) => {
if (value === void 0) return;
if (value === "agent" || value === "bidi" || value === "transcribe") return value;
return invalidRequest("mode must be agent, bidi, or transcribe");
};
const requireString = (value, name) => {
return normalizeOptionalString(value) ?? invalidRequest(`${name} required`);
};
const readSinceIndex = (raw) => {
try {
return readNonNegativeIntegerParam(raw, "sinceIndex");
} catch (error) {
return invalidRequest(formatErrorMessage(error));
}
};
const keepTrustedToolContext = (raw, client) => {
const { agentId: rawAgentId, requesterSessionKey: rawRequesterSessionKey, ...rest } = raw;
const trustedOwner = client?.internal?.pluginRuntimeOwnerId === options.id;
const agentId = trustedOwner ? normalizeOptionalString(rawAgentId) : void 0;
const requesterSessionKey = options.normalizeRequesterSessionKey(rawRequesterSessionKey, trustedOwner);
return {
...rest,
...agentId ? { agentId } : {},
...requesterSessionKey ? { requesterSessionKey } : {}
};
};
const trustedToolAgentId = (raw, client) => normalizeOptionalString(keepTrustedToolContext(raw, client).agentId);
const joinRequest = (raw, joinOptions) => {
if (!joinOptions?.allowTimeout && raw.timeoutMs !== void 0) return invalidRequest("timeoutMs is supported only by testSpeech or testListen");
try {
return {
url: options.normalizeUrl(requireString(raw.url, "url")),
transport: normalizeTransport(raw.transport),
mode: normalizeMode(raw.mode),
message: normalizeOptionalString(raw.message),
requesterSessionKey: normalizeOptionalString(raw.requesterSessionKey),
agentId: normalizeOptionalString(raw.agentId),
timeoutMs: readPositiveIntegerParam(raw, "timeoutMs")
};
} catch (error) {
if (options.isInvalidRequest(error)) throw error;
return invalidRequest(formatErrorMessage(error));
}
};
const gatewayMethod = (action) => `${options.gatewayMethodPrefix}.${action}`;
const callGatewayFromTool = async (params) => {
try {
const timeoutMs = options.resolveGatewayTimeoutMs(params.config);
if (params.runtime) return await params.runtime.gateway.request(gatewayMethod(params.action), params.raw, {
timeoutMs,
scopes: ["operator.admin"]
});
return await callGatewayFromCli(gatewayMethod(params.action), {
json: true,
timeout: String(timeoutMs)
}, params.raw, {
progress: false,
scopes: ["operator.admin"]
});
} catch (error) {
const details = readErrorDetails(error);
if (details && typeof details === "object") return details;
throw error;
}
};
return {
id: options.id,
name: options.name,
description: options.description,
configSchema: options.configSchema,
register(api) {
const config = options.configSchema.parse(api.pluginConfig);
let transcriptsEnabled = api.config.transcripts?.enabled !== false;
let runtime;
const ensureRuntime = async () => {
if (!config.enabled) throw new Error(options.disabledMessage);
if (!runtime) {
runtime = options.createRuntime({
api,
config
});
await runtime.reconcileTranscriptPolicy?.(transcriptsEnabled);
}
return runtime;
};
if (options.transcriptSource) {
api.registerService({
id: `${options.id}-transcripts`,
reload: { configPrefixes: ["transcripts.enabled"] },
start: ({ config: current }) => {
transcriptsEnabled = current.transcripts?.enabled !== false;
return runtime?.reconcileTranscriptPolicy?.(transcriptsEnabled);
},
stop: () => {
transcriptsEnabled = false;
return runtime?.reconcileTranscriptPolicy?.(false);
}
});
api.registerTranscriptSourceProvider(createMeetingTranscriptSourceProvider({
...options.transcriptSource,
runtime: async () => {
const resolved = await ensureRuntime();
if (!resolved.startTranscriptSource || !resolved.stopTranscriptSource) throw new Error(`${options.name} transcript source runtime is unavailable`);
return resolved;
}
}));
}
const registerGatewayMethod = (method, run) => {
api.registerGatewayMethod(`${options.gatewayMethodPrefix}.${method}`, async (request) => {
const { respond } = request;
try {
respond(true, await run(request));
} catch (error) {
const code = options.isInvalidRequest(error) ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE;
const payload = { error: formatErrorMessage(error) };
respond(false, payload, errorShape(code, payload.error, { details: payload }));
}
});
};
registerGatewayMethod("join", async ({ params, client }) => {
const raw = keepTrustedToolContext(asOptionalRecord(params) ?? {}, client);
return await (await ensureRuntime()).join(joinRequest(raw));
});
registerGatewayMethod("leave", async ({ params, client }) => {
const raw = asOptionalRecord(params) ?? {};
const agentId = trustedToolAgentId(raw, client);
const sessionId = requireString(raw.sessionId, "sessionId");
const rt = await ensureRuntime();
return agentId && !rt.ownsSession(agentId, sessionId) ? { found: false } : await rt.leave(sessionId);
});
registerGatewayMethod("status", async ({ params, client }) => {
const raw = asOptionalRecord(params) ?? {};
const agentId = trustedToolAgentId(raw, client);
const rt = await ensureRuntime();
return agentId ? await rt.statusForAgent(agentId, normalizeOptionalString(raw.sessionId)) : await rt.status(normalizeOptionalString(raw.sessionId));
});
registerGatewayMethod("transcript", async ({ params, client }) => {
const raw = asOptionalRecord(params) ?? {};
const sessionId = requireString(raw.sessionId, "sessionId");
const sinceIndex = readSinceIndex(raw);
const agentId = trustedToolAgentId(raw, client);
const rt = await ensureRuntime();
return agentId && !rt.ownsSession(agentId, sessionId) ? { found: false } : await rt.transcript(sessionId, sinceIndex === void 0 ? {} : { sinceIndex });
});
registerGatewayMethod("speak", async ({ params, client }) => {
const raw = asOptionalRecord(params) ?? {};
const sessionId = requireString(raw.sessionId, "sessionId");
const agentId = trustedToolAgentId(raw, client);
const rt = await ensureRuntime();
return agentId && !rt.ownsSession(agentId, sessionId) ? {
found: false,
spoken: false
} : await rt.speak(sessionId, normalizeOptionalString(raw.message));
});
registerGatewayMethod("setup", async ({ params }) => await (await ensureRuntime()).setupStatus({
mode: normalizeMode(params?.mode),
transport: normalizeTransport(params?.transport)
}));
for (const method of ["testSpeech", "testListen"]) registerGatewayMethod(method, async ({ params, client }) => {
const raw = keepTrustedToolContext(asOptionalRecord(params) ?? {}, client);
return await (await ensureRuntime())[method](joinRequest(raw, { allowTimeout: true }));
});
api.registerTool((toolContext) => ({
name: options.toolName,
label: options.toolLabel,
description: options.toolDescription,
parameters: options.toolParameters,
async execute(_toolCallId, params) {
const raw = asOptionalRecord(params) ?? {};
const action = raw.action;
const requesterSessionKey = normalizeOptionalString(toolContext.sessionKey);
const contextAgentId = toolContext.agentId ?? parseAgentSessionKey(requesterSessionKey)?.agentId;
const agentId = options.normalizeToolAgentId(contextAgentId);
try {
if (![
"join",
"leave",
"status",
"transcript",
"speak"
].includes(action)) throw new Error(options.unknownActionMessage);
const runtimeForTool = await options.resolveToolRuntime(api, agentId);
return jsonResult(await callGatewayFromTool({
action,
config,
raw: {
...raw,
...requesterSessionKey ? { requesterSessionKey } : {},
...runtimeForTool && agentId ? { agentId } : {}
},
runtime: runtimeForTool
}));
} catch (error) {
return jsonResult({ error: formatErrorMessage(error) });
}
}
}), { name: options.toolName });
if (options.registerNodeWhen(config)) {
api.registerNodeHostCommand({
command: options.nodeCommand,
cap: options.cap,
dangerous: true,
handle: (paramsJSON) => options.nodeHandler(paramsJSON),
hasActiveWork: options.nodeHandler.hasActiveWork
});
api.registerNodeInvokePolicy(options.createNodePolicy(config));
}
options.registerCli(api, config);
}
};
}
//#endregion
//#region src/meeting-bot/plugin-shell.ts
function createMeetingPluginNodeHostHandler(options) {
const platform = options.platform;
return createMeetingConfiguredNodeHost({
...options,
commandName: platform.nodeCommandName,
displayName: platform.displayName,
browserLabel: platform.browserLabel,
bridgeIdPrefix: `${platform.session.idPrefix}_node_`,
talkBackModes: /* @__PURE__ */ new Set(["agent", "bidi"]),
agentMode: "agent",
defaultAudio: {
backend: "auto",
bufferBytes: 4096,
format: "pcm16-24khz"
},
normalizeUrl: (value) => platform.urls.validateAndNormalize(value),
normalizeMeetingKey: (value) => platform.urls.normalizeForReuse(value),
browser: {
application: "Google Chrome",
buildProfileArgs: (profile) => ["--args", `--profile-directory=${profile}`],
openedStatus: "chrome-opened",
openedNotes: [`${options.browserPageName} page control is handled by OpenClaw browser automation when using chrome-node.`]
}
});
}
function createMeetingPluginNodeInvokePolicy(config, options) {
const platform = options.platform;
return createMeetingBrowserNodeInvokePolicy({
commandName: platform.nodeCommandName,
displayName: platform.displayName,
deniedCode: options.deniedCode,
supportedModes: /* @__PURE__ */ new Set([
"agent",
"bidi",
"transcribe"
]),
normalizeUrl: (value) => platform.urls.validateAndNormalize(value),
useConfiguredSetupCommands: true,
start: config.chrome
});
}
function createMeetingChromeRuntimeBindings() {
return {
createBindings: createMeetingRealtimeEngineBindings,
createLocalAudioTransport: createLocalMeetingRealtimeAudioTransport,
createNodeAudioTransport: createNodeMeetingRealtimeAudioTransport,
startAgentRealtimeEngine: startMeetingAgentRealtimeEngine,
startRealtimeEngine: startMeetingRealtimeEngine
};
}
function createMeetingPluginChromeTransport(options) {
return createMeetingChromeTransport({
...options,
browserNodeAdapter: options.platform,
isRealtimeRouteReady: isMeetingRealtimeRouteReady,
isTalkBackMode: isMeetingTalkBackMode,
nodeCommandName: options.platform.nodeCommandName
});
}
function createMeetingPluginShellEntry(options) {
const id = options.platform.id;
const methodPrefix = id.replaceAll("-", "");
const toolName = id.replaceAll("-", "_");
const loadCli = createLazyRuntimeModule(() => options.cli.load());
return createMeetingPluginEntryOptions({
...options,
id,
name: options.platform.displayName,
cap: id,
description: `Join ${options.platform.displayName} as a Chrome browser guest`,
disabledMessage: `${options.platform.displayName} plugin disabled in plugin config`,
gatewayMethodPrefix: methodPrefix,
nodeCommand: options.platform.nodeCommandName,
normalizeUrl: (value) => options.platform.urls.validateAndNormalize(value),
toolDescription: `Join and manage ${options.browserGuestLabel} browser guests. Guest admission, tenant sign-in, and media permissions may require manual action in the OpenClaw Chrome profile.`,
toolLabel: options.platform.displayName,
toolName,
transcriptSource: {
...options.transcriptSource,
name: options.platform.displayName
},
unknownActionMessage: `unknown ${toolName} action`,
createRuntime: ({ api, config }) => new options.runtime({
config,
fullConfig: api.config,
runtime: api.runtime,
logger: api.logger
}),
registerCli: (api, config) => {
api.registerCli(async ({ program }) => (await loadCli())({
program,
config
}), {
commands: [options.cli.descriptor.name],
descriptors: [options.cli.descriptor]
});
}
});
}
function createMeetingPluginTypes() {
return null;
}
//#endregion
//#region src/meeting-bot/runtime-facade.ts
const nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
function createMeetingRuntimeFacade(options) {
return class MeetingRuntimeFacade {
#defaultAgentId;
#sessions;
#requesterSessionKeys = /* @__PURE__ */ new Map();
constructor(params) {
this.params = params;
this.#defaultAgentId = normalizeAgentId(params.config.realtime.agentId ?? resolveDefaultAgentId(params.fullConfig));
this.#sessions = new MeetingSessionRuntime({
logger: params.logger,
logScope: options.platform.logScope,
formatError: formatErrorMessage,
reuseExistingBrowserTab: params.config.chrome.reuseExistingTab,
waitForInCallMs: params.config.chrome.waitForInCallMs,
joinTimeoutMs: params.config.chrome.joinTimeoutMs,
defaultSpeechInstructions: params.config.realtime.introMessage,
transientSpeechBlockedReasons: /* @__PURE__ */ new Set([
"not-in-call",
"browser-unverified",
options.messages.sessionRuntime.speech.microphoneMutedReason
]),
messages: options.messages.sessionRuntime,
resolveJoin: (request) => this.#resolveJoin(request),
createSession: ({ request, resolved, createdAt }) => {
const session = createMeetingSession({
platform: options.platform,
config: params.config,
resolved,
createdAt
});
if (request.requesterSessionKey) this.#requesterSessionKeys.set(session.id, request.requesterSessionKey);
return session;
},
resolveSpeechInstructions: (request) => request.message ?? params.config.realtime.introMessage,
isBrowserTransport: () => true,
isTalkBackMode: (mode) => mode === "agent" || mode === "bidi",
isTranscribeMode: (mode) => mode === "transcribe",
sameMeetingUrl: (left, right) => options.platform.urls.isSameMeeting(left, right),
normalizeMeetingUrlForReuse: (url) => options.platform.urls.normalizeForReuse(url),
getBrowser: (session) => session.chrome ? {
launched: session.chrome.launched,
nodeId: session.chrome.nodeId,
tab: session.chrome.browserTab,
health: session.chrome.health,
hasAudioBridge: options.hooks?.isAudioBridgeActive?.(session) ?? Boolean(session.chrome.audioBridge)
} : void 0,
setBrowserTab: (session, tab) => {
if (session.chrome) session.chrome.browserTab = tab;
},
setBrowserHealth: (session, health) => {
if (session.chrome) session.chrome.health = health;
},
joinTransport: async ({ request, session, context }) => await this.#joinTransport(request, session, context),
releaseBrowserTab: async (session) => await this.#releaseBrowserTab(session),
refreshBrowserHealth: async (session, refreshOptions) => await this.#refreshBrowserHealth(session, refreshOptions),
refreshStatus: async (session) => await this.#refreshStatus(session),
refreshReusableSession: async (session, request) => await options.hooks?.refreshReusableSession?.(session, request, this.#hookContext()),
ensureRealtimeBridge: async (session) => await this.#ensureRealtimeBridge(session),
captureTranscript: async (session, captureOptions) => await this.#captureTranscript(session, captureOptions),
speakViaTransport: async () => void 0,
durableTranscripts: {
config: params.fullConfig.transcripts,
openclawConfig: params.fullConfig,
...options.messages.durableTranscripts
}
});
}
list() {
return this.#sessions.list();
}
async startTranscriptSource(request) {
return await this.#sessions.startTranscriptSource(request);
}
reconcileTranscriptPolicy(enabled) {
return this.#sessions.reconcileTranscriptPolicy(enabled);
}
async stopTranscriptSource(request) {
return await this.#sessions.stopTranscriptSource(request);
}
ownsSession(agentId, sessionId) {
return this.list().some((session) => session.id === sessionId && session.agentId === agentId);
}
async join(request) {
try {
return await this.#sessions.join(options.hooks?.normalizeJoinRequest?.(request, this.#hookContext()) ?? request);
} catch (error) {
const activeIds = new Set(this.list().map((session) => session.id));
for (const sessionId of this.#requesterSessionKeys.keys()) if (!activeIds.has(sessionId)) this.#requesterSessionKeys.delete(sessionId);
throw error;
}
}
async leave(sessionId) {
try {
return await this.#sessions.leave(sessionId);
} finally {
this.#requesterSessionKeys.delete(sessionId);
}
}
async status(sessionId) {
return await this.#sessions.status(sessionId);
}
async statusForAgent(agentId, sessionId) {
if (sessionId) return this.ownsSession(agentId, sessionId) ? await this.#sessions.status(sessionId) : { found: false };
const sessions = this.list().filter((session) => session.agentId === agentId);
await Promise.all(sessions.map((session) => this.#sessions.status(session.id)));
return {
found: true,
sessions
};
}
async transcript(sessionId, transcriptOptions = {}) {
return await this.#sessions.transcript(sessionId, transcriptOptions);
}
async speak(sessionId, instructions) {
return await this.#sessions.speak(sessionId, instructions);
}
async setupStatus(setupOptions) {
return await options.probes.setupStatus({
...this.params,
options: setupOptions
});
}
async testSpeech(request) {
return await options.probes.testSpeech(this.#probeContext(), request);
}
async testListen(request) {
return await options.probes.testListening(this.#probeContext(), request);
}
#resolveJoin(request) {
return {
url: options.platform.urls.validateAndNormalize(request.url),
transport: request.transport ?? (this.params.config.chromeNode.node ? "chrome-node" : "chrome"),
mode: request.mode ?? this.params.config.defaultMode,
agentId: normalizeAgentId(request.agentId ?? this.#defaultAgentId)
};
}
#hookContext() {
return {
deleteRequesterSessionKey: (sessionId) => this.#requesterSessionKeys.delete(sessionId),
endSession: async (sessionId, leaveOptions) => {
await this.#sessions.leave(sessionId, leaveOptions);
this.#requesterSessionKeys.delete(sessionId);
},
noteSession: (session, note) => this.#noteSession(session, note),
refreshBrowserHealth: async (session, refreshOptions) => await this.#sessions.refreshBrowserHealth(session, refreshOptions),
resolvedJoin: (request) => this.#resolveJoin(request)
};
}
#probeContext() {
return {
config: this.params.config,
resolveAgentId: (request) => normalizeAgentId(request.agentId ?? this.#defaultAgentId),
list: () => this.list(),
join: async (request) => await this.join(request),
isReusable: (session, resolved) => this.#sessions.isReusableSession(session, resolved),
hasHealthHandle: (sessionId) => this.#sessions.hasHealthHandle(sessionId),
refreshHealth: (sessionId) => this.#sessions.refreshHealth(sessionId),
refreshCaptionHealth: async (session, timeoutMs) => await this.#refreshBrowserHealth(session, { timeoutMs })
};
}
async #joinTransport(request, session, context) {
const config = this.#withSessionAgentConfig(session.agentId);
const launchParams = {
runtime: this.params.runtime,
config,
fullConfig: this.params.fullConfig,
meetingSessionId: session.id,
requesterSessionKey: request.requesterSessionKey,
mode: session.mode,
url: session.url,
logger: this.params.logger
};
const result = session.transport === "chrome-node" ? await options.transport.launchOnNode(launchParams) : await options.transport.launchInChrome(launchParams);
const nodeId = result.nodeId;
const tab = context.inheritedBrowserTab({
session,
transport: session.transport,
nodeId,
meetingUrl: session.url,
tab: result.tab
});
session.chrome = {
audioBackend: result.audioBackend,
launched: result.launched,
nodeId,
browserProfile: this.params.config.chrome.browserProfile,
browserTab: tab,
health: result.browser
};
options.hooks?.validateLaunchResult?.(result);
const handles = this.#attachAudioBridge(session, result.audioBridge);
if (handles) context.attachRuntimeHandles(session, handles);
session.notes.push(result.audioBridge ? session.transport === "chrome-node" ? options.messages.joined.node : options.messages.joined.local : session.mode === "transcribe" ? options.messages.joined.transcribe : options.messages.joined.waiting);
this.#sessions.refreshSpeechReadiness(session);
return {};
}
#attachAudioBridge(session, audioBridge) {
if (!session.chrome || !audioBridge) return;
session.chrome.audioBridge = {
type: audioBridge.type,
provider: audioBridge.providerId
};
options.hooks?.afterAudioBridgeAttached?.(session);
return {
stop: audioBridge.stop,
speak: audioBridge.speak,
getHealth: audioBridge.getHealth
};
}
async #ensureRealtimeBridge(session) {
const audioBridgeActive = options.hooks?.isAudioBridgeActive?.(session) ?? Boolean(session.chrome?.audioBridge);
if (session.mode !== "agent" && session.mode !== "bidi" || session.state !== "active" || !session.chrome || audioBridgeActive || !isMeetingRealtimeRouteReady(session.mode, session.chrome.health)) return;
if (session.chrome.audioBridge) session.chrome.audioBridge = void 0;
const config = this.#withSessionAgentConfig(session.agentId);
const recoveryConfig = {
...config,
chrome: {
...config.chrome,
launch: false
},
chromeNode: { node: session.chrome.nodeId ?? config.chromeNode.node }
};
const launchParams = {
runtime: this.params.runtime,
config: recoveryConfig,
fullConfig: this.params.fullConfig,
meetingSessionId: session.id,
requesterSessionKey: this.#requesterSessionKeys.get(session.id),
mode: session.mode,
trackedTargetId: session.chrome.browserTab?.targetId,
url: session.url,
logger: this.params.logger
};
const result = session.transport === "chrome-node" ? await options.transport.launchOnNode(launchParams) : await options.transport.launchInChrome(launchParams);
if (result.tab) {
const currentTab = session.chrome.browserTab;
session.chrome.browserTab = {
...result.tab,
openedByPlugin: result.tab.targetId === currentTab?.targetId ? currentTab.openedByPlugin : result.tab.openedByPlugin
};
}
if (result.browser) session.chrome.health = {
...session.chrome.health,
...result.browser
};
session.updatedAt = nowIso();
return this.#attachAudioBridge(session, result.audioBridge);
}
async #refreshStatus(session) {
await this.#sessions.refreshBrowserHealth(session, {
force: true,
readOnly: !(options.hooks?.isAwaitingAdmission?.(session) ?? false)
});
await options.hooks?.afterStatusRefresh?.(session, this.#hookContext());
}
async #refreshBrowserHealth(session, refreshOptions = {}) {
try {
const result = await options.transport.recoverCurrentTab({
runtime: this.params.runtime,
config: this.params.config,
fullConfig: this.params.fullConfig,
meetingSessionId: session.id,
mode: session.mode,
nodeId: session.chrome?.nodeId,
readOnly: refreshOptions.readOnly,
trackedMeetingUrl: session.url,
trackedTargetId: session.chrome?.browserTab?.targetId,
transport: session.transport,
timeoutMs: refreshOptions.timeoutMs,
url: session.url
});
if (result.found && session.chrome) {
if (result.tab?.targetId) {
const currentTab = session.chrome.browserTab;
session.chrome.browserTab = {
targetId: result.tab.targetId,
openedByPlugin: result.tab.targetId === currentTab?.targetId ? currentTab.openedByPlugin : false
};
}
if (result.browser) session.chrome.health = {
...session.chrome.health,
...result.browser
};
session.updatedAt = nowIso();
} else if (session.chrome) options.hooks?.recordBrowserRecoveryFailure?.(session, {
kind: "missing",
message: result.message
});
} catch (error) {
const formattedError = formatErrorMessage(error);
const message = options.messages.browserReadinessFailed?.(formattedError) ?? formattedError;
if (options.hooks?.recordBrowserRecoveryFailure) {
this.params.logger.debug?.(`${options.platform.logScope} ${message}`);
options.hooks.recordBrowserRecoveryFailure(session, {
kind: "error",
message
});
} else this.params.logger.debug?.(`${options.platform.logScope} browser readiness refresh ignored: ${formatErrorMessage(error)}`);
}
}
async #captureTranscript(session, captureOptions = {}) {
await this.#sessions.refreshCaptionHealth(session);
const tab = session.chrome?.browserTab;
if (!tab) return;
return await options.transport.readTranscript({
runtime: this.params.runtime,
config: this.params.config,
finalize: captureOptions.finalize,
meetingUrl: session.url,
meetingSessionId: session.id,
nodeId: session.chrome?.nodeId,
tab
});
}
async #releaseBrowserTab(session) {
const tab = session.chrome?.browserTab;
if (!tab) {
this.#noteSession(session, options.messages.noTrackedTab);
session.browserLeft = false;
return false;
}
if (this.list().some((other) => other.id !== session.id && other.state === "active" && other.chrome?.browserTab?.targetId === tab.targetId && other.chrome?.nodeId === session.chrome?.nodeId)) {
this.#noteSession(session, options.messages.sharedTab);
return;
}
try {
const result = await options.transport.leaveInBrowser({
runtime: this.params.runtime,
config: this.params.config,
meetingSessionId: session.id,
meetingUrl: session.url,
nodeId: session.chrome?.nodeId,
tab
});
this.#noteSession(session, result.note);
if (result.left && session.chrome) {
session.chrome.browserTab = void 0;
if (session.chrome.health) session.chrome.health = {
...session.chrome.health,
captioning: false,
audioInputRouted: false,
audioOutputRouted: false,
providerConnected: false,
realtimeReady: false,
audioInputActive: false,
audioOutputActive: false
};
}
session.browserLeft = result.left;
return result.left;
} catch (error) {
this.#noteSession(session, options.messages.leaveFailed(formatErrorMessage(error)));
session.browserLeft = false;
return false;
}
}
#withSessionAgentConfig(agentId) {
return this.params.config.realtime.agentId === agentId ? this.params.config : {
...this.params.config,
realtime: {
...this.params.config.realtime,
agentId
}
};
}
#noteSession(session, note) {
session.notes = [...session.notes.filter((item) => item !== note), note];
}
};
}
//#endregion
//#region src/meeting-bot/runtime-probes.ts
function resolveMeetingProbeTimeoutMs(input, fallback, invalidRequest = (message) => new Error(message)) {
if (input === void 0) return Math.min(Math.max(fallback, 1), 12e4);
if (!Number.isFinite(input) || input <= 0) throw invalidRequest("timeoutMs must be a positive number");
return Math.min(Math.trunc(input), 12e4);
}
function createMeetingRuntimeProbes(options) {
const resolveJoin = (context, request, mode) => ({
url: options.normalizeUrl?.(request.url) ?? request.url,
transport: request.transport ?? options.defaultTransport?.(context.config) ?? (context.config.chromeNode.node ? "chrome-node" : "chrome"),
mode,
agentId: context.resolveAgentId(request)
});
const testSpeech = async (context, request) => {
const requestMode = options.resolveRequestMode?.(request.mode, context.config) ?? request.mode;
if (requestMode === "transcribe") throw options.invalidRequest(options.speechModeError ?? "test_speech requires mode: agent or bidi");
const requestedMode = requestMode ?? context.config.defaultMode;
const mode = options.talkBackMode(requestedMode) ? requestedMode : "agent";
const resolved = resolveJoin(context, request, mode);
const beforeSessions = context.list();
const before = new Set(beforeSessions.map((session) => session.id));
const existing = beforeSessions.find((session) => context.isReusable(session, resolved));
const existingBaseline = {
outputBytes: existing?.chrome?.health?.lastOutputBytes ?? 0,
outputGeneration: existing?.chrome?.health?.outputGeneration ?? 0
};
const result = await context.join({
...request,
...resolved,
message: request.message ?? options.defaultSpeechMessage
});
const baseline = existing?.id === result.session.id ? existingBaseline : {
outputBytes: 0,
outputGeneration: 0
};
let health = result.session.chrome?.health;
const verified = () => (health?.lastOutputBytes ?? 0) > baseline.outputBytes && (health?.outputGeneration ?? 0) > baseline.outputGeneration && health?.verifiedOutputGeneration === health?.outputGeneration;
const shouldWait = result.spoken === true && health?.manualAction === void 0 && context.hasHealthHandle(result.session.id);
if (shouldWait && !verified()) {
const deadline = performance.now() + (options.resolveSpeechTimeoutMs?.(request, context.config) ?? options.resolveTimeoutMs(request.timeoutMs, context.config.chrome.joinTimeoutMs));
while (performance.now() < deadline && !verified()) {
await sleep(100);
context.refreshHealth(result.session.id);
health = result.session.chrome?.health;
}
}
const speechOutputVerified = verified();
return {
createdSession: !before.has(result.session.id),
inCall: health?.inCall,
manualAction: health?.manualAction,
spoken: result.spoken ?? false,
speechOutputVerified,
speechOutputTimedOut: shouldWait && !speechOutputVerified,
speechReady: health?.speechReady,
speechBlockedReason: health?.speechBlockedReason,
speechBlockedMessage: health?.speechBlockedMessage,
audioOutputActive: health?.audioOutputActive,
lastOutputBytes: health?.lastOutputBytes,
outputLoopbackSignalBytes: health?.outputLoopbackSignalBytes,
lastOutputLoopbackAt: health?.lastOutputLoopbackAt,
lastOutputLoopbackCorrelation: health?.lastOutputLoopbackCorrelation,
lastOutputLoopbackRms: health?.lastOutputLoopbackRms,
lastOutputLoopbackPeak: health?.lastOutputLoopbackPeak,
outputGeneration: health?.outputGeneration,
verifiedOutputGeneration: health?.verifiedOutputGeneration,
session: result.session
};
};
const testListening = async (context, request) => {
const requestMode = options.resolveRequestMode?.(request.mode, context.config) ?? request.mode;
if (requestMode && requestMode !== "transcribe") throw options.invalidRequest(options.listeningModeError ?? "test_listen requires mode: transcribe");
const resolved = resolveJoin(context, request, "transcribe");
options.validateListeningTransport?.(resolved.transport);
const beforeSessions = context.list();
const before = new Set(beforeSessions.map((session) => session.id));
const existing = beforeSessions.find((session) => context.isReusable(session, resolved));
const start = {
lines: existing?.chrome?.health?.transcriptLines ?? 0,
at: existing?.chrome?.health?.lastCaptionAt,
text: existing?.chrome?.health?.lastCaptionText
};
const result = await context.join({
...request,
...resolved,
message: void 0
});
let health = result.session.chrome?.health;
const advanced = () => (health?.transcriptLines ?? 0) > (existing?.id === result.session.id ? start.lines : 0) || Boolean(health?.lastCaptionAt && health.lastCaptionAt !== start.at) || Boolean(health?.lastCaptionText && health.lastCaptionText !== start.text);
const shouldWait = health?.manualAction === void 0 && options.shouldWaitForListening(result.session);
let listenVerified = advanced();
if (shouldWait && !listenVerified) {
const deadline = performance.now() + options.resolveTimeoutMs(request.timeoutMs, context.config.chrome.joinTimeoutMs);
while (performance.now() < deadline) {
const remainingMs = Math.floor(deadline - performance.now());
if (remainingMs <= 0) break;
if (!await raceWithTimeout(() => (options.refreshCaptionHealth?.(context, result.session, remainingMs) ?? context.refreshCaptionHealth(result.session, remainingMs)).then(() => true), remainingMs, () => false)) break;
health = result.session.chrome?.health;
if (performance.now() >= deadline) break;
if (advanced()) listenVerified = true;
if (listenVerified || health?.manualAction) break;
const retryDelayMs = deadline - performance.now();
if (retryDelayMs <= 0) break;
await sleep(Math.min(250, retryDelayMs));
}
}
return {
createdSession: !before.has(result.session.id),
inCall: health?.inCall,
manualAction: health?.manualAction,
listenVerified,
listenTimedOut: shouldWait && !listenVerified && health?.manualAction === void 0,
captioning: health?.captioning,
captionsEnabledAttempted: health?.captionsEnabledAttempted,
transcriptLines: health?.transcriptLines,
lastCaptionAt: health?.lastCaptionAt,
lastCaptionSpeaker: health?.lastCaptionSpeaker,
lastCaptionText: health?.lastCaptionText,
recentTranscript: health?.recentTranscript,
session: result.session
};
};
return {
testListening,
testSpeech
};
}
//#endregion
//#region src/meeting-bot/setup-checks.ts
function createMeetingSetupStatus(checks) {
return {
ok: checks.every((check) => check.ok),
checks
};
}
function addMeetingSetupCheck(status, check) {
return createMeetingSetupStatus([...status.checks, check]);
}
//#endregion
//#region src/meeting-bot/runtime-setup.ts
async function commandExists(runtime, command) {
return (await runtime.system.runCommandWithTimeout([
"/bin/sh",
"-lc",
"command -v \"$1\" >/dev/null 2>&1",
"sh",
command
], { timeoutMs: 5e3 })).code === 0;
}
function createMeetingRuntimeSetup(options) {
return async (params) => {
const mode = params.options?.mode ?? params.config.defaultMode;
const transport = params.options?.transport ?? (params.config.chromeNode.node ? "chrome-node" : "chrome");
const talkBack = isMeetingTalkBackMode(mode);
const guestJoin = options.guestJoinCheck(params.config);
let status = createMeetingSetupStatus([
{
id: "chrome-profile",
ok: true,
message: params.config.chrome.browserProfile ? `Chrome node profile configured: ${params.config.chrome.browserProfile}` : "Local Chrome uses the configured OpenClaw browser profile"
},
{
id: "guest-join",
...guestJoin
},
{
id: "captions",
ok: true,
message: options.captionsMessage(mode)
}
]);
if (transport === "chrome-node") try {
const node = await resolveMeetingBrowserNodeInfo({
runtime: params.runtime,
requestedNode: params.config.chromeNode.node,
adapter: options.nodeAdapter
});
status = addMeetingSetupCheck(status, {
id: "chrome-node-connected",
ok: true,
message: options.connectedNodeMessage(node.displayName ?? node.remoteIp ?? node.nodeId)
});
if (talkBack) {
if (!node.nodeId) throw new Error(options.missingNodeIdMessage);
await params.runtime.nodes.invoke({
nodeId: node.nodeId,
command: options.nodeAdapter.nodeCommandName,
params: {
action: "setup",
audioBackend: params.config.chrome.audioBackend,
audioFormat: params.config.chrome.audioFormat,
audioBufferBytes: params.config.chrome.audioBufferBytes,
...params.config.chrome.audioInputCommandOverride ? { audioInputCommand: params.config.chrome.audioInputCommandOverride } : {},
...params.config.chrome.audioOutputCommandOverride ? { audioOutputCommand: params.config.chrome.audioOutputCommandOverride } : {},
...params.config.chrome.bargeInInputCommand ? { bargeInInputCommand: params.config.chrome.bargeInInputCommand } : {}
},
timeoutMs: 12e3
});
status = addMeetingSetupCheck(status, {
id: "chrome-node-audio-prerequisites",
ok: true,
message: "Remote virtual audio backend and command-pair prerequisites are ready"
});
}
} catch (error) {
const connected = status.checks.some((check) => check.id === "chrome-node-connected" && check.ok);
status = addMeetingSetupCheck(status, {
id: connected ? "chrome-node-audio-prerequisites" : "chrome-node-connected",
ok: false,
message: formatErrorMessage(error)
});
}
if (!talkBack) return status;
status = addMeetingSetupCheck(status, {
id: "audio-bridge",
ok: true,
message: `Command-pair audio bridge configured (${params.config.chrome.audioFormat})`
});
if (transport === "chrome-node") return status;
let audio;
try {
audio = resolveMeetingAudioRuntimeForFormat({
backend: params.config.chrome.audioBackend,
bufferBytes: params.config.chrome.audioBufferBytes,
format: params.config.chrome.audioFormat,
inputCommand: params.config.chrome.audioInputCommandOverride,
outputCommand: params.config.chrome.audioOutputCommandOverride
});
await options.assertAudioDeviceAvailable({
config: params.config,
runtime: params.runtime,
timeoutMs: Math.min(params.config.chrome.joinTimeoutMs, 1e4)
});
status = addMeetingSetupCheck(status, {
id: "chrome-local-audio-device",
ok: true,
message: "Virtual meeting audio backend is ready"
});
} catch (error) {
status = addMeetingSetupCheck(status, {
id: "chrome-local-audio-device",
ok: false,
message: formatErrorMessage(error)
});
if (!audio) return status;
}
const commands = uniqueStrings([
audio.inputCommand[0],
audio.outputCommand[0],
params.config.chrome.bargeInInputCommand?.[0]
].filter((value) => Boolean(value?.trim())));
const missing = [];
for (const command of commands) if (!await commandExists(params.runtime, command).catch(() => false)) missing.push(command);
return addMeetingSetupCheck(status, {
id: "chrome-local-audio-commands",
ok: missing.length === 0,
message: missing.length === 0 ? "Configured Chrome audio commands are available" : `Chrome audio commands missing: ${missing.join(", ")}`
});
};
}
//#endregion
//#region src/meeting-bot/browser-plugin.ts
function defineBrowserMeetingPlugin(spec) {
const { platform, labels } = spec;
const tabLabel = labels.tab ?? labels.meeting;
const config = createMeetingPluginConfigSchema(spec.config);
const invalidRequest = (message) => new spec.InvalidRequestError(message);
const chrome = createMeetingPluginChromeTransport({
meetingLabel: labels.meeting,
platform,
preserveTrackedBrowserOnEngineFailure: spec.preserveTrackedBrowserOnEngineFailure,
runtime: spec.chromeRuntime ?? createMeetingChromeRuntimeBindings()
});
const setupStatus = createMeetingRuntimeSetup({
...spec.setup,
assertAudioDeviceAvailable: chrome.assertAudioDeviceAvailable,
nodeAdapter: platform
});
const probes = createMeetingRuntimeProbes({
defaultSpeechMessage: spec.defaultSpeechMessage,
invalidRequest,
resolveTimeoutMs: (input, fallback) => resolveMeetingProbeTimeoutMs(input, fallback, invalidRequest),
shouldWaitForListening: spec.shouldWaitForListening,
talkBackMode: isMeetingTalkBackMode
});
const Runtime = createMeetingRuntimeFacade({
platform,
transport: chrome,
probes: {
setupStatus,
...probes
},
hooks: spec.hooks,
messages: {
browserReadinessFailed: spec.browserReadinessFailed,
durableTranscripts: {
providerId: spec.transcriptSource.id,
providerName: spec.transcriptSource.providerName
},
joined: {
local: `${labels.participant} joined in local Chrome with realtime audio through the native virtual-audio backend.`,
node: `${labels.participant} joined in Chrome on the selected node with realtime audio through the node bridge.`,
transcribe: `${labels.participant} joined observe-only with live-caption transcript capture.`,
waiting: `${labels.participant} join is waiting for the browser to become ready before starting realtime audio.`
},
leaveFailed: (error) => `Browser control could not leave the ${tabLabel} tab: ${error}`,
noTrackedTab: `No tracked ${tabLabel} tab; leave the browser meeting manually if it is still active.`,
sharedTab: `Kept the shared ${tabLabel} tab open for another active session.`,
sessionRuntime: {
previousBrowserLeaveFailed: `Could not leave the previous ${tabLabel} tab before reassignment.`,
replacementBrowserLeaveFailed: `Could not leave the previous ${tabLabel} tab before reassignment.`,
reassignedSessionNote: `Ended before the same ${tabLabel} tab was reassigned to another agent.`,
reusedSessionNote: `Reused existing active ${labels.brand} meeting session.`,
speechBlockedFallback: `Realtime speech blocked until ${labels.brand} is ready.`,
speech: {
audioBridgeUnavailable: "Realtime speech requires an active Chrome audio bridge.",
browserUnverified: `${labels.brand} browser state has not been verified yet.`,
microphoneMuted: `Turn on the OpenClaw ${labels.microphone} microphone before asking OpenClaw to speak.`,
microphoneMutedReason: spec.microphoneMutedReason,
notInCall: spec.notInCallMessage ?? `${labels.brand} has not reported that the browser guest is in the call.`,
notInCallReason: "not-in-call",
browserUnverifiedReason: "browser-unverified",
audioBridgeUnavailableReason: "audio-bridge-unavailable"
}
}
}
});
const nodeHandler = createMeetingPluginNodeHostHandler({
platform,
browserPageName: labels.browserPage,
meetingLabel: labels.meeting,
defaultAudioInputCommand: config.defaultAudioInputCommand,
defaultAudioOutputCommand: config.defaultAudioOutputCommand,
sharePrerequisiteDeadline: spec.sharePrerequisiteDeadline
});
const createNodePolicy = (resolved) => createMeetingPluginNodeInvokePolicy(resolved, {
platform,
deniedCode: spec.nodePolicyDeniedCode
});
const resolveCliGatewayTimeoutMs = (resolved, options) => {
const operationTimeoutMs = config.resolveGatewayOperationTimeoutMs(resolved);
if (spec.cli.resolveTimeoutMs) return spec.cli.resolveTimeoutMs(operationTimeoutMs, options);
const probeTimeoutMs = options.probe ? resolveMeetingProbeTimeoutMs(options.requestedTimeoutMs, resolved.chrome.joinTimeoutMs, invalidRequest) : void 0;
return probeTimeoutMs === void 0 ? operationTimeoutMs : addTimerTimeoutGraceMs(operationTimeoutMs, probeTimeoutMs) ?? 1;
};
const loadCli = async () => {
const { registerBrowserMeetingCli } = await import("../browser-plugin-cli-CHkJP2Ok.mjs");
return (params) => registerBrowserMeetingCli({
...params,
callGateway: spec.cli.callGateway,
descriptor: spec.cli.descriptor,
joinDescription: spec.cli.joinDescription,
meetingLabel: tabLabel,
resolveTimeoutMs: resolveCliGatewayTimeoutMs
});
};
return {
plugin: createMeetingPluginShellEntry({
platform,
browserGuestLabel: labels.meeting,
configSchema: config.configSchema,
invalidRequest,
isInvalidRequest: (error) => error instanceof spec.InvalidRequestError,
toolParameters: Type.Object({
action: Type.String({ enum: [
"join",
"leave",
"status",
"transcript",
"speak"
] }),
url: Type.Optional(Type.String({ description: spec.toolUrlDescription })),
transport: Type.Optional(Type.String({ enum: ["chrome", "chrome-node"] })),
mode: Type.Optional(Type.String({ enum: [
"agent",
"bidi",
"transcribe"
] })),
sessionId: Type.Optional(Type.String({ description: `${tabLabel} session ID` })),
sinceIndex: Type.Optional(Type.Integer({
minimum: 0,
description: "Resume transcript from this index"
})),
message: Type.Optional(Type.String({ description: "Instructions to speak" }))
}),
resolveGatewayTimeoutMs: config.resolveGatewayOperationTimeoutMs,
normalizeRequesterSessionKey: (value, trustedOwner) => trustedOwner && typeof value === "string" && value.trim() ? value.trim() : void 0,
normalizeToolAgentId: (agentId) => normalizeAgentId(agentId),
resolveToolRuntime: async (api) => {
if (!await api.runtime.gateway.isAvailable()) throw new Error(`${labels.meeting} tools require a Gateway-hosted agent run.`);
return api.runtime;
},
transcriptSource: {
id: spec.transcriptSource.id,
aliases: spec.transcriptSource.aliases
},
runtime: Runtime,
nodeHandler,
createNodePolicy,
registerNodeWhen: (resolved) => resolved.enabled,
...spec.entry,
cli: {
descriptor: spec.cli.descriptor,
load: loadCli
}
}),
config,
chrome,
Runtime,
probes,
setupStatus,
nodeHandler,
createNodePolicy,
loadCli,
resolveCliGatewayTimeoutMs
};
}
//#endregion
//#region src/meeting-bot/platform-adapter.ts
function browserResultString(result) {
if (!result || typeof result !== "object") return;
const value = result.result;
return typeof value === "string" && value.trim() ? value : void 0;
}
const optionalBrowserString = string().optional().catch(void 0);
const optionalBrowserBoolean = boolean().optional().catch(void 0);
const optionalBrowserNumber = number().optional().catch(void 0);
const invalidBrowserArrayItemSchema = unknown().transform(() => null);
const meetingTranscriptLineSchema = object({
at: optionalBrowserString,
speaker: optionalBrowserString,
text: string().refine((value) => value.trim().length > 0),
provenance: unknown().optional()
}).transform(({ at, speaker, text, provenance }) => ({
...at !== void 0 ? { at } : {},
...speaker !== void 0 ? { speaker } : {},
text,
...provenance !== void 0 ? { provenance } : {}
}));
const meetingTranscriptLinesSchema = array(union([meetingTranscriptLineSchema, invalidBrowserArrayItemSchema])).transform((lines) => lines.filter((line) => line !== null));
const meetingCaptionLinesSchema = array(union([meetingTranscriptLineSchema.and(object({ source: unknown().optional() })), invalidBrowserArrayItemSchema])).transform((lines) => lines.filter((line) => line !== null));
const meetingBrowserStatusSchema = looseObject({
inCall: optionalBrowserBoolean,
micMuted: optionalBrowserBoolean,
cameraOff: optionalBrowserBoolean,
lobbyWaiting: optionalBrowserBoolean,
captionCaptureRequested: optionalBrowserBoolean,
captioning: optionalBrowserBoolean,
captionsEnabledAttempted: optionalBrowserBoolean,
transcriptLines: optionalBrowserNumber,
lastCaptionAt: optionalBrowserString,
lastCaptionSpeaker: optionalBrowserString,
lastCaptionText: optionalBrowserString,
recentTranscript: meetingTranscriptLinesSchema.optional().catch(void 0),
audioInputRouted: optionalBrowserBoolean,
audioInputDeviceLabel: optionalBrowserString,
audioInputRouteError: optionalBrowserString,
audioOutputRouted: optionalBrowserBoolean,
audioOutputDeviceLabel: optionalBrowserString,
audioOutputRouteError: optionalBrowserString,
audioOutputRouteRetryable: optionalBrowserBoolean,
manualAction: object({
reason: string(),
message: string()
}).optional().catch(void 0),
url: optionalBrowserString,
title: optionalBrowserString,
notes: array(union([string(), invalidBrowserArrayItemSchema])).transform((notes) => notes.filter((note) => note !== null)).optional().catch(void 0)
});
function parseMeetingBrowserStatus(result, adapterId, options) {
const raw = browserResultString(result);
if (!raw) return;
let parsed;
try {
parsed = meetingBrowserStatusSchema.parse(JSON.parse(raw));
} catch {
throw new Error(options.malformedStatusMessage);
}
return {
inCall: parsed.inCall,
micMuted: parsed.micMuted,
cameraOff: parsed.cameraOff,
lobbyWaiting: parsed.lobbyWaiting,
captionCaptureRequested: parsed.captionCaptureRequested,
captioning: parsed.captioning,
captionsEnabledAttempted: parsed.captionsEnabledAttempted,
transcriptLines: parsed.transcriptLines,
lastCaptionAt: parsed.lastCaptionAt,
lastCaptionSpeaker: parsed.lastCaptionSpeaker,
lastCaptionText: parsed.lastCaptionText,
recentTranscript: parsed.recentTranscript?.map((line) => ({
...line,
...line.provenance !== void 0 ? { provenance: normalizeMeetingObservationProvenance(line.provenance, {
observer: adapterId,
observedAt: line.at,
speaker: line.speaker
}) } : {}
})),
audioInputRouted: parsed.audioInputRouted,
audioInputDeviceLabel: parsed.audioInputDeviceLabel,
audioInputRouteError: parsed.audioInputRouteError,
audioOutputRouted: parsed.audioOutputRouted,
audioOutputDeviceLabel: parsed.audioOutputDeviceLabel,
audioOutputRouteError: parsed.audioOutputRouteError,
audioOutputRouteRetryable: parsed.audioOutputRouteRetryable,
manualAction: parsed.manualAction,
browserUrl: parsed.url,
browserTitle: parsed.title,
status: "browser-control",
notes: parsed.notes,
...options.statusFields?.(parsed)
};
}
function parseMeetingLeaveResult(result) {
const raw = browserResultString(result);
if (!raw) return { departed: false };
try {
const parsed = JSON.parse(raw);
const leaveAction = parsed.leaveAction === "leave" || parsed.leaveAction === "confirm" ? parsed.leaveAction : void 0;
return {
departed: parsed.departed === true,
...leaveAction ? { leaveAction } : {},
...typeof parsed.sessionConflict === "boolean" ? { sessionConflict: parsed.sessionConflict } : {},
...typeof parsed.sessionMatched === "boolean" ? { sessionMatched: parsed.sessionMatched } : {},
...typeof parsed.urlMatched === "boolean" ? { urlMatched: parsed.urlMatched } : {}
};
} catch {
return { departed: false };
}
}
function parseMeetingTranscript(result, adapterId, options) {
const raw = browserResultString(result);
if (!raw) return {
droppedLines: 0,
lines: []
};
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(options.malformedTranscriptMessage);
}
if (!parsed || typeof parsed !== "object") throw new Error(options.invalidTranscriptMessage);
const payload = parsed;
const droppedLines = typeof payload.droppedLines === "number" && Number.isSafeInteger(payload.droppedLines) ? Math.max(0, payload.droppedLines) : 0;
const parseLines = (values) => meetingCaptionLinesSchema.catch([]).parse(values).map((line) => {
const source = meetingCaptionSourceSchema.safeParse(line.source);
const identity = source.success && source.data.epoch === payload.epoch ? source.data : void 0;
const transcriptLine = { text: line.text };
if (line.at !== void 0) transcriptLine.at = line.at;
if (line.speaker !== void 0) transcriptLine.speaker = line.speaker;
if (line.provenance !== void 0 || line.source !== void 0) transcriptLine.provenance = normalizeMeetingObservationProvenance(line.provenance, {
observer: adapterId,
epoch: payload.epoch,
observedAt: line.at,
speaker: line.speaker
});
if (identity) transcriptLine.source = identity;
return transcriptLine;
});
return {
droppedLines,
...typeof payload.epoch === "string" ? { epoch: payload.epoch } : {},
lines: parseLines(payload.lines),
...Array.isArray(payload.pendingLines) ? { pendingLines: parseLines(payload.pendingLines) } : {},
...typeof payload.urlMatched === "boolean" ? { urlMatched: payload.urlMatched } : {},
...typeof payload.sessionMatched === "boolean" ? { sessionMatched: payload.sessionMatched } : {}
};
}
function createMeetingPlatformAdapter(options) {
const { browser, parsing, ...platform } = options;
return {
...platform,
browser: {
...browser,
parseStatus: (result) => parseMeetingBrowserStatus(result, options.id, parsing),
classifyManualAction: (health) => {
if (!health.manualAction) return;
return {
category: parsing.classifyManualActionReason(health.manualAction.reason),
reason: health.manualAction.reason,
message: health.manualAction.message
};
},
parseLeaveResult: parseMeetingLeaveResult,
captions: {
...browser.captions,
parseTranscript: (result) => parseMeetingTranscript(result, options.id, parsing)
},
permissionNotes: browser.permissionNotes ?? (({ allowMicrophone, error, result }) => {
if (!allowMicrophone) return [`Observe-only mode does not request ${parsing.displayName} microphone access.`];
if (error) return [`Could not grant ${parsing.displayName} media permissions automatically: ${formatErrorMessage(error)}`];
const record = result && typeof result === "object" ? result : {};
const unsupportedPermissions = Array.isArray(record.unsupportedPermissions) ? record.unsupportedPermissions.filter((value) => typeof value === "string") : [];
const notes = [`Granted ${parsing.displayName} microphone permission through browser control.`];
if (unsupportedPermissions.includes("speakerSelection")) notes.push(`Chrome did not accept the optional ${parsing.displayName} speaker-selection permission.`);
return notes;
})
}
};
}
const MeetingPlatformAdapter = {
create: createMeetingPlatformAdapter,
createBrowserAdapterOptions: createMeetingBrowserAdapterOptions,
createPageScripts: createMeetingPageScripts,
defineBrowserMeetingPlugin,
createChromeTransport: createMeetingChromeTransport,
createChromeTransportWithExternalAudio: createMeetingChromeTransportWithExternalAudio,
createChromeRuntimeBindings: createMeetingChromeRuntimeBindings,
/** @deprecated Use defineBrowserMeetingPlugin for browser meeting plugins. */
createPluginChromeTransport: createMeetingPluginChromeTransport,
/** @deprecated Use defineBrowserMeetingPlugin for browser meeting plugins. */
createPluginConfigSchema: createMeetingPluginConfigSchema,
/** @deprecated Use defineBrowserMeetingPlugin for browser meeting plugins. */
createPluginNodeHostHandler: createMeetingPluginNodeHostHandler,
/** @deprecated Use defineBrowserMeetingPlugin for browser meeting plugins. */
createPluginNodeInvokePolicy: createMeetingPluginNodeInvokePolicy,
/** @deprecated Use defineBrowserMeetingPlugin for browser meeting plugins. */
createPluginShellEntry: createMeetingPluginShellEntry,
/** @deprecated Use defineBrowserMeetingPlugin for browser meeting plugins. */
createRuntimeFacade: createMeetingRuntimeFacade,
/** @deprecated Use defineBrowserMeetingPlugin for browser meeting plugins. */
createRuntimeSetup: createMeetingRuntimeSetup,
pluginTypes: createMeetingPluginTypes,
/** @deprecated Use defineBrowserMeetingPlugin for browser meeting plugins. */
registerPluginCli: registerMeetingPluginCli,
resolveProbeTimeoutMs: resolveMeetingProbeTimeoutMs,
createRuntimeProbes: createMeetingRuntimeProbes,
createNodeHostHandler: createMeetingConfiguredNodeHost,
createPluginEntry: createMeetingPluginEntryOptions,
createStatusCallSource: createMeetingStatusCallSource,
createStatusPreludeSource: createMeetingStatusPreludeSource,
isRealtimeRouteReady: isMeetingRealtimeRouteReady,
isTalkBackMode: isMeetingTalkBackMode,
ensureAudioBackend: ensureMeetingAudioBackend,
resolveAudioRuntimeForFormat: resolveMeetingAudioRuntimeForFormat
};
//#endregion
//#region src/meeting-bot/voice-call-gateway.ts
function createMeetingVoiceCallGateway(params) {
if (!params.config.gatewayUrl) return {
trustedPluginIdentity: true,
request: (method, requestParams) => params.runtime.gateway.request(method, requestParams, { timeoutMs: params.config.requestTimeoutMs })
};
return {
trustedPluginIdentity: false,
async request(method, requestParams) {
const client = await params.connectClient(params);
try {
return await client.request(method, requestParams, { timeoutMs: params.config.requestTimeoutMs });
} finally {
await client.stopAndWait({ timeoutMs: 1e3 }).catch(() => {});
}
}
};
}
function isMeetingVoiceCallMissingError(error) {
const message = formatErrorMessage(error).toLowerCase();
return message.includes("call not found") || message.includes("call is not active");
}
async function joinMeetingViaVoiceCallGateway(params) {
if (params.agentId && params.agentId !== "main" && !params.gateway.trustedPluginIdentity) throw new Error(`Per-agent Voice Call routing requires the local Gateway runtime. Remove ${params.surface.configPath} or omit agent routing.`);
params.logger?.info(`${params.surface.logScope} Delegating ${params.surface.providerLabel} join to Voice Call (dtmf=${params.dtmfSequence ? "pre-connect" : "none"}, intro=${params.message ? "delayed" : "none"})`);
const start = await params.gateway.request("voicecall.start", {
to: params.dialInNumber,
mode: "conversation",
...params.dtmfSequence ? { dtmfSequence: params.dtmfSequence } : {},
...params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {},
...params.agentId && params.gateway.trustedPluginIdentity ? { agentId: params.agentId } : {},
...params.sessionKey ? { sessionKey: params.sessionKey } : {}
});
if (!start.callId) throw new Error(start.error || "voicecall.start did not return callId");
params.logger?.info(`${params.surface.logScope} Voice Call ${params.surface.providerLabel} phone leg started: callId=${start.callId}`);
const dtmfSent = Boolean(params.dtmfSequence);
if (dtmfSent) params.logger?.info(`${params.surface.logScope} ${params.surface.meetingLabel} DTMF queued before realtime connect: callId=${start.callId} digits=${params.dtmfSequence?.length ?? 0}`);
let introSent = false;
if (params.message) {
const delayMs = params.dtmfSequence ? params.config.postDtmfSpeechDelayMs : 0;
if (delayMs > 0) {
params.logger?.info(`${params.surface.logScope} Waiting ${delayMs}ms after ${params.surface.meetingLabel} DTMF before speaking intro for callId=${start.callId}`);
await sleep(delayMs);
}
let spoken;
try {
spoken = await params.gateway.request("voicecall.speak", {
callId: start.callId,
allowTwimlFallback: false,
message: params.message
});
} catch (error) {
params.logger?.warn?.(`${params.surface.logScope} Skipped intro speech because realtime bridge was not ready: ${formatErrorMessage(error)}`);
spoken = { success: false };
}
if (spoken.success === false) params.logger?.warn?.(`${params.surface.logScope} Skipped intro speech because realtime bridge was not ready: ${spoken.error || "voicecall.speak failed"}`);
else {
introSent = true;
params.logger?.info(`${params.surface.logScope} Intro speech requested after ${params.surface.meetingLabel} dial sequence: callId=${start.callId}`);
}
}
return {
callId: start.callId,
dtmfSent,
introSent
};
}
async function endMeetingVoiceCallGatewayCall(params) {
try {
await params.gateway.request("voicecall.end", { callId: params.callId });
} catch (error) {
if (!isMeetingVoiceCallMissingError(error)) throw error;
}
}
async function getMeetingVoiceCallGatewayCall(params) {
return await params.gateway.request("voicecall.status", { callId: params.callId });
}
async function speakMeetingViaVoiceCallGateway(params) {
const spoken = await params.gateway.request("voicecall.speak", {
callId: params.callId,
message: params.message
});
if (spoken.success === false) throw new Error(spoken.error || "voicecall.speak failed");
}
//#endregion
//#region src/meeting-bot/participation-browser.ts
async function runMeetingParticipationWithBrowser(params) {
let dispatched = false;
let authorityCheckFailed = false;
const assertCurrent = () => {
try {
params.assertCurrent();
} catch (error) {
authorityCheckFailed = true;
throw error;
}
};
try {
assertCurrent();
if (!params.adapter.capabilities.includes(params.action.type)) return {
status: "unsupported",
message: "This meeting does not support that action."
};
const invalidAction = params.adapter.validateAction(params.action);
if (invalidAction) return {
status: "rejected",
message: invalidAction
};
const deadline = performance.now() + Math.max(1, params.timeoutMs);
const result = await runMeetingBrowserAct({
deadline,
targetId: params.targetId,
operation: async (remainingMs) => {
assertCurrent();
const tabs = asMeetingBrowserTabs(await params.callBrowser({
method: "GET",
path: "/tabs",
timeoutMs: remainingMs
}));
assertCurrent();
const tab = tabs.find((entry) => entry.targetId === params.targetId);
if (!tab || !params.isSameMeetingUrl(tab.url, params.meetingUrl)) return {
status: "rejected",
message: "The tracked browser tab no longer shows this meeting; no action was sent."
};
const scriptParams = {
meetingSessionId: params.meetingSessionId,
meetingUrl: params.meetingUrl,
requestId: params.requestId,
action: params.action
};
const remainingTimeoutMs = () => {
const timeoutMs = Math.floor(deadline - performance.now());
if (timeoutMs <= 0) throw new Error("Meeting participation timed out before dispatch.");
return timeoutMs;
};
if (params.adapter.buildPreparationScript) {
if (!params.adapter.parsePreparationResult) return {
status: "failed",
message: "The meeting adapter cannot verify action preparation."
};
const fn = params.adapter.buildPreparationScript(scriptParams);
const timeoutMs = remainingTimeoutMs();
assertCurrent();
const prepared = await params.callBrowser({
method: "POST",
path: "/act",
body: {
kind: "evaluate",
targetId: params.targetId,
fn
},
timeoutMs
});
assertCurrent();
const preparation = params.adapter.parsePreparationResult(prepared, params.action);
if (preparation.status !== "succeeded") return preparation;
}
const fn = params.adapter.buildActionScript(scriptParams);
const timeoutMs = remainingTimeoutMs();
assertCurrent();
dispatched = true;
const evaluated = await params.callBrowser({
method: "POST",
path: "/act",
body: {
kind: "evaluate",
targetId: params.targetId,
fn
},
timeoutMs
});
assertCurrent();
return params.adapter.parseActionResult(evaluated, params.action);
}
});
assertCurrent();
return result;
} catch (error) {
return {
status: dispatched ? "uncertain" : authorityCheckFailed ? "rejected" : "failed",
message: coerceErrorMessage(error)
};
}
}
//#endregion
export { MeetingPlatformAdapter, MeetingSessionRuntime, addMeetingSetupCheck, asMeetingBrowserTabs, buildMeetingSoxAudioCommands, callMeetingBrowserProxyOnNode, convertMeetingTtsAudioForBridge, createLocalMeetingRealtimeAudioTransport, createMeetingBrowserNodeInvokePolicy, createMeetingNodeHost, createMeetingRealtimeEngineBindings, createMeetingSession, createMeetingSetupStatus, createMeetingVoiceCallGateway, createNodeMeetingRealtimeAudioTransport, endMeetingVoiceCallGatewayCall, getMeetingVoiceCallGatewayCall, isMeetingVoiceCallMissingError, joinMeetingViaVoiceCallGateway, leaveMeetingWithBrowser, openMeetingWithBrowser, readMeetingBrowserTab, readMeetingTranscriptWithBrowser, recoverMeetingBrowserTab, resolveLocalMeetingBrowserRequest, resolveMeetingBrowserNode, resolveMeetingBrowserNodeInfo, runMeetingParticipationWithBrowser, speakMeetingViaVoiceCallGateway, startMeetingAgentRealtimeEngine, startMeetingRealtimeEngine };