From 2aa4ff08253dbe8839c394efa9c63fc9fb16e97f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 13 Jul 2026 03:22:00 -0700 Subject: [PATCH] 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 --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- packages/acp-core/src/error-format.ts | 23 +-------- packages/markdown-core/package.json | 1 + packages/markdown-core/src/chunk-text.ts | 21 ++------ packages/media-generation-core/package.json | 3 ++ .../src/capability-model-ref.ts | 2 +- packages/media-generation-core/src/string.ts | 10 +--- .../src/host/backend-config.ts | 22 ++++----- .../memory-host-sdk/src/host/batch-runner.ts | 4 +- .../memory-host-sdk/src/host/batch-upload.ts | 7 ++- .../memory-host-sdk/src/host/config-utils.ts | 49 ++++++------------- .../host/embedding-provider-adapter-utils.ts | 2 +- .../src/host/embeddings-debug.ts | 2 +- .../src/host/embeddings-remote-client.ts | 2 +- .../src/host/embeddings-worker.ts | 2 +- .../memory-host-sdk/src/host/embeddings.ts | 2 +- packages/memory-host-sdk/src/host/internal.ts | 17 +++++-- .../memory-host-sdk/src/host/multimodal.ts | 2 +- .../memory-host-sdk/src/host/post-json.ts | 7 ++- .../src/host/qmd-query-parser.ts | 2 +- .../memory-host-sdk/src/host/qmd-scope.ts | 4 +- .../src/host/query-expansion.ts | 2 +- .../memory-host-sdk/src/host/read-file.ts | 12 ++--- .../src/host/response-snippet.test.ts | 19 ++++--- .../src/host/response-snippet.ts | 2 +- .../src/host/secret-input-utils.ts | 8 +-- .../memory-host-sdk/src/host/secret-input.ts | 12 ++--- .../memory-host-sdk/src/host/sqlite-vec.ts | 2 +- packages/memory-host-sdk/src/host/sqlite.ts | 4 +- .../memory-host-sdk/src/host/string-utils.ts | 35 ------------- packages/model-catalog-core/package.json | 3 ++ .../model-catalog-core/src/provider-id.ts | 6 +-- packages/normalization-core/package.json | 7 ++- .../normalization-core/src/agent-id.test.ts | 24 +++++++++ packages/normalization-core/src/agent-id.ts | 32 ++++++++++++ .../src/error-coercion.test.ts | 15 +++++- .../normalization-core/src/error-coercion.ts | 18 +++++++ .../src/utf16-slice.test.ts | 21 +++++++- .../normalization-core/src/utf16-slice.ts | 14 ++++++ packages/terminal-core/package.json | 1 + packages/terminal-core/src/string.ts | 8 +-- pnpm-lock.yaml | 18 ++++++- src/agents/auth-profiles/profiles.ts | 21 +------- .../tool-result-char-estimator.ts | 3 +- src/agents/runtime/index.ts | 30 +----------- src/channels/registry.ts | 18 +------ src/cli/cron-cli/register.cron-edit.ts | 4 +- src/cli/cron-cli/schedule-options.ts | 4 +- src/cli/cron-cli/shared.test.ts | 30 ++++++------ src/cli/cron-cli/shared.ts | 6 +-- src/cli/deps.ts | 10 +--- src/gateway/control-ui-github-api.ts | 5 +- src/gateway/session-transcript-index.fs.ts | 8 +-- src/gateway/session-transcript-json.ts | 5 +- src/gateway/session-utils.fs.ts | 4 +- src/gateway/test-helpers.assertions.ts | 8 +-- src/hooks/config.ts | 7 ++- src/hooks/hooks-status.ts | 4 +- src/infra/errors.ts | 23 +-------- src/infra/home-dir.test.ts | 8 +++ src/infra/home-dir.ts | 9 ++-- src/infra/state-migrations.fs.test.ts | 4 +- src/infra/state-migrations.fs.ts | 2 +- src/infra/state-migrations.ts | 8 +-- src/plugin-sdk/provider-entry.ts | 12 +++-- .../plugin-sdk-native-resolver.test.ts | 5 ++ src/plugins/plugin-sdk-native-resolver.ts | 27 +++------- src/plugins/provider-catalog-result.ts | 6 +-- src/plugins/provider-thinking.ts | 3 +- src/plugins/runtime-channel-state.ts | 4 +- src/plugins/runtime-state-key.ts | 2 + src/plugins/runtime-state.ts | 3 +- src/plugins/runtime-workspace-state.ts | 2 +- src/plugins/sdk-alias.test.ts | 23 ++++++++- src/plugins/sdk-alias.ts | 31 +++--------- src/routing/session-key.ts | 33 +------------ src/shared/safe-record.ts | 8 +-- src/shared/text-chunking.ts | 19 ++----- src/skills/discovery/status.ts | 4 +- src/skills/loading/config.ts | 7 ++- src/utils.ts | 24 ++------- test/vitest/vitest.shared.config.ts | 4 ++ tsconfig.json | 3 ++ 83 files changed, 414 insertions(+), 478 deletions(-) delete mode 100644 packages/memory-host-sdk/src/host/string-utils.ts create mode 100644 packages/normalization-core/src/agent-id.test.ts create mode 100644 packages/normalization-core/src/agent-id.ts create mode 100644 src/plugins/runtime-state-key.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 73e9685c924f..2f11a9d124a1 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/packages/acp-core/src/error-format.ts b/packages/acp-core/src/error-format.ts index e39c9ddac816..908286e28d0c 100644 --- a/packages/acp-core/src/error-format.ts +++ b/packages/acp-core/src/error-format.ts @@ -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"; diff --git a/packages/markdown-core/package.json b/packages/markdown-core/package.json index 8e19aa8f52ce..056584af2c40 100644 --- a/packages/markdown-core/package.json +++ b/packages/markdown-core/package.json @@ -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" diff --git a/packages/markdown-core/src/chunk-text.ts b/packages/markdown-core/src/chunk-text.ts index e2accdf13de9..d457734a5258 100644 --- a/packages/markdown-core/src/chunk-text.ts +++ b/packages/markdown-core/src/chunk-text.ts @@ -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. * diff --git a/packages/media-generation-core/package.json b/packages/media-generation-core/package.json index 92e640aa5270..c80fc9f92b10 100644 --- a/packages/media-generation-core/package.json +++ b/packages/media-generation-core/package.json @@ -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", diff --git a/packages/media-generation-core/src/capability-model-ref.ts b/packages/media-generation-core/src/capability-model-ref.ts index ed61aab3f9e1..ceebc4b9031c 100644 --- a/packages/media-generation-core/src/capability-model-ref.ts +++ b/packages/media-generation-core/src/capability-model-ref.ts @@ -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 = { diff --git a/packages/media-generation-core/src/string.ts b/packages/media-generation-core/src/string.ts index be51a14206af..0c13c670a8b4 100644 --- a/packages/media-generation-core/src/string.ts +++ b/packages/media-generation-core/src/string.ts @@ -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[] { diff --git a/packages/memory-host-sdk/src/host/backend-config.ts b/packages/memory-host-sdk/src/host/backend-config.ts index a5fa7e83cf5d..95b03a8f607c 100644 --- a/packages/memory-host-sdk/src/host/backend-config.ts +++ b/packages/memory-host-sdk/src/host/backend-config.ts @@ -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(); diff --git a/packages/memory-host-sdk/src/host/batch-runner.ts b/packages/memory-host-sdk/src/host/batch-runner.ts index 827edf77db43..dbfa84416282 100644 --- a/packages/memory-host-sdk/src/host/batch-runner.ts +++ b/packages/memory-host-sdk/src/host/batch-runner.ts @@ -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(params: { timeoutMs: params.timeoutMs, }); - await runWithConcurrency(tasks, params.concurrency); + await runMemoryHostTasksWithConcurrency(tasks, params.concurrency); return byCustomId; } diff --git a/packages/memory-host-sdk/src/host/batch-upload.ts b/packages/memory-host-sdk/src/host/batch-upload.ts index 30c7ca6b9760..4f128ff080b9 100644 --- a/packages/memory-host-sdk/src/host/batch-upload.ts +++ b/packages/memory-host-sdk/src/host/batch-upload.ts @@ -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, { diff --git a/packages/memory-host-sdk/src/host/config-utils.ts b/packages/memory-host-sdk/src/host/config-utils.ts index 64ca108a10f6..68da151e2bbf 100644 --- a/packages/memory-host-sdk/src/host/config-utils.ts +++ b/packages/memory-host-sdk/src/host/config-utils.ts @@ -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 { diff --git a/packages/memory-host-sdk/src/host/embedding-provider-adapter-utils.ts b/packages/memory-host-sdk/src/host/embedding-provider-adapter-utils.ts index 4355eb9963be..e151cfa0ae1d 100644 --- a/packages/memory-host-sdk/src/host/embedding-provider-adapter-utils.ts +++ b/packages/memory-host-sdk/src/host/embedding-provider-adapter-utils.ts @@ -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. diff --git a/packages/memory-host-sdk/src/host/embeddings-debug.ts b/packages/memory-host-sdk/src/host/embeddings-debug.ts index 8a205b622671..8056a190cd6e 100644 --- a/packages/memory-host-sdk/src/host/embeddings-debug.ts +++ b/packages/memory-host-sdk/src/host/embeddings-debug.ts @@ -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. diff --git a/packages/memory-host-sdk/src/host/embeddings-remote-client.ts b/packages/memory-host-sdk/src/host/embeddings-remote-client.ts index fc970ccbb9e9..6c7c23ce1fa6 100644 --- a/packages/memory-host-sdk/src/host/embeddings-remote-client.ts +++ b/packages/memory-host-sdk/src/host/embeddings-remote-client.ts @@ -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. diff --git a/packages/memory-host-sdk/src/host/embeddings-worker.ts b/packages/memory-host-sdk/src/host/embeddings-worker.ts index e65539d34a42..fe61b0f71211 100644 --- a/packages/memory-host-sdk/src/host/embeddings-worker.ts +++ b/packages/memory-host-sdk/src/host/embeddings-worker.ts @@ -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. diff --git a/packages/memory-host-sdk/src/host/embeddings.ts b/packages/memory-host-sdk/src/host/embeddings.ts index 5ed9782eaea6..4be291cde561 100644 --- a/packages/memory-host-sdk/src/host/embeddings.ts +++ b/packages/memory-host-sdk/src/host/embeddings.ts @@ -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; diff --git a/packages/memory-host-sdk/src/host/internal.ts b/packages/memory-host-sdk/src/host/internal.ts index bd2e23cceb67..b758ac22b880 100644 --- a/packages/memory-host-sdk/src/host/internal.ts +++ b/packages/memory-host-sdk/src/host/internal.ts @@ -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(tasks: Array<() => Promise>, limit: number): Promise { +export function runMemoryHostTasksWithConcurrency( + tasks: Array<() => Promise>, + limit: number, +): Promise { return runWithConcurrencyImpl(tasks, limit); } + +export { runMemoryHostTasksWithConcurrency as runWithConcurrency }; diff --git a/packages/memory-host-sdk/src/host/multimodal.ts b/packages/memory-host-sdk/src/host/multimodal.ts index 603ca0246345..d0036c055123 100644 --- a/packages/memory-host-sdk/src/host/multimodal.ts +++ b/packages/memory-host-sdk/src/host/multimodal.ts @@ -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. diff --git a/packages/memory-host-sdk/src/host/post-json.ts b/packages/memory-host-sdk/src/host/post-json.ts index 2f7dcc250aef..e37b6cfd234e 100644 --- a/packages/memory-host-sdk/src/host/post-json.ts +++ b/packages/memory-host-sdk/src/host/post-json.ts @@ -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(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; }; diff --git a/packages/memory-host-sdk/src/host/qmd-query-parser.ts b/packages/memory-host-sdk/src/host/qmd-query-parser.ts index 5a31a376a07e..e9f30be8dc62 100644 --- a/packages/memory-host-sdk/src/host/qmd-query-parser.ts +++ b/packages/memory-host-sdk/src/host/qmd-query-parser.ts @@ -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. diff --git a/packages/memory-host-sdk/src/host/qmd-scope.ts b/packages/memory-host-sdk/src/host/qmd-scope.ts index fe4e21a559e2..43a3570f6038 100644 --- a/packages/memory-host-sdk/src/host/qmd-scope.ts +++ b/packages/memory-host-sdk/src/host/qmd-scope.ts @@ -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; diff --git a/packages/memory-host-sdk/src/host/query-expansion.ts b/packages/memory-host-sdk/src/host/query-expansion.ts index d8ef9554ad10..fde08f6bf9ce 100644 --- a/packages/memory-host-sdk/src/host/query-expansion.ts +++ b/packages/memory-host-sdk/src/host/query-expansion.ts @@ -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. diff --git a/packages/memory-host-sdk/src/host/read-file.ts b/packages/memory-host-sdk/src/host/read-file.ts index 215ac8310aea..41218811b1b9 100644 --- a/packages/memory-host-sdk/src/host/read-file.ts +++ b/packages/memory-host-sdk/src/host/read-file.ts @@ -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 { - 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, diff --git a/packages/memory-host-sdk/src/host/response-snippet.test.ts b/packages/memory-host-sdk/src/host/response-snippet.test.ts index e7f7f89e8485..4ede0a58c9a0 100644 --- a/packages/memory-host-sdk/src/host/response-snippet.test.ts +++ b/packages/memory-host-sdk/src/host/response-snippet.test.ts @@ -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>(() => {}), @@ -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, }); diff --git a/packages/memory-host-sdk/src/host/response-snippet.ts b/packages/memory-host-sdk/src/host/response-snippet.ts index 12172e4b5d25..baea7494b271 100644 --- a/packages/memory-host-sdk/src/host/response-snippet.ts +++ b/packages/memory-host-sdk/src/host/response-snippet.ts @@ -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 { diff --git a/packages/memory-host-sdk/src/host/secret-input-utils.ts b/packages/memory-host-sdk/src/host/secret-input-utils.ts index b700630a0947..1d3c4739a494 100644 --- a/packages/memory-host-sdk/src/host/secret-input-utils.ts +++ b/packages/memory-host-sdk/src/host/secret-input-utils.ts @@ -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; } diff --git a/packages/memory-host-sdk/src/host/secret-input.ts b/packages/memory-host-sdk/src/host/secret-input.ts index 44796861d4cc..061d022412c3 100644 --- a/packages/memory-host-sdk/src/host/secret-input.ts +++ b/packages/memory-host-sdk/src/host/secret-input.ts @@ -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, }); diff --git a/packages/memory-host-sdk/src/host/sqlite-vec.ts b/packages/memory-host-sdk/src/host/sqlite-vec.ts index f6282471783a..d1c929e61cc0 100644 --- a/packages/memory-host-sdk/src/host/sqlite-vec.ts +++ b/packages/memory-host-sdk/src/host/sqlite-vec.ts @@ -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; diff --git a/packages/memory-host-sdk/src/host/sqlite.ts b/packages/memory-host-sdk/src/host/sqlite.ts index 5d502c53f119..7e90bda997f0 100644 --- a/packages/memory-host-sdk/src/host/sqlite.ts +++ b/packages/memory-host-sdk/src/host/sqlite.ts @@ -14,7 +14,7 @@ import { const require = createRequire(import.meta.url); const sqliteWalMaintenanceByDb = new WeakMap(); -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, diff --git a/packages/memory-host-sdk/src/host/string-utils.ts b/packages/memory-host-sdk/src/host/string-utils.ts deleted file mode 100644 index 2d525a51ed35..000000000000 --- a/packages/memory-host-sdk/src/host/string-utils.ts +++ /dev/null @@ -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): string[] { - return values.map((value) => normalizeOptionalString(String(value)) ?? "").filter(Boolean); -} - -/** Return unique strings preserving first-seen order. */ -export function uniqueStrings(values: Iterable): string[] { - return [...new Set(values)]; -} diff --git a/packages/model-catalog-core/package.json b/packages/model-catalog-core/package.json index 497a7bf36152..9c98144a6e95 100644 --- a/packages/model-catalog-core/package.json +++ b/packages/model-catalog-core/package.json @@ -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", diff --git a/packages/model-catalog-core/src/provider-id.ts b/packages/model-catalog-core/src/provider-id.ts index 96b1c7357b93..52f109f74052 100644 --- a/packages/model-catalog-core/src/provider-id.ts +++ b/packages/model-catalog-core/src/provider-id.ts @@ -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); diff --git a/packages/normalization-core/package.json b/packages/normalization-core/package.json index eab642def161..64576af0bd8b 100644 --- a/packages/normalization-core/package.json +++ b/packages/normalization-core/package.json @@ -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" } } diff --git a/packages/normalization-core/src/agent-id.test.ts b/packages/normalization-core/src/agent-id.test.ts new file mode 100644 index 000000000000..e94af18454ac --- /dev/null +++ b/packages/normalization-core/src/agent-id.test.ts @@ -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); + }); +}); diff --git a/packages/normalization-core/src/agent-id.ts b/packages/normalization-core/src/agent-id.ts new file mode 100644 index 000000000000..b32b3db6c66e --- /dev/null +++ b/packages/normalization-core/src/agent-id.ts @@ -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); +} diff --git a/packages/normalization-core/src/error-coercion.test.ts b/packages/normalization-core/src/error-coercion.test.ts index 32d3635bbf1b..3605cf40068c 100644 --- a/packages/normalization-core/src/error-coercion.test.ts +++ b/packages/normalization-core/src/error-coercion.test.ts @@ -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]"); + }); +}); diff --git a/packages/normalization-core/src/error-coercion.ts b/packages/normalization-core/src/error-coercion.ts index 4e6bb8999e21..fdccbc9d964c 100644 --- a/packages/normalization-core/src/error-coercion.ts +++ b/packages/normalization-core/src/error-coercion.ts @@ -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); + } +} diff --git a/packages/normalization-core/src/utf16-slice.test.ts b/packages/normalization-core/src/utf16-slice.test.ts index a2d6cd75918b..cb2669b44d66 100644 --- a/packages/normalization-core/src/utf16-slice.test.ts +++ b/packages/normalization-core/src/utf16-slice.test.ts @@ -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", () => { diff --git a/packages/normalization-core/src/utf16-slice.ts b/packages/normalization-core/src/utf16-slice.ts index d497728500ad..e62c1d045bdf 100644 --- a/packages/normalization-core/src/utf16-slice.ts +++ b/packages/normalization-core/src/utf16-slice.ts @@ -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; diff --git a/packages/terminal-core/package.json b/packages/terminal-core/package.json index 272b8215358b..2d906c52d5a9 100644 --- a/packages/terminal-core/package.json +++ b/packages/terminal-core/package.json @@ -101,6 +101,7 @@ } }, "dependencies": { + "@openclaw/normalization-core": "workspace:*", "@clack/prompts": "1.6.0", "chalk": "5.6.2" } diff --git a/packages/terminal-core/src/string.ts b/packages/terminal-core/src/string.ts index 53d7f5ff6468..524829070a6c 100644 --- a/packages/terminal-core/src/string.ts +++ b/packages/terminal-core/src/string.ts @@ -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"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d0f78ad7b57..db46a38a440f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/agents/auth-profiles/profiles.ts b/src/agents/auth-profiles/profiles.ts index b378a86e8b0d..46b6642dcb72 100644 --- a/src/agents/auth-profiles/profiles.ts +++ b/src/agents/auth-profiles/profiles.ts @@ -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 { - 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; diff --git a/src/agents/embedded-agent-runner/tool-result-char-estimator.ts b/src/agents/embedded-agent-runner/tool-result-char-estimator.ts index 6f488d124e8a..6a6935aff852 100644 --- a/src/agents/embedded-agent-runner/tool-result-char-estimator.ts +++ b/src/agents/embedded-agent-runner/tool-result-char-estimator.ts @@ -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; diff --git a/src/agents/runtime/index.ts b/src/agents/runtime/index.ts index 0011e30c5318..e06e786ac8ad 100644 --- a/src/agents/runtime/index.ts +++ b/src/agents/runtime/index.ts @@ -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"; diff --git a/src/channels/registry.ts b/src/channels/registry.ts index de9874e36039..501db297729d 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -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. */ diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index b50bc21b4125..78b34f49d496 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -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."); } diff --git a/src/cli/cron-cli/schedule-options.ts b/src/cli/cron-cli/schedule-options.ts index b6112d35e588..f7d6136beb8f 100644 --- a/src/cli/cron-cli/schedule-options.ts +++ b/src/cli/cron-cli/schedule-options.ts @@ -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."); } diff --git a/src/cli/cron-cli/shared.test.ts b/src/cli/cron-cli/shared.test.ts index a4a0e8b9e559..3434a436e44c 100644 --- a/src/cli/cron-cli/shared.test.ts +++ b/src/cli/cron-cli/shared.test.ts @@ -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); }); }); diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index 907c8907e897..1c4bc1d9e398 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -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; diff --git a/src/cli/deps.ts b/src/cli/deps.ts index e64a9c60c2d8..19e6406d9e24 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -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"; diff --git a/src/gateway/control-ui-github-api.ts b/src/gateway/control-ui-github-api.ts index 601ceceed09a..02d5ad4c042c 100644 --- a/src/gateway/control-ui-github-api.ts +++ b/src/gateway/control-ui-github-api.ts @@ -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 { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - export function requiredString(record: Record, key: string): string { const value = record[key]; if (typeof value !== "string" || !value.trim()) { diff --git a/src/gateway/session-transcript-index.fs.ts b/src/gateway/session-transcript-index.fs.ts index 33ba1e71a20d..882aed4eda53 100644 --- a/src/gateway/session-transcript-index.fs.ts +++ b/src/gateway/session-transcript-index.fs.ts @@ -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 } : {}), diff --git a/src/gateway/session-transcript-json.ts b/src/gateway/session-transcript-json.ts index 36dc818304b2..9971d82be76e 100644 --- a/src/gateway/session-transcript-json.ts +++ b/src/gateway/session-transcript-json.ts @@ -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; } diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 2d2c9bf3ef4c..6d9e9b7a210d 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -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; } diff --git a/src/gateway/test-helpers.assertions.ts b/src/gateway/test-helpers.assertions.ts index 9a4c04a2f04f..e9e9c4087b43 100644 --- a/src/gateway/test-helpers.assertions.ts +++ b/src/gateway/test-helpers.assertions.ts @@ -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 { - 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 { diff --git a/src/hooks/config.ts b/src/hooks/config.ts index dcd0023cd227..5e9535a3bf5f 100644 --- a/src/hooks/config.ts +++ b/src/hooks/config.ts @@ -17,7 +17,10 @@ const DEFAULT_CONFIG_VALUES: Record = { 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), }); } diff --git a/src/hooks/hooks-status.ts b/src/hooks/hooks-status.ts index a551fc5c199c..e9f9a79c9a14 100644 --- a/src/hooks/hooks-status.ts +++ b/src/hooks/hooks-status.ts @@ -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({ diff --git a/src/infra/errors.ts b/src/infra/errors.ts index 8c10c7ddf752..eb731a3ce0fb 100644 --- a/src/infra/errors.ts +++ b/src/infra/errors.ts @@ -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") { diff --git a/src/infra/home-dir.test.ts b/src/infra/home-dir.test.ts index 5d34b2135d16..e32002b4b23f 100644 --- a/src/infra/home-dir.test.ts +++ b/src/infra/home-dir.test.ts @@ -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( diff --git a/src/infra/home-dir.ts b/src/infra/home-dir.ts index 18c75e87960a..42305fdf4cd4 100644 --- a/src/infra/home-dir.ts +++ b/src/infra/home-dir.ts @@ -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 }); } diff --git a/src/infra/state-migrations.fs.test.ts b/src/infra/state-migrations.fs.test.ts index 9d1bed6eef8f..249e9e402710 100644 --- a/src/infra/state-migrations.fs.test.ts +++ b/src/infra/state-migrations.fs.test.ts @@ -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"]); diff --git a/src/infra/state-migrations.fs.ts b/src/infra/state-migrations.fs.ts index 26f9a935d2ea..0ee1d4f2136c 100644 --- a/src/infra/state-migrations.fs.ts +++ b/src/infra/state-migrations.fs.ts @@ -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 }); } diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index cf799db8c40f..4e5d19603706 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -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) { diff --git a/src/plugin-sdk/provider-entry.ts b/src/plugin-sdk/provider-entry.ts index 4caf164fafbb..bc0597d84513 100644 --- a/src/plugin-sdk/provider-entry.ts +++ b/src/plugin-sdk/provider-entry.ts @@ -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: { diff --git a/src/plugins/plugin-sdk-native-resolver.test.ts b/src/plugins/plugin-sdk-native-resolver.test.ts index 77cca01a4eb0..4fa77b9213bb 100644 --- a/src/plugins/plugin-sdk-native-resolver.test.ts +++ b/src/plugins/plugin-sdk-native-resolver.test.ts @@ -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), ); diff --git a/src/plugins/plugin-sdk-native-resolver.ts b/src/plugins/plugin-sdk-native-resolver.ts index c2a2bfda65bf..da2263431e81 100644 --- a/src/plugins/plugin-sdk-native-resolver.ts +++ b/src/plugins/plugin-sdk-native-resolver.ts @@ -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) { diff --git a/src/plugins/provider-catalog-result.ts b/src/plugins/provider-catalog-result.ts index 4157e9b66cd5..247c6134b384 100644 --- a/src/plugins/provider-catalog-result.ts +++ b/src/plugins/provider-catalog-result.ts @@ -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; } diff --git a/src/plugins/provider-thinking.ts b/src/plugins/provider-thinking.ts index 3c0e0075f589..ecdb69f0e44c 100644 --- a/src/plugins/provider-thinking.ts +++ b/src/plugins/provider-thinking.ts @@ -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<{ diff --git a/src/plugins/runtime-channel-state.ts b/src/plugins/runtime-channel-state.ts index d7a3dffd4505..4a6b3aebfe76 100644 --- a/src/plugins/runtime-channel-state.ts +++ b/src/plugins/runtime-channel-state.ts @@ -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]?: { diff --git a/src/plugins/runtime-state-key.ts b/src/plugins/runtime-state-key.ts new file mode 100644 index 000000000000..fc925b5082ab --- /dev/null +++ b/src/plugins/runtime-state-key.ts @@ -0,0 +1,2 @@ +/** Process-global symbol shared by every plugin registry runtime projection. */ +export const PLUGIN_REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState"); diff --git a/src/plugins/runtime-state.ts b/src/plugins/runtime-state.ts index 4b1154ed1e46..8c84633dc0da 100644 --- a/src/plugins/runtime-state.ts +++ b/src/plugins/runtime-state.ts @@ -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; diff --git a/src/plugins/runtime-workspace-state.ts b/src/plugins/runtime-workspace-state.ts index 6eff1a8e826e..2d32196d9a97 100644 --- a/src/plugins/runtime-workspace-state.ts +++ b/src/plugins/runtime-workspace-state.ts @@ -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", ); diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index f0b823ab9b03..5d981406383e 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -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", () => { diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index e3d27d66d192..a4a7529d044f 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -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 = {}; 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; diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index ad098ab3425d..b3453645c2cb 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -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); } diff --git a/src/shared/safe-record.ts b/src/shared/safe-record.ts index 48807f6b949d..fb226b5f78db 100644 --- a/src/shared/safe-record.ts +++ b/src/shared/safe-record.ts @@ -1,5 +1,5 @@ /** Defensive object guard for values that may have hostile traps. */ -export function isRecord(value: unknown): value is Record { +export function isRecordWithoutThrowing(value: unknown): value is Record { try { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } catch { @@ -9,7 +9,7 @@ export function isRecord(value: unknown): value is Record { /** 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(value: unknown): Array<[string, T]> { - if (!isRecord(value)) { + if (!isRecordWithoutThrowing(value)) { return []; } @@ -68,7 +68,7 @@ export function copyRecordEntries(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]); } } diff --git a/src/shared/text-chunking.ts b/src/shared/text-chunking.ts index 76142b2aa19e..f9206bd53658 100644 --- a/src/shared/text-chunking.ts +++ b/src/shared/text-chunking.ts @@ -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, diff --git a/src/skills/discovery/status.ts b/src/skills/discovery/status.ts index 18203654f8fe..9fa423c3db5d 100644 --- a/src/skills/discovery/status.ts +++ b/src/skills/discovery/status.ts @@ -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; diff --git a/src/skills/loading/config.ts b/src/skills/loading/config.ts index d2423c095df3..0f40a6f9f4e5 100644 --- a/src/skills/loading/config.ts +++ b/src/skills/loading/config.ts @@ -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), }); } diff --git a/src/utils.ts b/src/utils.ts index 18d6b37d5873..d161692193b8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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(raw: string): T | null { export { isPlainObject }; -/** - * Type guard for Record (less strict than isPlainObject). - * Accepts any non-null object that isn't an array. - */ -export function isRecord(value: unknown): value is Record { - 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, diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index 2130ded548c5..515eb925c7da 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -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( diff --git a/tsconfig.json b/tsconfig.json index f9a2613f9f8c..ea6ef6b78a4e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" ],