From cebfa70277b59c0ff4e15e01dc72821f1a6b0176 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Apr 2026 17:27:42 +0100 Subject: [PATCH] refactor: dedupe auto-reply lowercase helpers --- src/auto-reply/command-detection.ts | 7 ++++-- src/auto-reply/commands-args.ts | 3 ++- src/auto-reply/commands-registry.ts | 4 +-- src/auto-reply/envelope.ts | 3 ++- src/auto-reply/media-note.ts | 6 +++-- src/auto-reply/model-runtime.ts | 5 ++-- src/auto-reply/reply/abort-primitives.ts | 7 +++--- src/auto-reply/reply/acp-reset-target.ts | 11 +++++--- .../reply/agent-runner-execution.ts | 3 ++- .../reply/agent-runner-reminder-guard.ts | 5 ++-- src/auto-reply/reply/commands-acp/context.ts | 3 ++- .../reply/commands-acp/diagnostics.ts | 7 ++++-- .../reply/commands-acp/runtime-options.ts | 3 ++- src/auto-reply/reply/commands-approve.ts | 5 ++-- src/auto-reply/reply/commands-compact.ts | 5 ++-- .../reply/commands-context-report.ts | 3 ++- src/auto-reply/reply/commands-models.ts | 9 ++++--- src/auto-reply/reply/commands-plugins.ts | 2 +- src/auto-reply/reply/commands-session.ts | 7 +++--- .../reply/commands-subagents/action-log.ts | 5 +++- .../reply/commands-subagents/shared.ts | 13 +++++++--- src/auto-reply/reply/commands-tts.ts | 2 +- .../reply/conversation-binding-input.ts | 3 ++- .../reply/directive-handling.auth.ts | 3 ++- .../reply/directive-handling.impl.ts | 6 ++++- .../reply/directive-handling.model-picker.ts | 9 +++++-- .../reply/directive-handling.model.ts | 7 ++++-- src/auto-reply/reply/dispatch-acp.ts | 12 ++++----- src/auto-reply/reply/dispatch-from-config.ts | 3 ++- src/auto-reply/reply/get-reply-directives.ts | 24 ++++++++++-------- src/auto-reply/reply/mentions.ts | 5 +++- src/auto-reply/reply/model-selection.ts | 8 +++--- .../reply/post-compaction-context.ts | 4 ++- src/auto-reply/reply/reply-inline.ts | 3 ++- .../reply/response-prefix-template.ts | 4 ++- src/auto-reply/reply/session-delivery.ts | 2 +- src/auto-reply/reply/session-system-events.ts | 9 ++++--- src/auto-reply/reply/session.ts | 7 +++--- src/auto-reply/reply/subagents-utils.ts | 13 +++++++--- src/auto-reply/skill-commands-base.ts | 7 ++++-- src/auto-reply/skill-commands.ts | 7 ++++-- src/auto-reply/status.ts | 25 +++++++++++-------- src/auto-reply/thinking.shared.ts | 13 ++++++---- 43 files changed, 189 insertions(+), 103 deletions(-) diff --git a/src/auto-reply/command-detection.ts b/src/auto-reply/command-detection.ts index 788bbb9e661..4f36acdcc2a 100644 --- a/src/auto-reply/command-detection.ts +++ b/src/auto-reply/command-detection.ts @@ -1,5 +1,8 @@ import type { OpenClawConfig } from "../config/types.js"; -import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, +} from "../shared/string-coerce.js"; import { type CommandNormalizeOptions, listChatCommands, @@ -29,7 +32,7 @@ export function hasControlCommand( if (!normalizedBody) { return false; } - const lowered = normalizedBody.toLowerCase(); + const lowered = normalizeLowercaseStringOrEmpty(normalizedBody); const commands = cfg ? listChatCommandsForConfig(cfg) : listChatCommands(); for (const command of commands) { for (const alias of command.textAliases) { diff --git a/src/auto-reply/commands-args.ts b/src/auto-reply/commands-args.ts index d8cfe73e98f..7ebad62336e 100644 --- a/src/auto-reply/commands-args.ts +++ b/src/auto-reply/commands-args.ts @@ -1,3 +1,4 @@ +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import type { CommandArgValues } from "./commands-registry.types.js"; export type CommandArgsFormatter = (values: CommandArgValues) => string | undefined; @@ -28,7 +29,7 @@ function formatActionArgs( formatKnownAction: (action: string, path: string | undefined) => string | undefined; }, ): string | undefined { - const action = normalizeArgValue(values.action)?.toLowerCase(); + const action = normalizeOptionalLowercaseString(normalizeArgValue(values.action)); const path = normalizeArgValue(values.path); const value = normalizeArgValue(values.value); if (!action) { diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 44774d4d68c..5c84e7c25b7 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -403,7 +403,7 @@ export function normalizeCommandBody(raw: string, options?: CommandNormalizeOpti ? normalized.match(/^\/([^\s@]+)@([^\s]+)(.*)$/) : null; const commandBody = - mentionMatch && mentionMatch[2].toLowerCase() === normalizedBotUsername + mentionMatch && normalizeLowercaseStringOrEmpty(mentionMatch[2]) === normalizedBotUsername ? `/${mentionMatch[1]}${mentionMatch[3] ?? ""}` : normalized; @@ -419,7 +419,7 @@ export function normalizeCommandBody(raw: string, options?: CommandNormalizeOpti return commandBody; } const [, token, rest] = tokenMatch; - const tokenKey = `/${token.toLowerCase()}`; + const tokenKey = `/${normalizeLowercaseStringOrEmpty(token)}`; const tokenSpec = textAliasMap.get(tokenKey); if (!tokenSpec) { return commandBody; diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index 5eedb19dd0c..62531638c82 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -8,6 +8,7 @@ import { formatZonedTimestamp, } from "../infra/format-time/format-datetime.ts"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; export type AgentEnvelopeParams = { channel: string; @@ -88,7 +89,7 @@ function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEn if (!trimmed) { return { mode: "local" }; } - const lowered = trimmed.toLowerCase(); + const lowered = normalizeLowercaseStringOrEmpty(trimmed); if (lowered === "utc" || lowered === "gmt") { return { mode: "utc" }; } diff --git a/src/auto-reply/media-note.ts b/src/auto-reply/media-note.ts index 7835988f56e..fe3b098b2f0 100644 --- a/src/auto-reply/media-note.ts +++ b/src/auto-reply/media-note.ts @@ -1,3 +1,4 @@ +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import type { MsgContext } from "./templating.js"; function formatMediaAttachedLine(params: { @@ -37,7 +38,7 @@ function isAudioPath(path: string | undefined): boolean { if (!path) { return false; } - const lower = path.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(path); for (const ext of AUDIO_EXTENSIONS) { if (lower.endsWith(ext)) { return true; @@ -113,7 +114,8 @@ export function buildInboundMediaNote(ctx: MsgContext): string | undefined { // Note: Only trust MIME type from per-entry types array, not fallback ctx.MediaType // which could misclassify non-audio attachments (greptile review feedback) const hasPerEntryType = types !== undefined; - const isAudioByMime = hasPerEntryType && entry.type?.toLowerCase().startsWith("audio/"); + const isAudioByMime = + hasPerEntryType && normalizeLowercaseStringOrEmpty(entry.type).startsWith("audio/"); const isAudioEntry = isAudioPath(entry.path) || isAudioByMime; if (!isAudioEntry) { return true; diff --git a/src/auto-reply/model-runtime.ts b/src/auto-reply/model-runtime.ts index e43bd663050..0e5bd704876 100644 --- a/src/auto-reply/model-runtime.ts +++ b/src/auto-reply/model-runtime.ts @@ -1,4 +1,5 @@ import type { SessionEntry } from "../config/sessions.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; export function formatProviderModelRef(providerRaw: string, modelRaw: string): string { const provider = String(providerRaw ?? "").trim(); @@ -10,7 +11,7 @@ export function formatProviderModelRef(providerRaw: string, modelRaw: string): s return provider; } const prefix = `${provider}/`; - if (model.toLowerCase().startsWith(prefix.toLowerCase())) { + if (normalizeLowercaseStringOrEmpty(model).startsWith(normalizeLowercaseStringOrEmpty(prefix))) { const normalizedModel = model.slice(prefix.length).trim(); if (normalizedModel) { return `${provider}/${normalizedModel}`; @@ -31,7 +32,7 @@ function normalizeModelWithinProvider(provider: string, modelRaw: string): strin return model; } const prefix = `${provider}/`; - if (model.toLowerCase().startsWith(prefix.toLowerCase())) { + if (normalizeLowercaseStringOrEmpty(model).startsWith(normalizeLowercaseStringOrEmpty(prefix))) { const withoutPrefix = model.slice(prefix.length).trim(); if (withoutPrefix) { return withoutPrefix; diff --git a/src/auto-reply/reply/abort-primitives.ts b/src/auto-reply/reply/abort-primitives.ts index cdb4d0d507c..6b1f46e5be4 100644 --- a/src/auto-reply/reply/abort-primitives.ts +++ b/src/auto-reply/reply/abort-primitives.ts @@ -1,3 +1,4 @@ +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { normalizeCommandBody, type CommandNormalizeOptions } from "../commands-registry.js"; const ABORT_TRIGGERS = new Set([ @@ -49,9 +50,7 @@ const ABORT_MEMORY_MAX = 2000; const TRAILING_ABORT_PUNCTUATION_RE = /[.!?…,,。;;::'"’”)\]}]+$/u; function normalizeAbortTriggerText(text: string): string { - return text - .trim() - .toLowerCase() + return normalizeLowercaseStringOrEmpty(text) .replace(/[’`]/g, "'") .replace(/\s+/g, " ") .replace(TRAILING_ABORT_PUNCTUATION_RE, "") @@ -74,7 +73,7 @@ export function isAbortRequestText(text?: string, options?: CommandNormalizeOpti if (!normalized) { return false; } - const normalizedLower = normalized.toLowerCase(); + const normalizedLower = normalizeLowercaseStringOrEmpty(normalized); return ( normalizedLower === "/stop" || normalizeAbortTriggerText(normalizedLower) === "/stop" || diff --git a/src/auto-reply/reply/acp-reset-target.ts b/src/auto-reply/reply/acp-reset-target.ts index 6d780ba8026..85b0235e8e0 100644 --- a/src/auto-reply/reply/acp-reset-target.ts +++ b/src/auto-reply/reply/acp-reset-target.ts @@ -8,7 +8,10 @@ import { listAcpBindings } from "../../config/bindings.js"; import type { OpenClawConfig } from "../../config/config.js"; import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; import { DEFAULT_ACCOUNT_ID, isAcpSessionKey } from "../../routing/session-key.js"; -import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../../shared/string-coerce.js"; const acpResetTargetDeps = { getSessionBindingService, @@ -57,7 +60,9 @@ function resolveRawConfiguredAcpSessionKey(params: { parentConversationId?: string; }): string | undefined { for (const binding of acpResetTargetDeps.listAcpBindings(params.cfg)) { - const bindingChannel = (normalizeOptionalString(binding.match.channel) ?? "").toLowerCase(); + const bindingChannel = normalizeLowercaseStringOrEmpty( + normalizeOptionalString(binding.match.channel), + ); if (!bindingChannel || bindingChannel !== params.channel) { continue; } @@ -111,7 +116,7 @@ export function resolveEffectiveResetTargetSessionKey(params: { activeSessionKey && isAcpSessionKey(activeSessionKey) ? activeSessionKey : undefined; const activeIsNonAcp = Boolean(activeSessionKey) && !activeAcpSessionKey; - const channel = (normalizeOptionalString(params.channel) ?? "").toLowerCase(); + const channel = normalizeLowercaseStringOrEmpty(normalizeOptionalString(params.channel)); const conversationId = normalizeOptionalString(params.conversationId) ?? ""; if (!channel || !conversationId) { return activeAcpSessionKey; diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 9c4670b7387..18fc362da97 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -36,6 +36,7 @@ import { CommandLaneClearedError, GatewayDrainingError } from "../../process/com import { defaultRuntime } from "../../runtime.js"; import { hasNonEmptyString, + normalizeLowercaseStringOrEmpty, normalizeOptionalString, readStringValue, } from "../../shared/string-coerce.js"; @@ -293,7 +294,7 @@ function isPureTransientRateLimitSummary(err: unknown): boolean { } function isToolResultTurnMismatchError(message: string): boolean { - const lower = message.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(message); return ( lower.includes("toolresult") && lower.includes("tooluse") && diff --git a/src/auto-reply/reply/agent-runner-reminder-guard.ts b/src/auto-reply/reply/agent-runner-reminder-guard.ts index 2a0d1ad7bd7..bd090ce2d41 100644 --- a/src/auto-reply/reply/agent-runner-reminder-guard.ts +++ b/src/auto-reply/reply/agent-runner-reminder-guard.ts @@ -1,4 +1,5 @@ import { loadCronStore, resolveCronStorePath } from "../../cron/store.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import type { ReplyPayload } from "../types.js"; export const UNSCHEDULED_REMINDER_NOTE = @@ -10,11 +11,11 @@ const REMINDER_COMMITMENT_PATTERNS: RegExp[] = [ ]; export function hasUnbackedReminderCommitment(text: string): boolean { - const normalized = text.toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(text); if (!normalized.trim()) { return false; } - if (normalized.includes(UNSCHEDULED_REMINDER_NOTE.toLowerCase())) { + if (normalized.includes(normalizeLowercaseStringOrEmpty(UNSCHEDULED_REMINDER_NOTE))) { return false; } return REMINDER_COMMITMENT_PATTERNS.some((pattern) => pattern.test(text)); diff --git a/src/auto-reply/reply/commands-acp/context.ts b/src/auto-reply/reply/commands-acp/context.ts index c1a1b2af843..9680236f81f 100644 --- a/src/auto-reply/reply/commands-acp/context.ts +++ b/src/auto-reply/reply/commands-acp/context.ts @@ -1,4 +1,5 @@ import { normalizeConversationText } from "../../../acp/conversation-id.js"; +import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js"; import type { HandleCommandsParams } from "../commands-types.js"; import { resolveConversationBindingAccountIdFromMessage, @@ -9,7 +10,7 @@ import { export function resolveAcpCommandChannel(params: HandleCommandsParams): string { const resolved = resolveConversationBindingChannelFromMessage(params.ctx, params.command.channel); - return normalizeConversationText(resolved).toLowerCase(); + return normalizeLowercaseStringOrEmpty(normalizeConversationText(resolved)); } export function resolveAcpCommandAccountId(params: HandleCommandsParams): string { diff --git a/src/auto-reply/reply/commands-acp/diagnostics.ts b/src/auto-reply/reply/commands-acp/diagnostics.ts index e5168aa453f..7d495078b81 100644 --- a/src/auto-reply/reply/commands-acp/diagnostics.ts +++ b/src/auto-reply/reply/commands-acp/diagnostics.ts @@ -6,7 +6,10 @@ import { resolveSessionStorePathForAcp } from "../../../acp/runtime/session-meta import { loadSessionStore } from "../../../config/sessions.js"; import type { SessionEntry } from "../../../config/sessions/types.js"; import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js"; -import { normalizeOptionalString } from "../../../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../../../shared/string-coerce.js"; import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js"; import { resolveAcpCommandBindingContext } from "./context.js"; import { @@ -101,7 +104,7 @@ export async function handleAcpDoctorAction( lines.push(formatAcpRuntimeErrorText(acpError)); lines.push(`next: ${installHint}`); lines.push(`next: openclaw config set plugins.entries.${backendId}.enabled true`); - if (backendId.toLowerCase() === "acpx") { + if (normalizeLowercaseStringOrEmpty(backendId) === "acpx") { lines.push("next: verify acpx is installed (`acpx --help`)."); } return stopWithText(lines.join("\n")); diff --git a/src/auto-reply/reply/commands-acp/runtime-options.ts b/src/auto-reply/reply/commands-acp/runtime-options.ts index cc5056c834c..fa014618de6 100644 --- a/src/auto-reply/reply/commands-acp/runtime-options.ts +++ b/src/auto-reply/reply/commands-acp/runtime-options.ts @@ -8,6 +8,7 @@ import { validateRuntimePermissionProfileInput, } from "../../../acp/control-plane/runtime-options.js"; import { resolveAcpSessionIdentifierLinesFromIdentity } from "../../../acp/runtime/session-identifiers.js"; +import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js"; import { findLatestTaskForRelatedSessionKeyForOwner } from "../../../tasks/task-owner-access.js"; import { sanitizeTaskStatusText } from "../../../tasks/task-status.js"; import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js"; @@ -230,7 +231,7 @@ export async function handleAcpSetAction( return await withAcpCommandErrorBoundary({ run: async () => { - const lowerKey = key.toLowerCase(); + const lowerKey = normalizeLowercaseStringOrEmpty(key); if (lowerKey === "cwd") { const cwd = validateRuntimeCwdInput(value); const options = await getAcpSessionManager().updateSessionRuntimeOptions({ diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index 91d5f05e85f..85a04824dfa 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -7,6 +7,7 @@ import { logVerbose } from "../../globals.js"; import { isApprovalNotFoundError } from "../../infra/approval-errors.js"; import { resolveApprovalCommandAuthorization } from "../../infra/channel-approval-auth.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { resolveChannelAccountId } from "./channel-context.js"; import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js"; @@ -53,8 +54,8 @@ function parseApproveCommand(raw: string): ParsedApproveCommand | null { return { ok: false, error: APPROVE_USAGE_TEXT }; } - const first = tokens[0].toLowerCase(); - const second = tokens[1].toLowerCase(); + const first = normalizeLowercaseStringOrEmpty(tokens[0]); + const second = normalizeLowercaseStringOrEmpty(tokens[1]); if (DECISION_ALIASES[first]) { return { diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index 0010c2db75d..23c82adc130 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { logVerbose } from "../../globals.js"; import { + normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; @@ -22,7 +23,7 @@ function extractCompactInstructions(params: { if (!trimmed) { return undefined; } - const lowered = trimmed.toLowerCase(); + const lowered = normalizeLowercaseStringOrEmpty(trimmed); const prefix = lowered.startsWith("/compact") ? "/compact" : null; if (!prefix) { return undefined; @@ -50,7 +51,7 @@ function formatCompactionReason(reason?: string): string | undefined { return undefined; } - const lower = text.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(text); if (lower.includes("nothing to compact")) { return "nothing compactable in this session yet"; } diff --git a/src/auto-reply/reply/commands-context-report.ts b/src/auto-reply/reply/commands-context-report.ts index 7e322adb862..20140fe9530 100644 --- a/src/auto-reply/reply/commands-context-report.ts +++ b/src/auto-reply/reply/commands-context-report.ts @@ -8,6 +8,7 @@ import { resolveFreshSessionTotalTokens, type SessionSystemPromptReport, } from "../../config/sessions/types.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { estimateTokensFromChars } from "../../utils/cjk-chars.js"; import type { ReplyPayload } from "../types.js"; import { resolveCommandsSystemPromptBundle } from "./commands-system-prompt.js"; @@ -76,7 +77,7 @@ async function resolveContextReport( export async function buildContextReply(params: HandleCommandsParams): Promise { const args = parseContextArgs(params.command.commandBodyNormalized); - const sub = args.split(/\s+/).filter(Boolean)[0]?.toLowerCase() ?? ""; + const sub = normalizeLowercaseStringOrEmpty(args.split(/\s+/).filter(Boolean)[0]); if (!sub || sub === "help") { return { diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index c59685db510..3615f73112d 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -11,7 +11,10 @@ import { import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; -import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../../shared/string-coerce.js"; import type { ReplyPayload } from "../types.js"; import { rejectUnauthorizedCommand } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; @@ -149,7 +152,7 @@ function parseModelsArgs(raw: string): { let page = 1; let all = false; for (const token of tokens.slice(1)) { - const lower = token.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(token); if (lower === "all" || lower === "--all") { all = true; continue; @@ -171,7 +174,7 @@ function parseModelsArgs(raw: string): { let pageSize = PAGE_SIZE_DEFAULT; for (const token of tokens) { - const lower = token.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(token); if (lower.startsWith("limit=") || lower.startsWith("size=")) { const rawValue = lower.slice(lower.indexOf("=") + 1); const value = Number.parseInt(rawValue, 10); diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index abaf54b5dc3..d484d7ae3d1 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -418,7 +418,7 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm reply: { text: formatPluginsList(loaded.report) }, }; } - if (pluginsCommand.name.toLowerCase() === "all") { + if (normalizeOptionalLowercaseString(pluginsCommand.name) === "all") { return { shouldContinue: false, reply: { diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 6fec8752807..de8d1c99347 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -13,6 +13,7 @@ import type { SessionBindingRecord } from "../../infra/outbound/session-binding- import { scheduleGatewaySigusr1Restart, triggerOpenClawRestart } from "../../infra/restart.js"; import { loadCostUsageSummary, loadSessionCostSummary } from "../../infra/session-cost-usage.js"; import { + normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; @@ -262,7 +263,7 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman const rawArgs = normalized === "/usage" ? "" : normalized.slice("/usage".length).trim(); const requested = rawArgs ? normalizeUsageDisplay(rawArgs) : undefined; - if (rawArgs.toLowerCase().startsWith("cost")) { + if (normalizeLowercaseStringOrEmpty(rawArgs).startsWith("cost")) { const sessionSummary = await loadSessionCostSummary({ sessionId: params.sessionEntry?.sessionId, sessionEntry: params.sessionEntry, @@ -347,7 +348,7 @@ export const handleFastCommand: CommandHandler = async (params, allowTextCommand } const rawArgs = normalized === "/fast" ? "" : normalized.slice("/fast".length).trim(); - const rawMode = rawArgs.toLowerCase(); + const rawMode = normalizeLowercaseStringOrEmpty(rawArgs); if (!rawMode || rawMode === "status") { const state = resolveFastModeState({ cfg: params.cfg, @@ -406,7 +407,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm const rest = normalized.slice(SESSION_COMMAND_PREFIX.length).trim(); const tokens = rest.split(/\s+/).filter(Boolean); - const action = tokens[0]?.toLowerCase(); + const action = normalizeOptionalLowercaseString(tokens[0]); if (action !== SESSION_ACTION_IDLE && action !== SESSION_ACTION_MAX_AGE) { return { shouldContinue: false, diff --git a/src/auto-reply/reply/commands-subagents/action-log.ts b/src/auto-reply/reply/commands-subagents/action-log.ts index e59451d0a33..65506e5b5eb 100644 --- a/src/auto-reply/reply/commands-subagents/action-log.ts +++ b/src/auto-reply/reply/commands-subagents/action-log.ts @@ -1,4 +1,5 @@ import { callGateway } from "../../../gateway/call.js"; +import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js"; import type { CommandHandlerResult } from "../commands-types.js"; import { formatRunLabel } from "../subagents-utils.js"; import { @@ -19,7 +20,9 @@ export async function handleSubagentsLogAction( return stopWithText("📜 Usage: /subagents log [limit]"); } - const includeTools = restTokens.some((token) => token.toLowerCase() === "tools"); + const includeTools = restTokens.some( + (token) => normalizeLowercaseStringOrEmpty(token) === "tools", + ); const limitToken = restTokens.find((token) => /^\d+$/.test(token)); const limit = limitToken ? Math.min(200, Math.max(1, Number.parseInt(limitToken, 10))) : 20; diff --git a/src/auto-reply/reply/commands-subagents/shared.ts b/src/auto-reply/reply/commands-subagents/shared.ts index a3b3dfbfc8b..d5e8504c964 100644 --- a/src/auto-reply/reply/commands-subagents/shared.ts +++ b/src/auto-reply/reply/commands-subagents/shared.ts @@ -19,7 +19,10 @@ import { formatTimeAgo } from "../../../infra/format-time/format-relative.ts"; import { parseAgentSessionKey } from "../../../routing/session-key.js"; import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { looksLikeSessionId } from "../../../sessions/session-id.js"; -import { normalizeOptionalString } from "../../../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../../../shared/string-coerce.js"; import { formatDurationCompact, formatTokenUsageDisplay, @@ -114,7 +117,11 @@ export function formatSubagentListLine(params: { ? params.sessionEntry.modelOverride : null, fallbackModel: params.entry.model, - })}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`; + })}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${ + normalizeLowercaseStringOrEmpty(task) !== normalizeLowercaseStringOrEmpty(label) + ? ` - ${task}` + : "" + }`; } function formatTimestamp(valueMs?: number) { @@ -268,7 +275,7 @@ export function resolveSubagentsAction(params: { }): SubagentsAction | null { if (params.handledPrefix === COMMAND) { const [actionRaw] = params.restTokens; - const action = (actionRaw?.toLowerCase() || "list") as SubagentsAction; + const action = (normalizeLowercaseStringOrEmpty(actionRaw) || "list") as SubagentsAction; if (!ACTIONS.has(action)) { return null; } diff --git a/src/auto-reply/reply/commands-tts.ts b/src/auto-reply/reply/commands-tts.ts index c1d02bbf63f..db249ee1738 100644 --- a/src/auto-reply/reply/commands-tts.ts +++ b/src/auto-reply/reply/commands-tts.ts @@ -47,7 +47,7 @@ function parseTtsCommand(normalized: string): ParsedTtsCommand | null { return { action: "status", args: "" }; } const [action, ...tail] = rest.split(/\s+/); - return { action: action.toLowerCase(), args: tail.join(" ").trim() }; + return { action: normalizeOptionalLowercaseString(action) ?? "", args: tail.join(" ").trim() }; } function formatAttemptDetails(attempts: TtsAttemptDetail[] | undefined): string | undefined { diff --git a/src/auto-reply/reply/conversation-binding-input.ts b/src/auto-reply/reply/conversation-binding-input.ts index cc9f2c5ff1f..bf55dbd8b65 100644 --- a/src/auto-reply/reply/conversation-binding-input.ts +++ b/src/auto-reply/reply/conversation-binding-input.ts @@ -2,6 +2,7 @@ import { normalizeConversationText } from "../../acp/conversation-id.js"; import { resolveConversationBindingContext } from "../../channels/conversation-binding-context.js"; import type { OpenClawConfig } from "../../config/config.js"; import { getActivePluginChannelRegistry } from "../../plugins/runtime.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import type { MsgContext } from "../templating.js"; import type { HandleCommandsParams } from "./commands-types.js"; @@ -25,7 +26,7 @@ type BindingMsgContext = Pick< function resolveBindingChannel(ctx: BindingMsgContext, commandChannel?: string | null): string { const raw = ctx.OriginatingChannel ?? commandChannel ?? ctx.Surface ?? ctx.Provider; - return normalizeConversationText(raw).toLowerCase(); + return normalizeLowercaseStringOrEmpty(normalizeConversationText(raw)); } function resolveBindingAccountId(params: { diff --git a/src/auto-reply/reply/directive-handling.auth.ts b/src/auto-reply/reply/directive-handling.auth.ts index c05fa218dab..96b2bc9cab3 100644 --- a/src/auto-reply/reply/directive-handling.auth.ts +++ b/src/auto-reply/reply/directive-handling.auth.ts @@ -13,6 +13,7 @@ import { import { findNormalizedProviderValue, normalizeProviderId } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import { coerceSecretRef } from "../../config/types.secrets.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { shortenHomePath } from "../../utils.js"; import { maskApiKey } from "../../utils/mask-api-key.js"; @@ -196,7 +197,7 @@ export const resolveAuthLabel = async ( if (envKey) { const isOAuthEnv = envKey.source.includes("ANTHROPIC_OAUTH_TOKEN") || - envKey.source.toLowerCase().includes("oauth"); + normalizeLowercaseStringOrEmpty(envKey.source).includes("oauth"); const label = isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey); return { label, source: mode === "verbose" ? envKey.source : "" }; } diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index e7618c92263..139ea02af72 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -7,6 +7,7 @@ import { updateSessionStore } from "../../config/sessions.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { applyVerboseOverride } from "../../sessions/level-overrides.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { formatThinkingLevels, formatXHighModelHint, supportsXHighThinking } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; import { resolveModelSelectionFromDirective } from "./directive-handling.model-selection.js"; @@ -152,7 +153,10 @@ export async function handleDirectiveOnly( }; } if (directives.hasFastDirective && directives.fastMode === undefined) { - if (!directives.rawFastMode || directives.rawFastMode.toLowerCase() === "status") { + if ( + !directives.rawFastMode || + normalizeLowercaseStringOrEmpty(directives.rawFastMode) === "status" + ) { const sourceSuffix = effectiveFastModeSource === "config" ? " (config)" diff --git a/src/auto-reply/reply/directive-handling.model-picker.ts b/src/auto-reply/reply/directive-handling.model-picker.ts index c0c7a6f6be2..d2075f6340d 100644 --- a/src/auto-reply/reply/directive-handling.model-picker.ts +++ b/src/auto-reply/reply/directive-handling.model-picker.ts @@ -4,7 +4,10 @@ import { normalizeProviderId, } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../../shared/string-coerce.js"; export type ModelPickerCatalogEntry = { provider: string; @@ -78,7 +81,9 @@ export function buildModelPickerItems(catalog: ModelPickerCatalogEntry[]): Model if (providerOrder !== 0) { return providerOrder; } - return a.model.toLowerCase().localeCompare(b.model.toLowerCase()); + return normalizeLowercaseStringOrEmpty(a.model).localeCompare( + normalizeLowercaseStringOrEmpty(b.model), + ); }); return out; diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index fc66e831661..e875fdca9af 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -9,7 +9,10 @@ import { import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; -import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../../shared/string-coerce.js"; import { shortenHomePath } from "../../utils.js"; import { resolveSelectedAndActiveModel } from "../model-runtime.js"; import type { ReplyPayload } from "../types.js"; @@ -207,7 +210,7 @@ export async function maybeHandleModelDirectiveInfo(params: { } const rawDirective = normalizeOptionalString(params.directives.rawModelDirective); - const directive = rawDirective?.toLowerCase(); + const directive = rawDirective ? normalizeLowercaseStringOrEmpty(rawDirective) : undefined; const wantsStatus = directive === "status"; const wantsSummary = !rawDirective; const wantsLegacyList = directive === "list"; diff --git a/src/auto-reply/reply/dispatch-acp.ts b/src/auto-reply/reply/dispatch-acp.ts index fa198e7b143..20b700807e2 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -15,6 +15,7 @@ import { generateSecureUuid } from "../../infra/secure-random.js"; import { prefixSystemMessage } from "../../infra/system-message.js"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { + normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; @@ -340,10 +341,9 @@ export async function tryDispatchAcpReply(params: { normalizeOptionalString(params.cfg.acp?.defaultAgent) ?? resolveAgentIdFromSessionKey(canonicalSessionKey)) : resolveAgentIdFromSessionKey(canonicalSessionKey); - const normalizedDispatchChannel = - normalizeOptionalString( - params.ctx.OriginatingChannel ?? params.ctx.Surface ?? params.ctx.Provider, - )?.toLowerCase() ?? ""; + const normalizedDispatchChannel = normalizeOptionalLowercaseString( + params.ctx.OriginatingChannel ?? params.ctx.Surface ?? params.ctx.Provider, + ); const explicitDispatchAccountId = normalizeOptionalString(params.ctx.AccountId); const effectiveDispatchAccountId = explicitDispatchAccountId ?? @@ -382,7 +382,7 @@ export async function tryDispatchAcpReply(params: { `acp-dispatch: session=${sessionKey} outcome=error code=${acpResolution.error.code} latencyMs=${Date.now() - acpDispatchStartedAt} queueDepth=${acpStats.turns.queueDepth} activeRuntimes=${acpStats.runtimeCache.activeSessions}`, ); params.recordProcessed("completed", { - reason: `acp_error:${acpResolution.error.code.toLowerCase()}`, + reason: `acp_error:${normalizeLowercaseStringOrEmpty(acpResolution.error.code)}`, }); params.markIdle("message_completed"); return { queuedFinal: delivered, counts }; @@ -505,7 +505,7 @@ export async function tryDispatchAcpReply(params: { `acp-dispatch: session=${sessionKey} outcome=error code=${acpError.code} latencyMs=${Date.now() - acpDispatchStartedAt} queueDepth=${acpStats.turns.queueDepth} activeRuntimes=${acpStats.runtimeCache.activeSessions}`, ); params.recordProcessed("completed", { - reason: `acp_error:${acpError.code.toLowerCase()}`, + reason: `acp_error:${normalizeLowercaseStringOrEmpty(acpError.code)}`, }); params.markIdle("message_completed"); return { queuedFinal, counts }; diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 6c27525fcdf..6665b01f3c6 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -37,6 +37,7 @@ import { } from "../../plugins/conversation-binding.js"; import { getGlobalHookRunner, getGlobalPluginRegistry } from "../../plugins/hook-runner-global.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -205,7 +206,7 @@ export async function dispatchReplyFromConfig(params: { }): Promise { const { ctx, cfg, dispatcher } = params; const diagnosticsEnabled = isDiagnosticsEnabled(cfg); - const channel = String(ctx.Surface ?? ctx.Provider ?? "unknown").toLowerCase(); + const channel = normalizeLowercaseStringOrEmpty(String(ctx.Surface ?? ctx.Provider ?? "unknown")); const chatId = ctx.To ?? ctx.From; const messageId = ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast; const sessionKey = ctx.SessionKey; diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index 848caf36d8a..e617898b547 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -8,7 +8,10 @@ import type { SkillCommandSpec } from "../../agents/skills.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { normalizeAgentId } from "../../routing/session-key.js"; -import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../../shared/string-coerce.js"; import { shouldHandleTextCommands } from "../commands-text-routing.js"; import type { MsgContext, TemplateContext } from "../templating.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; @@ -83,7 +86,7 @@ function resolveConfiguredDirectiveAliases(params: { return Object.values(params.cfg.agents?.defaults?.models ?? {}) .map((entry) => normalizeOptionalString(entry.alias)) .filter((alias): alias is string => Boolean(alias)) - .filter((alias) => !params.reservedCommands.has(alias.toLowerCase())); + .filter((alias) => !params.reservedCommands.has(normalizeLowercaseStringOrEmpty(alias))); } export type ReplyDirectiveContinuation = { @@ -242,7 +245,7 @@ export async function resolveReplyDirectives(params: { const { listChatCommands } = await loadCommandsRegistry(); for (const chatCommand of listChatCommands()) { for (const alias of chatCommand.textAliases) { - reservedCommands.add(alias.replace(/^\//, "").toLowerCase()); + reservedCommands.add(normalizeLowercaseStringOrEmpty(alias.replace(/^\//, ""))); } } } @@ -265,11 +268,11 @@ export async function resolveReplyDirectives(params: { }) : []; for (const command of skillCommands) { - reservedCommands.add(command.name.toLowerCase()); + reservedCommands.add(normalizeLowercaseStringOrEmpty(command.name)); } const configuredAliases = rawAliases.filter( - (alias) => !reservedCommands.has(alias.toLowerCase()), + (alias) => !reservedCommands.has(normalizeLowercaseStringOrEmpty(alias)), ); const allowStatusDirective = allowTextCommands && command.isAuthorizedSender; let parsedDirectives = parseInlineDirectives(commandText, { @@ -379,10 +382,11 @@ export async function resolveReplyDirectives(params: { sessionCtx.Body = cleanedBody; sessionCtx.BodyStripped = cleanedBody; - const messageProviderKey = - normalizeOptionalString(sessionCtx.Provider)?.toLowerCase() ?? - normalizeOptionalString(ctx.Provider)?.toLowerCase() ?? - ""; + const messageProviderKey = normalizeOptionalString(sessionCtx.Provider) + ? normalizeLowercaseStringOrEmpty(sessionCtx.Provider) + : normalizeOptionalString(ctx.Provider) + ? normalizeLowercaseStringOrEmpty(ctx.Provider) + : ""; const elevated = resolveElevatedPermissions({ cfg, agentId, @@ -526,7 +530,7 @@ export async function resolveReplyDirectives(params: { const isModelListAlias = directives.hasModelDirective && ["status", "list"].includes( - normalizeOptionalString(directives.rawModelDirective)?.toLowerCase() ?? "", + normalizeLowercaseStringOrEmpty(normalizeOptionalString(directives.rawModelDirective)), ); const effectiveModelDirective = isModelListAlias ? undefined : directives.rawModelDirective; diff --git a/src/auto-reply/reply/mentions.ts b/src/auto-reply/reply/mentions.ts index 2564ef56af1..ed84f4083c5 100644 --- a/src/auto-reply/reply/mentions.ts +++ b/src/auto-reply/reply/mentions.ts @@ -5,6 +5,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { compileConfigRegexes, type ConfigRegexRejectReason } from "../../security/config-regex.js"; import { + normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; @@ -133,7 +134,9 @@ export function buildMentionRegexes(cfg: OpenClawConfig | undefined, agentId?: s } export function normalizeMentionText(text: string): string { - return (text ?? "").replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "").toLowerCase(); + return normalizeLowercaseStringOrEmpty( + (text ?? "").replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, ""), + ); } export function matchesMentionPatterns(text: string, mentionRegexes: RegExp[]): boolean { diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index e799bebd169..26a7f159325 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -215,8 +215,8 @@ function scoreFuzzyMatch(params: { const provider = normalizeProviderId(params.provider); const model = params.model; const fragment = normalizeLowercaseStringOrEmpty(params.fragment); - const providerLower = provider.toLowerCase(); - const modelLower = model.toLowerCase(); + const providerLower = normalizeLowercaseStringOrEmpty(provider); + const modelLower = normalizeLowercaseStringOrEmpty(model); const haystack = `${providerLower}/${modelLower}`; const key = modelKey(provider, model); @@ -262,7 +262,7 @@ function scoreFuzzyMatch(params: { const aliases = params.aliasIndex.byKey.get(key) ?? []; for (const alias of aliases) { - score += scoreFragment(alias.toLowerCase(), { + score += scoreFragment(normalizeLowercaseStringOrEmpty(alias), { exact: 140, starts: 90, includes: 60, @@ -525,7 +525,7 @@ export function resolveModelDirectiveSelection(params: { const { raw, defaultProvider, defaultModel, aliasIndex, allowedModelKeys } = params; const rawTrimmed = raw.trim(); - const rawLower = rawTrimmed.toLowerCase(); + const rawLower = normalizeLowercaseStringOrEmpty(rawTrimmed); const pickAliasForKey = (provider: string, model: string): string | undefined => aliasIndex.byKey.get(modelKey(provider, model))?.[0]; diff --git a/src/auto-reply/reply/post-compaction-context.ts b/src/auto-reply/reply/post-compaction-context.ts index 3ede399d2f1..4be225c90fd 100644 --- a/src/auto-reply/reply/post-compaction-context.ts +++ b/src/auto-reply/reply/post-compaction-context.ts @@ -202,7 +202,9 @@ export function extractSections( if (!inSection) { // Check if this is our target section (case-insensitive) - if (headingText.toLowerCase() === name.toLowerCase()) { + if ( + normalizeLowercaseStringOrEmpty(headingText) === normalizeLowercaseStringOrEmpty(name) + ) { inSection = true; sectionLevel = level; sectionLines = [line]; diff --git a/src/auto-reply/reply/reply-inline.ts b/src/auto-reply/reply/reply-inline.ts index 367c946eae4..59fe87888e0 100644 --- a/src/auto-reply/reply/reply-inline.ts +++ b/src/auto-reply/reply/reply-inline.ts @@ -1,3 +1,4 @@ +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { collapseInlineHorizontalWhitespace } from "./reply-inline-whitespace.js"; const INLINE_SIMPLE_COMMAND_ALIASES = new Map([ @@ -21,7 +22,7 @@ export function extractInlineSimpleCommand(body?: string): { if (!match || match.index === undefined) { return null; } - const alias = `/${match[1].toLowerCase()}`; + const alias = `/${normalizeLowercaseStringOrEmpty(match[1])}`; const command = INLINE_SIMPLE_COMMAND_ALIASES.get(alias); if (!command) { return null; diff --git a/src/auto-reply/reply/response-prefix-template.ts b/src/auto-reply/reply/response-prefix-template.ts index 1ff5a3101e2..9c8d745ec9f 100644 --- a/src/auto-reply/reply/response-prefix-template.ts +++ b/src/auto-reply/reply/response-prefix-template.ts @@ -1,3 +1,5 @@ +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; + /** * Template interpolation for response prefix. * @@ -44,7 +46,7 @@ export function resolveResponsePrefixTemplate( } return template.replace(TEMPLATE_VAR_PATTERN, (match, varName: string) => { - const normalizedVar = varName.toLowerCase(); + const normalizedVar = normalizeLowercaseStringOrEmpty(varName); switch (normalizedVar) { case "model": diff --git a/src/auto-reply/reply/session-delivery.ts b/src/auto-reply/reply/session-delivery.ts index 0f33a016a2f..aedc752ea85 100644 --- a/src/auto-reply/reply/session-delivery.ts +++ b/src/auto-reply/reply/session-delivery.ts @@ -179,7 +179,7 @@ export function maybeRetireLegacyMainDeliveryRoute(params: { const canonicalMainSessionKey = buildAgentMainSessionKey({ agentId: params.agentId, mainKey: params.mainKey, - }).toLowerCase(); + }); if (params.sessionKey === canonicalMainSessionKey) { return undefined; } diff --git a/src/auto-reply/reply/session-system-events.ts b/src/auto-reply/reply/session-system-events.ts index 64e848731ef..e5bb9a772b4 100644 --- a/src/auto-reply/reply/session-system-events.ts +++ b/src/auto-reply/reply/session-system-events.ts @@ -7,7 +7,10 @@ import { resolveTimezone, } from "../../infra/format-time/format-datetime.ts"; import { drainSystemEventEntries } from "../../infra/system-events.js"; -import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../../shared/string-coerce.js"; /** Drain queued system events, format as `System:` lines, return the block (or undefined). */ export async function drainFormattedSystemEvents(params: { @@ -21,7 +24,7 @@ export async function drainFormattedSystemEvents(params: { if (!trimmed) { return null; } - const lower = trimmed.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(trimmed); if (lower.includes("reason periodic")) { return null; } @@ -44,7 +47,7 @@ export async function drainFormattedSystemEvents(params: { if (!raw) { return { mode: "local" as const }; } - const lowered = raw.toLowerCase(); + const lowered = normalizeLowercaseStringOrEmpty(raw); if (lowered === "utc" || lowered === "gmt") { return { mode: "utc" as const }; } diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 51af12b4841..3086fd4f5ac 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -34,6 +34,7 @@ import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import type { PluginHookSessionEndReason } from "../../plugins/types.js"; import { normalizeMainKey } from "../../routing/session-key.js"; import { + normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; @@ -318,8 +319,8 @@ export async function initSessionState(params: { : triggerBodyNormalized; // Reset triggers are configured as lowercased commands (e.g. "/new"), but users may type // "/NEW" etc. Match case-insensitively while keeping the original casing for any stripped body. - const trimmedBodyLower = trimmedBody.toLowerCase(); - const strippedForResetLower = strippedForReset.toLowerCase(); + const trimmedBodyLower = normalizeLowercaseStringOrEmpty(trimmedBody); + const strippedForResetLower = normalizeLowercaseStringOrEmpty(strippedForReset); let matchedResetTriggerLower: string | undefined; for (const trigger of resetTriggers) { @@ -329,7 +330,7 @@ export async function initSessionState(params: { if (!resetAuthorized) { break; } - const triggerLower = trigger.toLowerCase(); + const triggerLower = normalizeLowercaseStringOrEmpty(trigger); if (trimmedBodyLower === triggerLower || strippedForResetLower === triggerLower) { isNewSession = true; bodyStripped = ""; diff --git a/src/auto-reply/reply/subagents-utils.ts b/src/auto-reply/reply/subagents-utils.ts index 59374c3dda6..0c0121f3b27 100644 --- a/src/auto-reply/reply/subagents-utils.ts +++ b/src/auto-reply/reply/subagents-utils.ts @@ -1,5 +1,8 @@ import type { SubagentRunRecord } from "../../agents/subagent-registry.js"; -import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../../shared/string-coerce.js"; import { sanitizeTaskStatusText } from "../../tasks/task-status.js"; import { truncateUtf16Safe } from "../../utils.js"; @@ -92,8 +95,10 @@ export function resolveSubagentTargetFromRuns(params: { ? { entry: bySessionKey } : { error: params.errors.unknownSession(trimmed) }; } - const lowered = trimmed.toLowerCase(); - const byExactLabel = deduped.filter((entry) => params.label(entry).toLowerCase() === lowered); + const lowered = normalizeLowercaseStringOrEmpty(trimmed); + const byExactLabel = deduped.filter( + (entry) => normalizeLowercaseStringOrEmpty(params.label(entry)) === lowered, + ); if (byExactLabel.length === 1) { return { entry: byExactLabel[0] }; } @@ -101,7 +106,7 @@ export function resolveSubagentTargetFromRuns(params: { return { error: params.errors.ambiguousLabel(trimmed) }; } const byLabelPrefix = deduped.filter((entry) => - params.label(entry).toLowerCase().startsWith(lowered), + normalizeLowercaseStringOrEmpty(params.label(entry)).startsWith(lowered), ); if (byLabelPrefix.length === 1) { return { entry: byLabelPrefix[0] }; diff --git a/src/auto-reply/skill-commands-base.ts b/src/auto-reply/skill-commands-base.ts index 4d0ad49bdec..e763200f3c1 100644 --- a/src/auto-reply/skill-commands-base.ts +++ b/src/auto-reply/skill-commands-base.ts @@ -1,5 +1,8 @@ import type { SkillCommandSpec } from "../agents/skills.js"; -import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, +} from "../shared/string-coerce.js"; import { getChatCommands } from "./commands-registry.data.js"; export function listReservedChatSlashCommandNames(extraNames: string[] = []): Set { @@ -13,7 +16,7 @@ export function listReservedChatSlashCommandNames(extraNames: string[] = []): Se if (!trimmed.startsWith("/")) { continue; } - reserved.add(trimmed.slice(1).toLowerCase()); + reserved.add(normalizeLowercaseStringOrEmpty(trimmed.slice(1))); } } for (const name of extraNames) { diff --git a/src/auto-reply/skill-commands.ts b/src/auto-reply/skill-commands.ts index 3dd73bf0b2f..ce96de1044e 100644 --- a/src/auto-reply/skill-commands.ts +++ b/src/auto-reply/skill-commands.ts @@ -9,7 +9,10 @@ import { buildWorkspaceSkillCommandSpecs, type SkillCommandSpec } from "../agent import type { OpenClawConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; import { getRemoteSkillEligibility } from "../infra/skills-remote.js"; -import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, +} from "../shared/string-coerce.js"; import { listReservedChatSlashCommandNames } from "./skill-commands-base.js"; export { listReservedChatSlashCommandNames, @@ -119,7 +122,7 @@ export function listSkillCommandsForAgents(params: { reservedNames: used, }); for (const command of commands) { - used.add(command.name.toLowerCase()); + used.add(normalizeLowercaseStringOrEmpty(command.name)); entries.push(command); } } diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index d0b5763feeb..720f2ea89d6 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -31,7 +31,10 @@ import { resolveCommitHash } from "../infra/git-commit.js"; import type { MediaUnderstandingDecision } from "../media-understanding/types.js"; import { listPluginCommands } from "../plugins/commands.js"; import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; -import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, +} from "../shared/string-coerce.js"; import { resolveStatusTtsSnapshot } from "../tts/status-config.js"; import { estimateUsageCost, @@ -464,12 +467,11 @@ export function buildStatusMessage(args: StatusArgs): string { normalizeOptionalLowercaseString(runtimeModelRaw.slice(0, slashIndex)) ?? ""; const fallbackMatchesRuntimeModel = initialFallbackState.active && - runtimeModelRaw.toLowerCase() === - String(entry?.fallbackNoticeActiveModel ?? "") - .trim() - .toLowerCase(); + normalizeLowercaseStringOrEmpty(runtimeModelRaw) === + normalizeLowercaseStringOrEmpty(String(entry?.fallbackNoticeActiveModel ?? "").trim()); const runtimeMatchesSelectedModel = - runtimeModelRaw.toLowerCase() === (modelRefs.selected.label || "unknown").toLowerCase(); + normalizeLowercaseStringOrEmpty(runtimeModelRaw) === + normalizeLowercaseStringOrEmpty(modelRefs.selected.label || "unknown"); // Legacy fallback sessions can persist provider-qualified runtime ids // without a separate modelProvider field. Preserve provider-aware lookup // when the stored slash id is the selected model or the active fallback @@ -477,7 +479,7 @@ export function buildStatusMessage(args: StatusArgs): string { // slash ids. if ( (fallbackMatchesRuntimeModel || runtimeMatchesSelectedModel) && - embeddedProvider === activeProvider.toLowerCase() + embeddedProvider === normalizeLowercaseStringOrEmpty(activeProvider) ) { contextLookupProvider = activeProvider; contextLookupModel = activeModel; @@ -998,9 +1000,12 @@ function formatCommandEntry(command: ChatCommandDefinition): string { const aliases = command.textAliases .map((alias) => alias.trim()) .filter(Boolean) - .filter((alias) => alias.toLowerCase() !== primary.toLowerCase()) + .filter( + (alias) => + normalizeLowercaseStringOrEmpty(alias) !== normalizeLowercaseStringOrEmpty(primary), + ) .filter((alias) => { - const key = alias.toLowerCase(); + const key = normalizeLowercaseStringOrEmpty(alias); if (seen.has(key)) { return false; } @@ -1079,7 +1084,7 @@ export function buildCommandsMessagePaginated( options?: CommandsMessageOptions, ): CommandsMessageResult { const page = Math.max(1, options?.page ?? 1); - const surface = options?.surface?.toLowerCase(); + const surface = normalizeOptionalLowercaseString(options?.surface); const prefersPaginatedList = options?.forcePaginatedList === true || Boolean(surface && getChannelPlugin(surface)?.commands?.buildCommandsListChannelData); diff --git a/src/auto-reply/thinking.shared.ts b/src/auto-reply/thinking.shared.ts index 2998ffbb0c8..85a7e7e9068 100644 --- a/src/auto-reply/thinking.shared.ts +++ b/src/auto-reply/thinking.shared.ts @@ -1,4 +1,7 @@ -import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, +} from "../shared/string-coerce.js"; export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive"; export type VerboseLevel = "off" | "on" | "full"; @@ -140,7 +143,7 @@ export function normalizeUsageDisplay(raw?: string | null): UsageDisplayLevel | if (!raw) { return undefined; } - const key = raw.toLowerCase(); + const key = normalizeLowercaseStringOrEmpty(raw); if (["off", "false", "no", "0", "disable", "disabled"].includes(key)) { return "off"; } @@ -167,7 +170,7 @@ export function normalizeFastMode(raw?: string | boolean | null): boolean | unde if (!raw) { return undefined; } - const key = raw.toLowerCase(); + const key = normalizeLowercaseStringOrEmpty(raw); if (["off", "false", "no", "0", "disable", "disabled", "normal"].includes(key)) { return false; } @@ -181,7 +184,7 @@ export function normalizeElevatedLevel(raw?: string | null): ElevatedLevel | und if (!raw) { return undefined; } - const key = raw.toLowerCase(); + const key = normalizeLowercaseStringOrEmpty(raw); if (["off", "false", "no", "0"].includes(key)) { return "off"; } @@ -211,7 +214,7 @@ export function normalizeReasoningLevel(raw?: string | null): ReasoningLevel | u if (!raw) { return undefined; } - const key = raw.toLowerCase(); + const key = normalizeLowercaseStringOrEmpty(raw); if (["off", "false", "no", "0", "hide", "hidden", "disable", "disabled"].includes(key)) { return "off"; }