refactor: dedupe auto-reply lowercase helpers

This commit is contained in:
Peter Steinberger
2026-04-07 17:27:42 +01:00
parent d40dc8f025
commit cebfa70277
43 changed files with 189 additions and 103 deletions

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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" };
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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" ||

View File

@@ -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;

View File

@@ -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") &&

View File

@@ -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));

View File

@@ -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 {

View File

@@ -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"));

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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";
}

View File

@@ -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<ReplyPayload> {
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 {

View File

@@ -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);

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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 <id|#> [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;

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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: {

View File

@@ -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 : "" };
}

View File

@@ -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)"

View File

@@ -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;

View File

@@ -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";

View File

@@ -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 };

View File

@@ -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<DispatchFromConfigResult> {
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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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];

View File

@@ -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];

View File

@@ -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<string, string>([
@@ -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;

View File

@@ -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":

View File

@@ -179,7 +179,7 @@ export function maybeRetireLegacyMainDeliveryRoute(params: {
const canonicalMainSessionKey = buildAgentMainSessionKey({
agentId: params.agentId,
mainKey: params.mainKey,
}).toLowerCase();
});
if (params.sessionKey === canonicalMainSessionKey) {
return undefined;
}

View File

@@ -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 };
}

View File

@@ -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 = "";

View File

@@ -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] };

View File

@@ -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<string> {
@@ -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) {

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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";
}