From d3dc4e54f77fabbb359e479361e5f3bcc4ef1416 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 23:55:33 +0000 Subject: [PATCH] perf(runtime): trim hot-path allocations and cache channel plugin lookups --- src/auto-reply/tokens.ts | 34 ++++++++++++++---- src/channels/plugins/index.ts | 55 +++++++++++++++++++++++------ src/infra/outbound/deliver.ts | 55 +++++++++++++++-------------- src/infra/outbound/payloads.ts | 64 ++++++++++++++++++---------------- src/media/parse.ts | 5 +++ src/utils/directive-tags.ts | 9 +++++ 6 files changed, 148 insertions(+), 74 deletions(-) diff --git a/src/auto-reply/tokens.ts b/src/auto-reply/tokens.ts index 3a0f18d3d..5a0e405e9 100644 --- a/src/auto-reply/tokens.ts +++ b/src/auto-reply/tokens.ts @@ -3,6 +3,31 @@ import { escapeRegExp } from "../utils.js"; export const HEARTBEAT_TOKEN = "HEARTBEAT_OK"; export const SILENT_REPLY_TOKEN = "NO_REPLY"; +const silentExactRegexByToken = new Map(); +const silentTrailingRegexByToken = new Map(); + +function getSilentExactRegex(token: string): RegExp { + const cached = silentExactRegexByToken.get(token); + if (cached) { + return cached; + } + const escaped = escapeRegExp(token); + const regex = new RegExp(`^\\s*${escaped}\\s*$`); + silentExactRegexByToken.set(token, regex); + return regex; +} + +function getSilentTrailingRegex(token: string): RegExp { + const cached = silentTrailingRegexByToken.get(token); + if (cached) { + return cached; + } + const escaped = escapeRegExp(token); + const regex = new RegExp(`(?:^|\\s+|\\*+)${escaped}\\s*$`); + silentTrailingRegexByToken.set(token, regex); + return regex; +} + export function isSilentReplyText( text: string | undefined, token: string = SILENT_REPLY_TOKEN, @@ -10,11 +35,9 @@ export function isSilentReplyText( if (!text) { return false; } - const escaped = escapeRegExp(token); // Match only the exact silent token with optional surrounding whitespace. - // This prevents - // substantive replies ending with NO_REPLY from being suppressed (#19537). - return new RegExp(`^\\s*${escaped}\\s*$`).test(text); + // This prevents substantive replies ending with NO_REPLY from being suppressed (#19537). + return getSilentExactRegex(token).test(text); } /** @@ -23,8 +46,7 @@ export function isSilentReplyText( * If the result is empty, the entire message should be treated as silent. */ export function stripSilentToken(text: string, token: string = SILENT_REPLY_TOKEN): string { - const escaped = escapeRegExp(token); - return text.replace(new RegExp(`(?:^|\\s+|\\*+)${escaped}\\s*$`), "").trim(); + return text.replace(getSilentTrailingRegex(token), "").trim(); } export function isSilentReplyPrefixText( diff --git a/src/channels/plugins/index.ts b/src/channels/plugins/index.ts index 4c20cd5a5..d5aa15d3c 100644 --- a/src/channels/plugins/index.ts +++ b/src/channels/plugins/index.ts @@ -1,4 +1,4 @@ -import { requireActivePluginRegistry } from "../../plugins/runtime.js"; +import { getActivePluginRegistryKey, requireActivePluginRegistry } from "../../plugins/runtime.js"; import { CHAT_CHANNEL_ORDER, type ChatChannelId, normalizeAnyChannelId } from "../registry.js"; import type { ChannelId, ChannelPlugin } from "./types.js"; @@ -8,12 +8,6 @@ import type { ChannelId, ChannelPlugin } from "./types.js"; // Shared code paths (reply flow, command auth, sandbox explain) should depend on `src/channels/dock.ts` // instead, and only call `getChannelPlugin()` at execution boundaries. // -// Channel plugins are registered by the plugin loader (extensions/ or configured paths). -function listPluginChannels(): ChannelPlugin[] { - const registry = requireActivePluginRegistry(); - return registry.channels.map((entry) => entry.plugin); -} - function dedupeChannels(channels: ChannelPlugin[]): ChannelPlugin[] { const seen = new Set(); const resolved: ChannelPlugin[] = []; @@ -28,9 +22,31 @@ function dedupeChannels(channels: ChannelPlugin[]): ChannelPlugin[] { return resolved; } -export function listChannelPlugins(): ChannelPlugin[] { - const combined = dedupeChannels(listPluginChannels()); - return combined.toSorted((a, b) => { +type CachedChannelPlugins = { + registry: ReturnType | null; + registryKey: string | null; + sorted: ChannelPlugin[]; + byId: Map; +}; + +const EMPTY_CHANNEL_PLUGIN_CACHE: CachedChannelPlugins = { + registry: null, + registryKey: null, + sorted: [], + byId: new Map(), +}; + +let cachedChannelPlugins = EMPTY_CHANNEL_PLUGIN_CACHE; + +function resolveCachedChannelPlugins(): CachedChannelPlugins { + const registry = requireActivePluginRegistry(); + const registryKey = getActivePluginRegistryKey(); + const cached = cachedChannelPlugins; + if (cached.registry === registry && cached.registryKey === registryKey) { + return cached; + } + + const sorted = dedupeChannels(registry.channels.map((entry) => entry.plugin)).toSorted((a, b) => { const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId); const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId); const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA); @@ -40,6 +56,23 @@ export function listChannelPlugins(): ChannelPlugin[] { } return a.id.localeCompare(b.id); }); + const byId = new Map(); + for (const plugin of sorted) { + byId.set(plugin.id, plugin); + } + + const next: CachedChannelPlugins = { + registry, + registryKey, + sorted, + byId, + }; + cachedChannelPlugins = next; + return next; +} + +export function listChannelPlugins(): ChannelPlugin[] { + return resolveCachedChannelPlugins().sorted.slice(); } export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined { @@ -47,7 +80,7 @@ export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined { if (!resolvedId) { return undefined; } - return listChannelPlugins().find((plugin) => plugin.id === resolvedId); + return resolveCachedChannelPlugins().byId.get(resolvedId); } export function normalizeChannelId(raw?: string | null): ChannelId | null { diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 585a83dd5..3b77e54a3 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -469,33 +469,31 @@ async function deliverOutboundPayloadsCore( text: normalizedText, }; }; - const normalizedPayloads = normalizeReplyPayloadsForDelivery(payloads) - .map((payload) => { - // Strip HTML tags for plain-text surfaces (WhatsApp, Signal, etc.) - // Models occasionally produce
, , etc. that render as literal text. - // See https://github.com/openclaw/openclaw/issues/31884 - if (!isPlainTextSurface(channel) || !payload.text) { - return payload; - } + const normalizedPayloads: ReplyPayload[] = []; + for (const payload of normalizeReplyPayloadsForDelivery(payloads)) { + let sanitizedPayload = payload; + // Strip HTML tags for plain-text surfaces (WhatsApp, Signal, etc.) + // Models occasionally produce
, , etc. that render as literal text. + // See https://github.com/openclaw/openclaw/issues/31884 + if (isPlainTextSurface(channel) && payload.text) { // Telegram sendPayload uses textMode:"html". Preserve raw HTML in this path. - if (channel === "telegram" && payload.channelData) { - return payload; + if (!(channel === "telegram" && payload.channelData)) { + sanitizedPayload = { ...payload, text: sanitizeForPlainText(payload.text) }; } - return { ...payload, text: sanitizeForPlainText(payload.text) }; - }) - .flatMap((payload) => { - const normalized = normalizePayloadForChannelDelivery(payload, channel); - return normalized ? [normalized] : []; - }); + } + const normalized = normalizePayloadForChannelDelivery(sanitizedPayload, channel); + if (normalized) { + normalizedPayloads.push(normalized); + } + } const hookRunner = getGlobalHookRunner(); const sessionKeyForInternalHooks = params.mirror?.sessionKey ?? params.session?.key; const mirrorIsGroup = params.mirror?.isGroup; const mirrorGroupId = params.mirror?.groupId; - if ( - hookRunner?.hasHooks("message_sent") && - params.session?.agentId && - !sessionKeyForInternalHooks - ) { + const hasMessageSentHooks = hookRunner?.hasHooks("message_sent") ?? false; + const hasMessageSendingHooks = hookRunner?.hasHooks("message_sending") ?? false; + const canEmitInternalHook = Boolean(sessionKeyForInternalHooks); + if (hasMessageSentHooks && params.session?.agentId && !sessionKeyForInternalHooks) { log.warn( "deliverOutboundPayloads: session.agentId present without session key; internal message:sent hook will be skipped", { @@ -517,6 +515,9 @@ async function deliverOutboundPayloadsCore( error?: string; messageId?: string; }) => { + if (!hasMessageSentHooks && !canEmitInternalHook) { + return; + } const canonical = buildCanonicalSentMessageHookContext({ to, content: params.content, @@ -529,9 +530,9 @@ async function deliverOutboundPayloadsCore( isGroup: mirrorIsGroup, groupId: mirrorGroupId, }); - if (hookRunner?.hasHooks("message_sent")) { + if (hasMessageSentHooks) { fireAndForgetHook( - hookRunner.runMessageSent( + hookRunner!.runMessageSent( toPluginMessageSentEvent(canonical), toPluginMessageContext(canonical), ), @@ -541,7 +542,7 @@ async function deliverOutboundPayloadsCore( }, ); } - if (!sessionKeyForInternalHooks) { + if (!canEmitInternalHook) { return; } fireAndForgetHook( @@ -549,7 +550,7 @@ async function deliverOutboundPayloadsCore( createInternalHookEvent( "message", "sent", - sessionKeyForInternalHooks, + sessionKeyForInternalHooks!, toInternalMessageSentContext(canonical), ), ), @@ -564,9 +565,9 @@ async function deliverOutboundPayloadsCore( // Run message_sending plugin hook (may modify content or cancel) let effectivePayload = payload; - if (hookRunner?.hasHooks("message_sending")) { + if (hasMessageSendingHooks) { try { - const sendingResult = await hookRunner.runMessageSending( + const sendingResult = await hookRunner!.runMessageSending( { to, content: payloadSummary.text, diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index c5c99d003..9dae6a6c1 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -43,9 +43,10 @@ function mergeMediaUrls(...lists: Array | unde export function normalizeReplyPayloadsForDelivery( payloads: readonly ReplyPayload[], ): ReplyPayload[] { - return payloads.flatMap((payload) => { + const normalized: ReplyPayload[] = []; + for (const payload of payloads) { if (shouldSuppressReasoningPayload(payload)) { - return []; + continue; } const parsed = parseReplyDirectives(payload.text ?? ""); const explicitMediaUrls = payload.mediaUrls ?? parsed.mediaUrls; @@ -67,47 +68,50 @@ export function normalizeReplyPayloadsForDelivery( audioAsVoice: Boolean(payload.audioAsVoice || parsed.audioAsVoice), }; if (parsed.isSilent && mergedMedia.length === 0) { - return []; + continue; } if (!isRenderablePayload(next)) { - return []; + continue; } - return [next]; - }); + normalized.push(next); + } + return normalized; } export function normalizeOutboundPayloads( payloads: readonly ReplyPayload[], ): NormalizedOutboundPayload[] { - return normalizeReplyPayloadsForDelivery(payloads) - .map((payload) => { - const channelData = payload.channelData; - const normalized: NormalizedOutboundPayload = { - text: payload.text ?? "", - mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), - }; - if (channelData && Object.keys(channelData).length > 0) { - normalized.channelData = channelData; - } - return normalized; - }) - .filter( - (payload) => - payload.text || - payload.mediaUrls.length > 0 || - Boolean(payload.channelData && Object.keys(payload.channelData).length > 0), - ); + const normalizedPayloads: NormalizedOutboundPayload[] = []; + for (const payload of normalizeReplyPayloadsForDelivery(payloads)) { + const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const channelData = payload.channelData; + const hasChannelData = Boolean(channelData && Object.keys(channelData).length > 0); + const text = payload.text ?? ""; + if (!text && mediaUrls.length === 0 && !hasChannelData) { + continue; + } + normalizedPayloads.push({ + text, + mediaUrls, + ...(hasChannelData ? { channelData } : {}), + }); + } + return normalizedPayloads; } export function normalizeOutboundPayloadsForJson( payloads: readonly ReplyPayload[], ): OutboundPayloadJson[] { - return normalizeReplyPayloadsForDelivery(payloads).map((payload) => ({ - text: payload.text ?? "", - mediaUrl: payload.mediaUrl ?? null, - mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined), - channelData: payload.channelData, - })); + const normalized: OutboundPayloadJson[] = []; + for (const payload of normalizeReplyPayloadsForDelivery(payloads)) { + normalized.push({ + text: payload.text ?? "", + mediaUrl: payload.mediaUrl ?? null, + mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined), + channelData: payload.channelData, + }); + } + return normalized; } export function formatOutboundPayloadLog( diff --git a/src/media/parse.ts b/src/media/parse.ts index b11250975..8dd69d31b 100644 --- a/src/media/parse.ts +++ b/src/media/parse.ts @@ -96,6 +96,11 @@ export function splitMediaFromOutput(raw: string): { if (!trimmedRaw.trim()) { return { text: "" }; } + const mayContainMediaToken = /media:/i.test(trimmedRaw); + const mayContainAudioTag = trimmedRaw.includes("[["); + if (!mayContainMediaToken && !mayContainAudioTag) { + return { text: trimmedRaw }; + } const media: string[] = []; let foundMediaToken = false; diff --git a/src/utils/directive-tags.ts b/src/utils/directive-tags.ts index 97c31d466..e22e9a47c 100644 --- a/src/utils/directive-tags.ts +++ b/src/utils/directive-tags.ts @@ -96,6 +96,15 @@ export function parseInlineDirectives( hasReplyTag: false, }; } + if (!text.includes("[[")) { + return { + text: normalizeDirectiveWhitespace(text), + audioAsVoice: false, + replyToCurrent: false, + hasAudioTag: false, + hasReplyTag: false, + }; + } let cleaned = text; let audioAsVoice = false;