Files
openclaw/src/utils.ts
Peter Steinberger 062f88e3e3 refactor: extract reusable AI runtime package (#99059)
* refactor: extract reusable AI runtime package

* refactor: complete AI provider relocation

* refactor: keep llm core internal

* refactor(ai): make @openclaw/ai self-contained with host policy ports

Move pure transport helpers (tool projections, strict-schema normalization,
prompt-cache boundary, stream guards, anthropic/openai compat, request
activity) from src into packages/ai; move utf16-slice into
normalization-core. Inject host policy (guarded fetch, redaction,
strict-tool defaults, diagnostics logging) through AiTransportHost with
inert library defaults installed by src/llm/stream.ts. Narrow the public
barrel to instance-scoped createApiRegistry/createLlmRuntime; the
process-default runtime moves behind internal/ and
registerBuiltInApiProviders takes an explicit registry. Delete the
src/llm/api-registry re-export facade.

* fix(ai): teach node, jiti, and vite resolvers the @openclaw/ai and utf16-slice subpaths

The workspace alias tables in root-alias.cjs, plugin-sdk-native-resolver,
sdk-alias, the shared vitest config, and the Control UI vite config only
knew @openclaw/llm-core; Node-side plugin loading resolved @openclaw/ai
through the pnpm symlink to the unbuilt dist (checks-node-compact CI
failures), and the Control UI build broke on the new
normalization-core/utf16-slice subpath.

* chore(ui): drop leftover service-worker debug logging

* build(release): ship @openclaw/ai with its own shrinkwrap and honest dependency set

packages/ai declares only its six real runtime deps (kysely, chalk, json5,
tslog, zod, fs-safe, and proxyline were never imported); orphaned root deps
removed. generate-npm-shrinkwrap now treats publishable packages/* like
publishable plugins so the AI tarball pins its transitive tree even though
workspace deps are omitted from the root shrinkwrap. knip learns the
package entry points; the tsdown dts neverBundle option moves to its
documented deps.dts home; the README documents the no-semver internal/*
contract and host ports.

* docs(ai): add minimal external-consumer example app

examples/ai-chat consumes only the public @openclaw/ai surface (built dist
via the workspace link): isolated runtime, built-in provider registration,
one streamed completion. Supports Anthropic/OpenAI via env keys and a
keyless local Ollama target; live-verified against Ollama.

* docs(ai): document the @openclaw/ai package and workspace shrinkwrap boundary

* chore(check): include examples/ in duplicate-scan targets

* fix: emit normalization package subpaths

* fix: complete AI package boundary artifacts

* fix: align AI package boundary contracts

* fix(ci): stabilize package release contracts

* test: align documentation contract checks

* test: keep cron docs guard aligned

* test: align restored docs contract guards

* test: follow upstream docs contracts

* docs: drop superseded talk wording
2026-07-05 01:56:40 -04:00

179 lines
5.6 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";
export { escapeRegExp } from "./shared/regexp.js";
export { sleep } from "./utils/sleep.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}`;
}
// 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 "@openclaw/normalization-core/utf16-slice";
/** 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);
}