Files
openclaw/src/utils.ts
Bartok e09b9dfc1b fix(agents): truncate tool-display detail on code-point boundaries (#96958)
* fix(agents): truncate tool-display detail on code-point boundaries

coerceDisplayValue truncated a long first-line detail value with raw
UTF-16 slice() at half = floor((maxStringChars-1)/2). When an emoji
(surrogate pair) straddles the cut boundary, the head kept a lone high
surrogate and the tail could begin on a lone low surrogate, rendering as
the replacement character. Use the existing sliceUtf16Safe helper so the
whole code point is dropped at the boundary, matching the UTF-16-safe
truncation used elsewhere in the repo. Behavior-preserving for
non-surrogate input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(build): import surrogate-safe slice from node-free leaf module

tool-display-common.ts is in the UI browser bundle graph; importing
sliceUtf16Safe from utils.js dragged node:fs (via infra/fs-safe) into the
bundle and broke build-artifacts/QA Smoke. Extract sliceUtf16Safe/truncateUtf16Safe
into src/shared/utf16-slice.ts (dependency-free) and re-export from utils.js to
preserve the existing import surface.

* fix(build): import surrogate-safe slice from node-free leaf module

* fix(build): import surrogate-safe slice from node-free leaf module

* fix(build): import surrogate-safe slice from node-free leaf module

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 08:10:06 +08:00

186 lines
5.8 KiB
TypeScript

// Shared filesystem, path, and process helpers for the CLI.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathExists as fsSafePathExists } from "./infra/fs-safe.js";
import {
resolveEffectiveHomeDir,
resolveHomeRelativePath,
resolveRequiredHomeDir,
} from "./infra/home-dir.js";
import { isPlainObject } from "./infra/plain-object.js";
import { resolveTimerTimeoutMs } from "./shared/number-coercion.js";
export { escapeRegExp } from "./shared/regexp.js";
/** Creates a directory tree if it does not already exist. */
export async function ensureDir(dir: string) {
await fs.promises.mkdir(dir, { recursive: true });
}
/** Clamps a number to an inclusive min/max range. */
export function clampNumber(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/** Floors a number before clamping it to an inclusive min/max range. */
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;
/**
* Safely parse JSON, returning null on error instead of throwing.
*/
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- JSON parsing helper lets callers ascribe the expected payload type.
export function safeParseJson<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export { isPlainObject };
/**
* Type guard for Record<string, unknown> (less strict than isPlainObject).
* Accepts any non-null object that isn't an array.
*/
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Normalizes phone-like input into the loose E.164 shape used by channel helpers. */
export function normalizeE164(number: string): string {
const withoutPrefix = number.replace(/^[a-z][a-z0-9-]*:/i, "").trim();
const digits = withoutPrefix.replace(/[^\d+]/g, "");
if (digits.startsWith("+")) {
return `+${digits.slice(1)}`;
}
return `+${digits}`;
}
/** Promise-based sleep that clamps timer inputs through the shared timeout resolver. */
export function sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, resolveTimerTimeoutMs(ms, 0, 0));
});
}
// Surrogate-safe slicing helpers live in a node-free leaf module so browser/UI
// bundles can import them without pulling in filesystem code. Re-exported here
// to preserve the historical `utils.ts` import surface.
export { sliceUtf16Safe, truncateUtf16Safe } from "./shared/utf16-slice.js";
/** Resolves `~` and OpenClaw home-relative paths with injectable env/home sources. */
export function resolveUserPath(
input: string,
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
if (!input) {
return "";
}
return resolveHomeRelativePath(input, { env, homedir });
}
/** Resolves the OpenClaw config directory from state/config env overrides or home. */
export function resolveConfigDir(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
const override = env.OPENCLAW_STATE_DIR?.trim();
if (override) {
return resolveUserPath(override, env, homedir);
}
const configPath = env.OPENCLAW_CONFIG_PATH?.trim();
if (configPath) {
return path.dirname(resolveUserPath(configPath, env, homedir));
}
const newDir = path.join(resolveRequiredHomeDir(env, homedir), ".openclaw");
try {
const hasNew = fs.existsSync(newDir);
if (hasNew) {
return newDir;
}
} catch {
// best-effort
}
return newDir;
}
/** Resolves the effective OpenClaw home directory, if one can be determined. */
export function resolveHomeDir(): string | undefined {
return resolveEffectiveHomeDir(process.env, os.homedir);
}
function resolveHomeDisplayPrefix(): { home: string; prefix: string } | undefined {
const home = resolveHomeDir();
if (!home) {
return undefined;
}
const explicitHome = process.env.OPENCLAW_HOME?.trim();
if (explicitHome) {
return { home, prefix: "$OPENCLAW_HOME" };
}
return { home, prefix: "~" };
}
/** Replaces the leading home directory in a path with `~` or `$OPENCLAW_HOME`. */
export function shortenHomePath(input: string): string {
if (!input) {
return input;
}
const display = resolveHomeDisplayPrefix();
if (!display) {
return input;
}
const { home, prefix } = display;
if (input === home) {
return prefix;
}
if (input.startsWith(`${home}/`) || input.startsWith(`${home}\\`)) {
return `${prefix}${input.slice(home.length)}`;
}
return input;
}
/** Replaces all effective-home occurrences inside a diagnostic string. */
export function shortenHomeInString(input: string): string {
if (!input) {
return input;
}
const display = resolveHomeDisplayPrefix();
if (!display) {
return input;
}
return input.split(display.home).join(display.prefix);
}
/** Shortens a path for display without changing non-home paths. */
export function displayPath(input: string): string {
return shortenHomePath(input);
}
/** Shortens home paths embedded in arbitrary display text. */
export function displayString(input: string): string {
return shortenHomeInString(input);
}
// Gateway startup re-pins this live binding after config/state selection converges so modules
// imported during early CLI bootstrap cannot keep using the superseded configuration root.
export let CONFIG_DIR = resolveConfigDir();
export function pinConfigDir(env: NodeJS.ProcessEnv = process.env): string {
CONFIG_DIR = resolveConfigDir(env);
return CONFIG_DIR;
}
/**
* Check if a file or directory exists at the given path.
*/
export async function pathExists(targetPath: string): Promise<boolean> {
return await fsSafePathExists(targetPath);
}