@openclaw/diffs 2026.5.7 → 2026.5.10-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -106,6 +106,7 @@ Set plugin-wide defaults in `~/.openclaw/openclaw.json`:
106
106
  fileScale: 2,
107
107
  fileMaxWidth: 960,
108
108
  mode: "both",
109
+ ttlSeconds: 21600,
109
110
  },
110
111
  },
111
112
  },
@@ -120,6 +121,7 @@ Security options:
120
121
 
121
122
  - `security.allowRemoteViewer` (default `false`): allows non-loopback access to `/plugins/diffs/view/...` token URLs
122
123
  - `viewerBaseUrl` (optional): persistent viewer-link origin/path fallback for shareable URLs
124
+ - `defaults.ttlSeconds` (default `1800`, max `21600`): default artifact lifetime for viewer and standalone file outputs
123
125
 
124
126
  Example:
125
127
 
package/dist/index.js CHANGED
@@ -10,12 +10,15 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "opencl
10
10
  import crypto from "node:crypto";
11
11
  import fs from "node:fs/promises";
12
12
  import { fileURLToPath } from "node:url";
13
+ import { root, writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
14
+ import { stringEnum } from "openclaw/plugin-sdk/channel-actions";
13
15
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
14
16
  import { Type } from "typebox";
15
17
  import { constants } from "node:fs";
16
18
  import { chromium } from "playwright-core";
17
19
  import { RegisteredCustomThemes, ResolvedThemes, ResolvingThemes, parsePatchFiles, resolveLanguage } from "@pierre/diffs";
18
20
  import { preloadFileDiff, preloadMultiFileDiff } from "@pierre/diffs/ssr";
21
+ import { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
19
22
  //#region extensions/diffs/src/types.ts
20
23
  const DIFF_LAYOUTS = ["unified", "split"];
21
24
  const DIFF_MODES = [
@@ -104,7 +107,8 @@ const DEFAULT_DIFFS_TOOL_DEFAULTS = {
104
107
  fileQuality: "standard",
105
108
  fileScale: DEFAULT_IMAGE_QUALITY_PROFILES.standard.scale,
106
109
  fileMaxWidth: DEFAULT_IMAGE_QUALITY_PROFILES.standard.maxWidth,
107
- mode: "both"
110
+ mode: "both",
111
+ ttlSeconds: 1800
108
112
  };
109
113
  const DEFAULT_DIFFS_PLUGIN_SECURITY = { allowRemoteViewer: false };
110
114
  const VIEWER_BASE_URL_JSON_SCHEMA = {
@@ -143,7 +147,8 @@ const DiffsPluginJsonSchemaSource = z.strictObject({
143
147
  imageQuality: z.enum(DIFF_IMAGE_QUALITY_PRESETS).optional().describe("Deprecated alias for fileQuality."),
144
148
  imageScale: z.number().min(1).max(4).optional().describe("Deprecated alias for fileScale."),
145
149
  imageMaxWidth: z.number().min(640).max(2400).optional().describe("Deprecated alias for fileMaxWidth."),
146
- mode: z.enum(DIFF_MODES).default(DEFAULT_DIFFS_TOOL_DEFAULTS.mode).optional()
150
+ mode: z.enum(DIFF_MODES).default(DEFAULT_DIFFS_TOOL_DEFAULTS.mode).optional(),
151
+ ttlSeconds: z.number().min(1).max(21600).default(DEFAULT_DIFFS_TOOL_DEFAULTS.ttlSeconds).optional()
147
152
  }).optional(),
148
153
  security: z.strictObject({ allowRemoteViewer: z.boolean().default(DEFAULT_DIFFS_PLUGIN_SECURITY.allowRemoteViewer).optional() }).optional()
149
154
  });
@@ -222,7 +227,8 @@ function resolveDiffsPluginDefaults(config) {
222
227
  fileQuality,
223
228
  fileScale: normalizeFileScale(fileScale, profile.scale),
224
229
  fileMaxWidth: normalizeFileMaxWidth(fileMaxWidth, profile.maxWidth),
225
- mode: normalizeMode$1(defaults.mode)
230
+ mode: normalizeMode$1(defaults.mode),
231
+ ttlSeconds: normalizeTtlSeconds(defaults.ttlSeconds)
226
232
  };
227
233
  }
228
234
  function resolveDiffsPluginSecurity(config) {
@@ -276,6 +282,10 @@ function normalizeFileMaxWidth(fileMaxWidth, fallback) {
276
282
  function normalizeMode$1(mode) {
277
283
  return mode && DIFF_MODES.includes(mode) ? mode : DEFAULT_DIFFS_TOOL_DEFAULTS.mode;
278
284
  }
285
+ function normalizeTtlSeconds(ttlSeconds) {
286
+ if (ttlSeconds === void 0 || !Number.isFinite(ttlSeconds)) return DEFAULT_DIFFS_TOOL_DEFAULTS.ttlSeconds;
287
+ return Math.min(Math.max(Math.floor(ttlSeconds), 1), 21600);
288
+ }
279
289
  function resolveDiffImageRenderOptions(params) {
280
290
  const format = normalizeFileFormat(params.fileFormat ?? params.imageFormat ?? params.format ?? params.defaults.fileFormat);
281
291
  const qualityOverrideProvided = params.fileQuality !== void 0 || params.imageQuality !== void 0;
@@ -597,8 +607,9 @@ var DiffArtifactStore = class {
597
607
  htmlPath,
598
608
  ...params.context ? { context: params.context } : {}
599
609
  };
600
- await fs.mkdir(artifactDir, { recursive: true });
601
- await fs.writeFile(htmlPath, params.html, "utf8");
610
+ const root = await this.artifactRoot();
611
+ await root.mkdir(id);
612
+ await root.write(path.posix.join(id, "viewer.html"), params.html);
602
613
  await this.writeMeta(meta);
603
614
  this.scheduleCleanup();
604
615
  return meta;
@@ -617,7 +628,7 @@ var DiffArtifactStore = class {
617
628
  const meta = await this.readMeta(id);
618
629
  if (!meta) throw new Error(`Diff artifact not found: ${id}`);
619
630
  const htmlPath = this.normalizeStoredPath(meta.htmlPath, "htmlPath");
620
- return await fs.readFile(htmlPath, "utf8");
631
+ return await (await this.artifactRoot()).readText(this.relativeStoredPath(htmlPath));
621
632
  }
622
633
  async updateFilePath(id, filePath) {
623
634
  const meta = await this.readMeta(id);
@@ -654,7 +665,7 @@ var DiffArtifactStore = class {
654
665
  filePath: this.normalizeStoredPath(filePath, "filePath"),
655
666
  ...params.context ? { context: params.context } : {}
656
667
  };
657
- await fs.mkdir(artifactDir, { recursive: true });
668
+ await (await this.artifactRoot()).mkdir(id);
658
669
  await this.writeStandaloneMeta(meta);
659
670
  this.scheduleCleanup();
660
671
  return {
@@ -671,10 +682,9 @@ var DiffArtifactStore = class {
671
682
  this.maybeCleanupExpired();
672
683
  }
673
684
  async cleanupExpired() {
674
- await this.ensureRoot();
675
- const entries = await fs.readdir(this.rootDir, { withFileTypes: true }).catch(() => []);
685
+ const entries = await (await this.artifactRoot()).list("", { withFileTypes: true }).catch(() => []);
676
686
  const now = Date.now();
677
- await Promise.all(entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
687
+ await Promise.all(entries.filter((entry) => entry.isDirectory).map(async (entry) => {
678
688
  const id = entry.name;
679
689
  const meta = await this.readMeta(id);
680
690
  if (meta) {
@@ -686,15 +696,16 @@ var DiffArtifactStore = class {
686
696
  if (isExpired(standaloneMeta)) await this.deleteArtifact(id);
687
697
  return;
688
698
  }
689
- const artifactPath = this.artifactDir(id);
690
- const stat = await fs.stat(artifactPath).catch(() => null);
691
- if (!stat) return;
692
- if (now - stat.mtimeMs > SWEEP_FALLBACK_AGE_MS) await this.deleteArtifact(id);
699
+ if (now - entry.mtimeMs > SWEEP_FALLBACK_AGE_MS) await this.deleteArtifact(id);
693
700
  }));
694
701
  }
695
702
  async ensureRoot() {
696
703
  await fs.mkdir(this.rootDir, { recursive: true });
697
704
  }
705
+ async artifactRoot() {
706
+ await this.ensureRoot();
707
+ return await root(this.rootDir);
708
+ }
698
709
  maybeCleanupExpired() {
699
710
  const now = Date.now();
700
711
  if (this.cleanupInFlight || now < this.nextCleanupAt) return;
@@ -740,15 +751,12 @@ var DiffArtifactStore = class {
740
751
  return null;
741
752
  }
742
753
  }
743
- metaFilePath(id, fileName) {
744
- return path.join(this.artifactDir(id), fileName);
745
- }
746
754
  async writeJsonMeta(id, fileName, data) {
747
- await fs.writeFile(this.metaFilePath(id, fileName), JSON.stringify(data, null, 2), "utf8");
755
+ await (await this.artifactRoot()).writeJson(path.posix.join(id, fileName), data, { space: 2 });
748
756
  }
749
757
  async readJsonMeta(id, fileName, context) {
750
758
  try {
751
- const raw = await fs.readFile(this.metaFilePath(id, fileName), "utf8");
759
+ const raw = await (await this.artifactRoot()).readText(path.posix.join(id, fileName));
752
760
  return JSON.parse(raw);
753
761
  } catch (error) {
754
762
  if (isFileNotFound(error)) return null;
@@ -772,6 +780,9 @@ var DiffArtifactStore = class {
772
780
  this.assertWithinRoot(candidate, label);
773
781
  return candidate;
774
782
  }
783
+ relativeStoredPath(storedPath) {
784
+ return path.relative(this.rootDir, this.normalizeStoredPath(storedPath, "path")).split(path.sep).join(path.posix.sep);
785
+ }
775
786
  assertWithinRoot(candidate, label = "path") {
776
787
  const relative = path.relative(this.rootDir, candidate);
777
788
  if (relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) return;
@@ -790,7 +801,8 @@ function isExpired(meta) {
790
801
  return Date.now() >= expiresAt;
791
802
  }
792
803
  function isFileNotFound(error) {
793
- return error instanceof Error && "code" in error && error.code === "ENOENT";
804
+ const code = error instanceof Error && "code" in error ? error.code : void 0;
805
+ return code === "ENOENT" || code === "not-found";
794
806
  }
795
807
  function normalizeArtifactContext(value) {
796
808
  if (!value || typeof value !== "object" || Array.isArray(value)) return;
@@ -819,7 +831,6 @@ var PlaywrightDiffScreenshotter = class {
819
831
  this.browserIdleMs = params.browserIdleMs ?? DEFAULT_BROWSER_IDLE_MS;
820
832
  }
821
833
  async screenshotHtml(params) {
822
- await fs.mkdir(path.dirname(params.outputPath), { recursive: true });
823
834
  const lease = await acquireSharedBrowser({
824
835
  config: this.config,
825
836
  idleMs: this.browserIdleMs
@@ -918,16 +929,22 @@ var PlaywrightDiffScreenshotter = class {
918
929
  const estimatedPixels = pdfWidth * pdfHeight;
919
930
  const estimatedPages = Math.ceil(pdfHeight / PDF_REFERENCE_PAGE_HEIGHT_PX);
920
931
  if (estimatedPixels > params.image.maxPixels || estimatedPages > MAX_PDF_PAGES) throw new Error(IMAGE_SIZE_LIMIT_ERROR);
921
- await page.pdf({
922
- path: params.outputPath,
923
- width: `${pdfWidth}px`,
924
- height: `${pdfHeight}px`,
925
- printBackground: true,
926
- margin: {
927
- top: "0",
928
- right: "0",
929
- bottom: "0",
930
- left: "0"
932
+ const pageForPdf = page;
933
+ await writeExternalArtifactFile({
934
+ outputPath: params.outputPath,
935
+ write: async (tempPath) => {
936
+ await pageForPdf.pdf({
937
+ path: tempPath,
938
+ width: `${pdfWidth}px`,
939
+ height: `${pdfHeight}px`,
940
+ printBackground: true,
941
+ margin: {
942
+ top: "0",
943
+ right: "0",
944
+ bottom: "0",
945
+ left: "0"
946
+ }
947
+ });
931
948
  }
932
949
  });
933
950
  return params.outputPath;
@@ -956,15 +973,21 @@ var PlaywrightDiffScreenshotter = class {
956
973
  }
957
974
  throw new Error(IMAGE_SIZE_LIMIT_ERROR);
958
975
  }
959
- await page.screenshot({
960
- path: params.outputPath,
961
- type: "png",
962
- scale: "device",
963
- clip: {
964
- x,
965
- y,
966
- width: cssWidth,
967
- height: cssHeight
976
+ const pageForScreenshot = page;
977
+ await writeExternalArtifactFile({
978
+ outputPath: params.outputPath,
979
+ write: async (tempPath) => {
980
+ await pageForScreenshot.screenshot({
981
+ path: tempPath,
982
+ type: "png",
983
+ scale: "device",
984
+ clip: {
985
+ x,
986
+ y,
987
+ width: cssWidth,
988
+ height: cssHeight
989
+ }
990
+ });
968
991
  }
969
992
  });
970
993
  return params.outputPath;
@@ -980,6 +1003,15 @@ var PlaywrightDiffScreenshotter = class {
980
1003
  }
981
1004
  }
982
1005
  };
1006
+ async function writeExternalArtifactFile(params) {
1007
+ const rootDir = path.dirname(params.outputPath);
1008
+ await fs.mkdir(rootDir, { recursive: true });
1009
+ await writeExternalFileWithinRoot({
1010
+ rootDir,
1011
+ path: path.basename(params.outputPath),
1012
+ write: params.write
1013
+ });
1014
+ }
983
1015
  function injectBaseHref(html) {
984
1016
  if (html.includes("<base ")) return html;
985
1017
  return html.replace("<head>", `<head><base href="${LOCAL_VIEWER_BASE_HREF}" />`);
@@ -1169,13 +1201,8 @@ async function isExecutable(candidate) {
1169
1201
  //#endregion
1170
1202
  //#region extensions/diffs/src/language-hints.ts
1171
1203
  const PASSTHROUGH_LANGUAGE_HINTS = new Set(["ansi", "text"]);
1172
- function normalizeOptionalString$1(value) {
1173
- if (typeof value !== "string") return;
1174
- const trimmed = value.trim();
1175
- return trimmed ? trimmed : void 0;
1176
- }
1177
1204
  async function normalizeSupportedLanguageHint(value) {
1178
- const normalized = normalizeOptionalString$1(value);
1205
+ const normalized = normalizeOptionalString(value);
1179
1206
  if (!normalized) return;
1180
1207
  if (PASSTHROUGH_LANGUAGE_HINTS.has(normalized)) return normalized;
1181
1208
  try {
@@ -1246,9 +1273,9 @@ function createThemeLoader(themeName, themeSpecifier) {
1246
1273
  let cachedTheme;
1247
1274
  return async () => {
1248
1275
  if (cachedTheme) return cachedTheme;
1249
- const themePath = themeRequire.resolve(themeSpecifier);
1276
+ const { value: theme } = await readJsonFileWithFallback(themeRequire.resolve(themeSpecifier), {});
1250
1277
  cachedTheme = {
1251
- ...JSON.parse(await fs.readFile(themePath, "utf8")),
1278
+ ...theme,
1252
1279
  name: themeName
1253
1280
  };
1254
1281
  return cachedTheme;
@@ -1677,14 +1704,6 @@ const MAX_PATCH_BYTES = 2 * 1024 * 1024;
1677
1704
  const MAX_TITLE_BYTES = 1024;
1678
1705
  const MAX_PATH_BYTES = 2048;
1679
1706
  const MAX_LANG_BYTES = 128;
1680
- function stringEnum(values, description, options = {}) {
1681
- return Type.Unsafe({
1682
- type: "string",
1683
- enum: [...values],
1684
- description,
1685
- ...options
1686
- });
1687
- }
1688
1707
  const DiffsToolSchema = Type.Object({
1689
1708
  before: Type.Optional(Type.String({ description: "Original text content." })),
1690
1709
  after: Type.Optional(Type.String({ description: "Updated text content." })),
@@ -1704,11 +1723,11 @@ const DiffsToolSchema = Type.Object({
1704
1723
  description: "Optional title for the rendered diff.",
1705
1724
  maxLength: MAX_TITLE_BYTES
1706
1725
  })),
1707
- mode: Type.Optional(stringEnum(DIFF_MODES, "Output mode: view, file, image (deprecated alias for file), or both. Default: both.")),
1708
- theme: Type.Optional(stringEnum(DIFF_THEMES, "Viewer theme. Default: dark.")),
1709
- layout: Type.Optional(stringEnum(DIFF_LAYOUTS, "Diff layout. Default: unified.")),
1710
- fileQuality: Type.Optional(stringEnum(DIFF_IMAGE_QUALITY_PRESETS, "File quality preset: standard, hq, or print.")),
1711
- fileFormat: Type.Optional(stringEnum(DIFF_OUTPUT_FORMATS, "Rendered file format: png or pdf.")),
1726
+ mode: Type.Optional(stringEnum(DIFF_MODES, { description: "Output mode: view, file, image (deprecated alias for file), or both. Default: both." })),
1727
+ theme: Type.Optional(stringEnum(DIFF_THEMES, { description: "Viewer theme. Default: dark." })),
1728
+ layout: Type.Optional(stringEnum(DIFF_LAYOUTS, { description: "Diff layout. Default: unified." })),
1729
+ fileQuality: Type.Optional(stringEnum(DIFF_IMAGE_QUALITY_PRESETS, { description: "File quality preset: standard, hq, or print." })),
1730
+ fileFormat: Type.Optional(stringEnum(DIFF_OUTPUT_FORMATS, { description: "Rendered file format: png or pdf." })),
1712
1731
  fileScale: Type.Optional(Type.Number({
1713
1732
  description: "Optional rendered-file device scale factor override (1-4).",
1714
1733
  minimum: 1,
@@ -1720,9 +1739,15 @@ const DiffsToolSchema = Type.Object({
1720
1739
  maximum: 2400
1721
1740
  })),
1722
1741
  /** @deprecated Use fileQuality. */
1723
- imageQuality: Type.Optional(stringEnum(DIFF_IMAGE_QUALITY_PRESETS, "Deprecated alias for fileQuality.", { deprecated: true })),
1742
+ imageQuality: Type.Optional(stringEnum(DIFF_IMAGE_QUALITY_PRESETS, {
1743
+ description: "Deprecated alias for fileQuality.",
1744
+ deprecated: true
1745
+ })),
1724
1746
  /** @deprecated Use fileFormat. */
1725
- imageFormat: Type.Optional(stringEnum(DIFF_OUTPUT_FORMATS, "Deprecated alias for fileFormat.", { deprecated: true })),
1747
+ imageFormat: Type.Optional(stringEnum(DIFF_OUTPUT_FORMATS, {
1748
+ description: "Deprecated alias for fileFormat.",
1749
+ deprecated: true
1750
+ })),
1726
1751
  /** @deprecated Use fileScale. */
1727
1752
  imageScale: Type.Optional(Type.Number({
1728
1753
  description: "Deprecated alias for fileScale.",
@@ -1759,7 +1784,7 @@ function createDiffsTool(params) {
1759
1784
  const theme = normalizeTheme(toolParams.theme, params.defaults.theme);
1760
1785
  const layout = normalizeLayout(toolParams.layout, params.defaults.layout);
1761
1786
  const expandUnchanged = toolParams.expandUnchanged === true;
1762
- const ttlMs = normalizeTtlMs(toolParams.ttlSeconds);
1787
+ const ttlMs = normalizeTtlMs(toolParams.ttlSeconds ?? params.defaults.ttlSeconds);
1763
1788
  const image = resolveDiffImageRenderOptions({
1764
1789
  defaults: params.defaults,
1765
1790
  fileFormat: normalizeOutputFormat(toolParams.fileFormat ?? toolParams.imageFormat ?? toolParams.format),
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "diffs",
3
3
  "activation": {
4
- "onStartup": false
4
+ "onStartup": true
5
5
  },
6
6
  "name": "Diffs",
7
7
  "description": "Read-only diff viewer and file renderer for agents.",
@@ -75,6 +75,10 @@
75
75
  "label": "Default Output Mode",
76
76
  "help": "Tool default when mode is omitted. Use view for canvas/gateway viewer, file for PNG/PDF, or both."
77
77
  },
78
+ "defaults.ttlSeconds": {
79
+ "label": "Default Artifact TTL",
80
+ "help": "Default lifetime in seconds for diff viewer and file artifacts. Maximum: 21600."
81
+ },
78
82
  "security.allowRemoteViewer": {
79
83
  "label": "Allow Remote Viewer",
80
84
  "help": "Allow non-loopback access to diff viewer URLs when the token path is known."
@@ -190,6 +194,12 @@
190
194
  "type": "string",
191
195
  "enum": ["view", "image", "file", "both"],
192
196
  "default": "both"
197
+ },
198
+ "ttlSeconds": {
199
+ "type": "number",
200
+ "minimum": 1,
201
+ "maximum": 21600,
202
+ "default": 1800
193
203
  }
194
204
  }
195
205
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/diffs",
3
- "version": "2026.5.7",
3
+ "version": "2026.5.10-beta.1",
4
4
  "description": "OpenClaw diff viewer plugin",
5
5
  "repository": {
6
6
  "type": "git",
@@ -11,10 +11,10 @@
11
11
  "build:viewer": "bun build src/viewer-client.ts --target browser --format esm --minify --outfile assets/viewer-runtime.js"
12
12
  },
13
13
  "dependencies": {
14
- "@pierre/diffs": "1.1.20",
14
+ "@pierre/diffs": "1.1.21",
15
15
  "@pierre/theme": "0.0.29",
16
16
  "playwright-core": "1.59.1",
17
- "typebox": "1.1.37"
17
+ "typebox": "1.1.38"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@openclaw/plugin-sdk": "workspace:*"
@@ -30,10 +30,10 @@
30
30
  "minHostVersion": ">=2026.4.30"
31
31
  },
32
32
  "compat": {
33
- "pluginApi": ">=2026.5.7"
33
+ "pluginApi": ">=2026.5.10-beta.1"
34
34
  },
35
35
  "build": {
36
- "openclawVersion": "2026.5.7",
36
+ "openclawVersion": "2026.5.10-beta.1",
37
37
  "staticAssets": [
38
38
  {
39
39
  "source": "./assets/viewer-runtime.js",
@@ -56,7 +56,7 @@
56
56
  "skills/**"
57
57
  ],
58
58
  "peerDependencies": {
59
- "openclaw": ">=2026.5.7"
59
+ "openclaw": ">=2026.5.10-beta.1"
60
60
  },
61
61
  "peerDependenciesMeta": {
62
62
  "openclaw": {