refactor: consolidate duplicate utility functions (#12439)

* refactor: consolidate duplicate utility functions

- Add escapeRegExp to src/utils.ts and remove 10 local duplicates
- Rename bash-tools clampNumber to clampWithDefault (different signature)
- Centralize formatError calls to use formatErrorMessage from infra/errors.ts
- Re-export formatErrorMessage from cli/cli-utils.ts to preserve API

* refactor: consolidate remaining escapeRegExp duplicates

* refactor: consolidate sleep, stripAnsi, and clamp duplicates
This commit is contained in:
max
2026-02-08 23:59:43 -08:00
committed by GitHub
parent 8968d9a339
commit ec910a235e
29 changed files with 67 additions and 146 deletions

View File

@@ -6,6 +6,7 @@ import {
type MSTeamsReplyStyle,
type ReplyPayload,
SILENT_REPLY_TOKEN,
sleep,
} from "openclaw/plugin-sdk";
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
import type { StoredConversationReference } from "./conversation-store.js";
@@ -166,16 +167,6 @@ function clampMs(value: number, maxMs: number): number {
return Math.min(value, maxMs);
}
async function sleep(ms: number): Promise<void> {
const delay = Math.max(0, ms);
if (delay === 0) {
return;
}
await new Promise<void>((resolve) => {
setTimeout(resolve, delay);
});
}
function resolveRetryOptions(
retry: false | MSTeamsSendRetryOptions | undefined,
): Required<MSTeamsSendRetryOptions> & { enabled: boolean } {

View File

@@ -2,6 +2,7 @@ import type { Command } from "commander";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { sleep } from "openclaw/plugin-sdk";
import type { VoiceCallConfig } from "./config.js";
import type { VoiceCallRuntime } from "./runtime.js";
import { resolveUserPath } from "./utils.js";
@@ -40,10 +41,6 @@ function resolveDefaultStorePath(config: VoiceCallConfig): string {
return path.join(base, "calls.jsonl");
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function registerVoiceCallCli(params: {
program: Command;
config: VoiceCallConfig;

View File

@@ -4,6 +4,7 @@ import {
collectWhatsAppStatusIssues,
createActionGate,
DEFAULT_ACCOUNT_ID,
escapeRegExp,
formatPairingApproveHint,
getChatChannelMeta,
isWhatsAppGroupJid,
@@ -33,8 +34,6 @@ import { getWhatsAppRuntime } from "./runtime.js";
const meta = getChatChannelMeta("whatsapp");
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
id: "whatsapp",
meta: {

View File

@@ -1,4 +1,5 @@
import { spawn, type SpawnOptions } from "node:child_process";
import { stripAnsi } from "openclaw/plugin-sdk";
import type { ZcaResult, ZcaRunOptions } from "./types.js";
const ZCA_BINARY = "zca";
@@ -107,11 +108,6 @@ export function runZcaInteractive(args: string[], options?: ZcaRunOptions): Prom
});
}
function stripAnsi(str: string): string {
// oxlint-disable-next-line no-control-regex
return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
}
export function parseJsonOutput<T>(stdout: string): T | null {
try {
return JSON.parse(stdout) as T;

View File

@@ -43,7 +43,7 @@ import {
buildDockerExecArgs,
buildSandboxEnv,
chunkString,
clampNumber,
clampWithDefault,
coerceEnv,
killSession,
readEnvInt,
@@ -105,13 +105,13 @@ function validateHostEnv(env: Record<string, string>): void {
}
}
}
const DEFAULT_MAX_OUTPUT = clampNumber(
const DEFAULT_MAX_OUTPUT = clampWithDefault(
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
200_000,
1_000,
200_000,
);
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber(
const DEFAULT_PENDING_MAX_OUTPUT = clampWithDefault(
readEnvInt("OPENCLAW_BASH_PENDING_MAX_OUTPUT_CHARS"),
200_000,
1_000,
@@ -801,7 +801,7 @@ export function createExecTool(
defaults?: ExecToolDefaults,
// oxlint-disable-next-line typescript/no-explicit-any
): AgentTool<any, ExecToolDetails> {
const defaultBackgroundMs = clampNumber(
const defaultBackgroundMs = clampWithDefault(
defaults?.backgroundMs ?? readEnvInt("PI_BASH_YIELD_MS"),
10_000,
10,
@@ -860,7 +860,12 @@ export function createExecTool(
const yieldWindow = allowBackground
? backgroundRequested
? 0
: clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000)
: clampWithDefault(
params.yieldMs ?? defaultBackgroundMs,
defaultBackgroundMs,
10,
120_000,
)
: null;
const elevatedDefaults = defaults?.elevated;
const elevatedAllowed = Boolean(elevatedDefaults?.enabled && elevatedDefaults.allowed);

View File

@@ -146,7 +146,10 @@ function safeCwd() {
}
}
export function clampNumber(
/**
* Clamp a number within min/max bounds, using defaultValue if undefined or NaN.
*/
export function clampWithDefault(
value: number | undefined,
defaultValue: number,
min: number,

View File

@@ -10,6 +10,7 @@ import type { CliBackendConfig } from "../../config/types.js";
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
import { runExec } from "../../process/exec.js";
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
import { escapeRegExp } from "../../utils.js";
import { resolveDefaultModelForAgent } from "../model-selection.js";
import { detectRuntimeShell } from "../shell-utils.js";
import { buildSystemPromptParams } from "../system-prompt-params.js";
@@ -17,10 +18,6 @@ import { buildAgentSystemPrompt } from "../system-prompt.js";
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export async function cleanupResumeProcesses(
backend: CliBackendConfig,
sessionId: string,
@@ -43,7 +40,7 @@ export async function cleanupResumeProcesses(
const resumeTokens = resumeArgs.map((arg) => arg.replaceAll("{sessionId}", sessionId));
const pattern = [commandToken, ...resumeTokens]
.filter(Boolean)
.map((token) => escapeRegex(token))
.map((token) => escapeRegExp(token))
.join(".*");
if (!pattern) {
return;
@@ -95,9 +92,9 @@ function buildSessionMatchers(backend: CliBackendConfig): RegExp[] {
function tokenToRegex(token: string): string {
if (!token.includes("{sessionId}")) {
return escapeRegex(token);
return escapeRegExp(token);
}
const parts = token.split("{sessionId}").map((part) => escapeRegex(part));
const parts = token.split("{sessionId}").map((part) => escapeRegExp(part));
return parts.join("\\S+");
}

View File

@@ -1,3 +1,5 @@
import { escapeRegExp } from "../utils.js";
const ESC = "\x1b";
const CR = "\r";
const TAB = "\t";
@@ -12,10 +14,6 @@ type Modifiers = {
shift: boolean;
};
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const namedKeyMap = new Map<string, string>([
["enter", CR],
["return", CR],

View File

@@ -14,6 +14,7 @@ import type {
} from "./commands-registry.types.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { escapeRegExp } from "../utils.js";
import { getChatCommands, getNativeCommandSurfaces } from "./commands-registry.data.js";
export type {
@@ -68,10 +69,6 @@ function getTextAliasMap(): Map<string, TextAliasSpec> {
return map;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function buildSkillCommandDefinitions(skillCommands?: SkillCommandSpec[]): ChatCommandDefinition[] {
if (!skillCommands || skillCommands.length === 0) {
return [];

View File

@@ -1,6 +1,4 @@
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
import { escapeRegExp } from "../utils.js";
export function extractModelDirective(
body?: string,

View File

@@ -1,4 +1,5 @@
import type { NoticeLevel, ReasoningLevel } from "../thinking.js";
import { escapeRegExp } from "../../utils.js";
import {
type ElevatedLevel,
normalizeElevatedLevel,
@@ -17,8 +18,6 @@ type ExtractedLevel<T> = {
hasDirective: boolean;
};
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const matchLevelDirective = (
body: string,
names: string[],

View File

@@ -1,6 +1,7 @@
import type { MsgContext } from "../templating.js";
import { normalizeChatType } from "../../channels/chat-type.js";
import { listSenderLabelCandidates, resolveSenderLabel } from "../../channels/sender-label.js";
import { escapeRegExp } from "../../utils.js";
export function formatInboundBodyWithSenderMeta(params: { body: string; ctx: MsgContext }): string {
const body = params.body;
@@ -51,7 +52,3 @@ function hasSenderMetaLine(body: string, ctx: MsgContext): boolean {
return pattern.test(body);
});
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -3,10 +3,7 @@ import type { MsgContext } from "../templating.js";
import { resolveAgentConfig } from "../../agents/agent-scope.js";
import { getChannelDock } from "../../channels/dock.js";
import { normalizeChannelId } from "../../channels/plugins/index.js";
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
import { escapeRegExp } from "../../utils.js";
function deriveMentionPatterns(identity?: { name?: string; emoji?: string }) {
const patterns: string[] = [];

View File

@@ -1,10 +1,8 @@
import { escapeRegExp } from "../utils.js";
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
export const SILENT_REPLY_TOKEN = "NO_REPLY";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function isSilentReplyText(
text: string | undefined,
token: string = SILENT_REPLY_TOKEN,

View File

@@ -1,5 +1,6 @@
import type { BrowserRouteContext } from "../server-context.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { escapeRegExp } from "../../utils.js";
import { registerBrowserRoutes } from "./index.js";
type BrowserDispatchRequest = {
@@ -22,10 +23,6 @@ type RouteEntry = {
handler: (req: BrowserRequest, res: BrowserResponse) => void | Promise<void>;
};
function escapeRegex(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function compileRoute(path: string): { regex: RegExp; paramNames: string[] } {
const paramNames: string[] = [];
const parts = path.split("/").map((part) => {
@@ -34,7 +31,7 @@ function compileRoute(path: string): { regex: RegExp; paramNames: string[] } {
paramNames.push(name);
return "([^/]+)";
}
return escapeRegex(part);
return escapeRegExp(part);
});
return { regex: new RegExp(`^${parts.join("/")}$`), paramNames };
}

View File

@@ -18,7 +18,7 @@ import { resolveSignalAccount } from "../signal/accounts.js";
import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js";
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
import { resolveTelegramAccount } from "../telegram/accounts.js";
import { normalizeE164 } from "../utils.js";
import { escapeRegExp, normalizeE164 } from "../utils.js";
import { resolveWhatsAppAccount } from "../web/accounts.js";
import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js";
import {
@@ -76,8 +76,6 @@ const formatLower = (allowFrom: Array<string | number>) =>
.filter(Boolean)
.map((entry) => entry.toLowerCase());
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Channel docks: lightweight channel metadata/behavior for shared code paths.
//
// Rules:

View File

@@ -1,14 +1,13 @@
import type { Command } from "commander";
import { formatErrorMessage } from "../infra/errors.js";
export { formatErrorMessage };
export type ManagerLookupResult<T> = {
manager: T | null;
error?: string;
};
export function formatErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export async function withManager<T>(params: {
getManager: () => Promise<ManagerLookupResult<T>>;
onMissing: (error?: string) => void;

View File

@@ -1,10 +1,7 @@
import { describe, expect, it } from "vitest";
import { stripAnsi } from "../terminal/ansi.js";
import { formatHealthCheckFailure } from "./health-format.js";
const ansiEscape = String.fromCharCode(27);
const ansiRegex = new RegExp(`${ansiEscape}\\[[0-9;]*m`, "g");
const stripAnsi = (input: string) => input.replace(ansiRegex, "");
describe("formatHealthCheckFailure", () => {
it("keeps non-rich output stable", () => {
const err = new Error("gateway closed (1006 abnormal closure): no close reason");

View File

@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import type { SystemPresence } from "../infra/system-presence.js";
import { formatErrorMessage } from "../infra/errors.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { GatewayClient } from "./client.js";
@@ -26,13 +27,6 @@ export type GatewayProbeResult = {
configSnapshot: unknown;
};
function formatError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
return String(err);
}
export async function probeGateway(opts: {
url: string;
auth?: GatewayProbeAuth;
@@ -65,7 +59,7 @@ export async function probeGateway(opts: {
mode: GATEWAY_CLIENT_MODES.PROBE,
instanceId,
onConnectError: (err) => {
connectError = formatError(err);
connectError = formatErrorMessage(err);
},
onClose: (code, reason) => {
close = { code, reason };
@@ -93,7 +87,7 @@ export async function probeGateway(opts: {
settle({
ok: false,
connectLatencyMs,
error: formatError(err),
error: formatErrorMessage(err),
close,
health: null,
status: null,

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import type { GatewayRequestHandlers } from "./types.js";
import { getResolvedLoggerSettings } from "../../logging.js";
import { clamp } from "../../utils.js";
import {
ErrorCodes,
errorShape,
@@ -15,10 +16,6 @@ const MAX_LIMIT = 5000;
const MAX_BYTES = 1_000_000;
const ROLLING_LOG_RE = /^openclaw-\d{4}-\d{2}-\d{2}\.log$/;
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function isRollingLogFile(file: string): boolean {
return ROLLING_LOG_RE.test(path.basename(file));
}

View File

@@ -1,10 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { resolveConfigDir } from "../utils.js";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
import { escapeRegExp, resolveConfigDir } from "../utils.js";
export function upsertSharedEnvVar(params: {
key: string;

View File

@@ -1,6 +1,7 @@
import type { Llama, LlamaEmbeddingContext, LlamaModel } from "node-llama-cpp";
import fsSync from "node:fs";
import type { OpenClawConfig } from "../config/config.js";
import { formatErrorMessage } from "../infra/errors.js";
import { resolveUserPath } from "../utils.js";
import { createGeminiEmbeddingProvider, type GeminiEmbeddingClient } from "./embeddings-gemini.js";
import { createOpenAiEmbeddingProvider, type OpenAiEmbeddingClient } from "./embeddings-openai.js";
@@ -73,7 +74,7 @@ function canAutoSelectLocal(options: EmbeddingProviderOptions): boolean {
}
function isMissingApiKeyError(err: unknown): boolean {
const message = formatError(err);
const message = formatErrorMessage(err);
return message.includes("No API key found for provider");
}
@@ -149,7 +150,7 @@ export async function createEmbeddingProvider(
};
const formatPrimaryError = (err: unknown, provider: "openai" | "local" | "gemini" | "voyage") =>
provider === "local" ? formatLocalSetupError(err) : formatError(err);
provider === "local" ? formatLocalSetupError(err) : formatErrorMessage(err);
if (requestedProvider === "auto") {
const missingKeyErrors: string[] = [];
@@ -202,7 +203,7 @@ export async function createEmbeddingProvider(
} catch (fallbackErr) {
// oxlint-disable-next-line preserve-caught-error
throw new Error(
`${reason}\n\nFallback to ${fallback} failed: ${formatError(fallbackErr)}`,
`${reason}\n\nFallback to ${fallback} failed: ${formatErrorMessage(fallbackErr)}`,
{ cause: fallbackErr },
);
}
@@ -211,13 +212,6 @@ export async function createEmbeddingProvider(
}
}
function formatError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
return String(err);
}
function isNodeLlamaCppMissing(err: unknown): boolean {
if (!(err instanceof Error)) {
return false;
@@ -230,7 +224,7 @@ function isNodeLlamaCppMissing(err: unknown): boolean {
}
function formatLocalSetupError(err: unknown): string {
const detail = formatError(err);
const detail = formatErrorMessage(err);
const missing = isNodeLlamaCppMissing(err);
return [
"Local embeddings unavailable.",

View File

@@ -229,7 +229,8 @@ export {
} from "../agents/tools/common.js";
export { formatDocsLink } from "../terminal/links.js";
export type { HookEntry } from "../hooks/types.js";
export { normalizeE164 } from "../utils.js";
export { clamp, escapeRegExp, normalizeE164, sleep } from "../utils.js";
export { stripAnsi } from "../terminal/ansi.js";
export { missingTargetError } from "../infra/outbound/target-errors.js";
export { registerLogTransport } from "../logging/logger.js";
export type { LogTransport, LogTransportRecord } from "../logging/logger.js";

View File

@@ -21,6 +21,16 @@ export function clampInt(value: number, min: number, max: number): number {
return clampNumber(Math.floor(value), min, max);
}
/** Alias for clampNumber (shorter, more common name) */
export const clamp = clampNumber;
/**
* Escapes special regex characters in a string so it can be used in a RegExp constructor.
*/
export function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export type WebChannel = "web";
export function assertWebChannel(input: string): asserts input is WebChannel {

View File

@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import type { OpenClawConfig } from "../config/config.js";
import type { BackoffPolicy } from "../infra/backoff.js";
import { computeBackoff, sleepWithAbort } from "../infra/backoff.js";
import { clamp } from "../utils.js";
export type ReconnectPolicy = BackoffPolicy & {
maxAttempts: number;
@@ -16,8 +17,6 @@ export const DEFAULT_RECONNECT_POLICY: ReconnectPolicy = {
maxAttempts: 12,
};
const clamp = (val: number, min: number, max: number) => Math.max(min, Math.min(max, val));
export function resolveHeartbeatSeconds(cfg: OpenClawConfig, overrideSeconds?: number): number {
const candidate = overrideSeconds ?? cfg.web?.heartbeatSeconds;
if (typeof candidate === "number" && candidate > 0) {

View File

@@ -8,6 +8,7 @@ import path from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import { GatewayClient } from "../src/gateway/client.js";
import { loadOrCreateDeviceIdentity } from "../src/infra/device-identity.js";
import { sleep } from "../src/utils.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../src/utils/message-channel.js";
type GatewayInstance = {
@@ -32,8 +33,6 @@ type HealthPayload = { ok?: boolean };
const GATEWAY_START_TIMEOUT_MS = 45_000;
const E2E_TIMEOUT_MS = 120_000;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const getFreePort = async () => {
const srv = net.createServer();
await new Promise<void>((resolve) => srv.listen(0, "127.0.0.1", resolve));

View File

@@ -3,6 +3,8 @@ import {
formatZonedTimestamp,
} from "../../src/infra/format-time/format-datetime.js";
export { escapeRegExp } from "../../src/utils.js";
type EnvelopeTimestampZone = string;
export function formatEnvelopeTimestamp(date: Date, zone: EnvelopeTimestampZone = "utc"): string {
@@ -36,7 +38,3 @@ export function formatEnvelopeTimestamp(date: Date, zone: EnvelopeTimestampZone
export function formatLocalEnvelopeTimestamp(date: Date): string {
return formatEnvelopeTimestamp(date, "local");
}
export function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -1,32 +1,4 @@
function stripAnsi(input: string): string {
let out = "";
for (let i = 0; i < input.length; i++) {
const code = input.charCodeAt(i);
if (code !== 27) {
out += input[i];
continue;
}
const next = input[i + 1];
if (next !== "[") {
continue;
}
i += 1;
while (i + 1 < input.length) {
i += 1;
const c = input[i];
if (!c) {
break;
}
const isLetter = (c >= "A" && c <= "Z") || (c >= "a" && c <= "z") || c === "~";
if (isLetter) {
break;
}
}
}
return out;
}
import { stripAnsi } from "../../src/terminal/ansi.js";
export function normalizeTestText(input: string): string {
return stripAnsi(input)

View File

@@ -1,12 +1,10 @@
import { sleep } from "../../src/utils.js";
export type PollOptions = {
timeoutMs?: number;
intervalMs?: number;
};
function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
export async function pollUntil<T>(
fn: () => Promise<T | null | undefined>,
opts: PollOptions = {},