diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 74f2fe32dd0..a053924707b 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -57289c57bab5188013ab1895d26acabada49aff864b3207f1a3ea3e1d92c7791 plugin-sdk-api-baseline.json -445e5e72a321486831c2c936118302dbc2cde793f5e1dc5beab29100cae8150e plugin-sdk-api-baseline.jsonl +1805b1ddbd9036dac247b4eb01144c791e54e3a4826298bca4f85f67e936d8e2 plugin-sdk-api-baseline.json +e1a4138ec4962ecb09472bf97cabebb98477d82af75f701142323706dcaa95f2 plugin-sdk-api-baseline.jsonl diff --git a/extensions/file-transfer/src/shared/params.test.ts b/extensions/file-transfer/src/shared/params.test.ts index 988c1940faa..18cc1f00ebe 100644 --- a/extensions/file-transfer/src/shared/params.test.ts +++ b/extensions/file-transfer/src/shared/params.test.ts @@ -1,8 +1,14 @@ // File Transfer tests cover params plugin behavior. import { describe, expect, it } from "vitest"; -import { readClampedInt, readGatewayCallOptions } from "./params.js"; +import { humanSize, readClampedInt, readGatewayCallOptions } from "./params.js"; describe("file-transfer shared params", () => { + it("preserves spaced binary-scaled transfer labels", () => { + expect(humanSize(512)).toBe("512 B"); + expect(humanSize(1536)).toBe("1.5 KB"); + expect(humanSize(2 * 1024 * 1024)).toBe("2.00 MB"); + }); + it("normalizes string timeoutMs values for gateway calls", () => { expect(readGatewayCallOptions({ timeoutMs: "5000" }).timeoutMs).toBe(5000); }); diff --git a/extensions/file-transfer/src/shared/params.ts b/extensions/file-transfer/src/shared/params.ts index c2563bd1431..db47f4f8a5c 100644 --- a/extensions/file-transfer/src/shared/params.ts +++ b/extensions/file-transfer/src/shared/params.ts @@ -1,6 +1,7 @@ // Shared param-validation helpers used by all four agent tools. // Goal: identical validation behavior + identical error shapes everywhere. +import { formatByteSize } from "openclaw/plugin-sdk/number-runtime"; import { readPositiveIntegerParam } from "openclaw/plugin-sdk/param-readers"; type GatewayCallOptions = { @@ -50,11 +51,10 @@ export function readClampedInt(params: { } export function humanSize(bytes: number): string { - if (bytes < 1024) { - return `${bytes} B`; - } - if (bytes < 1024 * 1024) { - return `${(bytes / 1024).toFixed(1)} KB`; - } - return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + return formatByteSize(bytes, { + style: "legacy-binary", + maxUnit: "mega", + separator: " ", + fractionDigits: (_value, unit) => (unit === "byte" ? null : unit === "kilo" ? 1 : 2), + }); } diff --git a/extensions/qqbot/src/engine/commands/builtin/register-clear-storage.ts b/extensions/qqbot/src/engine/commands/builtin/register-clear-storage.ts index 3c545f97b90..195e5e10eef 100644 --- a/extensions/qqbot/src/engine/commands/builtin/register-clear-storage.ts +++ b/extensions/qqbot/src/engine/commands/builtin/register-clear-storage.ts @@ -1,6 +1,7 @@ // Qqbot plugin module implements register clear storage behavior. import fs from "node:fs"; import path from "node:path"; +import { formatByteSize } from "openclaw/plugin-sdk/number-runtime"; import { getQQBotMediaPath } from "../../utils/platform.js"; import type { SlashCommandRegistry } from "../slash-commands.js"; @@ -36,16 +37,12 @@ function scanDirectoryFiles(dirPath: string): { filePath: string; size: number } } function formatBytes(bytes: number): string { - if (bytes < 1024) { - return `${bytes} B`; - } - if (bytes < 1024 * 1024) { - return `${(bytes / 1024).toFixed(1)} KB`; - } - if (bytes < 1024 * 1024 * 1024) { - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - } - return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + return formatByteSize(bytes, { + style: "legacy-binary", + maxUnit: "giga", + separator: " ", + fractionDigits: (_value, unit) => (unit === "byte" ? null : 1), + }); } function removeEmptyDirs(dirPath: string): void { diff --git a/extensions/qqbot/src/engine/utils/file-utils.test.ts b/extensions/qqbot/src/engine/utils/file-utils.test.ts index 20b32e355e6..7637d67df59 100644 --- a/extensions/qqbot/src/engine/utils/file-utils.test.ts +++ b/extensions/qqbot/src/engine/utils/file-utils.test.ts @@ -31,11 +31,20 @@ import { checkFileSize, downloadFile, fileExistsAsync, + formatFileSize, getImageMimeType, getMimeType, readFileAsync, } from "./file-utils.js"; +describe("formatFileSize", () => { + it("preserves compact binary-scaled upload labels", () => { + expect(formatFileSize(512)).toBe("512B"); + expect(formatFileSize(1536)).toBe("1.5KB"); + expect(formatFileSize(2 * 1024 * 1024)).toBe("2.0MB"); + }); +}); + describe("qqbot file-utils MIME helpers", () => { it("uses the shared media MIME table for extension inference", () => { expect(getMimeType("voice.mp3")).toBe("audio/mpeg"); diff --git a/extensions/qqbot/src/engine/utils/file-utils.ts b/extensions/qqbot/src/engine/utils/file-utils.ts index 92664d718fd..eb980526bc2 100644 --- a/extensions/qqbot/src/engine/utils/file-utils.ts +++ b/extensions/qqbot/src/engine/utils/file-utils.ts @@ -3,6 +3,7 @@ import crypto from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime"; +import { formatByteSize } from "openclaw/plugin-sdk/number-runtime"; import { openLocalFileSafely, readRegularFile, @@ -124,13 +125,12 @@ export async function fileExistsAsync(filePath: string): Promise { /** Format a byte count into a human-readable size string. */ export function formatFileSize(bytes: number): string { - if (bytes < 1024) { - return `${bytes}B`; - } - if (bytes < 1024 * 1024) { - return `${(bytes / 1024).toFixed(1)}KB`; - } - return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; + return formatByteSize(bytes, { + style: "legacy-binary", + maxUnit: "mega", + separator: "", + fractionDigits: (_value, unit) => (unit === "byte" ? null : 1), + }); } /** Infer a MIME type from the file extension. */ diff --git a/packages/normalization-core/src/format.test.ts b/packages/normalization-core/src/format.test.ts new file mode 100644 index 00000000000..aa5e578ffdb --- /dev/null +++ b/packages/normalization-core/src/format.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { formatByteSize } from "./format.js"; + +describe("formatByteSize", () => { + it.each([ + [1024, { style: "iec", maxUnit: "mega", separator: " ", fractionDigits: 1 }, "1.0 KiB"], + [1024, { style: "legacy-binary", maxUnit: "mega", separator: "", fractionDigits: 1 }, "1.0KB"], + [ + 5 * 1024 * 1024, + { style: "legacy-binary", maxUnit: "kilo", separator: " ", fractionDigits: 1 }, + "5120.0 KB", + ], + ] as const)("formats %s bytes", (bytes, options, expected) => { + expect(formatByteSize(bytes, options)).toBe(expected); + }); + + it("supports caller-owned dynamic precision and rounding", () => { + expect( + formatByteSize(Math.floor(99.6 * 1024 * 1024), { + style: "legacy-binary", + maxUnit: "giga", + separator: " ", + fractionDigits: (_value, unit) => (unit === "giga" ? 1 : unit === "byte" ? null : 0), + floorUnits: ["kilo", "mega"], + }), + ).toBe("99 MB"); + }); +}); diff --git a/packages/normalization-core/src/format.ts b/packages/normalization-core/src/format.ts new file mode 100644 index 00000000000..09121269cbd --- /dev/null +++ b/packages/normalization-core/src/format.ts @@ -0,0 +1,41 @@ +type ByteSizeUnit = "byte" | "kilo" | "mega" | "giga" | "tera"; +type ByteSizeStyle = "iec" | "legacy-binary"; + +type ByteSizeFormatOptions = { + style: ByteSizeStyle; + maxUnit: ByteSizeUnit; + separator: "" | " "; + fractionDigits: number | ((value: number, unit: ByteSizeUnit) => number | null); + floorUnits?: readonly ByteSizeUnit[]; +}; + +const BYTE_SIZE_UNITS: readonly ByteSizeUnit[] = ["byte", "kilo", "mega", "giga", "tera"]; +const BYTE_SIZE_STYLES = { + iec: { base: 1024, labels: ["B", "KiB", "MiB", "GiB", "TiB"] }, + "legacy-binary": { base: 1024, labels: ["B", "KB", "MB", "GB", "TB"] }, +} as const satisfies Record; + +/** Formats a byte count with caller-explicit scale, labels, precision, and unit cap. */ +export function formatByteSize(bytes: number, options: ByteSizeFormatOptions): string { + const { base, labels } = BYTE_SIZE_STYLES[options.style]; + const maxUnitIndex = BYTE_SIZE_UNITS.indexOf(options.maxUnit); + let unitIndex = 0; + let value = bytes; + while (value >= base && unitIndex < maxUnitIndex) { + value /= base; + unitIndex += 1; + } + + const unit = BYTE_SIZE_UNITS[unitIndex]; + const fractionDigits = + typeof options.fractionDigits === "function" + ? options.fractionDigits(value, unit) + : options.fractionDigits; + if (fractionDigits === null) { + return `${value}${options.separator}${labels[unitIndex]}`; + } + if (options.floorUnits?.includes(unit)) { + value = Math.floor(value * 10 ** fractionDigits) / 10 ** fractionDigits; + } + return `${value.toFixed(fractionDigits)}${options.separator}${labels[unitIndex]}`; +} diff --git a/packages/normalization-core/src/index.ts b/packages/normalization-core/src/index.ts index 9bc9d57d82e..e0e122eae80 100644 --- a/packages/normalization-core/src/index.ts +++ b/packages/normalization-core/src/index.ts @@ -2,6 +2,7 @@ export * from "./boolean-coercion.js"; export * from "./error-coercion.js"; +export * from "./format.js"; export * from "./json-coercion.js"; export * from "./number-coercion.js"; export * from "./record-coerce.js"; diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 067888b19e0..83dce1f0608 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -192,12 +192,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { ), publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", - 10453, + 10454, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", - 5218, + 5219, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/agents/agent-tools.read.ts b/src/agents/agent-tools.read.ts index 7bc7c688059..b3bf74259ca 100644 --- a/src/agents/agent-tools.read.ts +++ b/src/agents/agent-tools.read.ts @@ -1,12 +1,12 @@ -/** - * Read/write/edit tool wrappers for host and sandbox workspaces. - * Adds workspace-root guards, adaptive read paging, image validation, memory - * append-only writes, and parameter cleanup around the session file tools. - */ +// Read/write/edit tool wrappers for host and sandbox workspaces. +// Adds workspace-root guards, adaptive read paging, image validation, memory +// append-only writes, and parameter cleanup around the session file tools. + import fs from "node:fs/promises"; import path from "node:path"; import { URL } from "node:url"; import { detectMime } from "@openclaw/media-core/mime"; +import { formatByteSize } from "@openclaw/normalization-core"; import { isWindowsDrivePath } from "../infra/archive-path.js"; import { toErrorObject } from "../infra/errors.js"; import { @@ -96,13 +96,12 @@ function malformedXmlArgValuePathError(key: string): Error { } function formatBytes(bytes: number): string { - if (bytes >= 1024 * 1024) { - return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; - } - if (bytes >= 1024) { - return `${Math.round(bytes / 1024)}KB`; - } - return `${bytes}B`; + return formatByteSize(bytes, { + style: "legacy-binary", + maxUnit: "mega", + separator: "", + fractionDigits: (_value, unit) => (unit === "byte" ? null : unit === "kilo" ? 0 : 1), + }); } function getToolResultText(result: AgentToolResult): string | undefined { diff --git a/src/agents/tool-images.ts b/src/agents/tool-images.ts index 0550c3a5534..39f392ff7ab 100644 --- a/src/agents/tool-images.ts +++ b/src/agents/tool-images.ts @@ -4,7 +4,7 @@ * Downscales and recompresses oversized base64 image blocks before provider replay. */ import { canonicalizeBase64 } from "@openclaw/media-core/base64"; -import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion"; +import { formatByteSize, resolveIntegerOption } from "@openclaw/normalization-core"; import { toErrorObject } from "../infra/errors.js"; import type { ImageContent } from "../llm/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -97,10 +97,12 @@ function formatBytesShort(bytes: number): string { if (!Number.isFinite(bytes) || bytes < 1024) { return `${Math.max(0, Math.round(bytes))}B`; } - if (bytes < 1024 * 1024) { - return `${(bytes / 1024).toFixed(1)}KB`; - } - return `${(bytes / (1024 * 1024)).toFixed(2)}MB`; + return formatByteSize(bytes, { + style: "legacy-binary", + maxUnit: "mega", + separator: "", + fractionDigits: (_value, unit) => (unit === "kilo" ? 1 : 2), + }); } function fileNameFromPathLike(pathLike: string): string | undefined { diff --git a/src/cli/gateway-cli/register.ts b/src/cli/gateway-cli/register.ts index 6e1fc378914..c805f46e6b3 100644 --- a/src/cli/gateway-cli/register.ts +++ b/src/cli/gateway-cli/register.ts @@ -1,4 +1,5 @@ // Commander registration for gateway status, health, diagnostics, discovery, and run commands. +import { formatByteSize } from "@openclaw/normalization-core"; import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { colorize, isRich, theme } from "../../../packages/terminal-core/src/theme.js"; @@ -232,15 +233,12 @@ function formatBytes(value: number | undefined): string { if (value === undefined) { return "n/a"; } - const units = ["B", "KiB", "MiB", "GiB"]; - let amount = value; - let unitIndex = 0; - while (amount >= 1024 && unitIndex < units.length - 1) { - amount /= 1024; - unitIndex += 1; - } - const digits = unitIndex === 0 || amount >= 100 ? 0 : 1; - return `${amount.toFixed(digits)} ${units[unitIndex]}`; + return formatByteSize(value, { + style: "iec", + maxUnit: "giga", + separator: " ", + fractionDigits: (amount, unit) => (unit === "byte" || amount >= 100 ? 0 : 1), + }); } function formatStabilityEvent(record: DiagnosticStabilityEventRecord): string { diff --git a/src/commands/doctor-disk-space.ts b/src/commands/doctor-disk-space.ts index 291e32f7831..10ab2e17a9d 100644 --- a/src/commands/doctor-disk-space.ts +++ b/src/commands/doctor-disk-space.ts @@ -1,5 +1,6 @@ -/** Doctor contribution for low disk space around the OpenClaw state directory. */ +// Doctor contribution for low disk space around the OpenClaw state directory. import os from "node:os"; +import { formatByteSize } from "@openclaw/normalization-core"; import { note } from "../../packages/terminal-core/src/note.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; @@ -28,16 +29,13 @@ export function formatBytes(bytes: number): string { if (bytes < 0 || !Number.isFinite(bytes)) { return "unknown"; } - if (bytes >= 1024 * 1024 * 1024) { - return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; - } - if (bytes >= 1024 * 1024) { - return `${Math.floor(bytes / (1024 * 1024))} MB`; - } - if (bytes >= 1024) { - return `${Math.floor(bytes / 1024)} KB`; - } - return `${bytes} B`; + return formatByteSize(bytes, { + style: "legacy-binary", + maxUnit: "giga", + separator: " ", + fractionDigits: (_value, unit) => (unit === "byte" ? null : unit === "giga" ? 1 : 0), + floorUnits: ["kilo", "mega"], + }); } /** diff --git a/src/plugin-sdk/number-runtime.ts b/src/plugin-sdk/number-runtime.ts index ce559cbc873..6f40bb3cb52 100644 --- a/src/plugin-sdk/number-runtime.ts +++ b/src/plugin-sdk/number-runtime.ts @@ -1,5 +1,6 @@ // Numeric coercion helpers for plugin runtime inputs. +export { formatByteSize } from "../../packages/normalization-core/src/format.js"; export { asDateTimestampMs, asFiniteNumberInRange, diff --git a/ui/src/lib/agents/display.test.ts b/ui/src/lib/agents/display.test.ts index 0c451d33258..cf330d3f37e 100644 --- a/ui/src/lib/agents/display.test.ts +++ b/ui/src/lib/agents/display.test.ts @@ -8,11 +8,22 @@ import { import { assistantAvatarFallbackUrl, buildAgentContext, + formatBytes, resolveConfiguredCronModelSuggestions, resolveEffectiveModelFallbacks, sortLocaleStrings, } from "./display.ts"; +describe("formatBytes", () => { + it("preserves the Control UI byte-size display contract", () => { + expect(formatBytes(undefined)).toBe("-"); + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(1536)).toBe("1.5 KB"); + expect(formatBytes(12 * 1024)).toBe("12 KB"); + expect(formatBytes(2 * 1024 * 1024)).toBe("2.0 MB"); + }); +}); + describe("resolveEffectiveModelFallbacks", () => { it("inherits defaults when no entry fallbacks are configured", () => { const entryModel = undefined; diff --git a/ui/src/lib/agents/display.ts b/ui/src/lib/agents/display.ts index 506c0f87612..408313cc14f 100644 --- a/ui/src/lib/agents/display.ts +++ b/ui/src/lib/agents/display.ts @@ -1,4 +1,5 @@ // Control UI view renders agents utils screen content. +import { formatByteSize } from "@openclaw/normalization-core"; import { html, nothing } from "lit"; import { expandToolGroups, @@ -236,17 +237,12 @@ export function formatBytes(bytes?: number) { if (bytes == null || !Number.isFinite(bytes)) { return "-"; } - if (bytes < 1024) { - return `${bytes} B`; - } - const units = ["KB", "MB", "GB", "TB"]; - let size = bytes / 1024; - let unitIndex = 0; - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex += 1; - } - return `${size.toFixed(size < 10 ? 1 : 0)} ${units[unitIndex]}`; + return formatByteSize(bytes, { + style: "legacy-binary", + maxUnit: "tera", + separator: " ", + fractionDigits: (value, unit) => (unit === "byte" ? null : value < 10 ? 1 : 0), + }); } export function resolveAgentConfig(config: Record | null, agentId: string) { diff --git a/ui/src/pages/skill-workshop/proposals.ts b/ui/src/pages/skill-workshop/proposals.ts index fa443afc085..3db2c481d8b 100644 --- a/ui/src/pages/skill-workshop/proposals.ts +++ b/ui/src/pages/skill-workshop/proposals.ts @@ -1,5 +1,6 @@ -import type { AgentSelectionCapability } from "../../app/agent-selection.ts"; // Control UI controller manages skill workshop gateway state. +import { formatByteSize } from "@openclaw/normalization-core"; +import type { AgentSelectionCapability } from "../../app/agent-selection.ts"; import type { ApplicationGateway } from "../../app/context.ts"; import { normalizeAgentId, @@ -251,10 +252,12 @@ function formatBytes(bytes: number): string { if (!Number.isFinite(bytes) || bytes <= 0) { return "0 B"; } - if (bytes < 1024) { - return `${bytes} B`; - } - return `${(bytes / 1024).toFixed(1)} KB`; + return formatByteSize(bytes, { + style: "legacy-binary", + maxUnit: "kilo", + separator: " ", + fractionDigits: (_value, unit) => (unit === "byte" ? null : 1), + }); } function byteLength(value: string): number {