refactor: dedupe agent lowercase helpers

This commit is contained in:
Peter Steinberger
2026-04-07 17:15:44 +01:00
parent d56fe040b4
commit d40dc8f025
23 changed files with 116 additions and 88 deletions

View File

@@ -48,7 +48,7 @@ const CLAUDE_CODE_TOOLS = [
"WebSearch",
] as const;
const CLAUDE_CODE_TOOL_LOOKUP = new Map(
CLAUDE_CODE_TOOLS.map((tool) => [tool.toLowerCase(), tool]),
CLAUDE_CODE_TOOLS.map((tool) => [normalizeLowercaseStringOrEmpty(tool), tool]),
);
type AnthropicTransportModel = Model<"anthropic-messages"> & {

View File

@@ -13,6 +13,7 @@ import {
refreshProviderOAuthCredentialWithPlugin,
} from "../../plugins/provider-runtime.runtime.js";
import { resolveSecretRefString, type SecretRefResolveCache } from "../../secrets/resolve.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import { refreshChutesTokens } from "../chutes-oauth.js";
import { writeCodexCliCredentials } from "../cli-credentials.js";
import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
@@ -124,7 +125,7 @@ function extractErrorMessage(error: unknown): string {
}
function isRefreshTokenReusedError(error: unknown): boolean {
const message = extractErrorMessage(error).toLowerCase();
const message = normalizeLowercaseStringOrEmpty(extractErrorMessage(error));
return (
message.includes("refresh_token_reused") ||
message.includes("refresh token has already been used") ||

View File

@@ -1,4 +1,5 @@
import type { ExecAsk, ExecSecurity, SystemRunApprovalPlan } from "../infra/exec-approvals.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import {
DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS,
DEFAULT_APPROVAL_TIMEOUT_MS,
@@ -120,7 +121,7 @@ export async function waitForExecApprovalDecision(id: string): Promise<string |
return parseDecision(decisionResult).value;
} catch (err) {
// Timeout/cleanup path: treat missing/expired as no decision so askFallback applies.
const message = String(err).toLowerCase();
const message = normalizeLowercaseStringOrEmpty(String(err));
if (message.includes("approval expired or not found")) {
return null;
}

View File

@@ -17,7 +17,11 @@ import {
} from "../infra/shell-env.js";
import { logInfo } from "../logger.js";
import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { splitShellArgs } from "../utils/shell-argv.js";
import { markBackgrounded } from "./bash-process-registry.js";
import { processGatewayAllowlist } from "./bash-tools.exec-host-gateway.js";
@@ -109,7 +113,7 @@ function isEnvExecutableToken(token: string | undefined): boolean {
if (!token) {
return false;
}
const base = token.split(/[\\/]/u).at(-1)?.toLowerCase() ?? "";
const base = normalizeOptionalLowercaseString(token.split(/[\\/]/u).at(-1)) ?? "";
const normalizedBase = base.endsWith(".exe") ? base.slice(0, -4) : base;
return normalizedBase === "env";
}
@@ -158,7 +162,7 @@ function findFirstPythonScriptArg(tokens: string[]): string | null {
const token = tokens[i];
if (token === "--") {
const next = tokens[i + 1];
return next?.toLowerCase().endsWith(".py") ? next : null;
return normalizeLowercaseStringOrEmpty(next).endsWith(".py") ? next : null;
}
if (token === "-") {
return null;
@@ -176,7 +180,7 @@ function findFirstPythonScriptArg(tokens: string[]): string | null {
if (token.startsWith("-")) {
continue;
}
return token.toLowerCase().endsWith(".py") ? token : null;
return normalizeLowercaseStringOrEmpty(token).endsWith(".py") ? token : null;
}
return null;
}
@@ -191,7 +195,7 @@ function findNodeScriptArgs(tokens: string[]): string[] {
if (token === "--") {
if (!hasInlineEvalOrPrint && !entryScript) {
const next = tokens[i + 1];
if (next?.toLowerCase().endsWith(".js")) {
if (normalizeLowercaseStringOrEmpty(next).endsWith(".js")) {
entryScript = next;
}
}
@@ -214,7 +218,7 @@ function findNodeScriptArgs(tokens: string[]): string[] {
}
if (optionsWithSeparateValue.has(token)) {
const next = tokens[i + 1];
if (next?.toLowerCase().endsWith(".js")) {
if (normalizeLowercaseStringOrEmpty(next).endsWith(".js")) {
preloadScripts.push(next);
}
i += 1;
@@ -228,7 +232,7 @@ function findNodeScriptArgs(tokens: string[]): string[] {
const inlineValue = token.startsWith("-r")
? token.slice(2)
: token.slice(token.indexOf("=") + 1);
if (inlineValue.toLowerCase().endsWith(".js")) {
if (normalizeLowercaseStringOrEmpty(inlineValue).endsWith(".js")) {
preloadScripts.push(inlineValue);
}
continue;
@@ -236,7 +240,11 @@ function findNodeScriptArgs(tokens: string[]): string[] {
if (token.startsWith("-")) {
continue;
}
if (!hasInlineEvalOrPrint && !entryScript && token.toLowerCase().endsWith(".js")) {
if (
!hasInlineEvalOrPrint &&
!entryScript &&
normalizeLowercaseStringOrEmpty(token).endsWith(".js")
) {
entryScript = token;
}
break;
@@ -258,7 +266,7 @@ function extractInterpreterScriptTargetFromArgv(
while (commandIdx < argv.length && /^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(argv[commandIdx])) {
commandIdx += 1;
}
const executable = argv[commandIdx]?.toLowerCase();
const executable = normalizeOptionalLowercaseString(argv[commandIdx]);
if (!executable) {
return null;
}
@@ -550,7 +558,8 @@ function hasUnquotedScriptHint(raw: string): boolean {
let token = "";
const flushToken = (): boolean => {
if (token.toLowerCase().endsWith(".py") || token.toLowerCase().endsWith(".js")) {
const normalizedToken = normalizeLowercaseStringOrEmpty(token);
if (normalizedToken.endsWith(".py") || normalizedToken.endsWith(".js")) {
return true;
}
token = "";
@@ -627,7 +636,7 @@ function resolveLeadingShellSegmentExecutable(rawSegment: string): string | unde
) {
commandIdx += 1;
}
return normalizedArgv[commandIdx]?.toLowerCase();
return normalizeOptionalLowercaseString(normalizedArgv[commandIdx]);
}
function analyzeInterpreterHeuristicsFromUnquoted(raw: string): {
@@ -666,7 +675,7 @@ function extractShellWrappedCommandPayload(
if (!executable) {
return null;
}
const executableBase = executable.split(/[\\/]/u).at(-1)?.toLowerCase() ?? "";
const executableBase = normalizeOptionalLowercaseString(executable.split(/[\\/]/u).at(-1)) ?? "";
const normalizedExecutable = executableBase.endsWith(".exe")
? executableBase.slice(0, -4)
: executableBase;
@@ -724,7 +733,7 @@ function shouldFailClosedInterpreterPreflight(command: string): {
) {
commandIdx += 1;
}
const directExecutable = argv?.[commandIdx]?.toLowerCase();
const directExecutable = normalizeOptionalLowercaseString(argv?.[commandIdx]);
const args = argv ? argv.slice(commandIdx + 1) : [];
const isDirectPythonExecutable = Boolean(
@@ -770,7 +779,7 @@ function shouldFailClosedInterpreterPreflight(command: string): {
) {
commandIdx += 1;
}
const executable = normalizedArgv[commandIdx]?.toLowerCase();
const executable = normalizeOptionalLowercaseString(normalizedArgv[commandIdx]);
if (!executable) {
return false;
}
@@ -988,9 +997,9 @@ function parseExecApprovalShellCommand(raw: string): ParsedExecApprovalCommand |
return {
approvalId: match[1],
decision:
match[2].toLowerCase() === "always"
normalizeLowercaseStringOrEmpty(match[2]) === "always"
? "allow-always"
: (match[2].toLowerCase() as ParsedExecApprovalCommand["decision"]),
: (normalizeLowercaseStringOrEmpty(match[2]) as ParsedExecApprovalCommand["decision"]),
};
}

View File

@@ -3,6 +3,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { logWarn } from "../logger.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import {
buildSafeToolName,
normalizeReservedToolNames,
@@ -95,7 +96,7 @@ export async function materializeBundleMcpToolsForRun(params: {
`bundle-mcp: tool "${tool.toolName}" from server "${tool.serverName}" registered as "${safeToolName}" to keep the tool name provider-safe.`,
);
}
reservedNames.add(safeToolName.toLowerCase());
reservedNames.add(normalizeLowercaseStringOrEmpty(safeToolName));
tools.push({
name: safeToolName,
label: tool.title ?? tool.toolName,

View File

@@ -1,4 +1,7 @@
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "../shared/string-coerce.js";
const TOOL_NAME_SAFE_RE = /[^A-Za-z0-9_-]/g;
export const TOOL_NAME_SEPARATOR = "__";
@@ -18,12 +21,12 @@ export function sanitizeServerName(raw: string, usedNames: Set<string>): string
const base = sanitizeToolFragment(raw, "mcp", TOOL_NAME_MAX_PREFIX);
let candidate = base;
let n = 2;
while (usedNames.has(candidate.toLowerCase())) {
while (usedNames.has(normalizeLowercaseStringOrEmpty(candidate))) {
const suffix = `-${n}`;
candidate = `${base.slice(0, Math.max(1, TOOL_NAME_MAX_PREFIX - suffix.length))}${suffix}`;
n += 1;
}
usedNames.add(candidate.toLowerCase());
usedNames.add(normalizeLowercaseStringOrEmpty(candidate));
return candidate;
}
@@ -53,7 +56,7 @@ export function buildSafeToolName(params: {
let candidateToolName = truncatedToolName || "tool";
let candidate = `${params.serverName}${TOOL_NAME_SEPARATOR}${candidateToolName}`;
let n = 2;
while (params.reservedNames.has(candidate.toLowerCase())) {
while (params.reservedNames.has(normalizeLowercaseStringOrEmpty(candidate))) {
const suffix = `-${n}`;
candidateToolName = `${(truncatedToolName || "tool").slice(0, Math.max(1, maxToolChars - suffix.length))}${suffix}`;
candidate = `${params.serverName}${TOOL_NAME_SEPARATOR}${candidateToolName}`;

View File

@@ -8,7 +8,10 @@ import {
parseApiErrorInfo,
parseApiErrorPayload,
} from "../../shared/assistant-error-format.js";
import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "../../shared/string-coerce.js";
export {
extractLeadingHttpStatus,
formatRawAssistantErrorForUi,
@@ -121,7 +124,7 @@ function formatTransportErrorCopy(raw: string): string | undefined {
if (!raw) {
return undefined;
}
const lower = raw.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(raw);
if (
/\beconnrefused\b/i.test(raw) ||
@@ -172,7 +175,7 @@ function formatDiskSpaceErrorCopy(raw: string): string | undefined {
if (!raw) {
return undefined;
}
const lower = raw.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(raw);
if (
/\benospc\b/i.test(raw) ||
lower.includes("no space left on device") ||
@@ -190,7 +193,7 @@ export function isReasoningConstraintErrorMessage(raw: string): boolean {
if (!raw) {
return false;
}
const lower = raw.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(raw);
return (
lower.includes("reasoning is mandatory") ||
lower.includes("reasoning is required") ||
@@ -203,7 +206,7 @@ function isInvalidStreamingEventOrderError(raw: string): boolean {
if (!raw) {
return false;
}
const lower = raw.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(raw);
return (
lower.includes("unexpected event order") &&
lower.includes("message_start") &&
@@ -212,7 +215,7 @@ function isInvalidStreamingEventOrderError(raw: string): boolean {
}
function hasRateLimitTpmHint(raw: string): boolean {
const lower = raw.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(raw);
return /\btpm\b/i.test(lower) || lower.includes("tokens per minute");
}
@@ -220,7 +223,7 @@ export function isContextOverflowError(errorMessage?: string): boolean {
if (!errorMessage) {
return false;
}
const lower = errorMessage.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(errorMessage);
// Groq uses 413 for TPM (tokens per minute) limits, which is a rate limit, not context overflow.
if (hasRateLimitTpmHint(errorMessage)) {
@@ -317,7 +320,7 @@ export function isCompactionFailureError(errorMessage?: string): boolean {
if (!errorMessage) {
return false;
}
const lower = errorMessage.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(errorMessage);
const hasCompactionTerm =
lower.includes("summarization failed") ||
lower.includes("auto-compaction") ||
@@ -865,7 +868,7 @@ function isLikelyHttpErrorText(raw: string): boolean {
if (status.code < 400) {
return false;
}
const message = status.rest.toLowerCase();
const message = normalizeLowercaseStringOrEmpty(status.rest);
return HTTP_ERROR_HINTS.some((hint) => message.includes(hint));
}
@@ -1155,7 +1158,7 @@ function isJsonApiInternalServerError(raw: string): boolean {
if (!raw) {
return false;
}
const value = raw.toLowerCase();
const value = normalizeLowercaseStringOrEmpty(raw);
// Providers wrap transient 5xx errors in JSON payloads like:
// {"type":"error","error":{"type":"api_error","message":"Internal server error"}}
// Non-standard providers (e.g. MiniMax) may use different message text:
@@ -1183,7 +1186,7 @@ export function parseImageDimensionError(raw: string): {
if (!raw) {
return null;
}
const lower = raw.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(raw);
if (!lower.includes("image dimensions exceed max allowed size")) {
return null;
}
@@ -1208,7 +1211,7 @@ export function parseImageSizeError(raw: string): {
if (!raw) {
return null;
}
const lower = raw.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(raw);
if (!lower.includes("image exceeds") || !lower.includes("mb")) {
return null;
}
@@ -1241,7 +1244,7 @@ export function isModelNotFoundErrorMessage(raw: string): boolean {
if (!raw) {
return false;
}
const lower = raw.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(raw);
// Direct pattern matches from OpenClaw internals and common providers.
if (
@@ -1272,7 +1275,7 @@ function isCliSessionExpiredErrorMessage(raw: string): boolean {
if (!raw) {
return false;
}
const lower = raw.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(raw);
return (
lower.includes("session not found") ||
lower.includes("session does not exist") ||

View File

@@ -1,3 +1,5 @@
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
type ErrorPattern = RegExp | string;
const PERIODIC_USAGE_LIMIT_RE =
@@ -145,7 +147,7 @@ function matchesErrorPatterns(raw: string, patterns: readonly ErrorPattern[]): b
if (!raw) {
return false;
}
const value = raw.toLowerCase();
const value = normalizeLowercaseStringOrEmpty(raw);
return patterns.some((pattern) =>
pattern instanceof RegExp ? pattern.test(value) : value.includes(pattern),
);
@@ -175,7 +177,7 @@ export function isPeriodicUsageLimitErrorMessage(raw: string): boolean {
}
export function isBillingErrorMessage(raw: string): boolean {
const value = raw.toLowerCase();
const value = normalizeLowercaseStringOrEmpty(raw);
if (!value) {
return false;
}

View File

@@ -1,3 +1,5 @@
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
const MIN_DUPLICATE_TEXT_LENGTH = 10;
/**
@@ -8,9 +10,7 @@ const MIN_DUPLICATE_TEXT_LENGTH = 10;
* - Collapses multiple spaces to single space
*/
export function normalizeTextForComparison(text: string): string {
return text
.trim()
.toLowerCase()
return normalizeLowercaseStringOrEmpty(text)
.replace(/\p{Emoji_Presentation}|\p{Extended_Pictographic}/gu, "")
.replace(/\s+/g, " ")
.trim();

View File

@@ -1,3 +1,5 @@
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
/**
* Runner abort check. Catches any abort-related message for embedded runners.
* More permissive than the core isAbortError since runners need to catch
@@ -12,6 +14,8 @@ export function isRunnerAbortError(err: unknown): boolean {
return true;
}
const message =
"message" in err && typeof err.message === "string" ? err.message.toLowerCase() : "";
"message" in err && typeof err.message === "string"
? normalizeLowercaseStringOrEmpty(err.message)
: "";
return message.includes("aborted");
}

View File

@@ -1,4 +1,5 @@
import { resolveProviderCacheTtlEligibility } from "../../plugins/provider-runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import { isAnthropicFamilyCacheTtlEligible } from "./anthropic-family-cache-semantics.js";
import { isGooglePromptCacheEligible } from "./prompt-cache-retention.js";
@@ -22,8 +23,8 @@ export function isCacheTtlEligibleProvider(
modelId: string,
modelApi?: string,
): boolean {
const normalizedProvider = provider.toLowerCase();
const normalizedModelId = modelId.toLowerCase();
const normalizedProvider = normalizeLowercaseStringOrEmpty(provider);
const normalizedModelId = normalizeLowercaseStringOrEmpty(modelId);
const pluginEligibility = resolveProviderCacheTtlEligibility({
provider: normalizedProvider,
context: {

View File

@@ -7,6 +7,7 @@ import type {
PluginHookBeforeModelResolveResult,
PluginHookBeforePromptBuildResult,
} from "../../plugins/types.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import type { FailoverReason } from "../pi-embedded-helpers/types.js";
import type { EmbeddedRunAttemptResult } from "./run/types.js";
@@ -144,7 +145,7 @@ export const mockedIsCompactionFailureError = vi.fn(() => false);
export const mockedIsFailoverAssistantError = vi.fn(() => false);
export const mockedIsFailoverErrorMessage = vi.fn(() => false);
export const mockedIsLikelyContextOverflowError = vi.fn((msg?: string) => {
const lower = (msg ?? "").toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(msg ?? "");
return (
lower.includes("request_too_large") ||
lower.includes("context window exceeded") ||
@@ -270,7 +271,7 @@ export function resetRunOverflowCompactionHarnessMocks(): void {
mockedIsFailoverErrorMessage.mockReturnValue(false);
mockedIsLikelyContextOverflowError.mockReset();
mockedIsLikelyContextOverflowError.mockImplementation((msg?: string) => {
const lower = (msg ?? "").toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(msg ?? "");
return (
lower.includes("request_too_large") ||
lower.includes("context window exceeded") ||

View File

@@ -5,6 +5,7 @@ import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../../../infra/lo
import type { PromptImageOrderEntry } from "../../../media/prompt-image-order.js";
import { resolveMediaBufferPath, getMediaDir } from "../../../media/store.js";
import { loadWebMedia } from "../../../media/web-media.js";
import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js";
import { resolveUserPath } from "../../../utils.js";
import type { ImageSanitizationLimits } from "../../image-sanitization.js";
import {
@@ -83,12 +84,12 @@ export interface DetectedImageRef {
* Checks if a file extension indicates an image file.
*/
function isImageExtension(filePath: string): boolean {
const ext = path.extname(filePath).toLowerCase();
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
return IMAGE_EXTENSIONS.has(ext);
}
function normalizeRefForDedupe(raw: string): string {
return process.platform === "win32" ? raw.toLowerCase() : raw;
return process.platform === "win32" ? normalizeLowercaseStringOrEmpty(raw) : raw;
}
export function mergePromptAttachmentImages(params: {

View File

@@ -1,3 +1,4 @@
import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js";
import { isLikelyMutatingToolName } from "../../tool-mutation.js";
import type { EmbeddedRunAttemptResult } from "./types.js";
@@ -137,13 +138,13 @@ function shouldApplyPlanningOnlyRetryGuard(params: {
}
function normalizeAckPrompt(text: string): string {
return text
const normalized = text
.normalize("NFKC")
.trim()
.toLowerCase()
.replace(/[\p{P}\p{S}]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
return normalizeLowercaseStringOrEmpty(normalized);
}
export function isLikelyExecutionAckPrompt(text: string): boolean {

View File

@@ -3,6 +3,7 @@ import type { TextContent } from "@mariozechner/pi-ai";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { formatErrorMessage } from "../../infra/errors.js";
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import { acquireSessionWriteLock } from "../session-write-lock.js";
import { log } from "./logger.js";
import { formatContextLimitTruncationNotice } from "./tool-result-context-guard.js";
@@ -71,7 +72,7 @@ const MIDDLE_OMISSION_MARKER =
*/
function hasImportantTail(text: string): boolean {
// Check last ~2000 chars for error-like patterns
const tail = text.slice(-2000).toLowerCase();
const tail = normalizeLowercaseStringOrEmpty(text.slice(-2000));
return (
/\b(error|exception|failed|fatal|traceback|panic|stack trace|errno|exit code)\b/.test(tail) ||
// JSON closing — if the output is JSON, the tail has closing structure

View File

@@ -1,10 +1,9 @@
import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js";
import { compileGlobPatterns, matchesAnyGlobPattern } from "../../glob-pattern.js";
import type { ContextPruningToolMatch } from "./settings.js";
function normalizeGlob(value: string) {
return String(value ?? "")
.trim()
.toLowerCase();
return normalizeLowercaseStringOrEmpty(String(value ?? ""));
}
export function makeToolPrunablePredicate(

View File

@@ -124,7 +124,7 @@ export function hasCursorModeSensitiveKeys(request: KeyEncodingRequest): boolean
if (hasAnyModifier(parsed.mods)) {
return false;
}
return parsed.base.toLowerCase() in DECCKM_SS3_KEYS;
return normalizeLowercaseStringOrEmpty(parsed.base) in DECCKM_SS3_KEYS;
}) ?? false
);
}
@@ -186,7 +186,7 @@ function encodeKeyToken(
const parsed = parseModifiers(token);
const base = parsed.base;
const baseLower = base.toLowerCase();
const baseLower = normalizeLowercaseStringOrEmpty(base);
if (baseLower === "tab" && parsed.mods.shift) {
return `${ESC}[Z`;
@@ -240,7 +240,7 @@ function parseModifiers(token: string) {
let sawModifiers = false;
while (rest.length > 2 && rest[1] === "-") {
const mod = rest[0].toLowerCase();
const mod = normalizeLowercaseStringOrEmpty(rest[0]);
if (mod === "c") {
mods.ctrl = true;
} else if (mod === "m") {

View File

@@ -1,5 +1,6 @@
import path from "node:path";
import { normalizeAgentId } from "../../routing/session-key.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import { resolveUserPath } from "../../utils.js";
import { resolveAgentIdFromSessionKey } from "../agent-scope.js";
import { hashTextSha256 } from "./hash.js";
@@ -7,8 +8,7 @@ import { hashTextSha256 } from "./hash.js";
export function slugifySessionKey(value: string) {
const trimmed = value.trim() || "session";
const hash = hashTextSha256(trimmed).slice(0, 8);
const safe = trimmed
.toLowerCase()
const safe = normalizeLowercaseStringOrEmpty(trimmed)
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "");
const base = safe.slice(0, 32) || "session";

View File

@@ -1,4 +1,5 @@
import path from "node:path";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import type {
CreateSandboxBackendParams,
SandboxBackendCommandParams,
@@ -291,8 +292,7 @@ function resolveSshRuntimePaths(workspaceRoot: string, scopeKey: string): Resolv
function buildSshSandboxRuntimeId(scopeKey: string): string {
const trimmed = scopeKey.trim() || "session";
const safe = trimmed
.toLowerCase()
const safe = normalizeLowercaseStringOrEmpty(trimmed)
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 32);

View File

@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "../../config/config.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import { resolveAgentConfig } from "../agent-scope.js";
import { compileGlobPatterns, matchesAnyGlobPattern } from "../glob-pattern.js";
import { expandToolGroups, normalizeToolName } from "../tool-policy.js";
@@ -157,13 +158,15 @@ function filterDefaultDenyForExplicitAllows(params: {
function expandResolvedPolicy(policy: SandboxToolPolicy): SandboxToolPolicy {
const expandedDeny = expandToolGroups(policy.deny ?? []);
let expandedAllow = expandToolGroups(policy.allow ?? []);
const expandedDenyLower = expandedDeny.map(normalizeLowercaseStringOrEmpty);
const expandedAllowLower = expandedAllow.map(normalizeLowercaseStringOrEmpty);
// `image` is essential for multimodal workflows; keep the existing sandbox
// behavior that auto-includes it for explicit allowlists unless it is denied.
if (
expandedAllow.length > 0 &&
!expandedDeny.map((value) => value.toLowerCase()).includes("image") &&
!expandedAllow.map((value) => value.toLowerCase()).includes("image")
!expandedDenyLower.includes("image") &&
!expandedAllowLower.includes("image")
) {
expandedAllow = [...expandedAllow, "image"];
}

View File

@@ -1,6 +1,10 @@
import type { OpenClawConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { loadEnabledClaudeBundleCommands } from "../../plugins/bundle-commands.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "../../shared/string-coerce.js";
import { resolveEffectiveAgentSkillFilter } from "./agent-filter.js";
import type { SkillEligibilityContext, SkillCommandSpec, SkillEntry } from "./types.js";
import {
@@ -27,8 +31,7 @@ function debugSkillCommandOnce(
}
function sanitizeSkillCommandName(raw: string): string {
const normalized = raw
.toLowerCase()
const normalized = normalizeLowercaseStringOrEmpty(raw)
.replace(/[^a-z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
@@ -37,7 +40,7 @@ function sanitizeSkillCommandName(raw: string): string {
}
function resolveUniqueSkillCommandName(base: string, used: Set<string>): string {
const normalizedBase = base.toLowerCase();
const normalizedBase = normalizeLowercaseStringOrEmpty(base);
if (!used.has(normalizedBase)) {
return base;
}
@@ -46,7 +49,7 @@ function resolveUniqueSkillCommandName(base: string, used: Set<string>): string
const maxBaseLength = Math.max(1, SKILL_COMMAND_MAX_LENGTH - suffix.length);
const trimmedBase = base.slice(0, maxBaseLength);
const candidate = `${trimmedBase}${suffix}`;
const candidateKey = candidate.toLowerCase();
const candidateKey = normalizeLowercaseStringOrEmpty(candidate);
if (!used.has(candidateKey)) {
return candidate;
}
@@ -85,7 +88,7 @@ export function buildWorkspaceSkillCommandSpecs(
const userInvocable = eligible.filter((entry) => entry.invocation?.userInvocable !== false);
const used = new Set<string>();
for (const reserved of opts?.reservedNames ?? []) {
used.add(reserved.toLowerCase());
used.add(normalizeLowercaseStringOrEmpty(reserved));
}
const specs: SkillCommandSpec[] = [];
@@ -107,20 +110,16 @@ export function buildWorkspaceSkillCommandSpecs(
{ rawName, deduped: `/${unique}` },
);
}
used.add(unique.toLowerCase());
used.add(normalizeLowercaseStringOrEmpty(unique));
const rawDescription = entry.skill.description?.trim() || rawName;
const description =
rawDescription.length > SKILL_COMMAND_DESCRIPTION_MAX_LENGTH
? rawDescription.slice(0, SKILL_COMMAND_DESCRIPTION_MAX_LENGTH - 1) + "…"
: rawDescription;
const dispatch = (() => {
const kindRaw = (
entry.frontmatter?.["command-dispatch"] ??
entry.frontmatter?.["command_dispatch"] ??
""
)
.trim()
.toLowerCase();
const kindRaw = normalizeLowercaseStringOrEmpty(
entry.frontmatter?.["command-dispatch"] ?? entry.frontmatter?.["command_dispatch"] ?? "",
);
if (!kindRaw || kindRaw !== "tool") {
return undefined;
}
@@ -139,13 +138,9 @@ export function buildWorkspaceSkillCommandSpecs(
return undefined;
}
const argModeRaw = (
entry.frontmatter?.["command-arg-mode"] ??
entry.frontmatter?.["command_arg_mode"] ??
""
)
.trim()
.toLowerCase();
const argModeRaw = normalizeOptionalLowercaseString(
entry.frontmatter?.["command-arg-mode"] ?? entry.frontmatter?.["command_arg_mode"] ?? "",
);
const argMode = !argModeRaw || argModeRaw === "raw" ? "raw" : null;
if (!argMode) {
debugSkillCommandOnce(
@@ -187,7 +182,7 @@ export function buildWorkspaceSkillCommandSpecs(
{ rawName: entry.rawName, deduped: `/${unique}` },
);
}
used.add(unique.toLowerCase());
used.add(normalizeLowercaseStringOrEmpty(unique));
const description =
entry.description.length > SKILL_COMMAND_DESCRIPTION_MAX_LENGTH
? entry.description.slice(0, SKILL_COMMAND_DESCRIPTION_MAX_LENGTH - 1) + "…"

View File

@@ -1,4 +1,5 @@
import { redactToolDetail } from "../logging/redact.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { shortenHomeInString } from "../utils.js";
import {
defaultTitle,
@@ -46,7 +47,7 @@ export function resolveToolDisplay(params: {
meta?: string;
}): ToolDisplay {
const name = normalizeToolName(params.name);
const key = name.toLowerCase();
const key = normalizeLowercaseStringOrEmpty(name);
const spec = TOOL_MAP[key];
const emoji = spec?.emoji ?? FALLBACK.emoji ?? "🧩";
const title = spec?.title ?? defaultTitle(name);

View File

@@ -1,10 +1,11 @@
import type { LookupFn } from "../../infra/net/ssrf.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
export function makeFetchHeaders(map: Record<string, string>): {
get: (key: string) => string | null;
} {
return {
get: (key) => map[key.toLowerCase()] ?? null,
get: (key) => map[normalizeLowercaseStringOrEmpty(key)] ?? null,
};
}