// Diffs plugin module implements tool behavior. import fs from "node:fs/promises"; import { optionalFiniteNumberSchema, stringEnum } from "openclaw/plugin-sdk/channel-actions"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { readFiniteNumberParam } from "openclaw/plugin-sdk/param-readers"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { Type } from "typebox"; import type { Static } from "typebox"; import type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js"; import { PlaywrightDiffScreenshotter, type DiffScreenshotter } from "./browser.js"; import { resolveDiffImageRenderOptions } from "./config.js"; import { DiffRenderInputError, renderDiffDocument } from "./render.js"; import type { DiffArtifactStore } from "./store.js"; import type { DiffArtifactContext, DiffRenderOptions, DiffRenderTarget, DiffToolDefaults, } from "./types.js"; import { DIFF_IMAGE_QUALITY_PRESETS, DIFF_LAYOUTS, DIFF_MODES, DIFF_OUTPUT_FORMATS, DIFF_THEMES, type DiffInput, type DiffImageQualityPreset, type DiffLayout, type DiffMode, type DiffOutputFormat, type DiffTheme, } from "./types.js"; import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js"; const MAX_BEFORE_AFTER_BYTES = 512 * 1024; const MAX_PATCH_BYTES = 2 * 1024 * 1024; const MAX_TITLE_BYTES = 1_024; const MAX_PATH_BYTES = 2_048; const MAX_LANG_BYTES = 128; const MAX_DIFF_ARTIFACT_TTL_SECONDS = 21_600; const DiffsToolSchema = Type.Object( { before: Type.Optional(Type.String({ description: "Original text content." })), after: Type.Optional(Type.String({ description: "Updated text content." })), patch: Type.Optional( Type.String({ description: "Unified diff or patch text.", maxLength: MAX_PATCH_BYTES, }), ), path: Type.Optional( Type.String({ description: "Display path for before/after input.", maxLength: MAX_PATH_BYTES, }), ), lang: Type.Optional( Type.String({ description: "Optional language override for before/after input.", maxLength: MAX_LANG_BYTES, }), ), title: Type.Optional( Type.String({ description: "Optional title for the rendered diff.", maxLength: MAX_TITLE_BYTES, }), ), mode: Type.Optional( stringEnum(DIFF_MODES, { description: "Output mode: view, file, image (deprecated alias for file), or both. Default: both.", }), ), theme: Type.Optional(stringEnum(DIFF_THEMES, { description: "Viewer theme. Default: dark." })), layout: Type.Optional( stringEnum(DIFF_LAYOUTS, { description: "Diff layout. Default: unified." }), ), fileQuality: Type.Optional( stringEnum(DIFF_IMAGE_QUALITY_PRESETS, { description: "File quality preset: standard, hq, or print.", }), ), fileFormat: Type.Optional( stringEnum(DIFF_OUTPUT_FORMATS, { description: "Rendered file format: png or pdf." }), ), fileScale: optionalFiniteNumberSchema({ description: "Optional rendered-file device scale factor override (1-4).", minimum: 1, maximum: 4, }), fileMaxWidth: optionalFiniteNumberSchema({ description: "Optional rendered-file max width in CSS pixels (640-2400).", minimum: 640, maximum: 2400, }), expandUnchanged: Type.Optional( Type.Boolean({ description: "Expand unchanged sections instead of collapsing them." }), ), ttlSeconds: optionalFiniteNumberSchema({ description: "Artifact lifetime in seconds. Default: 1800. Maximum: 21600.", minimum: 1, maximum: MAX_DIFF_ARTIFACT_TTL_SECONDS, }), baseUrl: Type.Optional( Type.String({ description: "Optional gateway base URL override used when building the viewer URL. Overrides configured viewerBaseUrl, for example https://gateway.example.com.", }), ), }, { additionalProperties: false }, ); type DiffsToolParams = Static; export function createDiffsTool(params: { api: OpenClawPluginApi; store: DiffArtifactStore; defaults: DiffToolDefaults; viewerBaseUrl?: string; languagePackAvailable?: boolean; screenshotter?: DiffScreenshotter; context?: OpenClawPluginToolContext; }): AnyAgentTool { return { name: "diffs", label: "Diffs", description: "Create a read-only diff viewer from before/after text or a unified patch. Returns a gateway viewer URL for canvas use and can also render the same diff to a PNG or PDF.", parameters: DiffsToolSchema, execute: async (_toolCallId, rawParams) => { const toolParams = rawParams as DiffsToolParams; const rawRecord = rawParams as Record; const artifactContext = buildArtifactContext(params.context); const input = normalizeDiffInput(toolParams); if (input.kind === "before_after" && input.before === input.after) { return { content: [ { type: "text", text: "Before and after are identical — no changes to render.", }, ], details: { changed: false, ...(artifactContext ? { context: artifactContext } : {}), }, }; } const mode = normalizeMode(toolParams.mode, params.defaults.mode); const theme = normalizeTheme(toolParams.theme, params.defaults.theme); const layout = normalizeLayout(toolParams.layout, params.defaults.layout); const expandUnchanged = toolParams.expandUnchanged === true; const ttlSeconds = readFiniteNumberParam(rawRecord, "ttlSeconds") ?? params.defaults.ttlSeconds; const fileScale = readFiniteNumberParam(rawRecord, "fileScale"); const fileMaxWidth = readFiniteNumberParam(rawRecord, "fileMaxWidth"); const ttlMs = normalizeTtlMs(ttlSeconds); const image = resolveDiffImageRenderOptions({ defaults: params.defaults, fileFormat: normalizeOutputFormat(toolParams.fileFormat), fileQuality: normalizeFileQuality(toolParams.fileQuality), fileScale, fileMaxWidth, }); const renderTarget = resolveRenderTarget(mode); const rendered = await renderDiffDocument( input, { presentation: { ...params.defaults, layout, theme, }, image, expandUnchanged, languagePackAvailable: params.languagePackAvailable, }, renderTarget, ).catch((error: unknown) => { if (error instanceof DiffRenderInputError) { throw new PluginToolInputError(error.message); } throw error; }); const screenshotter = params.screenshotter ?? new PlaywrightDiffScreenshotter({ config: params.api.config }); if (isArtifactOnlyMode(mode)) { const artifactFile = await renderDiffArtifactFile({ screenshotter, store: params.store, html: requireRenderedHtml(rendered.imageHtml, "image"), theme, image, ttlMs, context: artifactContext, }); return { content: [ { type: "text", text: buildFileArtifactMessage({ format: image.format, filePath: artifactFile.path, }), }, ], details: buildArtifactDetails({ baseDetails: { changed: true, ...(artifactFile.artifactId ? { artifactId: artifactFile.artifactId } : {}), ...(artifactFile.expiresAt ? { expiresAt: artifactFile.expiresAt } : {}), title: rendered.title, inputKind: rendered.inputKind, fileCount: rendered.fileCount, mode, ...(artifactContext ? { context: artifactContext } : {}), }, artifactFile, image, }), }; } const artifact = await params.store.createArtifact({ html: requireRenderedHtml(rendered.html, "viewer"), title: rendered.title, inputKind: rendered.inputKind, fileCount: rendered.fileCount, ttlMs, context: artifactContext, }); const viewerUrl = buildViewerUrl({ config: params.api.config, viewerPath: artifact.viewerPath, baseUrl: normalizeBaseUrl(toolParams.baseUrl) ?? params.viewerBaseUrl, }); const baseDetails = { changed: true, artifactId: artifact.id, viewerUrl, viewerPath: artifact.viewerPath, title: artifact.title, expiresAt: artifact.expiresAt, inputKind: artifact.inputKind, fileCount: artifact.fileCount, mode, ...(artifactContext ? { context: artifactContext } : {}), }; if (mode === "view") { return { content: [ { type: "text", text: `Diff viewer ready.\n${viewerUrl}`, }, ], details: baseDetails, }; } try { const artifactFile = await renderDiffArtifactFile({ screenshotter, store: params.store, html: requireRenderedHtml(rendered.imageHtml, "image"), theme, image, ttlMs, context: artifactContext, }); return { content: [ { type: "text", text: buildFileArtifactMessage({ format: image.format, filePath: artifactFile.path, viewerUrl, }), }, ], details: buildArtifactDetails({ baseDetails, artifactFile, image, }), }; } catch (error) { if (mode === "both") { const errorMessage = formatErrorMessage(error); return { content: [ { type: "text", text: `Diff viewer ready.\n${viewerUrl}\nFile rendering failed: ${errorMessage}`, }, ], details: { ...baseDetails, fileError: errorMessage, }, }; } throw error; } }, }; } function normalizeFileQuality( fileQuality: DiffImageQualityPreset | undefined, ): DiffImageQualityPreset | undefined { return fileQuality && DIFF_IMAGE_QUALITY_PRESETS.includes(fileQuality) ? fileQuality : undefined; } function normalizeOutputFormat(format: DiffOutputFormat | undefined): DiffOutputFormat | undefined { return format && DIFF_OUTPUT_FORMATS.includes(format) ? format : undefined; } function isArtifactOnlyMode(mode: DiffMode): mode is "image" | "file" { return mode === "image" || mode === "file"; } function resolveRenderTarget(mode: DiffMode): DiffRenderTarget { if (mode === "view") { return "viewer"; } if (isArtifactOnlyMode(mode)) { return "image"; } return "both"; } function requireRenderedHtml(html: string | undefined, target: DiffRenderTarget): string { if (html !== undefined) { return html; } throw new Error(`Missing ${target} render output.`); } function buildArtifactDetails(params: { baseDetails: Record; artifactFile: { path: string; bytes: number }; image: DiffRenderOptions["image"]; }) { return { ...params.baseDetails, filePath: params.artifactFile.path, // `path` mirrors filePath so the message tool can send the artifact directly. path: params.artifactFile.path, fileBytes: params.artifactFile.bytes, fileFormat: params.image.format, fileQuality: params.image.qualityPreset, fileScale: params.image.scale, fileMaxWidth: params.image.maxWidth, }; } function buildFileArtifactMessage(params: { format: DiffOutputFormat; filePath: string; viewerUrl?: string; }): string { const lines = params.viewerUrl ? [`Diff viewer: ${params.viewerUrl}`] : []; lines.push(`Diff ${params.format.toUpperCase()} generated at: ${params.filePath}`); lines.push("Use the `message` tool with `path` or `filePath` to send this file."); return lines.join("\n"); } async function renderDiffArtifactFile(params: { screenshotter: DiffScreenshotter; store: DiffArtifactStore; html: string; theme: DiffTheme; image: DiffRenderOptions["image"]; ttlMs?: number; context?: DiffArtifactContext; }): Promise<{ path: string; bytes: number; artifactId?: string; expiresAt?: string }> { const fileArtifact = await params.store.createStandaloneFileArtifact({ format: params.image.format, ttlMs: params.ttlMs, context: params.context, }); try { await params.screenshotter.screenshotHtml({ html: params.html, outputPath: fileArtifact.filePath, theme: params.theme, image: params.image, }); const stats = await fs.stat(fileArtifact.filePath); await params.store.completeFileArtifact(fileArtifact.id); return { path: fileArtifact.filePath, bytes: stats.size, artifactId: fileArtifact.id, expiresAt: fileArtifact.expiresAt, }; } catch (error) { await params.store.deleteFileArtifact(fileArtifact.id); throw error; } } function buildArtifactContext( context: OpenClawPluginToolContext | undefined, ): DiffArtifactContext | undefined { if (!context) { return undefined; } const agentId = normalizeOptionalString(context.agentId); const sessionId = normalizeOptionalString(context.sessionId); const messageChannel = normalizeOptionalString(context.messageChannel); const agentAccountId = normalizeOptionalString(context.agentAccountId); const artifactContext: DiffArtifactContext = { ...(agentId ? { agentId } : {}), ...(sessionId ? { sessionId } : {}), ...(messageChannel ? { messageChannel } : {}), ...(agentAccountId ? { agentAccountId } : {}), }; return Object.keys(artifactContext).length > 0 ? artifactContext : undefined; } function normalizeDiffInput(params: DiffsToolParams): DiffInput { const patch = params.patch?.trim(); const before = params.before; const after = params.after; if (patch) { assertMaxBytes(patch, "patch", MAX_PATCH_BYTES); if (before !== undefined || after !== undefined) { throw new PluginToolInputError("Provide either patch or before/after input, not both."); } const title = params.title?.trim(); if (title) { assertMaxBytes(title, "title", MAX_TITLE_BYTES); } return { kind: "patch", patch, title, }; } if (before === undefined || after === undefined) { throw new PluginToolInputError("Provide patch or both before and after text."); } assertMaxBytes(before, "before", MAX_BEFORE_AFTER_BYTES); assertMaxBytes(after, "after", MAX_BEFORE_AFTER_BYTES); const path = normalizeOptionalString(params.path); const lang = normalizeOptionalString(params.lang); const title = normalizeOptionalString(params.title); if (path) { assertMaxBytes(path, "path", MAX_PATH_BYTES); } if (lang) { assertMaxBytes(lang, "lang", MAX_LANG_BYTES); } if (title) { assertMaxBytes(title, "title", MAX_TITLE_BYTES); } return { kind: "before_after", before, after, path, lang, title, }; } function assertMaxBytes(value: string, label: string, maxBytes: number): void { if (Buffer.byteLength(value, "utf8") <= maxBytes) { return; } throw new PluginToolInputError(`${label} exceeds maximum size (${maxBytes} bytes).`); } function normalizeBaseUrl(baseUrl?: string): string | undefined { const normalized = baseUrl?.trim(); if (!normalized) { return undefined; } try { return normalizeViewerBaseUrl(normalized); } catch { throw new PluginToolInputError(`Invalid baseUrl: ${normalized}`); } } function normalizeMode(mode: DiffMode | undefined, fallback: DiffMode): DiffMode { return mode && DIFF_MODES.includes(mode) ? mode : fallback; } function normalizeTheme(theme: DiffTheme | undefined, fallback: DiffTheme): DiffTheme { return theme && DIFF_THEMES.includes(theme) ? theme : fallback; } function normalizeLayout(layout: DiffLayout | undefined, fallback: DiffLayout): DiffLayout { return layout && DIFF_LAYOUTS.includes(layout) ? layout : fallback; } function normalizeTtlMs(ttlSeconds?: number): number | undefined { if (!Number.isFinite(ttlSeconds) || ttlSeconds === undefined) { return undefined; } return Math.floor(Math.min(Math.max(ttlSeconds, 1), MAX_DIFF_ARTIFACT_TTL_SECONDS) * 1000); } class PluginToolInputError extends Error { constructor(message: string) { super(message); this.name = "ToolInputError"; } }