UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

405 lines (404 loc) 21 kB
import { F as resolveTimerTimeoutMs, M as resolveNonNegativeIntegerOption } from "../number-coercion-CLj0HTDM.js"; import { y as uniqueStrings } from "../string-normalization-DsCfAx8q.js"; import { r as truncateUtf16Safe } from "../utf16-slice-D_ngcYKd.js"; import { t as asBoolean } from "../boolean-DmBL0YJK.js"; import { k as withTimeout } from "../fs-safe-B6pvPGnf.js"; import { t as formatErrorMessage } from "../errors-Db3Ymjlb.js"; import "../with-timeout-C7lEewUq.js"; import { n as createRealtimeVoiceAudioQueue, t as RealtimeVoiceSessionLifecycle } from "../realtime-session-lifecycle-B2wf87mH.js"; import { a as normalizeRealtimeVoiceActivationNamePrefix, c as sortRealtimeVoiceActivationNames, i as normalizeRealtimeVoiceActivationName, n as isSupportedRealtimeVoiceActivationName, o as normalizeSupportedRealtimeVoiceActivationName, r as matchRealtimeVoiceActivationName, s as realtimeVoiceActivationNameWordCount, t as REALTIME_VOICE_ACTIVATION_NAME_MAX_WORDS } from "../activation-name-Cwt4_teS.js"; import { A as REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, C as collectRealtimeVoiceAgentConsultVisibleText, D as resolveRealtimeVoiceAgentConsultTools, E as resolveRealtimeVoiceAgentConsultToolPolicy, M as realtimeVoiceAudioDurationMs, N as toOpenAICompatibleRealtimeAudioFormat, O as resolveRealtimeVoiceAgentConsultToolsAllow, S as buildRealtimeVoiceSessionInstructions, T as parseRealtimeVoiceAgentConsultArgs, _ as REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES, a as buildRealtimeVoiceAgentCancelProviderResult, b as buildRealtimeVoiceAgentConsultPrompt, c as classifyRealtimeVoiceAgentControlText, d as normalizeRealtimeVoiceAgentControlMode, f as parseRealtimeVoiceAgentControlToolArgs, g as REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, h as REALTIME_VOICE_AGENT_CONSULT_TOOL, i as REALTIME_VOICE_AGENT_CONTROL_TOOL_NAME, j as normalizeRealtimeVoiceResponseOutcome, k as REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ, m as shouldAutoControlRealtimeVoiceAgentText, n as REALTIME_VOICE_AGENT_CONTROL_MODES, o as buildRealtimeVoiceAgentControlSpeechMessage, p as resolveRealtimeVoiceAgentControlIntent, r as REALTIME_VOICE_AGENT_CONTROL_TOOL, v as buildRealtimeVoiceAgentConsultChatMessage, w as isRealtimeVoiceAgentConsultToolPolicy, x as buildRealtimeVoiceAgentConsultWorkingResponse, y as buildRealtimeVoiceAgentConsultPolicyInstructions } from "../agent-run-control-shared-zVaKVJuy.js"; import { A as createTalkSessionController, D as normalizeRealtimeVoiceConsultQuestion, E as matchRealtimeVoiceConsultQuestions, F as createTalkDiagnosticEvent, I as recordTalkDiagnosticEvent, L as TALK_EVENT_TYPES, M as recordTalkObservabilityEvent, N as createTalkLogRecord, O as readRealtimeVoiceConsultQuestion, P as recordTalkLogEvent, R as createTalkEventSequencer, S as consultRealtimeVoiceAgent, T as createRealtimeVoiceForcedConsultCoordinator, _ as listRealtimeVoiceProviders, a as getRealtimeVoiceTranscriptHealth, b as REALTIME_VOICE_AGENT_CONSULT_SENDER_AUTH_VERSION, c as recordRealtimeVoiceTranscript, d as resolveConfiguredRealtimeVoiceProvider, g as getRealtimeVoiceProvider, h as canonicalizeRealtimeVoiceProviderId, i as getRealtimeVoiceBridgeEventHealth, j as normalizeTalkTransport, k as readSpeakableRealtimeVoiceToolResult, l as createRealtimeVoiceBridgeSession, o as isLikelyRealtimeVoiceAssistantEchoTranscript, r as extendRealtimeVoiceOutputEchoSuppression, s as recordRealtimeVoiceBridgeEvent, t as createRealtimeVoiceSessionHarness, v as normalizeRealtimeVoiceProviderId, w as createRealtimeVoiceOutputActivityTracker, x as assertRealtimeVoiceAgentConsultModelSelectionUnlocked, y as createRealtimeVoiceAgentTalkbackQueue } from "../realtime-session-harness-boRKVRPl.js"; import { n as controlRealtimeVoiceAgentRun, t as buildRealtimeVoiceAgentErrorProviderResult } from "../agent-run-control-DEOlANFu.js"; import { a as resamplePcm, i as pcmToMulaw, n as createStreamingPcmResampler, o as resamplePcmTo8k, r as mulawToPcm, t as convertPcmToMulaw8k } from "../audio-codec-DFkaYkvm.js"; import { n as createSpeechThresholdGate, r as readPcm16AudioStats, t as calculateMulawRms } from "../audio-energy-B7WMUoUD.js"; //#region src/talk/consult-transcript.ts /** * Transcript guardrails for realtime voice agent consults. * * ASR often emits partial fragments or polite closings that should not trigger * an OpenClaw consult. This classifier names those skip reasons for callers. */ const REALTIME_VOICE_CONSULT_TRAILING_FRAGMENT_WORDS = /* @__PURE__ */ new Set([ "a", "about", "an", "and", "as", "at", "because", "but", "by", "for", "from", "in", "of", "on", "or", "so", "that", "the", "then", "to", "with" ]); /** Classify transcript text that is empty, incomplete, fragmented, or non-actionable. */ function classifySkippableRealtimeVoiceConsultTranscript(text) { const normalized = text.replace(/\s+/g, " ").trim().toLowerCase(); if (!normalized) return "empty"; if (/(\.\.\.|…)\s*$/.test(normalized)) return "incomplete-transcript"; const lastWord = normalized.match(/[a-z']+$/)?.[0]?.replace(/^'+|'+$/g, ""); if (lastWord && REALTIME_VOICE_CONSULT_TRAILING_FRAGMENT_WORDS.has(lastWord)) return "trailing-fragment"; if (!normalized.includes("?") && (/^(i'?ll|i will) be (right )?back\b/.test(normalized) || /\b(see you|bye(?:-bye)?|goodbye)\b/.test(normalized))) return "non-actionable-closing"; } //#endregion //#region src/talk/turn-context-tracker.ts const DEFAULT_REALTIME_VOICE_TURN_CONTEXT_LIMIT = 32; const DEFAULT_REALTIME_VOICE_IGNORED_CONTEXT_TTL_MS = 1e4; function createRealtimeVoiceTurnContextTracker(options = {}) { const turns = []; let recentIgnoredContext; let nextId = 0; const owner = Symbol("realtimeVoiceTurnContextTracker"); const now = options.now ?? Date.now; const limit = resolveNonNegativeIntegerOption(options.limit, DEFAULT_REALTIME_VOICE_TURN_CONTEXT_LIMIT); const ignoredContextTtlMs = resolveNonNegativeIntegerOption(options.ignoredContextTtlMs, DEFAULT_REALTIME_VOICE_IGNORED_CONTEXT_TTL_MS); const deferUntilAudio = options.deferUntilAudio === true; const prune = () => { for (let index = turns.length - 1; index >= 0; index -= 1) { const turn = turns[index]; if (turn?.closed && !turn.hasAudio) turns.splice(index, 1); } while (turns.length > limit) { const completedIndex = turns.findIndex((turn) => turn.closed); turns.splice(Math.max(completedIndex, 0), 1); } }; const expireClosedTurnsBeforeLaterAudio = () => { let hasLaterAudio = false; for (let index = turns.length - 1; index >= 0; index -= 1) { const turn = turns[index]; if (!turn?.hasAudio) continue; if (turn.closed && hasLaterAudio) { turns.splice(index, 1); continue; } hasLaterAudio = true; } }; const prepareForAudioContextRead = () => { prune(); expireClosedTurnsBeforeLaterAudio(); }; const owns = (handle) => handle[owner] === true; return { open(context, ...extra) { const startedAt = now(); const handle = { ...extra[0] ?? {}, [owner]: true, id: `realtime-turn:${startedAt}:${++nextId}`, context, hasAudio: false, closed: false, startedAt }; if (!deferUntilAudio) { turns.push(handle); prune(); } return handle; }, markAudio(handle) { if (!owns(handle)) return; handle.hasAudio = true; handle.lastAudioAt = now(); if (!turns.includes(handle)) { turns.push(handle); prune(); } }, close(handle) { if (!owns(handle)) return; handle.closed = true; if (!turns.includes(handle)) return; prune(); }, consumeAudioContext() { prepareForAudioContextRead(); const index = turns.findIndex((turn) => turn.hasAudio); if (index < 0) return; const [turn] = turns.splice(index, 1); prune(); return turn?.context; }, peekAudioTurn() { prepareForAudioContextRead(); return turns.find((turn) => turn.hasAudio); }, hasAudioContext() { prepareForAudioContextRead(); return turns.some((turn) => turn.hasAudio); }, rememberIgnoredContext(context) { if (context === void 0) return; recentIgnoredContext = { context, createdAt: now() }; }, consumeIgnoredContext() { const recent = recentIgnoredContext; recentIgnoredContext = void 0; if (!recent || now() - recent.createdAt > ignoredContextTtlMs) return; return recent.context; }, size() { prune(); return turns.length; }, clear() { turns.length = 0; recentIgnoredContext = void 0; } }; } //#endregion //#region src/talk/exact-speech-protocol.ts /** Build the internal user message that asks a realtime model to speak exact text. */ function buildRealtimeVoiceSpeakExactMessage(params) { return [ "Internal OpenClaw voice playback result.", "Do not call openclaw_agent_consult or any other tool for this message.", `Speak this exact OpenClaw answer to ${params.surfaceLabel}, without adding, removing, or rephrasing words.`, `Answer: ${JSON.stringify(params.text)}` ].join("\n"); } /** Classify a provider consult call before normal agent delegation. */ function classifyRealtimeVoiceConsultToolCall(args, options) { const message = collectRealtimeConsultArgStrings(args).join("\n"); if (message.includes("Speak this exact OpenClaw answer")) { const text = readJsonStringAfterLabel(message, "Answer:"); if (text !== void 0 && options.retainedExactSpeechTexts.includes(text)) return { kind: "exact-speech-echo", text }; } for (const text of options.retainedExactSpeechTexts) if (text && message.includes(JSON.stringify(text))) return { kind: "exact-speech-echo", text }; try { return { kind: "consult", message: buildRealtimeVoiceAgentConsultChatMessage(args) }; } catch (error) { return { kind: "malformed", error: formatErrorMessage(error) }; } } function collectRealtimeConsultArgStrings(args) { if (!args || typeof args !== "object") return typeof args === "string" ? [args] : []; const values = []; for (const key of [ "question", "prompt", "query", "task", "context", "responseStyle" ]) { const value = args[key]; if (typeof value === "string") values.push(value); } return values; } function readJsonStringAfterLabel(text, label) { const labelIndex = text.indexOf(label); if (labelIndex < 0) return; const quoteIndex = text.indexOf("\"", labelIndex + label.length); if (quoteIndex < 0) return; for (let index = quoteIndex + 1; index < text.length; index += 1) { if (text[index] !== "\"" || isEscapedQuote(text, index)) continue; try { const parsed = JSON.parse(text.slice(quoteIndex, index + 1)); return typeof parsed === "string" ? parsed : void 0; } catch { return; } } } function isEscapedQuote(text, quoteIndex) { let backslashes = 0; for (let index = quoteIndex - 1; index >= 0 && text[index] === "\\"; index -= 1) backslashes += 1; return backslashes % 2 === 1; } //#endregion //#region src/talk/realtime-session-policy.ts /** Resolve generic consult, activation-name, and auto-response session policy. */ function resolveRealtimeVoiceSessionPolicy(params) { const toolPolicy = resolveRealtimeVoiceAgentConsultToolPolicy(params.configuredToolPolicy, params.isAgentProxy ? "owner" : "safe-read-only"); const consultPolicy = params.configuredConsultPolicy ?? (params.isAgentProxy ? "always" : "auto"); const wakeNamePolicy = resolveRealtimeVoiceWakeNamePolicy(params); const wakeNames = wakeNamePolicy === "never" ? [] : resolveRealtimeVoiceWakeNames({ configuredWakeNames: params.configuredWakeNames, cfg: params.cfg, agentId: params.agentId }); return { toolPolicy, consultToolsAllow: resolveRealtimeVoiceAgentConsultToolsAllow(toolPolicy), consultPolicy, wakeNamePolicy, wakeNames, autoRespondToAudio: wakeNamePolicy === "never" && (!params.isAgentProxy || consultPolicy !== "always") }; } function isRealtimeVoiceWakeNameRequired(policy, humanParticipantCount) { return policy === "always" || policy === "automatic" && humanParticipantCount > 1; } function resolveRealtimeVoiceInterruptResponseOnInputAudio(value) { return asBoolean(value) ?? true; } function resolveRealtimeVoiceBargeIn(params) { if (typeof params.configuredBargeIn === "boolean") return params.configuredBargeIn; return resolveRealtimeVoiceInterruptResponseOnInputAudio(params.interruptResponseOnInputAudio); } function resolveRealtimeVoiceMinBargeInAudioEndMs(configured) { return typeof configured === "number" ? configured : 250; } function resolveRealtimeVoiceWakeNamePolicy(params) { if (!params.isAgentProxy || !params.supportsActivationNameGating) return "never"; if (params.requireWakeName === true) return "always"; if (params.requireWakeName === false) return "never"; return "automatic"; } function resolveRealtimeVoiceWakeNames(params) { if (params.configuredWakeNames !== void 0) { const configured = params.configuredWakeNames.map((name) => normalizeSupportedRealtimeVoiceActivationName(name)).filter((name) => Boolean(name)); return sortRealtimeVoiceActivationNames(uniqueStrings(configured)); } const agent = params.cfg.agents?.list?.find((candidate) => candidate.id === params.agentId); const configuredAgentNames = [agent?.name, agent?.identity?.name].map((name) => normalizeSupportedRealtimeVoiceActivationName(name)).filter((name) => Boolean(name)); const productWakeNames = [normalizeSupportedRealtimeVoiceActivationName("OpenClaw")].filter((name) => Boolean(name)); const defaults = configuredAgentNames.length > 0 ? [...configuredAgentNames, ...productWakeNames] : [normalizeSupportedRealtimeVoiceActivationName(params.agentId), ...productWakeNames].filter((name) => Boolean(name)); return sortRealtimeVoiceActivationNames(uniqueStrings(defaults)); } //#endregion //#region src/talk/fast-context-runtime.ts /** * Fast context lookup for realtime voice consults. * * When memory/session search can answer quickly, Talk can return concise * context without launching a full agent consult; otherwise callers may fall * back to the normal consult flow. */ const MAX_SNIPPET_CHARS = 700; function normalizeSnippet(text) { const normalized = text.replace(/\s+/g, " ").trim(); if (normalized.length <= MAX_SNIPPET_CHARS) return normalized; return `${truncateUtf16Safe(normalized, 699).trimEnd()}...`; } function buildSearchQuery(args) { const parsed = parseRealtimeVoiceAgentConsultArgs(args); return [parsed.question, parsed.context].filter(Boolean).join("\n\n"); } function resolveLabels(labels) { return { audienceLabel: labels?.audienceLabel?.trim() || "person", contextName: labels?.contextName?.trim() || "OpenClaw memory context" }; } function buildContextText(params) { const hits = params.hits.map((hit, index) => { const location = `${hit.path}:${hit.startLine}-${hit.endLine}`; return `${index + 1}. [${hit.source}] ${location}\n${normalizeSnippet(hit.snippet)}`; }).join("\n\n"); return [ `Fast ${params.labels.contextName} found for the live ${params.labels.audienceLabel}.`, `Use this context only if it answers the ${params.labels.audienceLabel}'s question. If it is not relevant, say briefly that you do not have that context handy.`, `Question:\n${params.query}`, `Context:\n${hits}` ].join("\n\n"); } function buildMissText(query, labels) { return [ `No relevant ${labels.contextName} was found quickly for the live ${labels.audienceLabel}.`, `Answer briefly that you do not have that context handy. Do not keep checking unless the ${labels.audienceLabel} asks you to.`, `Question:\n${query}` ].join("\n\n"); } async function lookupFastContext(params) { const { authorizeActiveMemorySearchHits, getActiveMemorySearchManagerCore } = await import("../memory-runtime-5txY6g3V.js"); const memory = await getActiveMemorySearchManagerCore({ cfg: params.cfg, agentId: params.agentId }); if (!memory.manager) return { status: "unavailable", error: memory.error ?? "no active memory manager" }; const rawHits = await memory.manager.search(params.query, { maxResults: params.config.maxResults, sessionKey: params.sessionKey, sources: params.config.sources }); return { status: "hits", hits: await authorizeActiveMemorySearchHits({ cfg: params.cfg, agentId: params.agentId, requesterSessionKey: params.sessionKey, sandboxed: false, hits: rawHits }) }; } /** Try to answer a realtime consult from fast memory/session context. */ async function resolveRealtimeVoiceFastContextConsult(params) { if (!params.config.enabled) return { handled: false }; const labels = resolveLabels(params.labels); const query = buildSearchQuery(params.args); try { const timeoutMs = resolveTimerTimeoutMs(params.config.timeoutMs, 1); const lookup = await withTimeout(lookupFastContext({ cfg: params.cfg, agentId: params.agentId, sessionKey: params.sessionKey, config: params.config, query }), timeoutMs, { createError: () => /* @__PURE__ */ new Error(`fast context lookup timed out after ${timeoutMs}ms`) }); if (lookup.status === "unavailable") { params.logger.debug?.(`[talk] fast context unavailable: ${lookup.error}`); return params.config.fallbackToConsult ? { handled: false } : { handled: true, result: { text: buildMissText(query, labels) } }; } const { hits } = lookup; if (hits.length === 0) return params.config.fallbackToConsult ? { handled: false } : { handled: true, result: { text: buildMissText(query, labels) } }; return { handled: true, result: { text: buildContextText({ query, hits, labels }) } }; } catch (error) { const message = formatErrorMessage(error); params.logger.debug?.(`[talk] fast context lookup failed: ${message}`); return params.config.fallbackToConsult ? { handled: false } : { handled: true, result: { text: buildMissText(query, labels) } }; } } //#endregion export { REALTIME_VOICE_ACTIVATION_NAME_MAX_WORDS, REALTIME_VOICE_AGENT_CONSULT_SENDER_AUTH_VERSION, REALTIME_VOICE_AGENT_CONSULT_TOOL, REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES, REALTIME_VOICE_AGENT_CONTROL_MODES, REALTIME_VOICE_AGENT_CONTROL_TOOL, REALTIME_VOICE_AGENT_CONTROL_TOOL_NAME, REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ, REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, RealtimeVoiceSessionLifecycle, TALK_EVENT_TYPES, assertRealtimeVoiceAgentConsultModelSelectionUnlocked, buildRealtimeVoiceAgentCancelProviderResult, buildRealtimeVoiceAgentConsultChatMessage, buildRealtimeVoiceAgentConsultPolicyInstructions, buildRealtimeVoiceAgentConsultPrompt, buildRealtimeVoiceAgentConsultWorkingResponse, buildRealtimeVoiceAgentControlSpeechMessage, buildRealtimeVoiceAgentErrorProviderResult, buildRealtimeVoiceSessionInstructions, buildRealtimeVoiceSpeakExactMessage, calculateMulawRms, canonicalizeRealtimeVoiceProviderId, classifyRealtimeVoiceAgentControlText, classifyRealtimeVoiceConsultToolCall, classifySkippableRealtimeVoiceConsultTranscript, collectRealtimeVoiceAgentConsultVisibleText, consultRealtimeVoiceAgent, controlRealtimeVoiceAgentRun, convertPcmToMulaw8k, createRealtimeVoiceAgentTalkbackQueue, createRealtimeVoiceAudioQueue, createRealtimeVoiceBridgeSession, createRealtimeVoiceForcedConsultCoordinator, createRealtimeVoiceOutputActivityTracker, createRealtimeVoiceSessionHarness, createRealtimeVoiceTurnContextTracker, createSpeechThresholdGate, createStreamingPcmResampler, createTalkDiagnosticEvent, createTalkEventSequencer, createTalkLogRecord, createTalkSessionController, extendRealtimeVoiceOutputEchoSuppression, getRealtimeVoiceBridgeEventHealth, getRealtimeVoiceProvider, getRealtimeVoiceTranscriptHealth, isLikelyRealtimeVoiceAssistantEchoTranscript, isRealtimeVoiceAgentConsultToolPolicy, isRealtimeVoiceWakeNameRequired, isSupportedRealtimeVoiceActivationName, listRealtimeVoiceProviders, matchRealtimeVoiceActivationName, matchRealtimeVoiceConsultQuestions, mulawToPcm, normalizeRealtimeVoiceActivationName, normalizeRealtimeVoiceActivationNamePrefix, normalizeRealtimeVoiceAgentControlMode, normalizeRealtimeVoiceConsultQuestion, normalizeRealtimeVoiceProviderId, normalizeRealtimeVoiceResponseOutcome, normalizeSupportedRealtimeVoiceActivationName, normalizeTalkTransport, parseRealtimeVoiceAgentConsultArgs, parseRealtimeVoiceAgentControlToolArgs, pcmToMulaw, readPcm16AudioStats, readRealtimeVoiceConsultQuestion, readSpeakableRealtimeVoiceToolResult, realtimeVoiceActivationNameWordCount, realtimeVoiceAudioDurationMs, recordRealtimeVoiceBridgeEvent, recordRealtimeVoiceTranscript, recordTalkDiagnosticEvent, recordTalkLogEvent, recordTalkObservabilityEvent, resamplePcm, resamplePcmTo8k, resolveConfiguredRealtimeVoiceProvider, resolveRealtimeVoiceAgentConsultToolPolicy, resolveRealtimeVoiceAgentConsultTools, resolveRealtimeVoiceAgentConsultToolsAllow, resolveRealtimeVoiceAgentControlIntent, resolveRealtimeVoiceBargeIn, resolveRealtimeVoiceFastContextConsult, resolveRealtimeVoiceInterruptResponseOnInputAudio, resolveRealtimeVoiceMinBargeInAudioEndMs, resolveRealtimeVoiceSessionPolicy, shouldAutoControlRealtimeVoiceAgentText, sortRealtimeVoiceActivationNames, toOpenAICompatibleRealtimeAudioFormat };