@openclaw/diffs 2026.7.2-beta.2 → 2026.7.2-beta.3

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
@@ -220,7 +220,8 @@ diff --git a/src/example.ts b/src/example.ts
220
220
  - Multi-file patches start with a changed-files summary card: totals, per-file `+N`/`-N` stats, change badges, and anchor links.
221
221
  - Rendered PNG/PDF files keep the per-file header counts but omit the interactive view toggles.
222
222
  - The viewer is hosted locally through the gateway under `/plugins/diffs/...`.
223
- - Artifacts are ephemeral and stored in the plugin temp subfolder (`$TMPDIR/openclaw-diffs`).
223
+ - Viewer HTML and metadata are ephemeral SQLite plugin blobs. The URL token is returned to the caller while SQLite stores only its SHA-256 hash.
224
+ - Rendered PNG/PDF files remain temporary materializations in `$TMPDIR/openclaw-diffs` because delivery APIs require a file path. No JSON metadata sidecars are written or imported.
224
225
  - Default viewer URLs use loopback (`127.0.0.1`) unless you set plugin `viewerBaseUrl`, pass `baseUrl`, or use `gateway.bind=custom` + `gateway.customBindHost`.
225
226
  - If `gateway.trustedProxies` includes loopback for a same-host proxy (for example Tailscale Serve), raw `127.0.0.1` viewer requests without forwarded client-IP headers fail closed by design.
226
227
  - In that topology, prefer `mode=file` / `mode=both` for attachments, or intentionally enable remote viewers and set plugin `viewerBaseUrl` (or pass a proxy/public `baseUrl`) when you need a shareable viewer URL.
package/dist/index.js CHANGED
@@ -10,8 +10,9 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "opencl
10
10
  import crypto from "node:crypto";
11
11
  import fs$1 from "node:fs/promises";
12
12
  import { fileURLToPath } from "node:url";
13
+ import { gunzip, gzip } from "node:zlib";
13
14
  import { MAX_DATE_TIMESTAMP_MS, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
14
- import { root, safeEqualSecret, writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
15
+ import { safeEqualSecret, writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
15
16
  import { optionalFiniteNumberSchema, stringEnum } from "openclaw/plugin-sdk/channel-actions";
16
17
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
17
18
  import { readFiniteNumberParam } from "openclaw/plugin-sdk/param-readers";
@@ -472,19 +473,20 @@ function createDiffsHttpHandler(params) {
472
473
  respondText(res, 404, "Diff not found");
473
474
  return true;
474
475
  }
475
- if (!await params.store.getArtifact(id, token)) {
476
- recordRemoteFailure(viewerFailureLimiter, access);
477
- respondText(res, 404, "Diff not found or expired");
478
- return true;
479
- }
480
476
  try {
481
- const html = await params.store.readHtml(id);
477
+ const viewer = await params.store.readAuthorizedViewer(id, token);
478
+ if (!viewer) {
479
+ recordRemoteFailure(viewerFailureLimiter, access);
480
+ respondText(res, 404, "Diff not found or expired");
481
+ return true;
482
+ }
482
483
  resetRemoteFailures(viewerFailureLimiter, access);
483
484
  res.statusCode = 200;
484
485
  setSharedHeaders(res, "text/html; charset=utf-8");
485
486
  res.setHeader("content-security-policy", VIEWER_CONTENT_SECURITY_POLICY);
487
+ res.setHeader("content-length", String(viewer.html.byteLength));
486
488
  if (req.method === "HEAD") res.end();
487
- else res.end(html);
489
+ else res.end(Buffer.from(viewer.html));
488
490
  return true;
489
491
  } catch (error) {
490
492
  recordRemoteFailure(viewerFailureLimiter, access);
@@ -634,134 +636,167 @@ const DEFAULT_TTL_MS = 1800 * 1e3;
634
636
  const MAX_TTL_MS = 360 * 60 * 1e3;
635
637
  const SWEEP_FALLBACK_AGE_MS = 1440 * 60 * 1e3;
636
638
  const DEFAULT_CLEANUP_INTERVAL_MS = 300 * 1e3;
639
+ const MAX_DECODED_HTML_BYTES = 64 * 1024 * 1024;
640
+ const ARTIFACT_ID_ATTEMPTS = 8;
637
641
  const VIEWER_PREFIX = "/plugins/diffs/view";
642
+ const EMPTY_BLOB = /* @__PURE__ */ new Uint8Array();
643
+ function isBlobLimitError(error) {
644
+ return typeof error === "object" && error !== null && "code" in error && error.code === "PLUGIN_BLOB_LIMIT_EXCEEDED";
645
+ }
638
646
  var DiffArtifactStore = class {
639
647
  constructor(params) {
648
+ this.renderingFileIds = /* @__PURE__ */ new Set();
640
649
  this.cleanupInFlight = null;
641
650
  this.nextCleanupAt = 0;
642
651
  this.rootDir = path.resolve(params.rootDir);
652
+ this.blobStore = params.blobStore;
643
653
  this.logger = params.logger;
644
654
  this.cleanupIntervalMs = params.cleanupIntervalMs === void 0 ? DEFAULT_CLEANUP_INTERVAL_MS : Math.max(0, Math.floor(params.cleanupIntervalMs));
645
655
  }
646
656
  async createArtifact(params) {
647
- await this.ensureRoot();
648
- const id = crypto.randomBytes(10).toString("hex");
657
+ const html = Buffer.from(params.html, "utf8");
658
+ if (html.byteLength > MAX_DECODED_HTML_BYTES) throw new Error(`Diff viewer HTML exceeds ${MAX_DECODED_HTML_BYTES} bytes.`);
659
+ const compressedHtml = await gzipAsync(html);
649
660
  const token = crypto.randomBytes(24).toString("hex");
650
- const artifactDir = this.artifactDir(id);
651
- const htmlPath = path.join(artifactDir, "viewer.html");
652
661
  const ttlMs = normalizeTtlMs$1(params.ttlMs);
653
- const createdAt = /* @__PURE__ */ new Date();
654
- const createdAtIso = createdAt.toISOString();
655
- const expiresAt = resolveExpiresAtIso(createdAt.getTime(), ttlMs);
656
- const meta = {
657
- id,
658
- token,
662
+ const metadata = {
663
+ version: 1,
664
+ kind: "viewer",
665
+ encoding: "gzip",
666
+ tokenHash: hashToken(token),
659
667
  title: params.title,
660
668
  inputKind: params.inputKind,
661
669
  fileCount: params.fileCount,
662
- createdAt: createdAtIso,
663
- expiresAt,
664
- viewerPath: `${VIEWER_PREFIX}/${id}/${token}`,
665
- htmlPath,
670
+ decodedBytes: html.byteLength,
666
671
  ...params.context ? { context: params.context } : {}
667
672
  };
668
- const root = await this.artifactRoot();
669
- await root.mkdir(id);
670
- await root.write(path.posix.join(id, "viewer.html"), params.html);
671
- await this.writeMeta(meta);
673
+ const entry = await this.registerUnique(compressedHtml, metadata, ttlMs);
672
674
  this.scheduleCleanup();
673
- return meta;
675
+ return viewerEntryToMeta(entry, token);
674
676
  }
675
- async getArtifact(id, token) {
676
- const meta = await this.readMeta(id);
677
- if (!meta) return null;
678
- if (!safeEqualSecret(token, meta.token)) return null;
679
- if (isExpired(meta)) {
680
- await this.deleteArtifact(id);
677
+ async readAuthorizedViewer(id, token) {
678
+ if (!DIFF_ARTIFACT_ID_PATTERN.test(id) || !DIFF_ARTIFACT_TOKEN_PATTERN.test(token)) return null;
679
+ const entry = await this.blobStore.lookup(id);
680
+ if (!entry) {
681
+ const expired = await this.blobStore.deleteExpiredKey(id);
682
+ if (expired) await this.deleteExpiredFile(expired);
681
683
  return null;
682
684
  }
683
- return meta;
684
- }
685
- async readHtml(id) {
686
- const meta = await this.readMeta(id);
687
- if (!meta) throw new Error(`Diff artifact not found: ${id}`);
688
- const htmlPath = this.normalizeStoredPath(meta.htmlPath, "htmlPath");
689
- return await (await this.artifactRoot()).readText(this.relativeStoredPath(htmlPath));
690
- }
691
- async updateFilePath(id, filePath) {
692
- const meta = await this.readMeta(id);
693
- if (!meta) throw new Error(`Diff artifact not found: ${id}`);
694
- const normalizedFilePath = this.normalizeStoredPath(filePath, "filePath");
695
- const next = {
696
- ...meta,
697
- filePath: normalizedFilePath,
698
- imagePath: normalizedFilePath
685
+ if (!isViewerMetadata(entry.metadata)) return null;
686
+ if (!safeEqualSecret(hashToken(token), entry.metadata.tokenHash)) return null;
687
+ const html = await gunzipAsync(entry.bytes, MAX_DECODED_HTML_BYTES);
688
+ if (html.byteLength !== entry.metadata.decodedBytes) throw new Error(`Diff artifact ${id} decoded size does not match its metadata.`);
689
+ return {
690
+ artifact: viewerEntryToMeta(entry, token),
691
+ html
699
692
  };
700
- await this.writeMeta(next);
701
- return next;
702
- }
703
- async updateImagePath(id, imagePath) {
704
- return this.updateFilePath(id, imagePath);
705
- }
706
- allocateFilePath(id, format = "png") {
707
- return path.join(this.artifactDir(id), `preview.${format}`);
708
693
  }
709
694
  async createStandaloneFileArtifact(params = {}) {
710
- await this.ensureRoot();
711
- const id = crypto.randomBytes(10).toString("hex");
712
- const artifactDir = this.artifactDir(id);
713
695
  const format = params.format ?? "png";
714
- const filePath = path.join(artifactDir, `preview.${format}`);
715
696
  const ttlMs = normalizeTtlMs$1(params.ttlMs);
716
- const createdAt = /* @__PURE__ */ new Date();
717
- const meta = {
718
- kind: "standalone_file",
719
- id,
720
- createdAt: createdAt.toISOString(),
721
- expiresAt: resolveExpiresAtIso(createdAt.getTime(), ttlMs),
722
- filePath: this.normalizeStoredPath(filePath, "filePath"),
697
+ const metadata = {
698
+ version: 1,
699
+ kind: "rendered_file",
700
+ format,
723
701
  ...params.context ? { context: params.context } : {}
724
702
  };
725
- await (await this.artifactRoot()).mkdir(id);
726
- await this.writeStandaloneMeta(meta);
727
- this.scheduleCleanup();
728
- return {
729
- id,
730
- filePath: meta.filePath,
731
- expiresAt: meta.expiresAt,
732
- ...meta.context ? { context: meta.context } : {}
733
- };
703
+ for (let attempt = 0; attempt < ARTIFACT_ID_ATTEMPTS; attempt += 1) {
704
+ const id = crypto.randomBytes(10).toString("hex");
705
+ if (!await this.registerIfAbsentWithCleanup(id, EMPTY_BLOB, metadata, ttlMs)) continue;
706
+ const artifactDir = this.artifactDir(id);
707
+ try {
708
+ await fs$1.mkdir(this.rootDir, { recursive: true });
709
+ await fs$1.mkdir(artifactDir);
710
+ const entry = await this.blobStore.lookup(id);
711
+ if (!entry || !isRenderedFileMetadata(entry.metadata)) throw new Error(`Diff file artifact expired before materialization: ${id}`);
712
+ this.renderingFileIds.add(id);
713
+ this.scheduleCleanup();
714
+ return {
715
+ id,
716
+ filePath: path.join(artifactDir, `preview.${format}`),
717
+ expiresAt: resolveEntryExpiresAt(entry),
718
+ ...params.context ? { context: params.context } : {}
719
+ };
720
+ } catch (error) {
721
+ await this.blobStore.delete(id).catch(() => false);
722
+ await fs$1.rm(artifactDir, {
723
+ recursive: true,
724
+ force: true
725
+ }).catch(() => {});
726
+ if (isFileExists(error)) continue;
727
+ throw error;
728
+ }
729
+ }
730
+ throw new Error("Failed to allocate a unique diff file artifact id.");
731
+ }
732
+ async completeFileArtifact(id) {
733
+ try {
734
+ const entry = await this.blobStore.lookup(id);
735
+ if (!entry || !isRenderedFileMetadata(entry.metadata)) {
736
+ await fs$1.rm(this.artifactDir(id), {
737
+ recursive: true,
738
+ force: true
739
+ }).catch(() => {});
740
+ throw new Error(`Diff file artifact expired during rendering: ${id}`);
741
+ }
742
+ } finally {
743
+ this.renderingFileIds.delete(id);
744
+ }
734
745
  }
735
- allocateImagePath(id, format = "png") {
736
- return this.allocateFilePath(id, format);
746
+ async deleteFileArtifact(id) {
747
+ this.renderingFileIds.delete(id);
748
+ await this.blobStore.delete(id).catch(() => false);
749
+ await fs$1.rm(this.artifactDir(id), {
750
+ recursive: true,
751
+ force: true
752
+ }).catch(() => {});
737
753
  }
738
754
  scheduleCleanup() {
739
755
  this.maybeCleanupExpired();
740
756
  }
741
757
  async cleanupExpired() {
742
- const entries = await (await this.artifactRoot()).list("", { withFileTypes: true }).catch(() => []);
758
+ const expired = await this.blobStore.deleteExpired();
759
+ await Promise.all(expired.map(async (entry) => await this.deleteExpiredFile(entry)));
760
+ const entries = await fs$1.readdir(this.rootDir, { withFileTypes: true }).catch((error) => {
761
+ if (isFileNotFound(error)) return [];
762
+ throw error;
763
+ });
743
764
  const now = Date.now();
744
- await Promise.all(entries.filter((entry) => entry.isDirectory).map(async (entry) => {
745
- const id = entry.name;
746
- const meta = await this.readMeta(id);
747
- if (meta) {
748
- if (isExpired(meta)) await this.deleteArtifact(id);
749
- return;
750
- }
751
- const standaloneMeta = await this.readStandaloneMeta(id);
752
- if (standaloneMeta) {
753
- if (isExpired(standaloneMeta)) await this.deleteArtifact(id);
754
- return;
755
- }
756
- if (now - entry.mtimeMs > SWEEP_FALLBACK_AGE_MS) await this.deleteArtifact(id);
765
+ await Promise.all(entries.filter((entry) => entry.isDirectory() && DIFF_ARTIFACT_ID_PATTERN.test(entry.name)).map(async (entry) => {
766
+ if (this.renderingFileIds.has(entry.name) || await this.blobStore.lookup(entry.name)) return;
767
+ const artifactDir = this.artifactDir(entry.name);
768
+ const stats = await fs$1.stat(artifactDir).catch(() => null);
769
+ if (stats && now - stats.mtimeMs > SWEEP_FALLBACK_AGE_MS) await fs$1.rm(artifactDir, {
770
+ recursive: true,
771
+ force: true
772
+ }).catch(() => {});
757
773
  }));
758
774
  }
759
- async ensureRoot() {
760
- await fs$1.mkdir(this.rootDir, { recursive: true });
775
+ async registerUnique(bytes, metadata, ttlMs) {
776
+ for (let attempt = 0; attempt < ARTIFACT_ID_ATTEMPTS; attempt += 1) {
777
+ const id = crypto.randomBytes(10).toString("hex");
778
+ if (!await this.registerIfAbsentWithCleanup(id, bytes, metadata, ttlMs)) continue;
779
+ const entry = await this.blobStore.lookup(id);
780
+ if (entry) return entry;
781
+ }
782
+ throw new Error("Failed to allocate a unique diff artifact id.");
761
783
  }
762
- async artifactRoot() {
763
- await this.ensureRoot();
764
- return await root(this.rootDir);
784
+ async registerIfAbsentWithCleanup(id, bytes, metadata, ttlMs) {
785
+ try {
786
+ return await this.blobStore.registerIfAbsent(id, bytes, metadata, { ttlMs });
787
+ } catch (error) {
788
+ if (!isBlobLimitError(error)) throw error;
789
+ await this.cleanupExpired();
790
+ return await this.blobStore.registerIfAbsent(id, bytes, metadata, { ttlMs });
791
+ }
792
+ }
793
+ async deleteExpiredFile(entry) {
794
+ if (!isRenderedFileMetadata(entry.metadata) || this.renderingFileIds.has(entry.key)) return;
795
+ if (await this.blobStore.lookup(entry.key)) return;
796
+ await fs$1.rm(this.artifactDir(entry.key), {
797
+ recursive: true,
798
+ force: true
799
+ }).catch(() => {});
765
800
  }
766
801
  maybeCleanupExpired() {
767
802
  const now = Date.now();
@@ -776,104 +811,85 @@ var DiffArtifactStore = class {
776
811
  this.cleanupInFlight = cleanupPromise;
777
812
  }
778
813
  artifactDir(id) {
779
- return this.resolveWithinRoot(id);
780
- }
781
- async writeMeta(meta) {
782
- await this.writeJsonMeta(meta.id, "meta.json", meta);
783
- }
784
- async readMeta(id) {
785
- const parsed = await this.readJsonMeta(id, "meta.json", "diff artifact");
786
- if (!parsed) return null;
787
- return parsed;
788
- }
789
- async writeStandaloneMeta(meta) {
790
- await this.writeJsonMeta(meta.id, "file-meta.json", meta);
791
- }
792
- async readStandaloneMeta(id) {
793
- const parsed = await this.readJsonMeta(id, "file-meta.json", "standalone diff");
794
- if (!parsed) return null;
795
- try {
796
- const value = parsed;
797
- if (value.kind !== "standalone_file" || typeof value.id !== "string" || typeof value.createdAt !== "string" || typeof value.expiresAt !== "string" || typeof value.filePath !== "string") return null;
798
- return {
799
- kind: value.kind,
800
- id: value.id,
801
- createdAt: value.createdAt,
802
- expiresAt: value.expiresAt,
803
- filePath: this.normalizeStoredPath(value.filePath, "filePath"),
804
- ...value.context ? { context: normalizeArtifactContext(value.context) } : {}
805
- };
806
- } catch (error) {
807
- this.logger?.warn(`Failed to normalize standalone diff metadata for ${id}: ${String(error)}`);
808
- return null;
809
- }
810
- }
811
- async writeJsonMeta(id, fileName, data) {
812
- await (await this.artifactRoot()).writeJson(path.posix.join(id, fileName), data, { space: 2 });
813
- }
814
- async readJsonMeta(id, fileName, context) {
815
- try {
816
- const raw = await (await this.artifactRoot()).readText(path.posix.join(id, fileName));
817
- return JSON.parse(raw);
818
- } catch (error) {
819
- if (isFileNotFound(error)) return null;
820
- this.logger?.warn(`Failed to read ${context} metadata for ${id}: ${String(error)}`);
821
- return null;
822
- }
823
- }
824
- async deleteArtifact(id) {
825
- await fs$1.rm(this.artifactDir(id), {
826
- recursive: true,
827
- force: true
828
- }).catch(() => {});
829
- }
830
- resolveWithinRoot(...parts) {
831
- const candidate = path.resolve(this.rootDir, ...parts);
832
- this.assertWithinRoot(candidate);
833
- return candidate;
834
- }
835
- normalizeStoredPath(rawPath, label) {
836
- const candidate = path.isAbsolute(rawPath) ? path.resolve(rawPath) : path.resolve(this.rootDir, rawPath);
837
- this.assertWithinRoot(candidate, label);
838
- return candidate;
839
- }
840
- relativeStoredPath(storedPath) {
841
- return path.relative(this.rootDir, this.normalizeStoredPath(storedPath, "path")).split(path.sep).join(path.posix.sep);
842
- }
843
- assertWithinRoot(candidate, label = "path") {
844
- const relative = path.relative(this.rootDir, candidate);
845
- if (relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) return;
846
- throw new Error(`Diff artifact ${label} escapes store root: ${candidate}`);
814
+ if (!DIFF_ARTIFACT_ID_PATTERN.test(id)) throw new Error(`Invalid diff artifact id: ${id}`);
815
+ return path.join(this.rootDir, id);
847
816
  }
848
817
  };
818
+ function viewerEntryToMeta(entry, token) {
819
+ if (!isViewerMetadata(entry.metadata)) throw new Error(`Diff artifact ${entry.key} is not a viewer.`);
820
+ return {
821
+ id: entry.key,
822
+ token,
823
+ createdAt: timestampMsToIsoString(entry.createdAt) ?? "1970-01-01T00:00:00.000Z",
824
+ expiresAt: resolveEntryExpiresAt(entry),
825
+ title: entry.metadata.title,
826
+ inputKind: entry.metadata.inputKind,
827
+ fileCount: entry.metadata.fileCount,
828
+ viewerPath: `${VIEWER_PREFIX}/${entry.key}/${token}`,
829
+ ...entry.metadata.context ? { context: entry.metadata.context } : {}
830
+ };
831
+ }
832
+ function resolveEntryExpiresAt(entry) {
833
+ return timestampMsToIsoString(entry.expiresAt ?? MAX_DATE_TIMESTAMP_MS) ?? timestampMsToIsoString(MAX_DATE_TIMESTAMP_MS) ?? "1970-01-01T00:00:00.000Z";
834
+ }
835
+ function hashToken(token) {
836
+ return crypto.createHash("sha256").update(token).digest("hex");
837
+ }
849
838
  function normalizeTtlMs$1(value) {
850
- if (!Number.isFinite(value) || value === void 0) return DEFAULT_TTL_MS;
851
- const rounded = Math.floor(value);
852
- if (rounded <= 0) return DEFAULT_TTL_MS;
853
- return Math.min(rounded, MAX_TTL_MS);
839
+ const rounded = value === void 0 || !Number.isFinite(value) ? 0 : Math.floor(value);
840
+ const requestedTtlMs = rounded > 0 ? rounded : DEFAULT_TTL_MS;
841
+ const remainingDateRangeMs = Math.floor(MAX_DATE_TIMESTAMP_MS - Date.now());
842
+ return Math.min(requestedTtlMs, MAX_TTL_MS, Math.max(1, remainingDateRangeMs));
843
+ }
844
+ function isViewerMetadata(value) {
845
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
846
+ const metadata = value;
847
+ return metadata.version === 1 && metadata.kind === "viewer" && metadata.encoding === "gzip" && typeof metadata.tokenHash === "string" && /^[0-9a-f]{64}$/u.test(metadata.tokenHash) && typeof metadata.title === "string" && (metadata.inputKind === "before_after" || metadata.inputKind === "patch") && Number.isSafeInteger(metadata.fileCount) && typeof metadata.fileCount === "number" && metadata.fileCount >= 0 && Number.isSafeInteger(metadata.decodedBytes) && typeof metadata.decodedBytes === "number" && metadata.decodedBytes >= 0 && metadata.decodedBytes <= MAX_DECODED_HTML_BYTES && isArtifactContext(metadata.context);
848
+ }
849
+ function isRenderedFileMetadata(value) {
850
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
851
+ const metadata = value;
852
+ return metadata.version === 1 && metadata.kind === "rendered_file" && (metadata.format === "png" || metadata.format === "pdf") && isArtifactContext(metadata.context);
853
+ }
854
+ function isArtifactContext(value) {
855
+ if (value === void 0) return true;
856
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
857
+ const context = value;
858
+ const allowed = /* @__PURE__ */ new Set([
859
+ "agentId",
860
+ "sessionId",
861
+ "messageChannel",
862
+ "agentAccountId"
863
+ ]);
864
+ return Object.entries(context).every(([key, entry]) => allowed.has(key) && (entry === void 0 || typeof entry === "string"));
854
865
  }
855
- function resolveExpiresAtIso(createdAtMs, ttlMs) {
856
- return timestampMsToIsoString(createdAtMs + ttlMs) ?? timestampMsToIsoString(MAX_DATE_TIMESTAMP_MS) ?? "1970-01-01T00:00:00.000Z";
866
+ async function gzipAsync(input) {
867
+ return await new Promise((resolve, reject) => {
868
+ gzip(input, (error, result) => {
869
+ if (error) {
870
+ reject(error);
871
+ return;
872
+ }
873
+ resolve(result);
874
+ });
875
+ });
857
876
  }
858
- function isExpired(meta) {
859
- const expiresAt = Date.parse(meta.expiresAt);
860
- if (!Number.isFinite(expiresAt)) return true;
861
- return Date.now() >= expiresAt;
877
+ async function gunzipAsync(input, maxOutputLength) {
878
+ return await new Promise((resolve, reject) => {
879
+ gunzip(input, { maxOutputLength }, (error, result) => {
880
+ if (error) {
881
+ reject(error);
882
+ return;
883
+ }
884
+ resolve(result);
885
+ });
886
+ });
887
+ }
888
+ function isFileExists(error) {
889
+ return error instanceof Error && "code" in error && error.code === "EEXIST";
862
890
  }
863
891
  function isFileNotFound(error) {
864
- const code = error instanceof Error && "code" in error ? error.code : void 0;
865
- return code === "ENOENT" || code === "not-found";
866
- }
867
- function normalizeArtifactContext(value) {
868
- if (!value || typeof value !== "object" || Array.isArray(value)) return;
869
- const raw = value;
870
- const context = {
871
- agentId: normalizeOptionalString(raw.agentId),
872
- sessionId: normalizeOptionalString(raw.sessionId),
873
- messageChannel: normalizeOptionalString(raw.messageChannel),
874
- agentAccountId: normalizeOptionalString(raw.agentAccountId)
875
- };
876
- return Object.values(context).some((entry) => entry !== void 0) ? context : void 0;
892
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
877
893
  }
878
894
  //#endregion
879
895
  //#region extensions/diffs/src/browser.ts
@@ -2304,12 +2320,12 @@ function createDiffsTool(params) {
2304
2320
  const artifactFile = await renderDiffArtifactFile({
2305
2321
  screenshotter,
2306
2322
  store: params.store,
2307
- artifactId: artifact.id,
2308
2323
  html: requireRenderedHtml(rendered.imageHtml, "image"),
2309
2324
  theme,
2310
- image
2325
+ image,
2326
+ ttlMs,
2327
+ context: artifactContext
2311
2328
  });
2312
- await params.store.updateFilePath(artifact.id, artifactFile.path);
2313
2329
  return {
2314
2330
  content: [{
2315
2331
  type: "text",
@@ -2381,34 +2397,44 @@ function buildFileArtifactMessage(params) {
2381
2397
  return lines.join("\n");
2382
2398
  }
2383
2399
  async function renderDiffArtifactFile(params) {
2384
- const standaloneArtifact = params.artifactId ? void 0 : await params.store.createStandaloneFileArtifact({
2400
+ const fileArtifact = await params.store.createStandaloneFileArtifact({
2385
2401
  format: params.image.format,
2386
2402
  ttlMs: params.ttlMs,
2387
2403
  context: params.context
2388
2404
  });
2389
- const outputPath = params.artifactId ? params.store.allocateFilePath(params.artifactId, params.image.format) : standaloneArtifact.filePath;
2390
- await params.screenshotter.screenshotHtml({
2391
- html: params.html,
2392
- outputPath,
2393
- theme: params.theme,
2394
- image: params.image
2395
- });
2396
- return {
2397
- path: outputPath,
2398
- bytes: (await fs$1.stat(outputPath)).size,
2399
- ...standaloneArtifact?.id ? { artifactId: standaloneArtifact.id } : {},
2400
- ...standaloneArtifact?.expiresAt ? { expiresAt: standaloneArtifact.expiresAt } : {}
2401
- };
2405
+ try {
2406
+ await params.screenshotter.screenshotHtml({
2407
+ html: params.html,
2408
+ outputPath: fileArtifact.filePath,
2409
+ theme: params.theme,
2410
+ image: params.image
2411
+ });
2412
+ const stats = await fs$1.stat(fileArtifact.filePath);
2413
+ await params.store.completeFileArtifact(fileArtifact.id);
2414
+ return {
2415
+ path: fileArtifact.filePath,
2416
+ bytes: stats.size,
2417
+ artifactId: fileArtifact.id,
2418
+ expiresAt: fileArtifact.expiresAt
2419
+ };
2420
+ } catch (error) {
2421
+ await params.store.deleteFileArtifact(fileArtifact.id);
2422
+ throw error;
2423
+ }
2402
2424
  }
2403
2425
  function buildArtifactContext(context) {
2404
2426
  if (!context) return;
2427
+ const agentId = normalizeOptionalString(context.agentId);
2428
+ const sessionId = normalizeOptionalString(context.sessionId);
2429
+ const messageChannel = normalizeOptionalString(context.messageChannel);
2430
+ const agentAccountId = normalizeOptionalString(context.agentAccountId);
2405
2431
  const artifactContext = {
2406
- agentId: normalizeOptionalString(context.agentId),
2407
- sessionId: normalizeOptionalString(context.sessionId),
2408
- messageChannel: normalizeOptionalString(context.messageChannel),
2409
- agentAccountId: normalizeOptionalString(context.agentAccountId)
2432
+ ...agentId ? { agentId } : {},
2433
+ ...sessionId ? { sessionId } : {},
2434
+ ...messageChannel ? { messageChannel } : {},
2435
+ ...agentAccountId ? { agentAccountId } : {}
2410
2436
  };
2411
- return Object.values(artifactContext).some((value) => value !== void 0) ? artifactContext : void 0;
2437
+ return Object.keys(artifactContext).length > 0 ? artifactContext : void 0;
2412
2438
  }
2413
2439
  function normalizeDiffInput(params) {
2414
2440
  const patch = params.patch?.trim();
@@ -2478,9 +2504,20 @@ var PluginToolInputError = class extends Error {
2478
2504
  //#endregion
2479
2505
  //#region extensions/diffs/src/plugin.ts
2480
2506
  const DIFFS_LANGUAGE_PACK_PLUGIN_ID = "diffs-language-pack";
2507
+ const DIFF_ARTIFACT_NAMESPACE = "diff-artifacts";
2508
+ const DIFF_ARTIFACT_MAX_ENTRIES = 2048;
2509
+ const DIFF_ARTIFACT_MAX_BYTES_PER_ENTRY = 32 * 1024 * 1024;
2510
+ const DIFF_ARTIFACT_MAX_BYTES_PER_NAMESPACE = 256 * 1024 * 1024;
2481
2511
  function registerDiffsPlugin(api) {
2482
2512
  const store = new DiffArtifactStore({
2483
2513
  rootDir: path.join(resolvePreferredOpenClawTmpDir(), "openclaw-diffs"),
2514
+ blobStore: api.runtime.state.openBlobStore({
2515
+ namespace: DIFF_ARTIFACT_NAMESPACE,
2516
+ maxEntries: DIFF_ARTIFACT_MAX_ENTRIES,
2517
+ maxBytesPerEntry: DIFF_ARTIFACT_MAX_BYTES_PER_ENTRY,
2518
+ maxBytesPerNamespace: DIFF_ARTIFACT_MAX_BYTES_PER_NAMESPACE,
2519
+ overflowPolicy: "reject-new"
2520
+ }),
2484
2521
  logger: api.logger
2485
2522
  });
2486
2523
  const resolveCurrentPluginConfig = () => resolveLivePluginConfigObject(api.runtime.config?.current ? () => api.runtime.config.current() : void 0, "diffs", api.pluginConfig) ?? {};
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@openclaw/diffs",
3
- "version": "2026.7.2-beta.2",
3
+ "version": "2026.7.2-beta.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@openclaw/diffs",
9
- "version": "2026.7.2-beta.2",
9
+ "version": "2026.7.2-beta.3",
10
10
  "dependencies": {
11
11
  "@pierre/diffs": "1.2.12",
12
12
  "@shikijs/langs": "4.3.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/diffs",
3
- "version": "2026.7.2-beta.2",
3
+ "version": "2026.7.2-beta.3",
4
4
  "description": "OpenClaw read-only diff viewer plugin and file renderer for agents.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,13 +25,13 @@
25
25
  "minHostVersion": ">=2026.4.30"
26
26
  },
27
27
  "compat": {
28
- "pluginApi": ">=2026.7.2-beta.2"
28
+ "pluginApi": ">=2026.7.2-beta.3"
29
29
  },
30
30
  "assetScripts": {
31
31
  "build": "node ../../scripts/build-diffs-viewer-runtime.mjs curated"
32
32
  },
33
33
  "build": {
34
- "openclawVersion": "2026.7.2-beta.2",
34
+ "openclawVersion": "2026.7.2-beta.3",
35
35
  "staticAssets": [
36
36
  {
37
37
  "source": "./assets/viewer-runtime.js",
@@ -55,7 +55,7 @@
55
55
  "skills/**"
56
56
  ],
57
57
  "peerDependencies": {
58
- "openclaw": ">=2026.7.2-beta.2"
58
+ "openclaw": ">=2026.7.2-beta.3"
59
59
  },
60
60
  "peerDependenciesMeta": {
61
61
  "openclaw": {