2026-02-28 18:38:00 -05:00
import fs from "node:fs/promises" ;
import { Static , Type } from "@sinclair/typebox" ;
2026-03-04 02:32:57 -05:00
import type { AnyAgentTool , OpenClawPluginApi } from "openclaw/plugin-sdk/diffs" ;
2026-02-28 18:38:00 -05:00
import { PlaywrightDiffScreenshotter , type DiffScreenshotter } from "./browser.js" ;
2026-03-02 04:38:50 -05:00
import { resolveDiffImageRenderOptions } from "./config.js" ;
2026-02-28 18:38:00 -05:00
import { renderDiffDocument } from "./render.js" ;
import type { DiffArtifactStore } from "./store.js" ;
2026-03-02 04:38:50 -05:00
import type { DiffRenderOptions , DiffToolDefaults } from "./types.js" ;
2026-02-28 18:38:00 -05:00
import {
2026-03-02 04:38:50 -05:00
DIFF_IMAGE_QUALITY_PRESETS ,
2026-02-28 18:38:00 -05:00
DIFF_LAYOUTS ,
DIFF_MODES ,
2026-03-02 04:38:50 -05:00
DIFF_OUTPUT_FORMATS ,
2026-02-28 18:38:00 -05:00
DIFF_THEMES ,
type DiffInput ,
2026-03-02 04:38:50 -05:00
type DiffImageQualityPreset ,
2026-02-28 18:38:00 -05:00
type DiffLayout ,
type DiffMode ,
2026-03-02 04:38:50 -05:00
type DiffOutputFormat ,
2026-02-28 18:38:00 -05:00
type DiffTheme ,
} from "./types.js" ;
import { buildViewerUrl , normalizeViewerBaseUrl } from "./url.js" ;
2026-03-02 05:07:04 +00:00
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 ;
2026-02-28 18:38:00 -05:00
function stringEnum < T extends readonly string [ ] > ( values : T , description : string ) {
return Type . Unsafe < T [ number ] > ( {
type : "string" ,
enum : [ . . . values ] ,
description ,
} ) ;
}
const DiffsToolSchema = Type . Object (
{
before : Type.Optional ( Type . String ( { description : "Original text content." } ) ) ,
after : Type.Optional ( Type . String ( { description : "Updated text content." } ) ) ,
2026-03-02 05:07:04 +00:00
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 ,
} ) ,
) ,
2026-02-28 18:38:00 -05:00
lang : Type.Optional (
2026-03-02 05:07:04 +00:00
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 ,
} ) ,
2026-02-28 18:38:00 -05:00
) ,
mode : Type.Optional (
2026-03-02 04:38:50 -05:00
stringEnum ( DIFF_MODES , "Output mode: view, file, image, or both. Default: both." ) ,
2026-02-28 18:38:00 -05:00
) ,
theme : Type.Optional ( stringEnum ( DIFF_THEMES , "Viewer theme. Default: dark." ) ) ,
layout : Type.Optional ( stringEnum ( DIFF_LAYOUTS , "Diff layout. Default: unified." ) ) ,
2026-03-02 04:38:50 -05:00
fileQuality : Type.Optional (
stringEnum ( DIFF_IMAGE_QUALITY_PRESETS , "File quality preset: standard, hq, or print." ) ,
) ,
fileFormat : Type.Optional ( stringEnum ( DIFF_OUTPUT_FORMATS , "Rendered file format: png or pdf." ) ) ,
fileScale : Type.Optional (
Type . Number ( {
description : "Optional rendered-file device scale factor override (1-4)." ,
minimum : 1 ,
maximum : 4 ,
} ) ,
) ,
fileMaxWidth : Type.Optional (
Type . Number ( {
description : "Optional rendered-file max width in CSS pixels (640-2400)." ,
minimum : 640 ,
maximum : 2400 ,
} ) ,
) ,
imageQuality : Type.Optional (
stringEnum ( DIFF_IMAGE_QUALITY_PRESETS , "Deprecated alias for fileQuality." ) ,
) ,
imageFormat : Type.Optional ( stringEnum ( DIFF_OUTPUT_FORMATS , "Deprecated alias for fileFormat." ) ) ,
imageScale : Type.Optional (
Type . Number ( {
description : "Deprecated alias for fileScale." ,
minimum : 1 ,
maximum : 4 ,
} ) ,
) ,
imageMaxWidth : Type.Optional (
Type . Number ( {
description : "Deprecated alias for fileMaxWidth." ,
minimum : 640 ,
maximum : 2400 ,
} ) ,
) ,
2026-02-28 18:38:00 -05:00
expandUnchanged : Type.Optional (
Type . Boolean ( { description : "Expand unchanged sections instead of collapsing them." } ) ,
) ,
ttlSeconds : Type.Optional (
Type . Number ( {
description : "Artifact lifetime in seconds. Default: 1800. Maximum: 21600." ,
minimum : 1 ,
maximum : 21_600 ,
} ) ,
) ,
baseUrl : Type.Optional (
Type . String ( {
description :
"Optional gateway base URL override used when building the viewer URL, for example https://gateway.example.com." ,
} ) ,
) ,
} ,
{ additionalProperties : false } ,
) ;
type DiffsToolParams = Static < typeof DiffsToolSchema > ;
2026-03-02 04:38:50 -05:00
type DiffsToolRawParams = DiffsToolParams & {
// Keep backward compatibility for direct calls that still pass `format`.
format? : DiffOutputFormat ;
} ;
2026-02-28 18:38:00 -05:00
export function createDiffsTool ( params : {
api : OpenClawPluginApi ;
store : DiffArtifactStore ;
2026-02-28 19:20:07 -05:00
defaults : DiffToolDefaults ;
2026-02-28 18:38:00 -05:00
screenshotter? : DiffScreenshotter ;
} ) : AnyAgentTool {
return {
name : "diffs" ,
label : "Diffs" ,
description :
2026-03-02 04:38:50 -05:00
"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." ,
2026-02-28 18:38:00 -05:00
parameters : DiffsToolSchema ,
execute : async ( _toolCallId , rawParams ) = > {
2026-03-02 04:38:50 -05:00
const toolParams = rawParams as DiffsToolRawParams ;
2026-02-28 18:38:00 -05:00
const input = normalizeDiffInput ( toolParams ) ;
2026-02-28 19:20:07 -05:00
const mode = normalizeMode ( toolParams . mode , params . defaults . mode ) ;
const theme = normalizeTheme ( toolParams . theme , params . defaults . theme ) ;
const layout = normalizeLayout ( toolParams . layout , params . defaults . layout ) ;
2026-02-28 18:38:00 -05:00
const expandUnchanged = toolParams . expandUnchanged === true ;
const ttlMs = normalizeTtlMs ( toolParams . ttlSeconds ) ;
2026-03-02 04:38:50 -05:00
const image = resolveDiffImageRenderOptions ( {
defaults : params.defaults ,
fileFormat : normalizeOutputFormat (
toolParams . fileFormat ? ? toolParams . imageFormat ? ? toolParams . format ,
) ,
fileQuality : normalizeFileQuality ( toolParams . fileQuality ? ? toolParams . imageQuality ) ,
fileScale : toolParams.fileScale ? ? toolParams . imageScale ,
fileMaxWidth : toolParams.fileMaxWidth ? ? toolParams . imageMaxWidth ,
} ) ;
2026-02-28 18:38:00 -05:00
const rendered = await renderDiffDocument ( input , {
2026-02-28 19:20:07 -05:00
presentation : {
. . . params . defaults ,
layout ,
theme ,
} ,
2026-03-02 04:38:50 -05:00
image ,
2026-02-28 18:38:00 -05:00
expandUnchanged ,
} ) ;
2026-02-28 20:19:13 -05:00
const screenshotter =
params . screenshotter ? ? new PlaywrightDiffScreenshotter ( { config : params.api.config } ) ;
2026-03-02 04:38:50 -05:00
if ( isArtifactOnlyMode ( mode ) ) {
const artifactFile = await renderDiffArtifactFile ( {
screenshotter ,
store : params.store ,
2026-02-28 20:19:13 -05:00
html : rendered.imageHtml ,
theme ,
2026-03-02 04:38:50 -05:00
image ,
ttlMs ,
2026-02-28 20:19:13 -05:00
} ) ;
return {
content : [
{
type : "text" ,
2026-03-02 10:37:43 -05:00
text : buildFileArtifactMessage ( {
format : image.format ,
filePath : artifactFile.path ,
} ) ,
2026-02-28 20:19:13 -05:00
} ,
] ,
2026-03-02 12:13:21 +00:00
details : buildArtifactDetails ( {
baseDetails : {
title : rendered.title ,
inputKind : rendered.inputKind ,
fileCount : rendered.fileCount ,
mode ,
} ,
artifactFile ,
image ,
} ) ,
2026-02-28 20:19:13 -05:00
} ;
}
2026-02-28 18:38:00 -05:00
const artifact = await params . store . createArtifact ( {
html : rendered.html ,
title : rendered.title ,
inputKind : rendered.inputKind ,
fileCount : rendered.fileCount ,
ttlMs ,
} ) ;
const viewerUrl = buildViewerUrl ( {
config : params.api.config ,
viewerPath : artifact.viewerPath ,
baseUrl : normalizeBaseUrl ( toolParams . baseUrl ) ,
} ) ;
const baseDetails = {
artifactId : artifact.id ,
viewerUrl ,
viewerPath : artifact.viewerPath ,
title : artifact.title ,
expiresAt : artifact.expiresAt ,
inputKind : artifact.inputKind ,
fileCount : artifact.fileCount ,
mode ,
} ;
if ( mode === "view" ) {
return {
content : [
{
type : "text" ,
text : ` Diff viewer ready. \ n ${ viewerUrl } ` ,
} ,
] ,
details : baseDetails ,
} ;
}
try {
2026-03-02 04:38:50 -05:00
const artifactFile = await renderDiffArtifactFile ( {
screenshotter ,
store : params.store ,
artifactId : artifact.id ,
2026-02-28 20:19:13 -05:00
html : rendered.imageHtml ,
2026-02-28 18:38:00 -05:00
theme ,
2026-03-02 04:38:50 -05:00
image ,
2026-02-28 18:38:00 -05:00
} ) ;
2026-03-02 04:38:50 -05:00
await params . store . updateFilePath ( artifact . id , artifactFile . path ) ;
2026-02-28 18:38:00 -05:00
return {
content : [
{
type : "text" ,
2026-03-02 10:37:43 -05:00
text : buildFileArtifactMessage ( {
format : image.format ,
filePath : artifactFile.path ,
viewerUrl ,
} ) ,
2026-02-28 18:38:00 -05:00
} ,
] ,
2026-03-02 12:13:21 +00:00
details : buildArtifactDetails ( {
baseDetails ,
artifactFile ,
image ,
} ) ,
2026-02-28 18:38:00 -05:00
} ;
} catch ( error ) {
if ( mode === "both" ) {
return {
content : [
{
type : "text" ,
text :
` Diff viewer ready. \ n ${ viewerUrl } \ n ` +
2026-03-02 04:38:50 -05:00
` File rendering failed: ${ error instanceof Error ? error.message : String ( error ) } ` ,
2026-02-28 18:38:00 -05:00
} ,
] ,
details : {
. . . baseDetails ,
2026-03-02 04:38:50 -05:00
fileError : error instanceof Error ? error.message : String ( error ) ,
2026-02-28 18:38:00 -05:00
imageError : error instanceof Error ? error.message : String ( error ) ,
} ,
} ;
}
throw error ;
}
} ,
} ;
}
2026-03-02 04:38:50 -05:00
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" ;
}
2026-03-02 12:13:21 +00:00
function buildArtifactDetails ( params : {
baseDetails : Record < string , unknown > ;
artifactFile : { path : string ; bytes : number } ;
image : DiffRenderOptions [ "image" ] ;
} ) {
return {
. . . params . baseDetails ,
filePath : params.artifactFile.path ,
imagePath : params.artifactFile.path ,
path : params.artifactFile.path ,
fileBytes : params.artifactFile.bytes ,
imageBytes : params.artifactFile.bytes ,
format : params.image.format ,
fileFormat : params.image.format ,
fileQuality : params.image.qualityPreset ,
imageQuality : params.image.qualityPreset ,
fileScale : params.image.scale ,
imageScale : params.image.scale ,
fileMaxWidth : params.image.maxWidth ,
imageMaxWidth : params.image.maxWidth ,
} ;
}
2026-03-02 10:37:43 -05:00
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" ) ;
}
2026-03-02 04:38:50 -05:00
async function renderDiffArtifactFile ( params : {
screenshotter : DiffScreenshotter ;
store : DiffArtifactStore ;
artifactId? : string ;
html : string ;
theme : DiffTheme ;
image : DiffRenderOptions [ "image" ] ;
ttlMs? : number ;
} ) : Promise < { path : string ; bytes : number } > {
const outputPath = params . artifactId
? params . store . allocateFilePath ( params . artifactId , params . image . format )
: (
await params . store . createStandaloneFileArtifact ( {
format : params.image.format ,
ttlMs : params.ttlMs ,
} )
) . filePath ;
await params . screenshotter . screenshotHtml ( {
html : params.html ,
outputPath ,
theme : params.theme ,
image : params.image ,
} ) ;
const stats = await fs . stat ( outputPath ) ;
return {
path : outputPath ,
bytes : stats.size ,
} ;
}
2026-02-28 18:38:00 -05:00
function normalizeDiffInput ( params : DiffsToolParams ) : DiffInput {
const patch = params . patch ? . trim ( ) ;
const before = params . before ;
const after = params . after ;
if ( patch ) {
2026-03-02 05:07:04 +00:00
assertMaxBytes ( patch , "patch" , MAX_PATCH_BYTES ) ;
2026-02-28 18:38:00 -05:00
if ( before !== undefined || after !== undefined ) {
throw new PluginToolInputError ( "Provide either patch or before/after input, not both." ) ;
}
2026-03-02 05:07:04 +00:00
const title = params . title ? . trim ( ) ;
if ( title ) {
assertMaxBytes ( title , "title" , MAX_TITLE_BYTES ) ;
}
2026-02-28 18:38:00 -05:00
return {
kind : "patch" ,
patch ,
2026-03-02 05:07:04 +00:00
title ,
2026-02-28 18:38:00 -05:00
} ;
}
if ( before === undefined || after === undefined ) {
throw new PluginToolInputError ( "Provide patch or both before and after text." ) ;
}
2026-03-02 05:07:04 +00:00
assertMaxBytes ( before , "before" , MAX_BEFORE_AFTER_BYTES ) ;
assertMaxBytes ( after , "after" , MAX_BEFORE_AFTER_BYTES ) ;
const path = params . path ? . trim ( ) || undefined ;
const lang = params . lang ? . trim ( ) || undefined ;
const title = params . title ? . trim ( ) || undefined ;
if ( path ) {
assertMaxBytes ( path , "path" , MAX_PATH_BYTES ) ;
}
if ( lang ) {
assertMaxBytes ( lang , "lang" , MAX_LANG_BYTES ) ;
}
if ( title ) {
assertMaxBytes ( title , "title" , MAX_TITLE_BYTES ) ;
}
2026-02-28 18:38:00 -05:00
return {
kind : "before_after" ,
before ,
after ,
2026-03-02 05:07:04 +00:00
path ,
lang ,
title ,
2026-02-28 18:38:00 -05:00
} ;
}
2026-03-02 04:38:50 -05:00
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). ` ) ;
}
2026-02-28 18:38:00 -05:00
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 } ` ) ;
}
}
2026-02-28 19:20:07 -05:00
function normalizeMode ( mode : DiffMode | undefined , fallback : DiffMode ) : DiffMode {
return mode && DIFF_MODES . includes ( mode ) ? mode : fallback ;
2026-02-28 18:38:00 -05:00
}
2026-02-28 19:20:07 -05:00
function normalizeTheme ( theme : DiffTheme | undefined , fallback : DiffTheme ) : DiffTheme {
return theme && DIFF_THEMES . includes ( theme ) ? theme : fallback ;
2026-02-28 18:38:00 -05:00
}
2026-02-28 19:20:07 -05:00
function normalizeLayout ( layout : DiffLayout | undefined , fallback : DiffLayout ) : DiffLayout {
return layout && DIFF_LAYOUTS . includes ( layout ) ? layout : fallback ;
2026-02-28 18:38:00 -05:00
}
function normalizeTtlMs ( ttlSeconds? : number ) : number | undefined {
if ( ! Number . isFinite ( ttlSeconds ) || ttlSeconds === undefined ) {
return undefined ;
}
return Math . floor ( ttlSeconds * 1000 ) ;
}
class PluginToolInputError extends Error {
constructor ( message : string ) {
super ( message ) ;
this . name = "ToolInputError" ;
}
}