refactor: consolidate duplicate helper exports (#106016)

* refactor(normalization): consolidate string helpers

* refactor(normalization): consolidate agent ids

* refactor(paths): consolidate user path resolution

* refactor(gateway): preserve transcript helper facade

* fix(normalization): align helper build aliases

* fix(memory): keep helper import line neutral

* chore(plugin-sdk): align consolidated helper surface

* refactor(normalization): reuse lowercase string helper

* refactor(normalization): reuse record guard

* refactor(normalization): share surrogate boundary helper

* refactor(agents): share token estimate constant

* refactor(memory): distinguish standalone helper contracts

* refactor(normalization): share error cause formatter

* refactor(config): distinguish eligibility predicates

* refactor(plugins): share registry state symbol

* refactor(cli): reuse outbound dependency adapter

* refactor(cron): distinguish positive duration parser

* fix(gateway): keep transcript helper private

* fix(paths): preserve public helper status

* refactor(channels): reuse registry normalizer

* refactor(agents): reuse agent core runtime facade

* refactor(auth): reuse locked profile upsert

* refactor(records): distinguish trap-safe guard

* refactor(migrations): distinguish sync directory helper

* test(plugins): drop stale duration alias expectations

* fix(channels): remove stale registry import

* chore(plugin-sdk): refresh helper API baseline

* fix(memory): keep renamed helpers private

* fix(plugins): keep registry state key private

* fix(plugin-sdk): tighten wildcard surface budget

* chore(plugin-sdk): refresh rebased helper API baseline
This commit is contained in:
Peter Steinberger
2026-07-13 03:22:00 -07:00
committed by GitHub
parent 62e53919fd
commit 2aa4ff0825
83 changed files with 414 additions and 478 deletions
@@ -1,2 +1,2 @@
682886cad249cb42b80d77f8420947202be260c1b94f5cc11a780e64e781fa16 plugin-sdk-api-baseline.json
59ac0750a4d514753ce0051bc769a0d642ca6a004e64dec7e28ae7c46b00609f plugin-sdk-api-baseline.jsonl
258b7a39727a8306374bfdca2300d489fcbec41ef5a2f499c6a4b7cc83603e5c plugin-sdk-api-baseline.json
f3f0affc5cffbd83ffb84cbbc9be86fcaffb574f7022dd804212d0ceac04bd4a plugin-sdk-api-baseline.jsonl
+1 -22
View File
@@ -57,25 +57,4 @@ export function redactSensitiveText(value: string): string {
return redacted;
}
/**
* Render a non-Error `cause` value without leaking `[object Object]` or throwing
* while formatting nested ACP runtime failures.
*/
export function stringifyNonErrorCause(value: unknown): string {
if (value === null) {
return "null";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
try {
// JSON.stringify returns undefined (not a string) for functions/symbols/undefined; fall back to
// a tag string so this `string`-typed helper never leaks undefined (matches src/infra/errors.ts).
return JSON.stringify(value) ?? Object.prototype.toString.call(value);
} catch {
return Object.prototype.toString.call(value);
}
}
export { stringifyNonErrorCause } from "@openclaw/normalization-core/error-coercion";
+1
View File
@@ -56,6 +56,7 @@
}
},
"dependencies": {
"@openclaw/normalization-core": "workspace:*",
"markdown-it": "14.3.0",
"markdown-it-cjk-friendly": "2.0.2",
"yaml": "2.9.0"
+4 -17
View File
@@ -1,4 +1,8 @@
// Markdown Core module implements chunk text behavior.
import { avoidTrailingHighSurrogateBreak } from "@openclaw/normalization-core/utf16-slice";
export { avoidTrailingHighSurrogateBreak };
function resolveChunkEarlyReturn(text: string, limit: number): string[] | undefined {
if (!text) {
return [];
@@ -105,23 +109,6 @@ export function chunkTextRanges(text: string, options: ChunkTextRangesOptions):
return ranges;
}
/**
* Keeps UTF-16 chunk boundaries from separating a supplementary-plane character.
* A one-unit positive limit still needs to emit an entire surrogate pair.
*/
export function avoidTrailingHighSurrogateBreak(text: string, start: number, end: number): number {
if (
end >= text.length ||
text.charCodeAt(end - 1) < 0xd800 ||
text.charCodeAt(end - 1) > 0xdbff ||
text.charCodeAt(end) < 0xdc00 ||
text.charCodeAt(end) > 0xdfff
) {
return end;
}
return end - 1 > start ? end - 1 : end + 1;
}
/**
* Splits plain text into size-bounded chunks at readable boundaries.
*
@@ -8,6 +8,9 @@
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"dependencies": {
"@openclaw/normalization-core": "workspace:*"
},
"exports": {
".": {
"types": "./dist/index.d.mts",
@@ -1,5 +1,5 @@
// Media Generation Core module implements capability model ref behavior.
import { normalizeOptionalString } from "./string.js";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
/** Provider catalog entry shape used when resolving capability-scoped model references. */
export type CapabilityModelProviderCandidate = {
+1 -9
View File
@@ -1,13 +1,5 @@
// Shared string normalization helpers for media-generation packages.
/** Normalize optional strings, returning undefined for non-strings or empty values. */
export function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
/** Return unique trimmed strings while preserving first-seen order. */
export function uniqueTrimmedStrings(values: readonly unknown[]): string[] {
@@ -1,8 +1,13 @@
// Memory Host SDK module implements backend config behavior.
import fs from "node:fs";
import path from "node:path";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import {
CANONICAL_ROOT_MEMORY_FILENAME,
normalizeStringEntries,
uniqueStrings,
} from "@openclaw/normalization-core/string-normalization";
import {
MEMORY_HOST_ROOT_FILENAME,
type MemoryBackend,
type MemoryCitationsMode,
type MemoryQmdConfig,
@@ -11,19 +16,14 @@ import {
type MemoryQmdSearchMode,
type MemoryQmdStartupMode,
type OpenClawConfig,
resolveAgentWorkspaceDir,
resolveMemoryHostAgentWorkspaceDir,
normalizeAgentId,
resolveUserPath,
resolveMemoryHostUserPath,
type SessionSendPolicyConfig,
splitShellArgs,
} from "./config-utils.js";
import { isPathInside } from "./fs-utils.js";
import { parseDurationMs } from "./openclaw-runtime-config.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeStringEntries,
uniqueStrings,
} from "./string-utils.js";
function escapeQmdExactFilePattern(fileName: string): string {
return fileName.replace(/[\\*?[\]{}()!+@]/g, "\\$&");
@@ -209,7 +209,7 @@ function resolvePath(raw: string, workspaceDir: string): string {
throw new Error("path required");
}
if (trimmed.startsWith("~") || path.isAbsolute(trimmed)) {
return path.normalize(resolveUserPath(trimmed));
return path.normalize(resolveMemoryHostUserPath(trimmed));
}
return path.normalize(path.resolve(workspaceDir, trimmed));
}
@@ -407,7 +407,7 @@ function resolveDefaultCollections(
return [];
}
const entries: Array<{ path: string; pattern: string; base: string }> = [
{ path: workspaceDir, pattern: CANONICAL_ROOT_MEMORY_FILENAME, base: "memory-root" },
{ path: workspaceDir, pattern: MEMORY_HOST_ROOT_FILENAME, base: "memory-root" },
{ path: path.join(workspaceDir, "memory"), pattern: "**/*.md", base: "memory-dir" },
];
return entries.map((entry) => ({
@@ -429,7 +429,7 @@ export function resolveMemoryBackendConfig(params: {
return { backend: "builtin", citations };
}
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, normalizedAgentId);
const workspaceDir = resolveMemoryHostAgentWorkspaceDir(params.cfg, normalizedAgentId);
const qmdCfg = params.cfg.memory?.qmd;
const includeDefaultMemory = qmdCfg?.includeDefaultMemory !== false;
const nameSet = new Set<string>();
@@ -1,7 +1,7 @@
// Memory Host SDK module implements batch runner behavior.
import { resolveSafeTimeoutDelayMs } from "../../../gateway-client/src/timeouts.js";
import { splitBatchRequestsByLimits } from "./batch-utils.js";
import { runWithConcurrency } from "./internal.js";
import { runMemoryHostTasksWithConcurrency } from "./internal.js";
// Shared runner for splitting and executing remote embedding batch groups.
@@ -117,7 +117,7 @@ export async function runEmbeddingBatchGroups<TRequest>(params: {
timeoutMs: params.timeoutMs,
});
await runWithConcurrency(tasks, params.concurrency);
await runMemoryHostTasksWithConcurrency(tasks, params.concurrency);
return byCustomId;
}
@@ -6,7 +6,10 @@ import {
} from "./batch-utils.js";
import { hashText } from "./hash.js";
import { withRemoteHttpResponse } from "./remote-http.js";
import { readResponseJsonWithLimit, readResponseTextSnippet } from "./response-snippet.js";
import {
readMemoryHostResponseTextSnippet,
readResponseJsonWithLimit,
} from "./response-snippet.js";
// Uploads provider batch JSONL payloads through the shared remote HTTP guard.
@@ -40,7 +43,7 @@ export async function uploadBatchJsonlFile(params: {
},
onResponse: async (fileRes) => {
if (!fileRes.ok) {
const text = await readResponseTextSnippet(fileRes, { signal: params.signal });
const text = await readMemoryHostResponseTextSnippet(fileRes, { signal: params.signal });
throw new Error(`${params.errorPrefix}: ${fileRes.status} ${text}`);
}
return (await readResponseJsonWithLimit(fileRes, {
@@ -2,12 +2,16 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { normalizeAgentId } from "@openclaw/normalization-core/agent-id";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import {
normalizeStringEntries,
uniqueStrings,
} from "./string-utils.js";
} from "@openclaw/normalization-core/string-normalization";
export { normalizeAgentId };
export { splitShellArgs } from "./openclaw-runtime-io.js";
// Shared OpenClaw config helpers used by memory host, QMD, and agent context code.
@@ -165,34 +169,11 @@ export type OpenClawConfig = {
};
/** Root memory filename used in agent workspaces. */
export const CANONICAL_ROOT_MEMORY_FILENAME = "MEMORY.md";
export const MEMORY_HOST_ROOT_FILENAME = "MEMORY.md";
const DEFAULT_AGENT_ID = "main";
const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
const INVALID_CHARS_RE = /[^a-z0-9_-]+/g;
const LEADING_DASH_RE = /^-+/;
const TRAILING_DASH_RE = /-+$/;
const LEGACY_STATE_DIRNAMES = [".clawdbot"] as const;
const NEW_STATE_DIRNAME = ".openclaw";
/** Normalize user or config agent ids to the filesystem-safe canonical form. */
export function normalizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) {
return DEFAULT_AGENT_ID;
}
const normalized = normalizeLowercaseStringOrEmpty(trimmed);
if (VALID_ID_RE.test(trimmed)) {
return normalized;
}
return (
normalized
.replace(INVALID_CHARS_RE, "-")
.replace(LEADING_DASH_RE, "")
.replace(TRAILING_DASH_RE, "")
.slice(0, 64) || DEFAULT_AGENT_ID
);
}
/** Treat shell-placeholder home values as absent. */
function normalizeHomeValue(value: string | undefined): string | undefined {
const trimmed = normalizeOptionalString(value);
@@ -223,8 +204,8 @@ function resolveRequiredHomeDir(
return rawHome ? path.resolve(rawHome) : path.resolve(process.cwd());
}
/** Resolve absolute user paths, including "~" against the effective OpenClaw home. */
export function resolveUserPath(
/** Resolve standalone memory-host paths without importing core home-directory policy. */
export function resolveMemoryHostUserPath(
input: string,
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
@@ -251,7 +232,7 @@ function resolveStateDir(
): string {
const override = env.OPENCLAW_STATE_DIR?.trim();
if (override) {
return resolveUserPath(override, env, homedir);
return resolveMemoryHostUserPath(override, env, homedir);
}
const effectiveHome = () => resolveRequiredHomeDir(env, homedir);
const nextDir = path.join(effectiveHome(), NEW_STATE_DIRNAME);
@@ -308,7 +289,7 @@ function stripNullBytes(value: string): string {
}
/** Resolve the workspace directory for an agent id and config defaults. */
export function resolveAgentWorkspaceDir(
export function resolveMemoryHostAgentWorkspaceDir(
cfg: OpenClawConfig,
agentId: string,
env: NodeJS.ProcessEnv = process.env,
@@ -316,22 +297,22 @@ export function resolveAgentWorkspaceDir(
const id = normalizeAgentId(agentId);
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
if (configured) {
return stripNullBytes(resolveUserPath(configured, env));
return stripNullBytes(resolveMemoryHostUserPath(configured, env));
}
const fallback = cfg.agents?.defaults?.workspace?.trim();
if (id === resolveDefaultAgentId(cfg)) {
return stripNullBytes(
fallback ? resolveUserPath(fallback, env) : resolveDefaultAgentWorkspaceDir(env),
fallback ? resolveMemoryHostUserPath(fallback, env) : resolveDefaultAgentWorkspaceDir(env),
);
}
if (fallback) {
return stripNullBytes(path.join(resolveUserPath(fallback, env), id));
return stripNullBytes(path.join(resolveMemoryHostUserPath(fallback, env), id));
}
return stripNullBytes(path.join(resolveStateDir(env), `workspace-${id}`));
}
/** Resolve context limits for an agent with defaults fallback. */
export function resolveAgentContextLimits(
export function resolveMemoryHostAgentContextLimits(
cfg: OpenClawConfig | undefined,
agentId?: string | null,
): AgentContextLimitsConfig | undefined {
@@ -343,7 +324,7 @@ export function resolveAgentContextLimits(
}
/** Resolve enabled memory search config plus deduplicated extra paths for an agent. */
export function resolveMemorySearchConfig(
export function resolveMemoryHostSearchPathConfig(
cfg: OpenClawConfig,
agentId: string,
): { enabled: boolean; extraPaths: string[] } | null {
@@ -1,5 +1,5 @@
// Memory Host SDK helper module supports embedding provider adapter utils behavior.
import { normalizeLowercaseStringOrEmpty } from "./string-utils.js";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
// Adapter helpers shared by remote embedding provider implementations.
@@ -1,5 +1,5 @@
// Memory Host SDK module implements embeddings debug behavior.
import { normalizeLowercaseStringOrEmpty } from "./string-utils.js";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
// Lightweight stderr debug logging for memory embedding internals.
@@ -1,10 +1,10 @@
// Memory Host SDK module implements embeddings remote client behavior.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { EmbeddingProviderOptions } from "./embeddings.types.js";
import { requireApiKey, resolveApiKeyForProvider } from "./openclaw-runtime-auth.js";
import { buildRemoteBaseUrlPolicy } from "./remote-http.js";
import { resolveMemorySecretInputString } from "./secret-input.js";
import type { SsrFPolicy } from "./ssrf-policy.js";
import { normalizeOptionalString } from "./string-utils.js";
// Builds authenticated remote embedding HTTP clients from agent memory config.
@@ -2,6 +2,7 @@
import { fork, type ChildProcess } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { DEFAULT_LOCAL_MODEL } from "./embedding-defaults.js";
import {
createLocalEmbeddingWorkerFailureError,
@@ -17,7 +18,6 @@ import {
attachLocalEmbeddingRuntimeFacts,
type LocalEmbeddingRuntimeFacts,
} from "./local-embedding-runtime-facts.js";
import { normalizeOptionalString } from "./string-utils.js";
// Parent-side local embedding worker client for isolating node-llama-cpp state.
@@ -1,4 +1,5 @@
import { expectDefined } from "@openclaw/normalization-core";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { toRetryError } from "@openclaw/retry";
import { DEFAULT_LOCAL_MODEL } from "./embedding-defaults.js";
import { sanitizeAndNormalizeEmbedding } from "./embedding-vectors.js";
@@ -15,7 +16,6 @@ import {
type LlamaModel,
} from "./node-llama.js";
// Memory Host SDK module implements embeddings behavior.
import { normalizeOptionalString } from "./string-utils.js";
type DisposableResource = {
dispose?: () => Promise<void> | void;
+12 -5
View File
@@ -4,8 +4,9 @@ import fsSync from "node:fs";
import fs from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { normalizeStringEntries, uniqueStrings } from "@openclaw/normalization-core";
import { runWithConcurrency as runWithConcurrencyImpl } from "./concurrency.js";
import { CANONICAL_ROOT_MEMORY_FILENAME } from "./config-utils.js";
import { MEMORY_HOST_ROOT_FILENAME } from "./config-utils.js";
import { estimateStructuredEmbeddingInputBytes } from "./embedding-input-limits.js";
import { buildTextEmbeddingInput, type EmbeddingInput } from "./embedding-inputs.js";
import {
@@ -32,7 +33,6 @@ import {
shouldSkipRootMemoryAuxiliaryPath,
} from "./openclaw-runtime-memory.js";
import { retryTransientMemoryRead } from "./read-retry.js";
import { normalizeStringEntries, uniqueStrings } from "./string-utils.js";
export { hashText } from "./hash.js";
import { hashText } from "./hash.js";
@@ -69,11 +69,13 @@ const DISABLED_MULTIMODAL_SETTINGS: MemoryMultimodalSettings = {
maxFileBytes: 0,
};
export function ensureDir(dir: string): string {
function ensureMemoryHostDir(dir: string): string {
fsSync.mkdirSync(dir, { recursive: true });
return dir;
}
export { ensureMemoryHostDir as ensureDir };
function normalizeRelPath(value: string): string {
const trimmed = value.trim().replace(/^[./]+/, "");
return trimmed.replace(/\\/g, "/");
@@ -106,7 +108,7 @@ export function isMemoryPath(relPath: string): boolean {
if (!normalized) {
return false;
}
if (normalized === CANONICAL_ROOT_MEMORY_FILENAME || normalized.toLowerCase() === "dreams.md") {
if (normalized === MEMORY_HOST_ROOT_FILENAME || normalized.toLowerCase() === "dreams.md") {
return true;
}
return normalized.startsWith("memory/");
@@ -539,6 +541,11 @@ export function cosineSimilarity(a: number[], b: number[]): number {
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
export function runWithConcurrency<T>(tasks: Array<() => Promise<T>>, limit: number): Promise<T[]> {
export function runMemoryHostTasksWithConcurrency<T>(
tasks: Array<() => Promise<T>>,
limit: number,
): Promise<T[]> {
return runWithConcurrencyImpl(tasks, limit);
}
export { runMemoryHostTasksWithConcurrency as runWithConcurrency };
@@ -1,5 +1,5 @@
// Memory Host SDK module implements multimodal behavior.
import { normalizeLowercaseStringOrEmpty } from "./string-utils.js";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
// Multimodal memory settings and file classification helpers.
@@ -1,6 +1,9 @@
// Memory Host SDK module implements post json behavior.
import { withRemoteHttpResponse } from "./remote-http.js";
import { readResponseJsonWithLimit, readResponseTextSnippet } from "./response-snippet.js";
import {
readMemoryHostResponseTextSnippet,
readResponseJsonWithLimit,
} from "./response-snippet.js";
import type { SsrFPolicy } from "./ssrf-policy.js";
// Shared JSON POST helper for guarded remote memory provider calls.
@@ -30,7 +33,7 @@ export async function postJson<T>(params: {
},
onResponse: async (res) => {
if (!res.ok) {
const text = await readResponseTextSnippet(res, { signal: params.signal });
const text = await readMemoryHostResponseTextSnippet(res, { signal: params.signal });
const err = new Error(`${params.errorPrefix}: ${res.status} ${text}`) as Error & {
status?: number;
};
@@ -1,7 +1,7 @@
// Memory Host SDK module implements qmd query parser behavior.
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { formatErrorMessage } from "./error-utils.js";
import { normalizeLowercaseStringOrEmpty } from "./string-utils.js";
// Parser for qmd query JSON output, including noisy CLI wrapper output.
@@ -1,9 +1,9 @@
// Memory Host SDK module implements qmd scope behavior.
import type { ResolvedQmdConfig } from "./backend-config.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "./string-utils.js";
} from "@openclaw/normalization-core/string-coerce";
import type { ResolvedQmdConfig } from "./backend-config.js";
type ParsedQmdSessionScope = {
channel?: string;
@@ -1,5 +1,5 @@
// Memory Host SDK module implements query expansion behavior.
import { normalizeLowercaseStringOrEmpty } from "./string-utils.js";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
/**
* Query expansion for FTS-only search mode.
@@ -2,9 +2,9 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
resolveAgentContextLimits,
resolveAgentWorkspaceDir,
resolveMemorySearchConfig,
resolveMemoryHostAgentContextLimits,
resolveMemoryHostAgentWorkspaceDir,
resolveMemoryHostSearchPathConfig,
type OpenClawConfig,
} from "./config-utils.js";
import {
@@ -165,13 +165,13 @@ export async function readAgentMemoryFile(params: {
from?: number;
lines?: number;
}): Promise<MemoryReadResult> {
const settings = resolveMemorySearchConfig(params.cfg, params.agentId);
const settings = resolveMemoryHostSearchPathConfig(params.cfg, params.agentId);
if (!settings) {
throw new Error("memory search disabled");
}
const contextLimits = resolveAgentContextLimits(params.cfg, params.agentId);
const contextLimits = resolveMemoryHostAgentContextLimits(params.cfg, params.agentId);
return await readMemoryFile({
workspaceDir: resolveAgentWorkspaceDir(params.cfg, params.agentId),
workspaceDir: resolveMemoryHostAgentWorkspaceDir(params.cfg, params.agentId),
extraPaths: settings.extraPaths,
relPath: params.relPath,
from: params.from,
@@ -1,8 +1,11 @@
// Memory Host SDK tests cover response snippet behavior.
import { describe, expect, it } from "vitest";
import { readResponseJsonWithLimit, readResponseTextSnippet } from "./response-snippet.js";
import {
readMemoryHostResponseTextSnippet,
readResponseJsonWithLimit,
} from "./response-snippet.js";
describe("readResponseTextSnippet", () => {
describe("readMemoryHostResponseTextSnippet", () => {
function stallingResponse(onCancel: () => void): Response {
const reader = {
read: () => new Promise<ReadableStreamReadResult<Uint8Array>>(() => {}),
@@ -30,15 +33,15 @@ describe("readResponseTextSnippet", () => {
});
await expect(
readResponseTextSnippet(new Response(stream), { maxBytes: 4, maxChars: 100 }),
readMemoryHostResponseTextSnippet(new Response(stream), { maxBytes: 4, maxChars: 100 }),
).resolves.toBe("abcd... [truncated]");
expect(canceled).toBe(true);
});
it("does not split surrogate pairs when truncating text snippets", async () => {
await expect(readResponseTextSnippet(new Response("abc🤖tail"), { maxChars: 4 })).resolves.toBe(
"abc... [truncated]",
);
await expect(
readMemoryHostResponseTextSnippet(new Response("abc🤖tail"), { maxChars: 4 }),
).resolves.toBe("abc... [truncated]");
});
it("drops partial UTF-8 characters when byte-capped snippets truncate a stream", async () => {
@@ -50,7 +53,7 @@ describe("readResponseTextSnippet", () => {
});
await expect(
readResponseTextSnippet(new Response(stream), { maxBytes: 3, maxChars: 100 }),
readMemoryHostResponseTextSnippet(new Response(stream), { maxBytes: 3, maxChars: 100 }),
).resolves.toBe("ab... [truncated]");
});
@@ -60,7 +63,7 @@ describe("readResponseTextSnippet", () => {
canceled = true;
});
const controller = new AbortController();
const read = readResponseTextSnippet(response, {
const read = readMemoryHostResponseTextSnippet(response, {
maxBytes: 1024,
signal: controller.signal,
});
@@ -27,7 +27,7 @@ type ResponsePrefix = {
};
/** Read a small collapsed text snippet from a response body. */
export async function readResponseTextSnippet(
export async function readMemoryHostResponseTextSnippet(
res: Response,
options: ResponseTextSnippetOptions = {},
): Promise<string> {
@@ -118,7 +118,7 @@ function coerceSecretRef(value: unknown): SecretRef | null {
}
/** Return true when a secret input has either a literal value or resolvable reference shape. */
export function hasConfiguredSecretInput(value: unknown): boolean {
export function hasConfiguredMemorySecretInputValue(value: unknown): boolean {
if (normalizeSecretInputString(value)) {
return true;
}
@@ -138,12 +138,12 @@ function createUnresolvedSecretInputError(params: { path: string; ref: SecretRef
}
/** Return a canonical SecretRef when the input is a supported reference shape. */
export function resolveSecretInputRef(value: unknown): SecretRef | null {
export function resolveMemorySecretInputRef(value: unknown): SecretRef | null {
return coerceSecretRef(value);
}
/** Normalize literal secrets, or throw for refs that still require gateway resolution. */
export function normalizeResolvedSecretInputString(params: {
export function normalizeResolvedMemorySecretInputString(params: {
value: unknown;
path: string;
}): string | undefined {
@@ -151,7 +151,7 @@ export function normalizeResolvedSecretInputString(params: {
if (normalized) {
return normalized;
}
const ref = resolveSecretInputRef(params.value);
const ref = resolveMemorySecretInputRef(params.value);
if (!ref) {
return undefined;
}
@@ -1,16 +1,16 @@
// Memory Host SDK module implements secret input behavior.
import {
hasConfiguredSecretInput,
hasConfiguredMemorySecretInputValue,
normalizeEnvSecretInputString,
normalizeResolvedSecretInputString,
resolveSecretInputRef,
normalizeResolvedMemorySecretInputString,
resolveMemorySecretInputRef,
} from "./secret-input-utils.js";
// Memory-specific facade for resolving provider secret input from config.
/** Return true when a configured memory secret contains a literal value or reference. */
export function hasConfiguredMemorySecretInput(value: unknown): boolean {
return hasConfiguredSecretInput(value);
return hasConfiguredMemorySecretInputValue(value);
}
/** Resolve memory secret input, reading env refs directly when available. */
@@ -18,14 +18,14 @@ export function resolveMemorySecretInputString(params: {
value: unknown;
path: string;
}): string | undefined {
const ref = resolveSecretInputRef(params.value);
const ref = resolveMemorySecretInputRef(params.value);
if (ref?.source === "env") {
const envValue = normalizeEnvSecretInputString(process.env[ref.id]);
if (envValue) {
return envValue;
}
}
return normalizeResolvedSecretInputString({
return normalizeResolvedMemorySecretInputString({
value: params.value,
path: params.path,
});
@@ -1,8 +1,8 @@
// Memory Host SDK module implements sqlite vec behavior.
import type { DatabaseSync } from "node:sqlite";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { formatErrorMessage } from "./error-utils.js";
import { resolveSqliteVecPlatformVariant } from "./sqlite-vec-platform-variant.js";
import { normalizeOptionalString } from "./string-utils.js";
type SqliteVecModule = {
getLoadablePath: () => string;
+3 -1
View File
@@ -14,7 +14,7 @@ import {
const require = createRequire(import.meta.url);
const sqliteWalMaintenanceByDb = new WeakMap<DatabaseSync, SqliteWalMaintenance>();
export function requireNodeSqlite(): typeof import("node:sqlite") {
function requireMemoryHostNodeSqlite(): typeof import("node:sqlite") {
installProcessWarningFilter();
try {
return require("node:sqlite") as typeof import("node:sqlite");
@@ -29,6 +29,8 @@ export function requireNodeSqlite(): typeof import("node:sqlite") {
}
}
export { requireMemoryHostNodeSqlite as requireNodeSqlite };
export function configureMemorySqliteWalMaintenance(
db: DatabaseSync,
options?: SqliteWalMaintenanceOptions & Pick<SqliteConnectionPragmaOptions, "busyTimeoutMs">,
@@ -1,35 +0,0 @@
// Small string normalization helpers kept local to memory-host-sdk for package
// builds that should not depend on the full normalization package graph.
/** Normalize a non-empty string or return null. */
function normalizeNullableString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
/** Normalize a non-empty string or return undefined. */
export function normalizeOptionalString(value: unknown): string | undefined {
return normalizeNullableString(value) ?? undefined;
}
/** Normalize a non-empty string to lowercase or return undefined. */
export function normalizeOptionalLowercaseString(value: unknown): string | undefined {
return normalizeOptionalString(value)?.toLowerCase();
}
/** Normalize a value to lowercase text, defaulting to an empty string. */
export function normalizeLowercaseStringOrEmpty(value: unknown): string {
return normalizeOptionalLowercaseString(value) ?? "";
}
/** Normalize an array-like list of values into non-empty strings. */
export function normalizeStringEntries(values: ReadonlyArray<unknown>): string[] {
return values.map((value) => normalizeOptionalString(String(value)) ?? "").filter(Boolean);
}
/** Return unique strings preserving first-seen order. */
export function uniqueStrings(values: Iterable<string>): string[] {
return [...new Set(values)];
}
+3
View File
@@ -8,6 +8,9 @@
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"dependencies": {
"@openclaw/normalization-core": "workspace:*"
},
"exports": {
".": {
"types": "./dist/index.d.mts",
@@ -1,7 +1,7 @@
// Model Catalog Core module implements provider id behavior.
export function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
export { normalizeLowercaseStringOrEmpty };
export function normalizeProviderId(provider: string): string {
return normalizeLowercaseStringOrEmpty(provider);
+6 -1
View File
@@ -14,6 +14,11 @@
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./agent-id": {
"types": "./dist/agent-id.d.mts",
"import": "./dist/agent-id.mjs",
"default": "./dist/agent-id.mjs"
},
"./boolean-coercion": {
"types": "./dist/boolean-coercion.d.mts",
"import": "./dist/boolean-coercion.mjs",
@@ -61,6 +66,6 @@
}
},
"scripts": {
"build": "tsdown src/index.ts src/boolean-coercion.ts src/error-coercion.ts src/expect.ts src/number-coercion.ts src/record-coerce.ts src/result.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
"build": "tsdown src/index.ts src/agent-id.ts src/boolean-coercion.ts src/error-coercion.ts src/expect.ts src/number-coercion.ts src/record-coerce.ts src/result.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
}
}
@@ -0,0 +1,24 @@
import { isValidAgentId, normalizeAgentId } from "@openclaw/normalization-core/agent-id";
import { describe, expect, it } from "vitest";
describe("normalization-core/agent-id", () => {
it.each([
[undefined, "main"],
[" OPS ", "ops"],
["Agent not found: xyz", "agent-not-found-xyz"],
["../../../etc/passwd", "etc-passwd"],
["_".repeat(80), "_".repeat(64)],
])("normalizes %j", (input, expected) => {
expect(normalizeAgentId(input)).toBe(expected);
});
it.each([
["main", true],
["my-research_agent01", true],
["", false],
["Agent not found: xyz", false],
["a".repeat(65), false],
])("validates %j", (input, expected) => {
expect(isValidAgentId(input)).toBe(expected);
});
});
@@ -0,0 +1,32 @@
import { normalizeLowercaseStringOrEmpty } from "./string-coerce.js";
const DEFAULT_AGENT_ID = "main";
const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
const INVALID_CHARS_RE = /[^a-z0-9_-]+/g;
const LEADING_DASH_RE = /^-+/;
const TRAILING_DASH_RE = /-+$/;
/** Normalizes an OpenClaw agent id to its filesystem-safe canonical form. */
export function normalizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) {
return DEFAULT_AGENT_ID;
}
const normalized = normalizeLowercaseStringOrEmpty(trimmed);
if (VALID_ID_RE.test(trimmed)) {
return normalized;
}
return (
normalized
.replace(INVALID_CHARS_RE, "-")
.replace(LEADING_DASH_RE, "")
.replace(TRAILING_DASH_RE, "")
.slice(0, 64) || DEFAULT_AGENT_ID
);
}
/** Returns whether a value is already a canonical agent-id input. */
export function isValidAgentId(value: string | undefined | null): boolean {
const trimmed = (value ?? "").trim();
return Boolean(trimmed) && VALID_ID_RE.test(trimmed);
}
@@ -1,6 +1,6 @@
// Normalization core tests cover shared error coercion and formatting behavior.
import { describe, expect, it } from "vitest";
import { formatErrorMessage, toErrorObject } from "./error-coercion.js";
import { formatErrorMessage, stringifyNonErrorCause, toErrorObject } from "./error-coercion.js";
const keepText = (text: string): string => text;
const format = (value: unknown): string => formatErrorMessage(value, { redact: keepText });
@@ -57,3 +57,16 @@ describe("toErrorObject", () => {
expect(error.cause).toBe(value);
});
});
describe("stringifyNonErrorCause", () => {
it("renders primitive and structured values", () => {
expect(stringifyNonErrorCause(null)).toBe("null");
expect(stringifyNonErrorCause(42)).toBe("42");
expect(stringifyNonErrorCause({ ok: true })).toBe('{"ok":true}');
});
it("falls back to object tags when JSON has no string result", () => {
expect(stringifyNonErrorCause(undefined)).toBe("[object Undefined]");
expect(stringifyNonErrorCause(Symbol("value"))).toBe("[object Symbol]");
});
});
@@ -122,3 +122,21 @@ export function toErrorObject(value: unknown, fallbackMessage: string): Error {
}
return error;
}
/** Renders a non-Error cause as useful text without throwing. */
export function stringifyNonErrorCause(value: unknown): string {
if (value === null) {
return "null";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
try {
return JSON.stringify(value) ?? Object.prototype.toString.call(value);
} catch {
return Object.prototype.toString.call(value);
}
}
@@ -1,6 +1,25 @@
// Tests for surrogate-safe UTF-16 string slicing helpers.
import { describe, expect, it } from "vitest";
import { sliceUtf16Safe, truncateUtf16Safe } from "./utf16-slice.js";
import {
avoidTrailingHighSurrogateBreak,
sliceUtf16Safe,
truncateUtf16Safe,
} from "./utf16-slice.js";
describe("avoidTrailingHighSurrogateBreak", () => {
it("keeps ordinary and terminal boundaries unchanged", () => {
expect(avoidTrailingHighSurrogateBreak("hello", 0, 3)).toBe(3);
expect(avoidTrailingHighSurrogateBreak("hello", 0, 5)).toBe(5);
});
it("moves a split before a surrogate pair when room remains", () => {
expect(avoidTrailingHighSurrogateBreak("a🤖b", 0, 2)).toBe(1);
});
it("includes the full pair when a one-unit chunk starts with it", () => {
expect(avoidTrailingHighSurrogateBreak("🤖b", 0, 1)).toBe(2);
});
});
describe("sliceUtf16Safe", () => {
it("slices ASCII string normally", () => {
@@ -12,6 +12,20 @@ function isLowSurrogate(codeUnit: number): boolean {
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
}
/** Moves a chunk boundary away from the middle of a UTF-16 surrogate pair. */
export function avoidTrailingHighSurrogateBreak(text: string, start: number, end: number): number {
if (
end <= start ||
end >= text.length ||
!isHighSurrogate(text.charCodeAt(end - 1)) ||
!isLowSurrogate(text.charCodeAt(end))
) {
return end;
}
const adjusted = end - 1;
return adjusted > start ? adjusted : end + 1;
}
/** Slices a UTF-16 string without returning dangling surrogate halves at either edge. */
export function sliceUtf16Safe(input: string, start: number, end?: number): string {
const len = input.length;
+1
View File
@@ -101,6 +101,7 @@
}
},
"dependencies": {
"@openclaw/normalization-core": "workspace:*",
"@clack/prompts": "1.6.0",
"chalk": "5.6.2"
}
+1 -7
View File
@@ -1,9 +1,3 @@
// Shared terminal string normalization helpers.
/** Normalize string input to lowercase, returning empty string for non-strings. */
export function normalizeLowercaseStringOrEmpty(value: unknown): string {
if (typeof value !== "string") {
return "";
}
return value.trim().toLowerCase();
}
export { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
+16 -2
View File
@@ -2027,6 +2027,9 @@ importers:
packages/markdown-core:
dependencies:
'@openclaw/normalization-core':
specifier: workspace:*
version: link:../normalization-core
markdown-it:
specifier: 14.3.0
version: 14.3.0
@@ -2046,7 +2049,11 @@ importers:
specifier: 22.0.1
version: 22.0.1
packages/media-generation-core: {}
packages/media-generation-core:
dependencies:
'@openclaw/normalization-core':
specifier: workspace:*
version: link:../normalization-core
packages/media-understanding-common: {}
@@ -2062,7 +2069,11 @@ importers:
specifier: 7.0.5
version: 7.0.5
packages/model-catalog-core: {}
packages/model-catalog-core:
dependencies:
'@openclaw/normalization-core':
specifier: workspace:*
version: link:../normalization-core
packages/net-policy:
dependencies:
@@ -2092,6 +2103,9 @@ importers:
packages/terminal-core:
dependencies:
'@openclaw/normalization-core':
specifier: workspace:*
version: link:../normalization-core
'@clack/prompts':
specifier: 1.6.0
version: 1.6.0
+1 -20
View File
@@ -23,6 +23,7 @@ export {
listProfilesForProvider,
resolveSubscriptionAuthModeForProfiles,
} from "./profile-list.js";
export { upsertAuthProfileWithLock } from "./upsert-with-lock.js";
const authProfileProfilesLog = createSubsystemLogger("agent/embedded");
@@ -175,26 +176,6 @@ export function upsertAuthProfile(params: {
});
}
/** Upserts an auth profile under the auth store lock. */
export async function upsertAuthProfileWithLock(params: {
profileId: string;
credential: AuthProfileCredential;
agentDir?: string;
}): Promise<AuthProfileStore | null> {
const credential = normalizeAuthProfileCredential(params.credential);
return await updateAuthProfileStoreWithLock({
agentDir: params.agentDir,
saveOptions: {
filterExternalAuthProfiles: false,
syncExternalCli: false,
},
updater: (store) => {
store.profiles[params.profileId] = credential;
return true;
},
});
}
/** Removes all auth profiles and related state for a provider. */
export async function removeProviderAuthProfilesWithLock(params: {
provider: string;
@@ -1,3 +1,4 @@
import { CHARS_PER_TOKEN_ESTIMATE } from "../../utils/cjk-chars.js";
/**
* Estimates message and tool-result character costs for context guards.
*/
@@ -10,7 +11,7 @@ import {
bashExecutionToText,
} from "../runtime/index.js";
export const CHARS_PER_TOKEN_ESTIMATE = 4;
export { CHARS_PER_TOKEN_ESTIMATE };
export const TOOL_RESULT_CHARS_PER_TOKEN_ESTIMATE = 2;
const IMAGE_CHAR_ESTIMATE = 8_000;
+2 -28
View File
@@ -1,28 +1,2 @@
/**
* OpenClaw-owned agent runtime facade.
*
* Wires agent-core to the plugin SDK LLM runtime and re-exports reusable runtime helpers.
*/
import {
Agent as CoreAgent,
type AgentOptions as CoreAgentOptions,
} from "../../../packages/agent-core/src/agent.js";
import type { AgentCoreRuntimeDeps } from "../../../packages/agent-core/src/runtime-deps.js";
import type { CompleteSimpleFn, StreamFn } from "../../../packages/llm-core/src/index.js";
import { completeSimple, streamSimple } from "../../plugin-sdk/llm.js";
export const openClawAgentCoreRuntime = {
completeSimple: completeSimple as unknown as CompleteSimpleFn,
streamSimple: streamSimple as unknown as StreamFn,
} satisfies AgentCoreRuntimeDeps;
export class Agent extends CoreAgent {
constructor(options: CoreAgentOptions = {}) {
super({ runtime: openClawAgentCoreRuntime, ...options });
}
}
// OpenClaw-owned reusable agent core
export * from "../../../packages/agent-core/src/index.js";
// Proxy utilities
export * from "./proxy.js";
/** OpenClaw-owned agent runtime facade; the plugin SDK module owns the adapter. */
export * from "../../plugin-sdk/agent-core.js";
+2 -16
View File
@@ -1,19 +1,16 @@
// Public channel registry facade for channel ids, metadata, and setup copy.
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { normalizeChatChannelId, type ChatChannelId } from "./ids.js";
import type { ChannelId } from "./plugins/channel-id.types.js";
import type { ChannelMeta } from "./plugins/types.core.js";
import {
findRegisteredChannelPluginEntry,
findRegisteredChannelPluginEntryById,
listRegisteredChannelPluginEntries,
} from "./registry-lookup.js";
export { findChatChannelMeta, getChatChannelMeta } from "./chat-meta.js";
export { CHAT_CHANNEL_ORDER } from "./ids.js";
export type { ChatChannelId } from "./ids.js";
export { normalizeAnyChannelId } from "./registry-normalize.js";
export { normalizeChatChannelId };
/**
@@ -23,17 +20,6 @@ export function normalizeChannelId(raw?: string | null): ChatChannelId | null {
return normalizeChatChannelId(raw);
}
/**
* Normalizes any registered channel plugin id or alias after registry initialization.
*/
export function normalizeAnyChannelId(raw?: string | null): ChannelId | null {
const key = normalizeOptionalLowercaseString(raw);
if (!key) {
return null;
}
return findRegisteredChannelPluginEntry(key)?.plugin.id ?? null;
}
/**
* Lists registered channel plugin ids without importing their runtime implementations.
*/
+2 -2
View File
@@ -21,7 +21,7 @@ import {
parseCronCommandEnv,
parseCronFallbacks,
parseCronToolsAllow,
parseDurationMs,
parsePositiveCronDurationMs,
warnIfCronSchedulerDisabled,
} from "./shared.js";
import { normalizeCronSessionTargetOption, parseCronThreadIdOption } from "./thread-id-shared.js";
@@ -605,7 +605,7 @@ export function registerCronEditCommand(cron: Command) {
failureAlert.to = to ? to : undefined;
}
if (hasFailureAlertCooldown) {
const cooldownMs = parseDurationMs(String(opts.failureAlertCooldown));
const cooldownMs = parsePositiveCronDurationMs(String(opts.failureAlertCooldown));
if (!cooldownMs && cooldownMs !== 0) {
throw new Error("Invalid --failure-alert-cooldown.");
}
+2 -2
View File
@@ -1,7 +1,7 @@
// Shared schedule option resolver for cron create/edit commands.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { CronSchedule } from "../../cron/types.js";
import { parseAt, parseCronStaggerMs, parseDurationMs } from "./shared.js";
import { parseAt, parseCronStaggerMs, parsePositiveCronDurationMs } from "./shared.js";
type ScheduleOptionInput = {
at?: unknown;
@@ -169,7 +169,7 @@ function resolveDirectSchedule(options: NormalizedScheduleOptions): CronSchedule
return { kind: "at", at: atIso };
}
if (options.every) {
const everyMs = parseDurationMs(options.every);
const everyMs = parsePositiveCronDurationMs(options.every);
if (!everyMs) {
throw new Error("Invalid --every. Use a duration like 10m, 1h, or 1d.");
}
+15 -15
View File
@@ -9,7 +9,7 @@ import {
getCronChannelOptions,
parseAt,
parseCronToolsAllow,
parseDurationMs,
parsePositiveCronDurationMs,
printCronList,
printCronShow,
} from "./shared.js";
@@ -526,29 +526,29 @@ describe("coerceCronDeliveryPreviews", () => {
});
});
describe("parseDurationMs", () => {
describe("parsePositiveCronDurationMs", () => {
it("parses valid positive durations", () => {
expect(parseDurationMs("500ms")).toBe(500);
expect(parseDurationMs("30s")).toBe(30_000);
expect(parseDurationMs("1.5h")).toBe(5_400_000);
expect(parseDurationMs("1h30m")).toBe(5_400_000);
expect(parseDurationMs("1d")).toBe(86_400_000);
expect(parsePositiveCronDurationMs("500ms")).toBe(500);
expect(parsePositiveCronDurationMs("30s")).toBe(30_000);
expect(parsePositiveCronDurationMs("1.5h")).toBe(5_400_000);
expect(parsePositiveCronDurationMs("1h30m")).toBe(5_400_000);
expect(parsePositiveCronDurationMs("1d")).toBe(86_400_000);
});
it("rejects non-positive and malformed durations", () => {
expect(parseDurationMs("0s")).toBeNull();
expect(parseDurationMs("0.5ms")).toBe(1);
expect(parseDurationMs("0.001ms")).toBeNull();
expect(parseDurationMs("-5s")).toBeNull();
expect(parseDurationMs("abc")).toBeNull();
expect(parseDurationMs("")).toBeNull();
expect(parsePositiveCronDurationMs("0s")).toBeNull();
expect(parsePositiveCronDurationMs("0.5ms")).toBe(1);
expect(parsePositiveCronDurationMs("0.001ms")).toBeNull();
expect(parsePositiveCronDurationMs("-5s")).toBeNull();
expect(parsePositiveCronDurationMs("abc")).toBeNull();
expect(parsePositiveCronDurationMs("")).toBeNull();
});
it("rejects durations that overflow to a non-finite millisecond value (#83906)", () => {
// A finite mantissa can still overflow once multiplied by a large unit factor.
expect(parseDurationMs(`1${"0".repeat(302)}d`)).toBeNull();
expect(parsePositiveCronDurationMs(`1${"0".repeat(302)}d`)).toBeNull();
// A large-but-finite result is still accepted.
expect(parseDurationMs(`9${"0".repeat(15)}ms`)).toBe(9_000_000_000_000_000);
expect(parsePositiveCronDurationMs(`9${"0".repeat(15)}ms`)).toBe(9_000_000_000_000_000);
});
});
+3 -3
View File
@@ -220,7 +220,7 @@ export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
}
}
export function parseDurationMs(input: string): number | null {
export function parsePositiveCronDurationMs(input: string): number | null {
try {
const result = parseSharedDurationMs(input);
if (result <= 0) {
@@ -242,7 +242,7 @@ export function parseCronStaggerMs(params: {
if (!params.staggerRaw) {
return undefined;
}
const parsed = parseDurationMs(params.staggerRaw);
const parsed = parsePositiveCronDurationMs(params.staggerRaw);
if (!parsed) {
throw new Error("Invalid --stagger; use e.g. 30s, 1m, 5m");
}
@@ -301,7 +301,7 @@ export function parseAt(input: string, tz?: string): string | null {
return timestampMsToIsoString(absolute) ?? null;
}
const durationInput = raw.startsWith("+") ? raw.slice(1) : raw;
const dur = parseDurationMs(durationInput);
const dur = parsePositiveCronDurationMs(durationInput);
if (dur !== null) {
const expiresAt = resolveExpiresAtMsFromDurationMs(dur);
return timestampMsToIsoString(expiresAt) ?? null;
+2 -8
View File
@@ -1,12 +1,8 @@
// Default CLI dependency surface with lazy outbound channel send adapters.
import { normalizeChannelId } from "../channels/registry.js";
import type { OutboundSendDeps } from "../infra/outbound/send-deps.js";
import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js";
import type { CliDeps } from "./deps.types.js";
import {
CLI_OUTBOUND_SEND_FACTORY,
createOutboundSendDepsFromCliSource,
} from "./outbound-send-mapping.js";
import { CLI_OUTBOUND_SEND_FACTORY } from "./outbound-send-mapping.js";
/**
* Lazy-loaded per-channel send functions, keyed by channel ID.
@@ -118,6 +114,4 @@ export function createDefaultDeps(): CliDeps {
});
}
export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps {
return createOutboundSendDepsFromCliSource(deps);
}
export { createOutboundSendDeps } from "./outbound-send-deps.js";
+1 -4
View File
@@ -1,6 +1,7 @@
// Shared api.github.com plumbing for Control UI GitHub surfaces (link
// previews, session pull request chips): pinned origin, manual redirects,
// bounded bodies, and normalized upstream error statuses.
export { isRecord } from "@openclaw/normalization-core/record-coerce";
import { readResponseWithLimit } from "../infra/http-body.js";
export const GITHUB_API_ORIGIN = "https://api.github.com";
@@ -19,10 +20,6 @@ export class ControlUiGitHubError extends Error {
}
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function requiredString(record: Record<string, unknown>, key: string): string {
const value = record[key];
if (typeof value !== "string" || !value.trim()) {
+5 -3
View File
@@ -10,7 +10,7 @@ import {
extractJsonNullableStringFieldPrefix,
extractJsonNumberFieldPrefix,
extractJsonStringFieldPrefix,
normalizeOptionalString,
readNonBlankStringPreservingWhitespace,
} from "./session-transcript-json.js";
const TRANSCRIPT_INDEX_READ_CHUNK_BYTES = 64 * 1024;
@@ -264,9 +264,11 @@ async function buildSessionTranscriptIndex(
if (!isIndexableTranscriptRecord(parsed)) {
return;
}
const id = normalizeOptionalString(parsed.id);
const id = readNonBlankStringPreservingWhitespace(parsed.id);
const parentId =
parsed.parentId === null ? null : (normalizeOptionalString(parsed.parentId) ?? undefined);
parsed.parentId === null
? null
: (readNonBlankStringPreservingWhitespace(parsed.parentId) ?? undefined);
const treeEntry = parseSessionTranscriptTreeEntry(parsed);
const rawEntry: IndexedRawEntry = {
...(id ? { id } : {}),
+3 -2
View File
@@ -1,7 +1,8 @@
// Shared bounded JSONL metadata parsing for gateway transcript readers.
import { escapeRegExp } from "../shared/regexp.js";
export function normalizeOptionalString(value: unknown): string | undefined {
/** Reads a nonblank transcript field while preserving its original whitespace. */
export function readNonBlankStringPreservingWhitespace(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
@@ -38,7 +39,7 @@ export function extractJsonStringFieldPrefix(prefix: string, field: string): str
}
try {
const decoded = JSON.parse(`"${match[1]}"`) as unknown;
return normalizeOptionalString(decoded);
return readNonBlankStringPreservingWhitespace(decoded);
} catch {
return undefined;
}
+2 -2
View File
@@ -39,7 +39,7 @@ import {
extractJsonNullableStringFieldPrefix,
extractJsonNumberFieldPrefix,
extractJsonStringFieldPrefix,
normalizeOptionalString,
readNonBlankStringPreservingWhitespace,
} from "./session-transcript-json.js";
import type { SessionPreviewItem } from "./session-utils.types.js";
@@ -362,7 +362,7 @@ function extractJsonStringFieldWindow(
}
try {
const decoded = JSON.parse(`"${match[1]}"`) as unknown;
return normalizeOptionalString(decoded);
return readNonBlankStringPreservingWhitespace(decoded);
} catch {
return undefined;
}
+2 -6
View File
@@ -1,13 +1,9 @@
// Gateway test assertion helpers narrow unknown protocol payloads to records
// and assert selected fields with useful labels.
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { expect } from "vitest";
/**
* Record-shape assertion helpers for gateway tests.
*/
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export { isRecord };
/** Requires an unknown value to be a record and throws with a test label. */
export function requireRecord(value: unknown, label: string): Record<string, unknown> {
+5 -2
View File
@@ -17,7 +17,10 @@ const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
export { hasBinary };
/** Evaluate a config path with hook-specific defaults for legacy runtime requirements. */
export function isConfigPathTruthy(config: OpenClawConfig | undefined, pathStr: string): boolean {
export function isHookConfigPathTruthy(
config: OpenClawConfig | undefined,
pathStr: string,
): boolean {
return isConfigPathTruthyWithDefaults(config, pathStr, DEFAULT_CONFIG_VALUES);
}
@@ -45,7 +48,7 @@ function evaluateHookRuntimeEligibility(params: {
...base,
hasBin: hasBinary,
hasEnv: (envName) => Boolean(process.env[envName] || hookConfig?.env?.[envName]),
isConfigPathTruthy: (configPath) => isConfigPathTruthy(config, configPath),
isConfigPathTruthy: (configPath) => isHookConfigPathTruthy(config, configPath),
});
}
+2 -2
View File
@@ -4,7 +4,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { evaluateEntryRequirementsForCurrentPlatform } from "../shared/entry-status.js";
import type { RequirementConfigCheck, Requirements } from "../shared/requirements.js";
import { CONFIG_DIR } from "../utils.js";
import { hasBinary, isConfigPathTruthy } from "./config.js";
import { hasBinary, isHookConfigPathTruthy } from "./config.js";
import { isKnownInternalHookEventKey } from "./internal-hook-types.js";
import {
resolveHookConfig,
@@ -102,7 +102,7 @@ function buildHookStatus(
const unknownEvents = events.filter((event) => !isKnownInternalHookEventKey(event));
const isEnvSatisfied = (envName: string) =>
Boolean(process.env[envName] || hookConfig?.env?.[envName]);
const isConfigSatisfied = (pathStr: string) => isConfigPathTruthy(config, pathStr);
const isConfigSatisfied = (pathStr: string) => isHookConfigPathTruthy(config, pathStr);
const { emoji, homepage, required, missing, requirementsSatisfied, configChecks } =
evaluateEntryRequirementsForCurrentPlatform({
+1 -22
View File
@@ -71,28 +71,7 @@ export function formatErrorMessage(err: unknown): string {
return formatSharedErrorMessage(err, { redact: redactSensitiveText });
}
/**
* Render a non-Error `cause` value (string, number, plain object, etc.) for inclusion in
* a flattened error chain. Returns `[object Object]`-free text without throwing.
*/
export function stringifyNonErrorCause(value: unknown): string {
if (value === null) {
return "null";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
try {
return JSON.stringify(value) ?? Object.prototype.toString.call(value);
} catch {
return Object.prototype.toString.call(value);
}
}
export { toErrorObject } from "@openclaw/normalization-core/error-coercion";
export { stringifyNonErrorCause, toErrorObject } from "@openclaw/normalization-core/error-coercion";
export function formatUncaughtError(err: unknown): string {
if (extractErrorCode(err) === "INVALID_CONFIG") {
+8
View File
@@ -9,6 +9,7 @@ import {
resolveOsHomeRelativePath,
resolveRequiredHomeDir,
resolveRequiredOsHomeDir,
resolveUserPath,
} from "./home-dir.js";
describe("resolveEffectiveHomeDir", () => {
@@ -275,6 +276,13 @@ describe("resolveHomeRelativePath", () => {
});
});
describe("resolveUserPath", () => {
it("preserves the historical falsy-input contract", () => {
expect(resolveUserPath(undefined as unknown as string)).toBe("");
expect(resolveUserPath(null as unknown as string)).toBe("");
});
});
describe("resolveOsHomeRelativePath", () => {
it("expands tilde paths using the OS home instead of OPENCLAW_HOME", () => {
expect(
+4 -5
View File
@@ -143,16 +143,15 @@ export function resolveHomeRelativePath(
return path.resolve(trimmed);
}
/**
* Backward-compatible alias for resolving user paths against the effective home.
*
* @deprecated Use resolveHomeRelativePath.
*/
/** Resolves a user path against the effective home, preserving an empty input. */
export function resolveUserPath(
input: string,
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
if (!input) {
return "";
}
return resolveHomeRelativePath(input, { env, homedir });
}
+2 -2
View File
@@ -4,7 +4,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
import {
ensureDir,
ensureMigrationDir,
existsDir,
fileExists,
isLegacyWhatsAppAuthFile,
@@ -18,7 +18,7 @@ describe("state migration fs helpers", () => {
const nested = path.join(base, "nested");
expect(safeReadDir(nested)).toStrictEqual([]);
ensureDir(nested);
ensureMigrationDir(nested);
fs.writeFileSync(path.join(nested, "file.txt"), "ok", "utf8");
expect(safeReadDir(nested).map((entry) => entry.name)).toEqual(["file.txt"]);
+1 -1
View File
@@ -27,7 +27,7 @@ export function existsDir(dir: string): boolean {
}
/** Creates a directory tree for migration targets. */
export function ensureDir(dir: string) {
export function ensureMigrationDir(dir: string) {
fs.mkdirSync(dir, { recursive: true });
}
+4 -4
View File
@@ -100,7 +100,7 @@ import {
migrateLegacyDebugProxyCaptureSidecar,
} from "./state-migrations.debug-proxy.js";
import {
ensureDir,
ensureMigrationDir,
existsDir,
fileExists,
parseSessionStoreJson5,
@@ -3112,7 +3112,7 @@ async function runLegacyMigrationPlans(
continue;
}
try {
ensureDir(path.dirname(plan.targetPath));
ensureMigrationDir(path.dirname(plan.targetPath));
if (plan.kind === "move") {
fs.renameSync(plan.sourcePath, plan.targetPath);
changes.push(`Moved ${plan.label}${plan.targetPath}`);
@@ -4628,7 +4628,7 @@ async function migrateLegacySessions(
return { changes, warnings };
}
ensureDir(detected.sessions.targetDir);
ensureMigrationDir(detected.sessions.targetDir);
const legacyParsed = fileExists(detected.sessions.legacyStorePath)
? readSessionStoreJson5(detected.sessions.legacyStorePath)
@@ -4895,7 +4895,7 @@ export async function migrateLegacyAgentDir(
return { changes, warnings };
}
ensureDir(detected.agentDir.targetDir);
ensureMigrationDir(detected.agentDir.targetDir);
const entries = safeReadDir(detected.agentDir.legacyDir);
for (const entry of entries) {
+9 -3
View File
@@ -15,7 +15,11 @@ import type {
UnifiedModelCatalogProviderContext,
ProviderPluginWizardSetup,
} from "../plugins/types.js";
import { copyArrayEntries, isRecord, readRecordValue } from "../shared/safe-record.js";
import {
copyArrayEntries,
isRecordWithoutThrowing,
readRecordValue,
} from "../shared/safe-record.js";
import { definePluginEntry } from "./plugin-entry.js";
import type {
OpenClawPluginApi,
@@ -183,11 +187,13 @@ function resolveWizardSetup(params: {
}
function copyProviderAuthOptions(value: unknown): SingleProviderPluginApiKeyAuthOptions[] {
return copyArrayEntries(value).filter(isRecord) as SingleProviderPluginApiKeyAuthOptions[];
return copyArrayEntries(value).filter(
isRecordWithoutThrowing,
) as SingleProviderPluginApiKeyAuthOptions[];
}
function copyProviderAuthMethods(value: unknown): ProviderAuthMethod[] {
return copyArrayEntries(value).filter(isRecord) as ProviderAuthMethod[];
return copyArrayEntries(value).filter(isRecordWithoutThrowing) as ProviderAuthMethod[];
}
function resolveEnvVars(params: {
@@ -382,6 +382,7 @@ describe("installOpenClawPluginSdkNativeResolver", () => {
"boolean-coercion.ts",
);
const resultSource = writeInternalCorePackageSource(root, "normalization-core", "result.ts");
const agentIdSource = writeInternalCorePackageSource(root, "normalization-core", "agent-id.ts");
const mediaCoreSource = writeInternalCorePackageSource(root, "media-core", "mime.ts");
const markdownCoreSource = writeInternalCorePackageSource(
root,
@@ -418,6 +419,7 @@ describe("installOpenClawPluginSdkNativeResolver", () => {
expect(installedAliases).toContain("@openclaw/normalization-core/string-coerce");
expect(installedAliases).toContain("@openclaw/normalization-core/boolean-coercion");
expect(installedAliases).toContain("@openclaw/normalization-core/result");
expect(installedAliases).toContain("@openclaw/normalization-core/agent-id");
expect(installedAliases).toContain("@openclaw/media-core/mime");
expect(installedAliases).toContain("@openclaw/markdown-core/code-spans");
expect(installedAliases).toContain("@openclaw/ai/internal/retry-after");
@@ -437,6 +439,9 @@ describe("installOpenClawPluginSdkNativeResolver", () => {
expect(
fs.realpathSync(requireFromCoreSource.resolve("@openclaw/normalization-core/result")),
).toBe(fs.realpathSync(resultSource));
expect(
fs.realpathSync(requireFromCoreSource.resolve("@openclaw/normalization-core/agent-id")),
).toBe(fs.realpathSync(agentIdSource));
expect(fs.realpathSync(requireFromCoreSource.resolve("@openclaw/media-core/mime"))).toBe(
fs.realpathSync(mediaCoreSource),
);
+6 -21
View File
@@ -67,21 +67,6 @@ const INTERNAL_CORE_PACKAGE_ALIASES = [
["types", "types.ts"],
],
},
{
packageName: "@openclaw/normalization-core",
packageDir: "normalization-core",
subpaths: [
["", "index.ts"],
["boolean-coercion", "boolean-coercion.ts"],
["error-coercion", "error-coercion.ts"],
["number-coercion", "number-coercion.ts"],
["record-coerce", "record-coerce.ts"],
["result", "result.ts"],
["string-coerce", "string-coerce.ts"],
["string-normalization", "string-normalization.ts"],
["utf16-slice", "utf16-slice.ts"],
],
},
{
// Mirrors packages/ai/package.json exports; dist file names do not follow
// the src layout (dist/diagnostics.mjs <- src/utils/diagnostics.ts), so the
@@ -331,15 +316,15 @@ function listInternalCorePackageNativeAliases(
}> = [];
const internalCorePackageAliases = [
...INTERNAL_CORE_PACKAGE_ALIASES,
{
packageName: "@openclaw/acp-core",
packageDir: "acp-core",
...["normalization-core", "acp-core"].map((packageDir) => ({
packageName: `@openclaw/${packageDir}`,
packageDir,
subpaths: listWorkspacePackageExportAliasEntries({
packageRoot,
packageName: "@openclaw/acp-core",
packageDir: "acp-core",
packageName: `@openclaw/${packageDir}`,
packageDir,
}).map((entry) => [entry.subpath, entry.srcFile] as const),
},
})),
];
for (const entry of internalCorePackageAliases) {
for (const [subpath, srcFile] of entry.subpaths) {
+3 -3
View File
@@ -3,7 +3,7 @@ import type { ModelDefinitionConfig, ModelProviderConfig } from "../config/types
import {
copyArrayEntries,
copyRecordEntries,
isRecord,
isRecordWithoutThrowing,
readRecordValue,
} from "../shared/safe-record.js";
import type { ProviderCatalogResult } from "./types.js";
@@ -92,7 +92,7 @@ export function copyProviderCatalogModels(
}
function copyProviderCatalogModel(model: unknown): ModelDefinitionConfig | undefined {
if (!isRecord(model)) {
if (!isRecordWithoutThrowing(model)) {
return undefined;
}
const id = readRecordValue(model, "id");
@@ -118,7 +118,7 @@ function copyProviderCatalogModel(model: unknown): ModelDefinitionConfig | undef
function copyProviderCatalogProviderConfig(
providerConfig: unknown,
): ModelProviderConfig | undefined {
if (!isRecord(providerConfig)) {
if (!isRecordWithoutThrowing(providerConfig)) {
return undefined;
}
+1 -2
View File
@@ -7,6 +7,7 @@ import type {
ProviderThinkingProfile,
ProviderThinkingPolicyContext,
} from "./provider-thinking.types.js";
import { PLUGIN_REGISTRY_STATE } from "./runtime-state-key.js";
type ThinkingProviderPlugin = {
id: string;
@@ -22,8 +23,6 @@ type ThinkingProviderPlugin = {
) => "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive" | null | undefined;
};
const PLUGIN_REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState");
type ThinkingRegistryState = {
activeRegistry?: {
providers?: Array<{
+1 -3
View File
@@ -1,8 +1,6 @@
// Stores active plugin channel registry state for the current runtime.
import type { ActivePluginChannelRegistry } from "./channel-registry-state.types.js";
/** Global symbol that stores process-current plugin registry state. */
const PLUGIN_REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState");
import { PLUGIN_REGISTRY_STATE } from "./runtime-state-key.js";
type GlobalChannelRegistryState = typeof globalThis & {
[PLUGIN_REGISTRY_STATE]?: {
+2
View File
@@ -0,0 +1,2 @@
/** Process-global symbol shared by every plugin registry runtime projection. */
export const PLUGIN_REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState");
+2 -1
View File
@@ -1,7 +1,8 @@
import { PLUGIN_REGISTRY_STATE } from "./runtime-state-key.js";
// Stores plugin runtime registry state for the current process lifecycle.
import { getActivePluginRegistryWorkspaceDirFromState as getPinnedWorkspaceDirFromState } from "./runtime-workspace-state.js";
export const PLUGIN_REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState");
export { PLUGIN_REGISTRY_STATE };
type PluginRegistry = import("./registry-types.js").PluginRegistry;
+1 -1
View File
@@ -1,8 +1,8 @@
// Shares plugin runtime workspace state across module reloads.
import { AsyncLocalStorage } from "node:async_hooks";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { PLUGIN_REGISTRY_STATE } from "./runtime-state-key.js";
const PLUGIN_REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState");
const PINNED_PLUGIN_REGISTRY_WORKSPACE_KEY = Symbol.for(
"openclaw.pinnedPluginRegistryWorkspaceDir",
);
+22 -1
View File
@@ -1565,6 +1565,12 @@ describe("plugin sdk alias helpers", () => {
srcFile: "result.ts",
distFile: "result.mjs",
});
const normalizationAgentId = writeWorkspacePackageEntry({
root: fixture.root,
packageDir: "normalization-core",
srcFile: "agent-id.ts",
distFile: "agent-id.mjs",
});
const normalizationStringCoerce = writeWorkspacePackageEntry({
root: fixture.root,
packageDir: "normalization-core",
@@ -1641,6 +1647,7 @@ describe("plugin sdk alias helpers", () => {
fs.rmSync(normalizationCore.distFile);
fs.rmSync(normalizationBooleanCoercion.distFile);
fs.rmSync(normalizationResult.distFile);
fs.rmSync(normalizationAgentId.distFile);
fs.rmSync(normalizationStringCoerce.distFile);
fs.rmSync(retry.distFile);
fs.rmSync(terminalCore.distFile);
@@ -1706,6 +1713,9 @@ describe("plugin sdk alias helpers", () => {
expect(fs.realpathSync(aliases["@openclaw/normalization-core/result"] ?? "")).toBe(
fs.realpathSync(normalizationResult.srcFile),
);
expect(fs.realpathSync(aliases["@openclaw/normalization-core/agent-id"] ?? "")).toBe(
fs.realpathSync(normalizationAgentId.srcFile),
);
expect(fs.realpathSync(aliases["@openclaw/normalization-core/string-coerce"] ?? "")).toBe(
fs.realpathSync(normalizationStringCoerce.srcFile),
);
@@ -1860,7 +1870,7 @@ describe("plugin sdk alias helpers", () => {
).toBe(fs.realpathSync(modelCatalogCore.distFile));
});
it("derives acp-core aliases from packaged root dist when package metadata is absent", () => {
it("derives workspace aliases from packaged root dist when package metadata is absent", () => {
const fixture = createPluginSdkAliasFixture();
const sourcePluginEntry = writePluginEntry(
fixture.root,
@@ -1869,6 +1879,14 @@ describe("plugin sdk alias helpers", () => {
const acpRuntimeErrors = path.join(fixture.root, "dist", "acp-core", "runtime", "errors.js");
mkdirSafeDir(path.dirname(acpRuntimeErrors));
fs.writeFileSync(acpRuntimeErrors, "export {};\n", "utf-8");
const normalizationAgentId = path.join(
fixture.root,
"dist",
"normalization-core",
"agent-id.js",
);
mkdirSafeDir(path.dirname(normalizationAgentId));
fs.writeFileSync(normalizationAgentId, "export {};\n", "utf-8");
const cwdWithoutOpenClawPackage = makeTempDir();
const aliases = withCwd(cwdWithoutOpenClawPackage, () =>
@@ -1880,6 +1898,9 @@ describe("plugin sdk alias helpers", () => {
expect(fs.realpathSync(aliases["@openclaw/acp-core/runtime/errors"] ?? "")).toBe(
fs.realpathSync(acpRuntimeErrors),
);
expect(fs.realpathSync(aliases["@openclaw/normalization-core/agent-id"] ?? "")).toBe(
fs.realpathSync(normalizationAgentId),
);
});
it("aliases bundled plugin package public surfaces for source plugin transforms", () => {
+7 -24
View File
@@ -768,25 +768,6 @@ const WORKSPACE_PACKAGE_ALIAS_ENTRIES: WorkspacePackageAliasEntry[] = [
srcFile: "read-byte-stream-with-limit.ts",
distFile: "read-byte-stream-with-limit.mjs",
},
...(
[
["", "index"],
["boolean-coercion", "boolean-coercion"],
["error-coercion", "error-coercion"],
["number-coercion", "number-coercion"],
["record-coerce", "record-coerce"],
["result", "result"],
["string-coerce", "string-coerce"],
["string-normalization", "string-normalization"],
["utf16-slice", "utf16-slice"],
] as const
).map(([subpath, file]) => ({
packageName: "@openclaw/normalization-core",
packageDir: "normalization-core",
subpath,
srcFile: `${file}.ts`,
distFile: `${file}.mjs`,
})),
{
packageName: "@openclaw/retry",
packageDir: "retry",
@@ -1343,11 +1324,13 @@ function resolveWorkspacePackageAliasMap(params: {
const aliasMap: Record<string, string> = {};
const workspacePackageAliasEntries = [
...WORKSPACE_PACKAGE_ALIAS_ENTRIES,
...listWorkspacePackageExportAliasEntries({
packageRoot,
packageName: "@openclaw/acp-core",
packageDir: "acp-core",
}),
...["normalization-core", "acp-core"].flatMap((packageDir) =>
listWorkspacePackageExportAliasEntries({
packageRoot,
packageName: `@openclaw/${packageDir}`,
packageDir,
}),
),
];
for (const entry of workspacePackageAliasEntries) {
const alias = entry.subpath ? `${entry.packageName}/${entry.subpath}` : entry.packageName;
+2 -31
View File
@@ -1,3 +1,4 @@
import { isValidAgentId, normalizeAgentId } from "@openclaw/normalization-core/agent-id";
// Routing session key helpers build stable session keys from route targets.
import {
normalizeLowercaseStringOrEmpty,
@@ -26,17 +27,12 @@ export {
normalizeAccountId,
normalizeOptionalAccountId,
} from "./account-id.js";
export { isValidAgentId, normalizeAgentId };
export const DEFAULT_AGENT_ID = "main";
export const DEFAULT_MAIN_KEY = "main";
type SessionKeyShape = "missing" | "agent" | "legacy_or_alias" | "malformed_agent";
// Pre-compiled regex
const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
const INVALID_CHARS_RE = /[^a-z0-9_-]+/g;
const LEADING_DASH_RE = /^-+/;
const TRAILING_DASH_RE = /-+$/;
function normalizeToken(value: string | undefined | null): string {
return normalizeLowercaseStringOrEmpty(value);
}
@@ -174,36 +170,11 @@ export function scopeLegacySessionKeyToAgent(params: {
});
}
export function normalizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) {
return DEFAULT_AGENT_ID;
}
const normalized = normalizeLowercaseStringOrEmpty(trimmed);
// Keep it path-safe + shell-friendly.
if (VALID_ID_RE.test(trimmed)) {
return normalized;
}
// Best-effort fallback: collapse invalid characters to "-"
return (
normalized
.replace(INVALID_CHARS_RE, "-")
.replace(LEADING_DASH_RE, "")
.replace(TRAILING_DASH_RE, "")
.slice(0, 64) || DEFAULT_AGENT_ID
);
}
export function normalizeOptionalAgentId(value: unknown): string | undefined {
const trimmed = normalizeOptionalString(value);
return trimmed ? normalizeAgentId(trimmed) : undefined;
}
export function isValidAgentId(value: string | undefined | null): boolean {
const trimmed = (value ?? "").trim();
return Boolean(trimmed) && VALID_ID_RE.test(trimmed);
}
export function sanitizeAgentId(value: string | undefined | null): string {
return normalizeAgentId(value);
}
+4 -4
View File
@@ -1,5 +1,5 @@
/** Defensive object guard for values that may have hostile traps. */
export function isRecord(value: unknown): value is Record<string, unknown> {
export function isRecordWithoutThrowing(value: unknown): value is Record<string, unknown> {
try {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
} catch {
@@ -9,7 +9,7 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
/** Read one property from a record-like value without letting traps escape. */
export function readRecordValue(value: unknown, key: string): unknown {
if (!isRecord(value)) {
if (!isRecordWithoutThrowing(value)) {
return undefined;
}
try {
@@ -52,7 +52,7 @@ export function copyArrayEntries(value: unknown): unknown[] {
/** Copy record entries whose values are also record-shaped. */
export function copyRecordEntries<T>(value: unknown): Array<[string, T]> {
if (!isRecord(value)) {
if (!isRecordWithoutThrowing(value)) {
return [];
}
@@ -68,7 +68,7 @@ export function copyRecordEntries<T>(value: unknown): Array<[string, T]> {
const entry = readRecordValue(value, key);
// Callers use this for nested config maps; non-object leaves are ignored so
// later code does not need repeated record guards.
if (isRecord(entry)) {
if (isRecordWithoutThrowing(entry)) {
entries.push([key, entry as T]);
}
}
+4 -15
View File
@@ -1,24 +1,13 @@
import { avoidTrailingHighSurrogateBreak } from "@openclaw/normalization-core/utf16-slice";
export { avoidTrailingHighSurrogateBreak };
/**
* Splits text into bounded chunks using caller-owned soft-break selection.
*
* The resolver sees each limit-sized window and returns an in-window break index;
* invalid indexes fall back to the hard limit so chunking always makes progress.
*/
export function avoidTrailingHighSurrogateBreak(text: string, start: number, end: number): number {
if (end <= start || end >= text.length) {
return end;
}
const previous = text.charCodeAt(end - 1);
const next = text.charCodeAt(end);
const splitsSurrogatePair =
previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff;
if (!splitsSurrogatePair) {
return end;
}
const adjusted = end - 1;
return adjusted > start ? adjusted : end + 1;
}
export function chunkTextByBreakResolver(
text: string,
limit: number,
+2 -2
View File
@@ -16,7 +16,7 @@ import { resolveBundledSkillsContext } from "../loading/bundled-context.js";
import {
hasBinary,
isBundledSkillAllowed,
isConfigPathTruthy,
isSkillConfigPathTruthy,
resolveBundledAllowlist,
resolveSkillConfig,
resolveSkillsInstallPreferences,
@@ -272,7 +272,7 @@ function buildSkillStatus(
skillConfig?.env?.[envName] ||
(skillConfig?.apiKey && entry.metadata?.primaryEnv === envName),
);
const isConfigSatisfied = (pathStr: string) => isConfigPathTruthy(config, pathStr);
const isConfigSatisfied = (pathStr: string) => isSkillConfigPathTruthy(config, pathStr);
const skillSource = indexed.source;
const bundled = indexed.bundled;
+5 -2
View File
@@ -34,7 +34,10 @@ export function resolveSkillsInstallPreferences(config?: OpenClawConfig): Skills
return { preferBrew, nodeManager };
}
export function isConfigPathTruthy(config: OpenClawConfig | undefined, pathStr: string): boolean {
export function isSkillConfigPathTruthy(
config: OpenClawConfig | undefined,
pathStr: string,
): boolean {
return isConfigPathTruthyWithDefaults(config, pathStr, DEFAULT_CONFIG_VALUES);
}
@@ -115,6 +118,6 @@ export function shouldIncludeSkill(params: {
skillConfig?.env?.[envName] ||
(skillConfig?.apiKey && entry.metadata?.primaryEnv === envName),
),
isConfigPathTruthy: (configPath) => isConfigPathTruthy(config, configPath),
isConfigPathTruthy: (configPath) => isSkillConfigPathTruthy(config, configPath),
});
}
+3 -21
View File
@@ -5,12 +5,14 @@ import path from "node:path";
import { pathExists as fsSafePathExists } from "./infra/fs-safe.js";
import {
resolveEffectiveHomeDir,
resolveHomeRelativePath,
resolveRequiredHomeDir,
resolveUserPath,
} from "./infra/home-dir.js";
import { isPlainObject } from "./infra/plain-object.js";
export { escapeRegExp } from "./shared/regexp.js";
export { sleep } from "./utils/sleep.js";
export { isRecord } from "@openclaw/normalization-core/record-coerce";
export { resolveUserPath };
/** Creates a directory tree if it does not already exist. */
export async function ensureDir(dir: string) {
@@ -44,14 +46,6 @@ export function safeParseJson<T>(raw: string): T | 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();
@@ -64,18 +58,6 @@ export function normalizeE164(number: string): string {
// 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,
+4
View File
@@ -395,6 +395,10 @@ export const sharedVitestConfig = {
find: "@openclaw/net-policy",
replacement: path.join(repoRoot, "packages", "net-policy", "src", "index.ts"),
},
{
find: "@openclaw/normalization-core/agent-id",
replacement: path.join(repoRoot, "packages", "normalization-core", "src", "agent-id.ts"),
},
{
find: "@openclaw/normalization-core/boolean-coercion",
replacement: path.join(
+3
View File
@@ -133,6 +133,9 @@
"@openclaw/markdown-core/types": ["./packages/markdown-core/src/types.ts"],
"@openclaw/markdown-core/*": ["./packages/markdown-core/src/*"],
"@openclaw/normalization-core": ["./packages/normalization-core/src/index.ts"],
"@openclaw/normalization-core/agent-id": [
"./packages/normalization-core/src/agent-id.ts"
],
"@openclaw/normalization-core/boolean-coercion": [
"./packages/normalization-core/src/boolean-coercion.ts"
],