UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

515 lines (514 loc) 20.1 kB
import { a as normalizeLowercaseStringOrEmpty, c as normalizeOptionalString } from "./string-coerce-mnp54Vah.js"; import { t as runTasksWithConcurrency } from "./run-with-concurrency-DivrDqxu.js"; import { a as shouldLogVerbose, r as logVerbose } from "./globals-GTrXU4s9.js"; import { t as resolveConcurrency } from "./resolve-BmY6318x.js"; import { C as resolveAttachmentKind, f as extractFileContentFromSource, g as resolveInputFileLimits, h as normalizeMimeType } from "./runner.entries-B23jIAju.js"; import { a as runCapability, i as resolveMediaAttachmentLocalRoots, o as createMediaAttachmentCache, s as normalizeMediaAttachments, t as buildProviderRegistry } from "./runner-CswjdYkd.js"; import { n as sendTranscriptEcho } from "./echo-transcript-B1g_NN9U.js"; import { i as wrapExternalContent } from "./external-content-pX-Pk1Iu.js"; import { t as finalizeInboundContext } from "./inbound-context-CZx-NgvC.js"; import { t as renderFileContextBlock } from "./file-context-M6CFQU8x.js"; import path from "node:path"; //#region src/media-understanding/concurrency.ts /** Runs media tasks under a fixed concurrency limit while preserving successful results. */ async function runWithConcurrency(tasks, limit) { const { results } = await runTasksWithConcurrency({ tasks, limit, onTaskError(err) { if (shouldLogVerbose()) logVerbose(`Media understanding task failed: ${String(err)}`); } }); return results; } //#endregion //#region packages/media-understanding-common/src/format.ts const MEDIA_PLACEHOLDER_RE = /^<media:[^>]+>(\s*\([^)]*\))?$/i; const MEDIA_PLACEHOLDER_TOKEN_RE = /^<media:[^>]+>(\s*\([^)]*\))?\s*/i; /** Extracts user-authored text while ignoring synthetic media placeholder tokens. */ function extractMediaUserText(body) { const trimmed = body?.trim() ?? ""; if (!trimmed) return; if (MEDIA_PLACEHOLDER_RE.test(trimmed)) return; return trimmed.replace(MEDIA_PLACEHOLDER_TOKEN_RE, "").trim() || void 0; } function formatSection(title, kind, text, userText) { const lines = [`[${title}]`]; if (userText) lines.push(`User text:\n${userText}`); lines.push(`${kind}:\n${text}`); return lines.join("\n"); } /** Formats media-understanding outputs into the chat body sent back to the model. */ function formatMediaUnderstandingBody(params) { const outputs = params.outputs.filter((output) => output.text.trim()); if (outputs.length === 0) return params.body ?? ""; const userText = extractMediaUserText(params.body); const sections = []; if (userText && outputs.length > 1) sections.push(`User text:\n${userText}`); const counts = /* @__PURE__ */ new Map(); for (const output of outputs) counts.set(output.kind, (counts.get(output.kind) ?? 0) + 1); const seen = /* @__PURE__ */ new Map(); for (const output of outputs) { const count = counts.get(output.kind) ?? 1; const next = (seen.get(output.kind) ?? 0) + 1; seen.set(output.kind, next); const suffix = count > 1 ? ` ${next}/${count}` : ""; if (output.kind === "audio.transcription") { sections.push(formatSection(`Audio${suffix}`, "Transcript", output.text, outputs.length === 1 ? userText : void 0)); continue; } if (output.kind === "image.description") { sections.push(formatSection(`Image${suffix}`, "Description", output.text, outputs.length === 1 ? userText : void 0)); continue; } sections.push(formatSection(`Video${suffix}`, "Description", output.text, outputs.length === 1 ? userText : void 0)); } return sections.join("\n\n").trim(); } /** Formats one or more audio transcript outputs for legacy transcript-only callers. */ function formatAudioTranscripts(outputs) { if (outputs.length === 1) return outputs[0].text; return outputs.map((output, index) => `Audio ${index + 1}:\n${output.text}`).join("\n\n"); } //#endregion //#region src/media-understanding/apply.ts const CAPABILITY_ORDER = [ "image", "audio", "video" ]; const EMPTY_VOICE_NOTE_PLACEHOLDER = "[Voice note could not be transcribed because the audio attachment was too small]"; const EXTRA_TEXT_MIMES = [ "application/xml", "text/xml", "application/x-yaml", "text/yaml", "application/yaml", "application/javascript", "text/javascript", "text/tab-separated-values" ]; const TEXT_EXT_MIME = new Map([ [".csv", "text/csv"], [".tsv", "text/tab-separated-values"], [".txt", "text/plain"], [".md", "text/markdown"], [".log", "text/plain"], [".ini", "text/plain"], [".cfg", "text/plain"], [".conf", "text/plain"], [".env", "text/plain"], [".json", "application/json"], [".yaml", "text/yaml"], [".yml", "text/yaml"], [".xml", "application/xml"] ]); const MIME_TYPE = String.raw`([a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+)`; const HTTP_TOKEN = String.raw`[a-z0-9!#$%&'*+.^_\x60|~-]+`; const HTTP_QUOTED_STRING = String.raw`"(?:[\t !#-\[\]-~]|\\[\t -~])*"`; const MIME_PARAMETER = String.raw`[ \t]*;[ \t]*${HTTP_TOKEN}=(?:${HTTP_TOKEN}|${HTTP_QUOTED_STRING})`; const MIME_TYPE_WITH_OPTIONAL_PARAMS = new RegExp(String.raw`^${MIME_TYPE}(?:${MIME_PARAMETER})*$`, "i"); function sanitizeMimeType(value) { const trimmed = normalizeOptionalString(value); if (!trimmed) return; return trimmed.match(MIME_TYPE_WITH_OPTIONAL_PARAMS)?.[1]?.toLowerCase(); } function resolveFileLimits(cfg) { const files = cfg.gateway?.http?.endpoints?.responses?.files; const allowedMimesConfigured = Boolean(files?.allowedMimes?.length); return { ...resolveInputFileLimits(files), allowedMimesConfigured }; } function appendFileBlocks(body, blocks) { if (!blocks || blocks.length === 0) return body ?? ""; const base = typeof body === "string" ? body.trim() : ""; const suffix = blocks.join("\n\n").trim(); if (!base) return suffix; return `${base}\n\n${suffix}`.trim(); } function wrapUntrustedAttachmentContent(content) { return wrapExternalContent(content, { source: "unknown", includeWarning: false }); } function resolveUtf16Charset(buffer) { if (!buffer || buffer.length < 2) return; const b0 = buffer[0]; const b1 = buffer[1]; if (b0 === 255 && b1 === 254) return "utf-16le"; if (b0 === 254 && b1 === 255) return "utf-16be"; const sampleLen = Math.min(buffer.length, 2048); let zeroEven = 0; let zeroOdd = 0; for (let i = 0; i < sampleLen; i += 1) { if (buffer[i] !== 0) continue; if (i % 2 === 0) zeroEven += 1; else zeroOdd += 1; } if ((zeroEven + zeroOdd) / sampleLen > .2) return zeroOdd >= zeroEven ? "utf-16le" : "utf-16be"; } const WORDISH_CHAR = /[\p{L}\p{N}]/u; const CP1252_MAP = [ "€", void 0, "‚", "ƒ", "„", "…", "†", "‡", "ˆ", "‰", "Š", "‹", "Œ", void 0, "Ž", void 0, void 0, "‘", "’", "“", "”", "•", "–", "—", "˜", "™", "š", "›", "œ", void 0, "ž", "Ÿ" ]; function decodeLegacyText(buffer) { let output = ""; for (const byte of buffer) { if (byte >= 128 && byte <= 159) { const mapped = CP1252_MAP[byte - 128]; output += mapped ?? String.fromCharCode(byte); continue; } output += String.fromCharCode(byte); } return output; } function getTextStats(text) { if (!text) return { printableRatio: 0, wordishRatio: 0 }; let printable = 0; let control = 0; let wordish = 0; for (const char of text) { const code = char.codePointAt(0) ?? 0; if (code === 9 || code === 10 || code === 13 || code === 32) { printable += 1; wordish += 1; continue; } if (code < 32 || code >= 127 && code <= 159) { control += 1; continue; } printable += 1; if (WORDISH_CHAR.test(char)) wordish += 1; } const total = printable + control; if (total === 0) return { printableRatio: 0, wordishRatio: 0 }; return { printableRatio: printable / total, wordishRatio: wordish / total }; } function isMostlyPrintable(text) { return getTextStats(text).printableRatio > .85; } function looksLikeLegacyTextBytes(buffer) { if (buffer.length === 0) return false; const { printableRatio, wordishRatio } = getTextStats(decodeLegacyText(buffer)); return printableRatio > .95 && wordishRatio > .3; } function looksLikeUtf8Text(buffer) { if (!buffer || buffer.length === 0) return false; const sample = buffer.subarray(0, Math.min(buffer.length, 4096)); try { return isMostlyPrintable(new TextDecoder("utf-8", { fatal: true }).decode(sample)); } catch { return looksLikeLegacyTextBytes(sample); } } function hasSuspiciousBinarySignal(buffer) { if (!buffer || buffer.length === 0) return false; const sample = buffer.subarray(0, Math.min(buffer.length, 4096)); if (sample.length < 4 || sample[0] !== 80 || sample[1] !== 75) return false; const signature = sample[2] << 8 | sample[3]; return signature === 772 || signature === 258 || signature === 1286; } function decodeTextSample(buffer) { if (!buffer || buffer.length === 0) return ""; const sample = buffer.subarray(0, Math.min(buffer.length, 8192)); const utf16Charset = resolveUtf16Charset(sample); if (utf16Charset === "utf-16be") { const swapped = Buffer.alloc(sample.length); for (let i = 0; i + 1 < sample.length; i += 2) { swapped[i] = sample[i + 1]; swapped[i + 1] = sample[i]; } return new TextDecoder("utf-16le").decode(swapped); } if (utf16Charset === "utf-16le") return new TextDecoder("utf-16le").decode(sample); return new TextDecoder("utf-8").decode(sample); } function guessDelimitedMime(text) { if (!text) return; const line = text.split(/\r?\n/)[0] ?? ""; const tabs = (line.match(/\t/g) ?? []).length; if ((line.match(/,/g) ?? []).length > 0) return "text/csv"; if (tabs > 0) return "text/tab-separated-values"; } function resolveTextMimeFromName(name) { if (!name) return; const ext = normalizeLowercaseStringOrEmpty(path.extname(name)); return TEXT_EXT_MIME.get(ext); } function buildSyntheticSkippedAudioOutputs(decisions) { const audioDecision = decisions.find((decision) => decision.capability === "audio"); if (!audioDecision) return []; return audioDecision.attachments.flatMap((attachment) => { if (!attachment.attempts.some((attempt) => attempt.reason?.trim().startsWith("tooSmall"))) return []; return [{ kind: "audio.transcription", attachmentIndex: attachment.attachmentIndex, text: EMPTY_VOICE_NOTE_PLACEHOLDER, provider: "openclaw", model: "synthetic-empty-audio" }]; }); } function isBinaryMediaMime(mime) { if (!mime) return false; if (mime.startsWith("image/") || mime.startsWith("audio/") || mime.startsWith("video/")) return true; if (mime === "application/octet-stream") return true; if (mime === "application/zip" || mime === "application/x-zip-compressed" || mime === "application/gzip" || mime === "application/x-gzip" || mime === "application/x-rar-compressed" || mime === "application/x-7z-compressed" || mime === "application/msword" || mime === "application/x-cfb") return true; if (mime.endsWith("+zip")) return true; if (mime.startsWith("application/vnd.")) { if (mime.endsWith("+json") || mime.endsWith("+xml")) return false; return true; } return false; } async function extractFileBlocks(params) { const { attachments, cache, cfg, limits, skipAttachmentIndexes } = params; if (!attachments || attachments.length === 0) return []; const blocks = []; for (const attachment of attachments) { if (!attachment) continue; if (skipAttachmentIndexes?.has(attachment.index)) continue; const forcedTextMime = resolveTextMimeFromName(attachment.path ?? attachment.url ?? ""); const kind = forcedTextMime ? "document" : resolveAttachmentKind(attachment); if (!forcedTextMime && (kind === "image" || kind === "video" || kind === "audio")) continue; if (!limits.allowUrl && attachment.url && !attachment.path) { if (shouldLogVerbose()) logVerbose(`media: file attachment skipped (url disabled) index=${attachment.index}`); continue; } let bufferResult; try { bufferResult = await cache.getBuffer({ attachmentIndex: attachment.index, maxBytes: limits.maxBytes, timeoutMs: limits.timeoutMs }); } catch (err) { if (shouldLogVerbose()) logVerbose(`media: file attachment skipped (buffer): ${String(err)}`); continue; } const nameHint = bufferResult?.fileName ?? attachment.path ?? attachment.url; const forcedTextMimeResolved = forcedTextMime ?? resolveTextMimeFromName(nameHint ?? ""); const rawMime = bufferResult?.mime ?? attachment.mime; const normalizedRawMime = normalizeMimeType(rawMime); if (!forcedTextMimeResolved && isBinaryMediaMime(normalizedRawMime)) continue; if (hasSuspiciousBinarySignal(bufferResult?.buffer)) continue; const utf16Charset = resolveUtf16Charset(bufferResult?.buffer); const textSample = decodeTextSample(bufferResult?.buffer); const textLike = normalizedRawMime !== "application/pdf" && (Boolean(utf16Charset) || looksLikeUtf8Text(bufferResult?.buffer)); const guessedDelimited = textLike ? guessDelimitedMime(textSample) : void 0; const textHint = forcedTextMimeResolved ?? guessedDelimited ?? (textLike ? "text/plain" : void 0); const mimeType = sanitizeMimeType(textHint ?? normalizeMimeType(rawMime)); if (textHint && rawMime && !rawMime.startsWith("text/")) logVerbose(`media: MIME override from "${rawMime}" to "${textHint}" for index=${attachment.index}`); if (!mimeType) { if (shouldLogVerbose()) logVerbose(`media: file attachment skipped (unknown mime) index=${attachment.index}`); continue; } const allowedMimes = new Set(limits.allowedMimes); if (!limits.allowedMimesConfigured) { for (const extra of EXTRA_TEXT_MIMES) allowedMimes.add(extra); if (mimeType.startsWith("text/")) allowedMimes.add(mimeType); } if (!allowedMimes.has(mimeType)) { if (shouldLogVerbose()) logVerbose(`media: file attachment skipped (unsupported mime ${mimeType}) index=${attachment.index}`); continue; } let extracted; try { const mediaType = utf16Charset ? `${mimeType}; charset=${utf16Charset}` : mimeType; const { allowedMimesConfigured: _allowedMimesConfigured, ...baseLimits } = limits; extracted = await extractFileContentFromSource({ source: { type: "base64", data: bufferResult.buffer.toString("base64"), mediaType, filename: bufferResult.fileName }, limits: { ...baseLimits, allowedMimes }, config: cfg }); } catch (err) { if (shouldLogVerbose()) logVerbose(`media: file attachment skipped (extract): ${String(err)}`); continue; } const text = extracted?.text?.trim() ?? ""; let blockText = text ? wrapUntrustedAttachmentContent(text) : ""; if (!blockText) if (extracted?.images && extracted.images.length > 0) blockText = "[PDF content rendered to images; images not forwarded to model]"; else blockText = "[No extractable text]"; blocks.push(renderFileContextBlock({ filename: bufferResult.fileName, fallbackName: `file-${attachment.index + 1}`, mimeType, content: blockText })); } return blocks; } async function applyMediaUnderstanding(params) { const { ctx, cfg } = params; const mediaWorkspaceDir = ctx.MediaWorkspaceDir ?? params.workspaceDir; const originalUserText = [ ctx.CommandBody, ctx.RawBody, ctx.Body ].map((value) => extractMediaUserText(value)).find((value) => value && value.trim()) ?? void 0; const attachments = normalizeMediaAttachments(ctx); const providerRegistry = buildProviderRegistry(params.providers, cfg); const cache = createMediaAttachmentCache(attachments, { localPathRoots: resolveMediaAttachmentLocalRoots({ cfg, ctx, workspaceDir: params.workspaceDir }), ssrfPolicy: cfg.tools?.web?.fetch?.ssrfPolicy, workspaceDir: mediaWorkspaceDir }); try { const results = await runWithConcurrency(CAPABILITY_ORDER.map((capability) => async () => { const config = cfg.tools?.media?.[capability]; return await runCapability({ capability, cfg, ctx, attachments: cache, media: attachments, agentId: params.agentId, agentDir: params.agentDir, workspaceDir: params.workspaceDir, providerRegistry, config, activeModel: params.activeModel }); }), resolveConcurrency(cfg)); const outputs = []; const decisions = []; for (const entry of results) { if (!entry) continue; for (const output of entry.outputs) outputs.push(output); decisions.push(entry.decision); } const audioOutputAttachmentIndexes = new Set(outputs.filter((output) => output.kind === "audio.transcription").map((output) => output.attachmentIndex)); const syntheticSkippedAudioOutputs = buildSyntheticSkippedAudioOutputs(decisions).filter((output) => !audioOutputAttachmentIndexes.has(output.attachmentIndex)); if (syntheticSkippedAudioOutputs.length > 0) { const audioAttachmentOrder = decisions.find((decision) => decision.capability === "audio")?.attachments.map((attachment) => attachment.attachmentIndex) ?? []; const audioOutputsByAttachmentIndex = /* @__PURE__ */ new Map(); for (const output of outputs) if (output.kind === "audio.transcription") audioOutputsByAttachmentIndex.set(output.attachmentIndex, output); for (const output of syntheticSkippedAudioOutputs) audioOutputsByAttachmentIndex.set(output.attachmentIndex, output); const mergedAudio = audioAttachmentOrder.map((attachmentIndex) => audioOutputsByAttachmentIndex.get(attachmentIndex)).filter((output) => Boolean(output)); const firstAudioIdx = outputs.findIndex((o) => o.kind === "audio.transcription"); if (firstAudioIdx >= 0) { const before = outputs.slice(0, firstAudioIdx); const afterLastAudio = outputs.slice(outputs.reduce((last, o, i) => o.kind === "audio.transcription" ? i : last, firstAudioIdx) + 1); outputs.length = 0; outputs.push(...before, ...mergedAudio, ...afterLastAudio); } else { const firstVideoIdx = outputs.findIndex((o) => o.kind === "video.description"); const audioInsertIdx = firstVideoIdx >= 0 ? firstVideoIdx : outputs.length; outputs.splice(audioInsertIdx, 0, ...mergedAudio); } } if (decisions.length > 0) ctx.MediaUnderstandingDecisions = [...ctx.MediaUnderstandingDecisions ?? [], ...decisions]; if (outputs.length > 0) { ctx.Body = formatMediaUnderstandingBody({ body: ctx.Body, outputs }); const audioOutputs = outputs.filter((output) => output.kind === "audio.transcription"); if (audioOutputs.length > 0) { const transcript = formatAudioTranscripts(audioOutputs); ctx.Transcript = transcript; if (originalUserText) { ctx.CommandBody = originalUserText; ctx.RawBody = originalUserText; } else { ctx.CommandBody = transcript; ctx.RawBody = transcript; } const audioCfg = cfg.tools?.media?.audio; if (audioCfg?.echoTranscript && transcript) await sendTranscriptEcho({ ctx, cfg, transcript, format: audioCfg.echoFormat ?? "📝 \"{transcript}\"" }); } else if (originalUserText) { ctx.CommandBody = originalUserText; ctx.RawBody = originalUserText; } ctx.MediaUnderstanding = [...ctx.MediaUnderstanding ?? [], ...outputs]; } const syntheticAudioIndexes = new Set(syntheticSkippedAudioOutputs.map((o) => o.attachmentIndex)); const audioAttachmentIndexes = new Set(outputs.filter((output) => output.kind === "audio.transcription" && !syntheticAudioIndexes.has(output.attachmentIndex)).map((output) => output.attachmentIndex)); const fileBlocks = await extractFileBlocks({ attachments, cache, cfg, limits: resolveFileLimits(cfg), skipAttachmentIndexes: audioAttachmentIndexes.size > 0 ? audioAttachmentIndexes : void 0 }); if (fileBlocks.length > 0) ctx.Body = appendFileBlocks(ctx.Body, fileBlocks); if (outputs.length > 0 || fileBlocks.length > 0) finalizeInboundContext(ctx, { forceBodyForAgent: true, forceBodyForCommands: outputs.length > 0 || fileBlocks.length > 0 }); return { outputs, decisions, appliedImage: outputs.some((output) => output.kind === "image.description"), appliedAudio: outputs.some((output) => output.kind === "audio.transcription"), appliedVideo: outputs.some((output) => output.kind === "video.description"), appliedFile: fileBlocks.length > 0 }; } finally { await cache.cleanup(); } } //#endregion export { applyMediaUnderstanding as t };