diffs plugin

This commit is contained in:
Gustavo Madeira Santana
2026-02-28 18:38:00 -05:00
parent fca0467082
commit 612ed5b3e1
23 changed files with 4067 additions and 4 deletions

124
extensions/diffs/README.md Normal file
View File

@@ -0,0 +1,124 @@
# @openclaw/diffs
Read-only diff viewer plugin for **OpenClaw** agents.
It gives agents one tool, `diffs`, that can:
- render a gateway-hosted diff viewer for canvas use
- render the same diff to a PNG image
- accept either arbitrary `before`/`after` text or a unified patch
## What Agents Get
The tool can return:
- `details.viewerUrl`: a gateway URL that can be opened in the canvas
- `details.imagePath`: a local PNG artifact when image rendering is requested
This means an agent can:
- call `diffs` with `mode=view`, then pass `details.viewerUrl` to `canvas present`
- call `diffs` with `mode=image`, then send the PNG through the normal `message` tool using `path` or `filePath`
- call `diffs` with `mode=both` when it wants both outputs
## Tool Inputs
Before/after:
```json
{
"before": "# Hello\n\nOne",
"after": "# Hello\n\nTwo",
"path": "docs/example.md",
"mode": "view"
}
```
Patch:
```json
{
"patch": "diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-const x = 1;\n+const x = 2;\n",
"mode": "both"
}
```
Useful options:
- `mode`: `view`, `image`, or `both`
- `layout`: `unified` or `split`
- `theme`: `light` or `dark` (default: `dark`)
- `expandUnchanged`: expand unchanged sections
- `path`: display name for before/after input
- `title`: explicit viewer title
- `ttlSeconds`: artifact lifetime
- `baseUrl`: override the gateway base URL used in the returned viewer link
## Example Agent Prompts
Open in canvas:
```text
Use the `diffs` tool in `view` mode for this before/after content, then open the returned viewer URL in the canvas.
Path: docs/example.md
Before:
# Hello
This is version one.
After:
# Hello
This is version two.
```
Render a PNG:
```text
Use the `diffs` tool in `image` mode for this before/after input. After it returns `details.imagePath`, use the `message` tool with `path` or `filePath` to send me the rendered diff image.
Path: README.md
Before:
OpenClaw supports plugins.
After:
OpenClaw supports plugins and hosted diff views.
```
Do both:
```text
Use the `diffs` tool in `both` mode for this diff. Open the viewer in the canvas and then send the rendered PNG by passing `details.imagePath` to the `message` tool.
Path: src/demo.ts
Before:
const status = "old";
After:
const status = "new";
```
Patch input:
```text
Use the `diffs` tool with this unified patch in `view` mode. After it returns the viewer URL, present it in the canvas.
diff --git a/src/example.ts b/src/example.ts
--- a/src/example.ts
+++ b/src/example.ts
@@ -1,3 +1,3 @@
export function add(a: number, b: number) {
- return a + b;
+ return a + b + 1;
}
```
## Notes
- The viewer is hosted locally through the gateway under `/plugins/diffs/...`.
- Artifacts are ephemeral and stored in the local temp directory.
- PNG rendering requires a Chromium-compatible browser. Set `browser.executablePath` if auto-detection is not enough.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from "vitest";
import plugin from "./index.js";
describe("diffs plugin registration", () => {
it("registers the tool, http handler, and prompt guidance hook", () => {
const registerTool = vi.fn();
const registerHttpHandler = vi.fn();
const on = vi.fn();
plugin.register?.({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {},
runtime: {} as never,
logger: {
info() {},
warn() {},
error() {},
},
registerTool,
registerHook() {},
registerHttpHandler,
registerHttpRoute() {},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
registerService() {},
registerProvider() {},
registerCommand() {},
resolvePath(input: string) {
return input;
},
on,
});
expect(registerTool).toHaveBeenCalledTimes(1);
expect(registerHttpHandler).toHaveBeenCalledTimes(1);
expect(on).toHaveBeenCalledTimes(1);
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
});
});

28
extensions/diffs/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
import { createDiffsHttpHandler } from "./src/http.js";
import { DIFFS_AGENT_GUIDANCE } from "./src/prompt-guidance.js";
import { DiffArtifactStore } from "./src/store.js";
import { createDiffsTool } from "./src/tool.js";
const plugin = {
id: "diffs",
name: "Diffs",
description: "Read-only diff viewer and PNG renderer for agents.",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
const store = new DiffArtifactStore({
rootDir: path.join(resolvePreferredOpenClawTmpDir(), "openclaw-diffs"),
logger: api.logger,
});
api.registerTool(createDiffsTool({ api, store }));
api.registerHttpHandler(createDiffsHttpHandler({ store, logger: api.logger }));
api.on("before_prompt_build", async () => ({
prependContext: DIFFS_AGENT_GUIDANCE,
}));
},
};
export default plugin;

View File

@@ -0,0 +1,10 @@
{
"id": "diffs",
"name": "Diffs",
"description": "Read-only diff viewer and image renderer for agents.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,20 @@
{
"name": "@openclaw/diffs",
"version": "2026.2.27",
"private": true,
"description": "OpenClaw diff viewer plugin",
"type": "module",
"scripts": {
"build:viewer": "bun build src/viewer-client.ts --target browser --format esm --minify --outfile assets/viewer-runtime.js"
},
"dependencies": {
"@pierre/diffs": "1.0.11",
"@sinclair/typebox": "0.34.48",
"playwright-core": "1.58.2"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,261 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { chromium } from "playwright-core";
import type { DiffTheme } from "./types.js";
import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js";
export type DiffScreenshotter = {
screenshotHtml(params: { html: string; outputPath: string; theme: DiffTheme }): Promise<string>;
};
export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
private readonly config: OpenClawConfig;
constructor(params: { config: OpenClawConfig }) {
this.config = params.config;
}
async screenshotHtml(params: {
html: string;
outputPath: string;
theme: DiffTheme;
}): Promise<string> {
await fs.mkdir(path.dirname(params.outputPath), { recursive: true });
const executablePath = await resolveBrowserExecutablePath(this.config);
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
try {
browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
args: ["--disable-dev-shm-usage", "--disable-gpu"],
});
const page = await browser.newPage({
viewport: { width: 1200, height: 900 },
colorScheme: params.theme,
});
await page.route(`http://127.0.0.1${VIEWER_ASSET_PREFIX}*`, async (route) => {
const pathname = new URL(route.request().url()).pathname;
const asset = await getServedViewerAsset(pathname);
if (!asset) {
await route.abort();
return;
}
await route.fulfill({
status: 200,
contentType: asset.contentType,
body: asset.body,
});
});
await page.setContent(injectBaseHref(params.html), { waitUntil: "load" });
await page.waitForFunction(
() => {
if (document.documentElement.dataset.openclawDiffsReady === "true") {
return true;
}
return [...document.querySelectorAll("[data-openclaw-diff-host]")].every((element) => {
return (
element instanceof HTMLElement && element.shadowRoot?.querySelector("[data-diffs]")
);
});
},
{
timeout: 10_000,
},
);
await page.evaluate(async () => {
await document.fonts.ready;
});
await page.evaluate(() => {
const frame = document.querySelector(".oc-frame");
if (frame instanceof HTMLElement) {
frame.dataset.renderMode = "image";
}
});
const frame = page.locator(".oc-frame");
await frame.waitFor();
const initialBox = await frame.boundingBox();
if (!initialBox) {
throw new Error("Diff frame did not render.");
}
const padding = 20;
const clipWidth = Math.ceil(initialBox.width + padding * 2);
const clipHeight = Math.ceil(Math.max(initialBox.height + padding * 2, 320));
await page.setViewportSize({
width: Math.max(clipWidth + padding, 900),
height: Math.max(clipHeight + padding, 700),
});
const box = await frame.boundingBox();
if (!box) {
throw new Error("Diff frame was lost after resizing.");
}
await page.screenshot({
path: params.outputPath,
type: "png",
clip: {
x: Math.max(box.x - padding, 0),
y: Math.max(box.y - padding, 0),
width: clipWidth,
height: clipHeight,
},
});
return params.outputPath;
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`Diff image rendering requires a Chromium-compatible browser. Set browser.executablePath or install Chrome/Chromium. ${reason}`,
);
} finally {
await browser?.close().catch(() => {});
}
}
}
function injectBaseHref(html: string): string {
if (html.includes("<base ")) {
return html;
}
return html.replace("<head>", '<head><base href="http://127.0.0.1/" />');
}
async function resolveBrowserExecutablePath(config: OpenClawConfig): Promise<string | undefined> {
const configPath = config.browser?.executablePath?.trim();
if (configPath) {
await assertExecutable(configPath, "browser.executablePath");
return configPath;
}
const envCandidates = [
process.env.OPENCLAW_BROWSER_EXECUTABLE_PATH,
process.env.BROWSER_EXECUTABLE_PATH,
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
]
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value));
for (const candidate of envCandidates) {
if (await isExecutable(candidate)) {
return candidate;
}
}
for (const candidate of await collectExecutableCandidates()) {
if (await isExecutable(candidate)) {
return candidate;
}
}
return undefined;
}
async function collectExecutableCandidates(): Promise<string[]> {
const candidates = new Set<string>();
for (const command of pathCommandsForPlatform()) {
const resolved = await findExecutableInPath(command);
if (resolved) {
candidates.add(resolved);
}
}
for (const candidate of commonExecutablePathsForPlatform()) {
candidates.add(candidate);
}
return [...candidates];
}
function pathCommandsForPlatform(): string[] {
if (process.platform === "win32") {
return ["chrome.exe", "msedge.exe", "brave.exe"];
}
if (process.platform === "darwin") {
return ["google-chrome", "chromium", "msedge", "brave-browser", "brave"];
}
return [
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"msedge",
"brave-browser",
"brave",
];
}
function commonExecutablePathsForPlatform(): string[] {
if (process.platform === "darwin") {
return [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
];
}
if (process.platform === "win32") {
const localAppData = process.env.LOCALAPPDATA ?? "";
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
return [
path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
path.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
path.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
path.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
path.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
path.join(programFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
path.join(programFilesX86, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
];
}
return [
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/msedge",
"/usr/bin/brave-browser",
"/snap/bin/chromium",
];
}
async function findExecutableInPath(command: string): Promise<string | undefined> {
const pathValue = process.env.PATH;
if (!pathValue) {
return undefined;
}
for (const directory of pathValue.split(path.delimiter)) {
if (!directory) {
continue;
}
const candidate = path.join(directory, command);
if (await isExecutable(candidate)) {
return candidate;
}
}
return undefined;
}
async function assertExecutable(candidate: string, label: string): Promise<void> {
if (!(await isExecutable(candidate))) {
throw new Error(`${label} not found or not executable: ${candidate}`);
}
}
async function isExecutable(candidate: string): Promise<boolean> {
try {
await fs.access(candidate, fsConstants.X_OK);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,115 @@
import fs from "node:fs/promises";
import type { IncomingMessage } from "node:http";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js";
import { createDiffsHttpHandler } from "./http.js";
import { DiffArtifactStore } from "./store.js";
describe("createDiffsHttpHandler", () => {
let rootDir: string;
let store: DiffArtifactStore;
beforeEach(async () => {
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-http-"));
store = new DiffArtifactStore({ rootDir });
});
afterEach(async () => {
await fs.rm(rootDir, { recursive: true, force: true });
});
it("serves a stored diff document", async () => {
const artifact = await store.createArtifact({
html: "<html>viewer</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
{
method: "GET",
url: artifact.viewerPath,
} as IncomingMessage,
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("<html>viewer</html>");
expect(res.getHeader("content-security-policy")).toContain("default-src 'none'");
});
it("rejects invalid tokens", async () => {
const artifact = await store.createArtifact({
html: "<html>viewer</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
{
method: "GET",
url: artifact.viewerPath.replace(artifact.token, "bad-token"),
} as IncomingMessage,
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
});
it("rejects malformed artifact ids before reading from disk", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
{
method: "GET",
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
} as IncomingMessage,
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
});
it("serves the shared viewer asset", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
{
method: "GET",
url: "/plugins/diffs/assets/viewer.js",
} as IncomingMessage,
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain("/plugins/diffs/assets/viewer-runtime.js?v=");
});
it("serves the shared viewer runtime asset", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
{
method: "GET",
url: "/plugins/diffs/assets/viewer-runtime.js",
} as IncomingMessage,
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain("openclawDiffsReady");
});
});

View File

@@ -0,0 +1,136 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { PluginLogger } from "openclaw/plugin-sdk";
import type { DiffArtifactStore } from "./store.js";
import { DIFF_ARTIFACT_ID_PATTERN, DIFF_ARTIFACT_TOKEN_PATTERN } from "./types.js";
import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js";
const VIEW_PREFIX = "/plugins/diffs/view/";
const VIEWER_CONTENT_SECURITY_POLICY = [
"default-src 'none'",
"script-src 'self'",
"style-src 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self' data:",
"connect-src 'none'",
"base-uri 'none'",
"frame-ancestors 'self'",
"object-src 'none'",
].join("; ");
export function createDiffsHttpHandler(params: {
store: DiffArtifactStore;
logger?: PluginLogger;
}) {
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
const parsed = parseRequestUrl(req.url);
if (!parsed) {
return false;
}
if (parsed.pathname.startsWith(VIEWER_ASSET_PREFIX)) {
return await serveAsset(req, res, parsed.pathname, params.logger);
}
if (!parsed.pathname.startsWith(VIEW_PREFIX)) {
return false;
}
if (req.method !== "GET" && req.method !== "HEAD") {
respondText(res, 405, "Method not allowed");
return true;
}
const pathParts = parsed.pathname.split("/").filter(Boolean);
const id = pathParts[3];
const token = pathParts[4];
if (
!id ||
!token ||
!DIFF_ARTIFACT_ID_PATTERN.test(id) ||
!DIFF_ARTIFACT_TOKEN_PATTERN.test(token)
) {
respondText(res, 404, "Diff not found");
return true;
}
const artifact = await params.store.getArtifact(id, token);
if (!artifact) {
respondText(res, 404, "Diff not found or expired");
return true;
}
try {
const html = await params.store.readHtml(id);
res.statusCode = 200;
setSharedHeaders(res, "text/html; charset=utf-8");
res.setHeader("content-security-policy", VIEWER_CONTENT_SECURITY_POLICY);
if (req.method === "HEAD") {
res.end();
} else {
res.end(html);
}
return true;
} catch (error) {
params.logger?.warn(`Failed to serve diff artifact ${id}: ${String(error)}`);
respondText(res, 500, "Failed to load diff");
return true;
}
};
}
function parseRequestUrl(rawUrl?: string): URL | null {
if (!rawUrl) {
return null;
}
try {
return new URL(rawUrl, "http://127.0.0.1");
} catch {
return null;
}
}
async function serveAsset(
req: IncomingMessage,
res: ServerResponse,
pathname: string,
logger?: PluginLogger,
): Promise<boolean> {
if (req.method !== "GET" && req.method !== "HEAD") {
respondText(res, 405, "Method not allowed");
return true;
}
try {
const asset = await getServedViewerAsset(pathname);
if (!asset) {
respondText(res, 404, "Asset not found");
return true;
}
res.statusCode = 200;
setSharedHeaders(res, asset.contentType);
if (req.method === "HEAD") {
res.end();
} else {
res.end(asset.body);
}
return true;
} catch (error) {
logger?.warn(`Failed to serve diffs asset ${pathname}: ${String(error)}`);
respondText(res, 500, "Failed to load asset");
return true;
}
}
function respondText(res: ServerResponse, statusCode: number, body: string): void {
res.statusCode = statusCode;
setSharedHeaders(res, "text/plain; charset=utf-8");
res.end(body);
}
function setSharedHeaders(res: ServerResponse, contentType: string): void {
res.setHeader("cache-control", "no-store, max-age=0");
res.setHeader("content-type", contentType);
res.setHeader("x-content-type-options", "nosniff");
res.setHeader("referrer-policy", "no-referrer");
}

View File

@@ -0,0 +1,9 @@
export const DIFFS_AGENT_GUIDANCE = [
"When you need to show edits as a real diff, prefer the `diffs` tool instead of writing a manual summary.",
"The `diffs` tool accepts either `before` + `after` text, or a unified `patch` string.",
"Use `mode=view` when you want an interactive gateway-hosted viewer. After the tool returns, use `details.viewerUrl` with the canvas tool via `canvas present` or `canvas navigate`.",
"Use `mode=image` when you need a rendered PNG. The tool result includes `details.imagePath` for the generated file.",
"When you need to deliver the PNG to a user or channel, do not rely on the raw tool-result image renderer. Instead, call the `message` tool and pass `details.imagePath` through `path` or `filePath`.",
"Use `mode=both` when you want both the gateway viewer URL and the PNG artifact.",
"Good defaults: `theme=dark` for canvas rendering, `layout=unified` for most diffs, and include `path` for before/after text when you know the file name.",
].join("\n");

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { renderDiffDocument } from "./render.js";
describe("renderDiffDocument", () => {
it("renders before/after input into a complete viewer document", async () => {
const rendered = await renderDiffDocument(
{
kind: "before_after",
before: "const value = 1;\n",
after: "const value = 2;\n",
path: "src/example.ts",
},
{
layout: "unified",
expandUnchanged: false,
theme: "light",
},
);
expect(rendered.title).toBe("src/example.ts");
expect(rendered.fileCount).toBe(1);
expect(rendered.html).toContain("data-openclaw-diff-root");
expect(rendered.html).toContain("src/example.ts");
expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js");
expect(rendered.html).not.toContain("fonts.googleapis.com");
});
it("renders multi-file patch input", async () => {
const patch = [
"diff --git a/a.ts b/a.ts",
"--- a/a.ts",
"+++ b/a.ts",
"@@ -1 +1 @@",
"-const a = 1;",
"+const a = 2;",
"diff --git a/b.ts b/b.ts",
"--- a/b.ts",
"+++ b/b.ts",
"@@ -1 +1 @@",
"-const b = 1;",
"+const b = 2;",
].join("\n");
const rendered = await renderDiffDocument(
{
kind: "patch",
patch,
title: "Workspace patch",
},
{
layout: "split",
expandUnchanged: true,
theme: "dark",
},
);
expect(rendered.title).toBe("Workspace patch");
expect(rendered.fileCount).toBe(2);
expect(rendered.html).toContain("Workspace patch");
});
});

View File

@@ -0,0 +1,351 @@
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
import { parsePatchFiles } from "@pierre/diffs";
import { preloadFileDiff, preloadMultiFileDiff } from "@pierre/diffs/ssr";
import type {
DiffInput,
DiffRenderOptions,
DiffViewerOptions,
DiffViewerPayload,
RenderedDiffDocument,
} from "./types.js";
import { VIEWER_LOADER_PATH } from "./viewer-assets.js";
const DEFAULT_FILE_NAME = "diff.txt";
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function escapeJsonScript(value: unknown): string {
return JSON.stringify(value).replaceAll("<", "\\u003c");
}
function buildDiffTitle(input: DiffInput): string {
if (input.title?.trim()) {
return input.title.trim();
}
if (input.kind === "before_after") {
return input.path?.trim() || "Text diff";
}
return "Patch diff";
}
function resolveBeforeAfterFileName(input: Extract<DiffInput, { kind: "before_after" }>): string {
if (input.path?.trim()) {
return input.path.trim();
}
if (input.lang?.trim()) {
return `diff.${input.lang.trim().replace(/^\.+/, "")}`;
}
return DEFAULT_FILE_NAME;
}
function buildDiffOptions(options: DiffRenderOptions): DiffViewerOptions {
return {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: options.layout,
expandUnchanged: options.expandUnchanged,
themeType: options.theme,
overflow: "wrap" as const,
unsafeCSS: `
:host {
--diffs-font-family: "Fira Code", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--diffs-header-font-family: "Fira Code", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--diffs-font-size: 15px;
--diffs-line-height: 24px;
}
[data-diffs-header] {
min-height: 64px;
padding-inline: 18px 14px;
}
[data-header-content] {
gap: 10px;
}
[data-metadata] {
gap: 10px;
}
.oc-diff-toolbar {
display: inline-flex;
align-items: center;
gap: 6px;
margin-inline-start: 6px;
flex: 0 0 auto;
}
.oc-diff-toolbar-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
margin: 0;
border: 0;
border-radius: 0;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.6;
line-height: 0;
overflow: visible;
transition: opacity 120ms ease;
flex: 0 0 auto;
}
.oc-diff-toolbar-button:hover {
opacity: 1;
}
.oc-diff-toolbar-button[data-active="true"] {
opacity: 0.92;
}
.oc-diff-toolbar-button svg {
display: block;
width: 16px;
height: 16px;
min-width: 16px;
min-height: 16px;
overflow: visible;
flex: 0 0 auto;
color: inherit;
fill: currentColor;
stroke: currentColor;
pointer-events: none;
}
`,
};
}
function normalizeSupportedLanguage(value?: string): SupportedLanguages | undefined {
const normalized = value?.trim();
return normalized ? (normalized as SupportedLanguages) : undefined;
}
function buildPayloadLanguages(payload: {
fileDiff?: FileDiffMetadata;
oldFile?: FileContents;
newFile?: FileContents;
}): SupportedLanguages[] {
const langs = new Set<SupportedLanguages>();
if (payload.fileDiff?.lang) {
langs.add(payload.fileDiff.lang);
}
if (payload.oldFile?.lang) {
langs.add(payload.oldFile.lang);
}
if (payload.newFile?.lang) {
langs.add(payload.newFile.lang);
}
if (langs.size === 0) {
langs.add("text");
}
return [...langs];
}
function renderDiffCard(payload: DiffViewerPayload): string {
return `<section class="oc-diff-card">
<diffs-container class="oc-diff-host" data-openclaw-diff-host>
<template shadowrootmode="open">${payload.prerenderedHTML}</template>
</diffs-container>
<script type="application/json" data-openclaw-diff-payload>${escapeJsonScript(payload)}</script>
</section>`;
}
function buildHtmlDocument(params: {
title: string;
bodyHtml: string;
theme: DiffRenderOptions["theme"];
}): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>${escapeHtml(params.title)}</title>
<style>
* {
box-sizing: border-box;
}
html {
background: #05070b;
}
body {
margin: 0;
padding: 22px;
font-family:
"Fira Code",
"SF Mono",
Monaco,
Consolas,
monospace;
background: #05070b;
color: #f8fafc;
}
body[data-theme="light"] {
background: #f3f5f8;
color: #0f172a;
}
.oc-frame {
max-width: 1560px;
margin: 0 auto;
}
.oc-frame[data-render-mode="image"] {
max-width: 1120px;
}
[data-openclaw-diff-root] {
display: grid;
gap: 18px;
}
.oc-diff-card {
overflow: hidden;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(15, 23, 42, 0.14);
box-shadow: 0 18px 48px rgba(2, 6, 23, 0.22);
}
body[data-theme="light"] .oc-diff-card {
border-color: rgba(148, 163, 184, 0.22);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}
.oc-diff-host {
display: block;
}
.oc-frame[data-render-mode="image"] .oc-diff-card {
min-height: 240px;
}
@media (max-width: 720px) {
body {
padding: 12px;
}
[data-openclaw-diff-root] {
gap: 12px;
}
}
</style>
</head>
<body data-theme="${params.theme}">
<main class="oc-frame" data-render-mode="viewer">
<div data-openclaw-diff-root>
${params.bodyHtml}
</div>
</main>
<script type="module" src="${VIEWER_LOADER_PATH}"></script>
</body>
</html>`;
}
async function renderBeforeAfterDiff(
input: Extract<DiffInput, { kind: "before_after" }>,
options: DiffRenderOptions,
): Promise<{ bodyHtml: string; fileCount: number }> {
const fileName = resolveBeforeAfterFileName(input);
const lang = normalizeSupportedLanguage(input.lang);
const oldFile: FileContents = {
name: fileName,
contents: input.before,
...(lang ? { lang } : {}),
};
const newFile: FileContents = {
name: fileName,
contents: input.after,
...(lang ? { lang } : {}),
};
const payloadOptions = buildDiffOptions(options);
const result = await preloadMultiFileDiff({
oldFile,
newFile,
options: payloadOptions,
});
return {
bodyHtml: renderDiffCard({
prerenderedHTML: result.prerenderedHTML,
oldFile: result.oldFile,
newFile: result.newFile,
options: payloadOptions,
langs: buildPayloadLanguages({ oldFile: result.oldFile, newFile: result.newFile }),
}),
fileCount: 1,
};
}
async function renderPatchDiff(
input: Extract<DiffInput, { kind: "patch" }>,
options: DiffRenderOptions,
): Promise<{ bodyHtml: string; fileCount: number }> {
const files = parsePatchFiles(input.patch).flatMap((entry) => entry.files ?? []);
if (files.length === 0) {
throw new Error("Patch input did not contain any file diffs.");
}
const payloadOptions = buildDiffOptions(options);
const sections = await Promise.all(
files.map(async (fileDiff) => {
const result = await preloadFileDiff({
fileDiff,
options: payloadOptions,
});
return renderDiffCard({
prerenderedHTML: result.prerenderedHTML,
fileDiff: result.fileDiff,
options: payloadOptions,
langs: buildPayloadLanguages({ fileDiff: result.fileDiff }),
});
}),
);
return {
bodyHtml: sections.join("\n"),
fileCount: files.length,
};
}
export async function renderDiffDocument(
input: DiffInput,
options: DiffRenderOptions,
): Promise<RenderedDiffDocument> {
const title = buildDiffTitle(input);
const rendered =
input.kind === "before_after"
? await renderBeforeAfterDiff(input, options)
: await renderPatchDiff(input, options);
return {
html: buildHtmlDocument({
title,
bodyHtml: rendered.bodyHtml,
theme: options.theme,
}),
title,
fileCount: rendered.fileCount,
inputKind: input.kind,
};
}

View File

@@ -0,0 +1,64 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DiffArtifactStore } from "./store.js";
describe("DiffArtifactStore", () => {
let rootDir: string;
let store: DiffArtifactStore;
beforeEach(async () => {
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-store-"));
store = new DiffArtifactStore({ rootDir });
});
afterEach(async () => {
vi.useRealTimers();
await fs.rm(rootDir, { recursive: true, force: true });
});
it("creates and retrieves an artifact", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const loaded = await store.getArtifact(artifact.id, artifact.token);
expect(loaded?.id).toBe(artifact.id);
expect(await store.readHtml(artifact.id)).toBe("<html>demo</html>");
});
it("expires artifacts after the ttl", async () => {
vi.useFakeTimers();
const now = new Date("2026-02-27T16:00:00Z");
vi.setSystemTime(now);
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "patch",
fileCount: 2,
ttlMs: 1_000,
});
vi.setSystemTime(new Date(now.getTime() + 2_000));
const loaded = await store.getArtifact(artifact.id, artifact.token);
expect(loaded).toBeNull();
});
it("updates the stored image path", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const imagePath = store.allocateImagePath(artifact.id);
const updated = await store.updateImagePath(artifact.id, imagePath);
expect(updated.imagePath).toBe(imagePath);
});
});

View File

@@ -0,0 +1,183 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { PluginLogger } from "openclaw/plugin-sdk";
import type { DiffArtifactMeta } from "./types.js";
const DEFAULT_TTL_MS = 30 * 60 * 1000;
const MAX_TTL_MS = 6 * 60 * 60 * 1000;
const SWEEP_FALLBACK_AGE_MS = 24 * 60 * 60 * 1000;
const VIEWER_PREFIX = "/plugins/diffs/view";
type CreateArtifactParams = {
html: string;
title: string;
inputKind: DiffArtifactMeta["inputKind"];
fileCount: number;
ttlMs?: number;
};
export class DiffArtifactStore {
private readonly rootDir: string;
private readonly logger?: PluginLogger;
constructor(params: { rootDir: string; logger?: PluginLogger }) {
this.rootDir = params.rootDir;
this.logger = params.logger;
}
async createArtifact(params: CreateArtifactParams): Promise<DiffArtifactMeta> {
await this.ensureRoot();
await this.cleanupExpired();
const id = crypto.randomBytes(10).toString("hex");
const token = crypto.randomBytes(24).toString("hex");
const artifactDir = this.artifactDir(id);
const htmlPath = path.join(artifactDir, "viewer.html");
const ttlMs = normalizeTtlMs(params.ttlMs);
const createdAt = new Date();
const expiresAt = new Date(createdAt.getTime() + ttlMs);
const meta: DiffArtifactMeta = {
id,
token,
title: params.title,
inputKind: params.inputKind,
fileCount: params.fileCount,
createdAt: createdAt.toISOString(),
expiresAt: expiresAt.toISOString(),
viewerPath: `${VIEWER_PREFIX}/${id}/${token}`,
htmlPath,
};
await fs.mkdir(artifactDir, { recursive: true });
await fs.writeFile(htmlPath, params.html, "utf8");
await this.writeMeta(meta);
return meta;
}
async getArtifact(id: string, token: string): Promise<DiffArtifactMeta | null> {
const meta = await this.readMeta(id);
if (!meta) {
return null;
}
if (meta.token !== token) {
return null;
}
if (isExpired(meta)) {
await this.deleteArtifact(id);
return null;
}
return meta;
}
async readHtml(id: string): Promise<string> {
const meta = await this.readMeta(id);
if (!meta) {
throw new Error(`Diff artifact not found: ${id}`);
}
return await fs.readFile(meta.htmlPath, "utf8");
}
async updateImagePath(id: string, imagePath: string): Promise<DiffArtifactMeta> {
const meta = await this.readMeta(id);
if (!meta) {
throw new Error(`Diff artifact not found: ${id}`);
}
const next: DiffArtifactMeta = {
...meta,
imagePath,
};
await this.writeMeta(next);
return next;
}
allocateImagePath(id: string): string {
return path.join(this.artifactDir(id), "preview.png");
}
async cleanupExpired(): Promise<void> {
await this.ensureRoot();
const entries = await fs.readdir(this.rootDir, { withFileTypes: true }).catch(() => []);
const now = Date.now();
await Promise.all(
entries
.filter((entry) => entry.isDirectory())
.map(async (entry) => {
const id = entry.name;
const meta = await this.readMeta(id);
if (meta) {
if (isExpired(meta)) {
await this.deleteArtifact(id);
}
return;
}
const artifactPath = this.artifactDir(id);
const stat = await fs.stat(artifactPath).catch(() => null);
if (!stat) {
return;
}
if (now - stat.mtimeMs > SWEEP_FALLBACK_AGE_MS) {
await this.deleteArtifact(id);
}
}),
);
}
private async ensureRoot(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true });
}
private artifactDir(id: string): string {
return path.join(this.rootDir, id);
}
private metaPath(id: string): string {
return path.join(this.artifactDir(id), "meta.json");
}
private async writeMeta(meta: DiffArtifactMeta): Promise<void> {
await fs.writeFile(this.metaPath(meta.id), JSON.stringify(meta, null, 2), "utf8");
}
private async readMeta(id: string): Promise<DiffArtifactMeta | null> {
try {
const raw = await fs.readFile(this.metaPath(id), "utf8");
return JSON.parse(raw) as DiffArtifactMeta;
} catch (error) {
if (isFileNotFound(error)) {
return null;
}
this.logger?.warn(`Failed to read diff artifact metadata for ${id}: ${String(error)}`);
return null;
}
}
private async deleteArtifact(id: string): Promise<void> {
await fs.rm(this.artifactDir(id), { recursive: true, force: true }).catch(() => {});
}
}
function normalizeTtlMs(value?: number): number {
if (!Number.isFinite(value) || value === undefined) {
return DEFAULT_TTL_MS;
}
const rounded = Math.floor(value);
if (rounded <= 0) {
return DEFAULT_TTL_MS;
}
return Math.min(rounded, MAX_TTL_MS);
}
function isExpired(meta: DiffArtifactMeta): boolean {
const expiresAt = Date.parse(meta.expiresAt);
if (!Number.isFinite(expiresAt)) {
return true;
}
return Date.now() >= expiresAt;
}
function isFileNotFound(error: unknown): boolean {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}

View File

@@ -0,0 +1,147 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DiffArtifactStore } from "./store.js";
import { createDiffsTool } from "./tool.js";
describe("diffs tool", () => {
let rootDir: string;
let store: DiffArtifactStore;
beforeEach(async () => {
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-tool-"));
store = new DiffArtifactStore({ rootDir });
});
afterEach(async () => {
await fs.rm(rootDir, { recursive: true, force: true });
});
it("returns a viewer URL in view mode", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
});
const result = await tool.execute?.("tool-1", {
before: "one\n",
after: "two\n",
path: "README.md",
mode: "view",
});
const text = readTextContent(result, 0);
expect(text).toContain("http://127.0.0.1:18789/plugins/diffs/view/");
expect((result?.details as Record<string, unknown>).viewerUrl).toBeDefined();
});
it("returns an image artifact in image mode", async () => {
const screenshotter = {
screenshotHtml: vi.fn(async ({ outputPath }: { outputPath: string }) => {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
}),
};
const tool = createDiffsTool({
api: createApi(),
store,
screenshotter,
});
const result = await tool.execute?.("tool-2", {
before: "one\n",
after: "two\n",
mode: "image",
});
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
expect(readTextContent(result, 0)).toContain("Diff image generated at:");
expect(readTextContent(result, 0)).toContain("Use the `message` tool");
expect(result?.content).toHaveLength(1);
expect((result?.details as Record<string, unknown>).imagePath).toBeDefined();
});
it("falls back to view output when both mode cannot render an image", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
screenshotter: {
screenshotHtml: vi.fn(async () => {
throw new Error("browser missing");
}),
},
});
const result = await tool.execute?.("tool-3", {
before: "one\n",
after: "two\n",
mode: "both",
});
expect(result?.content).toHaveLength(1);
expect(readTextContent(result, 0)).toContain("Image rendering failed");
expect((result?.details as Record<string, unknown>).imageError).toBe("browser missing");
});
it("rejects invalid base URLs as tool input errors", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
});
await expect(
tool.execute?.("tool-4", {
before: "one\n",
after: "two\n",
mode: "view",
baseUrl: "javascript:alert(1)",
}),
).rejects.toThrow("Invalid baseUrl");
});
});
function createApi(): OpenClawPluginApi {
return {
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {
gateway: {
port: 18789,
bind: "loopback",
},
},
runtime: {} as OpenClawPluginApi["runtime"],
logger: {
info() {},
warn() {},
error() {},
},
registerTool() {},
registerHook() {},
registerHttpHandler() {},
registerHttpRoute() {},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
registerService() {},
registerProvider() {},
registerCommand() {},
resolvePath(input: string) {
return input;
},
on() {},
};
}
function readTextContent(result: unknown, index: number): string {
const content = (result as { content?: Array<{ type?: string; text?: string }> } | undefined)
?.content;
const entry = content?.[index];
return entry?.type === "text" ? (entry.text ?? "") : "";
}

View File

@@ -0,0 +1,245 @@
import fs from "node:fs/promises";
import { Static, Type } from "@sinclair/typebox";
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
import { PlaywrightDiffScreenshotter, type DiffScreenshotter } from "./browser.js";
import { renderDiffDocument } from "./render.js";
import type { DiffArtifactStore } from "./store.js";
import {
DIFF_LAYOUTS,
DIFF_MODES,
DIFF_THEMES,
type DiffInput,
type DiffLayout,
type DiffMode,
type DiffTheme,
} from "./types.js";
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
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." })),
patch: Type.Optional(Type.String({ description: "Unified diff or patch text." })),
path: Type.Optional(Type.String({ description: "Display path for before/after input." })),
lang: Type.Optional(
Type.String({ description: "Optional language override for before/after input." }),
),
title: Type.Optional(Type.String({ description: "Optional title for the rendered diff." })),
mode: Type.Optional(
stringEnum(DIFF_MODES, "Output mode: view, image, or both. Default: both."),
),
theme: Type.Optional(stringEnum(DIFF_THEMES, "Viewer theme. Default: dark.")),
layout: Type.Optional(stringEnum(DIFF_LAYOUTS, "Diff layout. Default: unified.")),
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>;
export function createDiffsTool(params: {
api: OpenClawPluginApi;
store: DiffArtifactStore;
screenshotter?: DiffScreenshotter;
}): 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.",
parameters: DiffsToolSchema,
execute: async (_toolCallId, rawParams) => {
const toolParams = rawParams as DiffsToolParams;
const input = normalizeDiffInput(toolParams);
const mode = normalizeMode(toolParams.mode);
const theme = normalizeTheme(toolParams.theme);
const layout = normalizeLayout(toolParams.layout);
const expandUnchanged = toolParams.expandUnchanged === true;
const ttlMs = normalizeTtlMs(toolParams.ttlSeconds);
const rendered = await renderDiffDocument(input, {
layout,
expandUnchanged,
theme,
});
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,
};
}
const screenshotter =
params.screenshotter ?? new PlaywrightDiffScreenshotter({ config: params.api.config });
try {
const imagePath = params.store.allocateImagePath(artifact.id);
await screenshotter.screenshotHtml({
html: rendered.html,
outputPath: imagePath,
theme,
});
await params.store.updateImagePath(artifact.id, imagePath);
const imageStats = await fs.stat(imagePath);
return {
content: [
{
type: "text",
text:
`Diff viewer: ${viewerUrl}\n` +
`Diff image generated at: ${imagePath}\n` +
"Use the `message` tool with `path` or `filePath` to send the PNG.",
},
],
details: {
...baseDetails,
imagePath,
path: imagePath,
imageBytes: imageStats.size,
},
};
} catch (error) {
if (mode === "both") {
return {
content: [
{
type: "text",
text:
`Diff viewer ready.\n${viewerUrl}\n` +
`Image rendering failed: ${error instanceof Error ? error.message : String(error)}`,
},
],
details: {
...baseDetails,
imageError: error instanceof Error ? error.message : String(error),
},
};
}
throw error;
}
},
};
}
function normalizeDiffInput(params: DiffsToolParams): DiffInput {
const patch = params.patch?.trim();
const before = params.before;
const after = params.after;
if (patch) {
if (before !== undefined || after !== undefined) {
throw new PluginToolInputError("Provide either patch or before/after input, not both.");
}
return {
kind: "patch",
patch,
title: params.title?.trim() || undefined,
};
}
if (before === undefined || after === undefined) {
throw new PluginToolInputError("Provide patch or both before and after text.");
}
return {
kind: "before_after",
before,
after,
path: params.path?.trim() || undefined,
lang: params.lang?.trim() || undefined,
title: params.title?.trim() || undefined,
};
}
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): DiffMode {
return mode && DIFF_MODES.includes(mode) ? mode : "both";
}
function normalizeTheme(theme?: DiffTheme): DiffTheme {
return theme && DIFF_THEMES.includes(theme) ? theme : "dark";
}
function normalizeLayout(layout?: DiffLayout): DiffLayout {
return layout && DIFF_LAYOUTS.includes(layout) ? layout : "unified";
}
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";
}
}

View File

@@ -0,0 +1,76 @@
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
export const DIFF_LAYOUTS = ["unified", "split"] as const;
export const DIFF_MODES = ["view", "image", "both"] as const;
export const DIFF_THEMES = ["light", "dark"] as const;
export type DiffLayout = (typeof DIFF_LAYOUTS)[number];
export type DiffMode = (typeof DIFF_MODES)[number];
export type DiffTheme = (typeof DIFF_THEMES)[number];
export type BeforeAfterDiffInput = {
kind: "before_after";
before: string;
after: string;
path?: string;
lang?: string;
title?: string;
};
export type PatchDiffInput = {
kind: "patch";
patch: string;
title?: string;
};
export type DiffInput = BeforeAfterDiffInput | PatchDiffInput;
export type DiffRenderOptions = {
layout: DiffLayout;
expandUnchanged: boolean;
theme: DiffTheme;
};
export type DiffViewerOptions = {
theme: {
light: "pierre-light";
dark: "pierre-dark";
};
diffStyle: DiffLayout;
expandUnchanged: boolean;
themeType: DiffTheme;
overflow: "scroll" | "wrap";
unsafeCSS: string;
};
export type DiffViewerPayload = {
prerenderedHTML: string;
options: DiffViewerOptions;
langs: SupportedLanguages[];
oldFile?: FileContents;
newFile?: FileContents;
fileDiff?: FileDiffMetadata;
};
export type RenderedDiffDocument = {
html: string;
title: string;
fileCount: number;
inputKind: DiffInput["kind"];
};
export type DiffArtifactMeta = {
id: string;
token: string;
createdAt: string;
expiresAt: string;
title: string;
inputKind: DiffInput["kind"];
fileCount: number;
viewerPath: string;
htmlPath: string;
imagePath?: string;
};
export const DIFF_ARTIFACT_ID_PATTERN = /^[0-9a-f]{20}$/;
export const DIFF_ARTIFACT_TOKEN_PATTERN = /^[0-9a-f]{48}$/;

120
extensions/diffs/src/url.ts Normal file
View File

@@ -0,0 +1,120 @@
import os from "node:os";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
const DEFAULT_GATEWAY_PORT = 18789;
export function buildViewerUrl(params: {
config: OpenClawConfig;
viewerPath: string;
baseUrl?: string;
}): string {
const baseUrl = params.baseUrl?.trim() || resolveGatewayBaseUrl(params.config);
const normalizedBase = normalizeViewerBaseUrl(baseUrl);
const normalizedPath = params.viewerPath.startsWith("/")
? params.viewerPath
: `/${params.viewerPath}`;
return `${normalizedBase}${normalizedPath}`;
}
export function normalizeViewerBaseUrl(raw: string): string {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error(`Invalid baseUrl: ${raw}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`baseUrl must use http or https: ${raw}`);
}
const withoutTrailingSlash = parsed.toString().replace(/\/+$/, "");
return withoutTrailingSlash;
}
function resolveGatewayBaseUrl(config: OpenClawConfig): string {
const scheme = config.gateway?.tls?.enabled ? "https" : "http";
const port =
typeof config.gateway?.port === "number" ? config.gateway.port : DEFAULT_GATEWAY_PORT;
const bind = config.gateway?.bind ?? "loopback";
if (bind === "custom" && config.gateway?.customBindHost?.trim()) {
return `${scheme}://${config.gateway.customBindHost.trim()}:${port}`;
}
if (bind === "lan") {
return `${scheme}://${pickPrimaryLanIPv4() ?? "127.0.0.1"}:${port}`;
}
if (bind === "tailnet") {
return `${scheme}://${pickPrimaryTailnetIPv4() ?? "127.0.0.1"}:${port}`;
}
return `${scheme}://127.0.0.1:${port}`;
}
function pickPrimaryLanIPv4(): string | undefined {
const nets = os.networkInterfaces();
const preferredNames = ["en0", "eth0"];
for (const name of preferredNames) {
const candidate = pickPrivateAddress(nets[name]);
if (candidate) {
return candidate;
}
}
for (const entries of Object.values(nets)) {
const candidate = pickPrivateAddress(entries);
if (candidate) {
return candidate;
}
}
return undefined;
}
function pickPrimaryTailnetIPv4(): string | undefined {
const nets = os.networkInterfaces();
for (const entries of Object.values(nets)) {
const candidate = entries?.find((entry) => isTailnetIPv4(entry.address) && !entry.internal);
if (candidate?.address) {
return candidate.address;
}
}
return undefined;
}
function pickPrivateAddress(entries: os.NetworkInterfaceInfo[] | undefined): string | undefined {
return entries?.find(
(entry) => entry.family === "IPv4" && !entry.internal && isPrivateIPv4(entry.address),
)?.address;
}
function isPrivateIPv4(address: string): boolean {
const octets = parseIpv4(address);
if (!octets) {
return false;
}
const [a, b] = octets;
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
}
function isTailnetIPv4(address: string): boolean {
const octets = parseIpv4(address);
if (!octets) {
return false;
}
const [a, b] = octets;
return a === 100 && b >= 64 && b <= 127;
}
function parseIpv4(address: string): number[] | null {
const parts = address.split(".");
if (parts.length !== 4) {
return null;
}
const octets = parts.map((part) => Number.parseInt(part, 10));
if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return null;
}
return octets;
}

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { getServedViewerAsset, VIEWER_LOADER_PATH, VIEWER_RUNTIME_PATH } from "./viewer-assets.js";
describe("viewer assets", () => {
it("serves a stable loader that points at the current runtime bundle", async () => {
const loader = await getServedViewerAsset(VIEWER_LOADER_PATH);
expect(loader?.contentType).toBe("text/javascript; charset=utf-8");
expect(String(loader?.body)).toContain(`${VIEWER_RUNTIME_PATH}?v=`);
});
it("serves the runtime bundle body", async () => {
const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH);
expect(runtime?.contentType).toBe("text/javascript; charset=utf-8");
expect(String(runtime?.body)).toContain("openclawDiffsReady");
});
it("returns null for unknown asset paths", async () => {
await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,62 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import { fileURLToPath } from "node:url";
export const VIEWER_ASSET_PREFIX = "/plugins/diffs/assets/";
export const VIEWER_LOADER_PATH = `${VIEWER_ASSET_PREFIX}viewer.js`;
export const VIEWER_RUNTIME_PATH = `${VIEWER_ASSET_PREFIX}viewer-runtime.js`;
const VIEWER_RUNTIME_FILE_URL = new URL("../assets/viewer-runtime.js", import.meta.url);
export type ServedViewerAsset = {
body: string | Buffer;
contentType: string;
};
type RuntimeAssetCache = {
mtimeMs: number;
runtimeBody: Buffer;
loaderBody: string;
};
let runtimeAssetCache: RuntimeAssetCache | null = null;
export async function getServedViewerAsset(pathname: string): Promise<ServedViewerAsset | null> {
if (pathname !== VIEWER_LOADER_PATH && pathname !== VIEWER_RUNTIME_PATH) {
return null;
}
const assets = await loadViewerAssets();
if (pathname === VIEWER_LOADER_PATH) {
return {
body: assets.loaderBody,
contentType: "text/javascript; charset=utf-8",
};
}
if (pathname === VIEWER_RUNTIME_PATH) {
return {
body: assets.runtimeBody,
contentType: "text/javascript; charset=utf-8",
};
}
return null;
}
async function loadViewerAssets(): Promise<RuntimeAssetCache> {
const runtimePath = fileURLToPath(VIEWER_RUNTIME_FILE_URL);
const runtimeStat = await fs.stat(runtimePath);
if (runtimeAssetCache && runtimeAssetCache.mtimeMs === runtimeStat.mtimeMs) {
return runtimeAssetCache;
}
const runtimeBody = await fs.readFile(runtimePath);
const hash = crypto.createHash("sha1").update(runtimeBody).digest("hex").slice(0, 12);
runtimeAssetCache = {
mtimeMs: runtimeStat.mtimeMs,
runtimeBody,
loaderBody: `import "${VIEWER_RUNTIME_PATH}?v=${hash}";\n`,
};
return runtimeAssetCache;
}

View File

@@ -0,0 +1,295 @@
import { FileDiff, preloadHighlighter } from "@pierre/diffs";
import type {
FileContents,
FileDiffMetadata,
FileDiffOptions,
SupportedLanguages,
} from "@pierre/diffs";
import type { DiffViewerPayload, DiffLayout, DiffTheme } from "./types.js";
type ViewerState = {
theme: DiffTheme;
layout: DiffLayout;
backgroundEnabled: boolean;
wrapEnabled: boolean;
};
type DiffController = {
payload: DiffViewerPayload;
diff: FileDiff;
};
const controllers: DiffController[] = [];
const viewerState: ViewerState = {
theme: "dark",
layout: "unified",
backgroundEnabled: true,
wrapEnabled: true,
};
function parsePayload(element: HTMLScriptElement): DiffViewerPayload {
const raw = element.textContent?.trim();
if (!raw) {
throw new Error("Diff payload was empty.");
}
return JSON.parse(raw) as DiffViewerPayload;
}
function getCards(): Array<{ host: HTMLElement; payload: DiffViewerPayload }> {
return [...document.querySelectorAll<HTMLElement>(".oc-diff-card")].flatMap((card) => {
const host = card.querySelector<HTMLElement>("[data-openclaw-diff-host]");
const payloadNode = card.querySelector<HTMLScriptElement>("[data-openclaw-diff-payload]");
if (!host || !payloadNode) {
return [];
}
return [{ host, payload: parsePayload(payloadNode) }];
});
}
function ensureShadowRoot(host: HTMLElement): void {
if (host.shadowRoot) {
return;
}
const template = host.querySelector<HTMLTemplateElement>(
":scope > template[shadowrootmode='open']",
);
if (!template) {
return;
}
const shadowRoot = host.attachShadow({ mode: "open" });
shadowRoot.append(template.content.cloneNode(true));
template.remove();
}
function getHydrateProps(payload: DiffViewerPayload): {
fileDiff?: FileDiffMetadata;
oldFile?: FileContents;
newFile?: FileContents;
} {
if (payload.fileDiff) {
return { fileDiff: payload.fileDiff };
}
return {
oldFile: payload.oldFile,
newFile: payload.newFile,
};
}
function createToolbarButton(params: {
title: string;
active: boolean;
iconMarkup: string;
onClick: () => void;
}): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "oc-diff-toolbar-button";
button.dataset.active = String(params.active);
button.title = params.title;
button.setAttribute("aria-label", params.title);
button.innerHTML = params.iconMarkup;
button.addEventListener("click", (event) => {
event.preventDefault();
params.onClick();
});
return button;
}
function splitIcon(): string {
return `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" d="M14 0H8.5v16H14a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2m-1.5 6.5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0"></path>
<path fill="currentColor" opacity="0.35" d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h5.5V0zm.5 7.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1 0-1"></path>
</svg>`;
}
function unifiedIcon(): string {
return `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" fill-rule="evenodd" d="M16 14a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V8.5h16zm-8-4a.5.5 0 0 0-.5.5v1h-1a.5.5 0 0 0 0 1h1v1a.5.5 0 0 0 1 0v-1h1a.5.5 0 0 0 0-1h-1v-1A.5.5 0 0 0 8 10" clip-rule="evenodd"></path>
<path fill="currentColor" fill-rule="evenodd" opacity="0.4" d="M14 0a2 2 0 0 1 2 2v5.5H0V2a2 2 0 0 1 2-2zM6.5 3.5a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1z" clip-rule="evenodd"></path>
</svg>`;
}
function wrapIcon(active: boolean): string {
if (active) {
return `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" opacity="0.88" d="M2 4.25h8.25a2.75 2.75 0 1 1 0 5.5H7.5a.75.75 0 0 0 0 1.5h3.1l-1.07 1.06a.75.75 0 1 0 1.06 1.06l2.35-2.34a.75.75 0 0 0 0-1.06l-2.35-2.34a.75.75 0 1 0-1.06 1.06l1.07 1.06H10.25a1.25 1.25 0 1 0 0-2.5H2z"></path>
<rect x="2" y="11.75" width="4.75" height="1.5" rx=".75" fill="currentColor" opacity="0.55"></rect>
</svg>`;
}
return `<svg viewBox="0 0 16 16" aria-hidden="true">
<rect x="2" y="4" width="12" height="1.5" rx=".75" fill="currentColor"></rect>
<rect x="2" y="7.25" width="12" height="1.5" rx=".75" fill="currentColor" opacity="0.82"></rect>
<rect x="2" y="10.5" width="12" height="1.5" rx=".75" fill="currentColor" opacity="0.64"></rect>
</svg>`;
}
function backgroundIcon(active: boolean): string {
if (active) {
return `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" opacity="0.4" d="M0 2.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 2.25"></path>
<path fill="currentColor" fill-rule="evenodd" d="M15 5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM2.5 9a.5.5 0 0 0 0 1h8a.5.5 0 0 0 0-1zm0-2a.5.5 0 0 0 0 1h11a.5.5 0 0 0 0-1z" clip-rule="evenodd"></path>
<path fill="currentColor" opacity="0.4" d="M0 14.75A.75.75 0 0 1 .75 14h5.5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75"></path>
</svg>`;
}
return `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" opacity="0.22" d="M0 2.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 2.25"></path>
<path fill="currentColor" opacity="0.22" fill-rule="evenodd" d="M15 5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM2.5 9a.5.5 0 0 0 0 1h8a.5.5 0 0 0 0-1zm0-2a.5.5 0 0 0 0 1h11a.5.5 0 0 0 0-1z" clip-rule="evenodd"></path>
<path fill="currentColor" opacity="0.22" d="M0 14.75A.75.75 0 0 1 .75 14h5.5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75"></path>
<path d="M2.5 13.5 13.5 2.5" stroke="currentColor" stroke-width="1.35" stroke-linecap="round"></path>
</svg>`;
}
function themeIcon(theme: DiffTheme): string {
if (theme === "dark") {
return `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" d="M10.794 3.647a.217.217 0 0 1 .412 0l.387 1.162c.173.518.58.923 1.097 1.096l1.162.388a.217.217 0 0 1 0 .412l-1.162.386a1.73 1.73 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.74 1.74 0 0 0 9.31 7.092l-1.162-.386a.217.217 0 0 1 0-.412l1.162-.388a1.73 1.73 0 0 0 1.097-1.096zM13.863.598a.144.144 0 0 1 .221-.071.14.14 0 0 1 .053.07l.258.775c.115.345.386.616.732.731l.774.258a.145.145 0 0 1 0 .274l-.774.259a1.16 1.16 0 0 0-.732.732l-.258.773a.145.145 0 0 1-.274 0l-.258-.773a1.16 1.16 0 0 0-.732-.732l-.774-.259a.145.145 0 0 1 0-.273l.774-.259c.346-.115.617-.386.732-.732z"></path>
<path fill="currentColor" d="M6.25 1.742a.67.67 0 0 1 .07.75 6.3 6.3 0 0 0-.768 3.028c0 2.746 1.746 5.084 4.193 5.979H1.774A7.2 7.2 0 0 1 1 8.245c0-3.013 1.85-5.598 4.484-6.694a.66.66 0 0 1 .766.19M.75 12.499a.75.75 0 0 0 0 1.5h14.5a.75.75 0 0 0 0-1.5z"></path>
</svg>`;
}
return `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" d="M6.856.764a.75.75 0 0 1 .094 1.035A5.75 5.75 0 0 0 13.81 10.95a.75.75 0 1 1 1.13.99A7.251 7.251 0 1 1 6.762.858a.75.75 0 0 1 .094-.094"></path>
</svg>`;
}
function createToolbar(): HTMLElement {
const toolbar = document.createElement("div");
toolbar.className = "oc-diff-toolbar";
toolbar.append(
createToolbarButton({
title: viewerState.layout === "unified" ? "Switch to split diff" : "Switch to unified diff",
active: viewerState.layout === "split",
iconMarkup: viewerState.layout === "split" ? splitIcon() : unifiedIcon(),
onClick: () => {
viewerState.layout = viewerState.layout === "unified" ? "split" : "unified";
syncAllControllers();
},
}),
);
toolbar.append(
createToolbarButton({
title: viewerState.wrapEnabled ? "Disable word wrap" : "Enable word wrap",
active: viewerState.wrapEnabled,
iconMarkup: wrapIcon(viewerState.wrapEnabled),
onClick: () => {
viewerState.wrapEnabled = !viewerState.wrapEnabled;
syncAllControllers();
},
}),
);
toolbar.append(
createToolbarButton({
title: viewerState.backgroundEnabled
? "Hide background highlights"
: "Show background highlights",
active: viewerState.backgroundEnabled,
iconMarkup: backgroundIcon(viewerState.backgroundEnabled),
onClick: () => {
viewerState.backgroundEnabled = !viewerState.backgroundEnabled;
syncAllControllers();
},
}),
);
toolbar.append(
createToolbarButton({
title: viewerState.theme === "dark" ? "Switch to light theme" : "Switch to dark theme",
active: viewerState.theme === "dark",
iconMarkup: themeIcon(viewerState.theme),
onClick: () => {
viewerState.theme = viewerState.theme === "dark" ? "light" : "dark";
syncAllControllers();
},
}),
);
return toolbar;
}
function createRenderOptions(payload: DiffViewerPayload): FileDiffOptions<undefined> {
return {
theme: payload.options.theme,
themeType: viewerState.theme,
diffStyle: viewerState.layout,
expandUnchanged: payload.options.expandUnchanged,
overflow: viewerState.wrapEnabled ? "wrap" : "scroll",
disableBackground: !viewerState.backgroundEnabled,
unsafeCSS: payload.options.unsafeCSS,
renderHeaderMetadata: () => createToolbar(),
};
}
function syncDocumentTheme(): void {
document.body.dataset.theme = viewerState.theme;
}
function applyState(controller: DiffController): void {
controller.diff.setOptions(createRenderOptions(controller.payload));
controller.diff.rerender();
}
function syncAllControllers(): void {
syncDocumentTheme();
for (const controller of controllers) {
applyState(controller);
}
}
async function hydrateViewer(): Promise<void> {
const cards = getCards();
const langs = new Set<SupportedLanguages>();
const firstPayload = cards[0]?.payload;
if (firstPayload) {
viewerState.theme = firstPayload.options.themeType;
viewerState.layout = firstPayload.options.diffStyle;
viewerState.wrapEnabled = firstPayload.options.overflow === "wrap";
}
for (const { payload } of cards) {
for (const lang of payload.langs) {
langs.add(lang);
}
}
await preloadHighlighter({
themes: ["pierre-light", "pierre-dark"],
langs: langs.size > 0 ? [...langs] : ["text"],
});
syncDocumentTheme();
for (const { host, payload } of cards) {
ensureShadowRoot(host);
const diff = new FileDiff(createRenderOptions(payload));
diff.hydrate({
fileContainer: host,
prerenderedHTML: payload.prerenderedHTML,
...getHydrateProps(payload),
});
const controller = { payload, diff };
controllers.push(controller);
applyState(controller);
}
}
async function main(): Promise<void> {
try {
await hydrateViewer();
document.documentElement.dataset.openclawDiffsReady = "true";
} catch (error) {
document.documentElement.dataset.openclawDiffsError = "true";
console.error("Failed to hydrate diff viewer", error);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
void main();
});
} else {
void main();
}

388
pnpm-lock.yaml generated
View File

@@ -307,6 +307,18 @@ importers:
specifier: ^1.39.0
version: 1.39.0
extensions/diffs:
dependencies:
'@pierre/diffs':
specifier: 1.0.11
version: 1.0.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@sinclair/typebox':
specifier: 0.34.48
version: 0.34.48
playwright-core:
specifier: 1.58.2
version: 1.58.2
extensions/discord: {}
extensions/feishu:
@@ -2153,6 +2165,12 @@ packages:
cpu: [x64]
os: [win32]
'@pierre/diffs@1.0.11':
resolution: {integrity: sha512-j6zIEoyImQy1HfcJqbrDwP0O5I7V2VNXAaw53FqQ+SykRfaNwABeZHs9uibXO4supaXPmTx6LEH9Lffr03e1Tw==}
peerDependencies:
react: ^18.3.1 || ^19.0.0
react-dom: ^18.3.1 || ^19.0.0
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@@ -2465,6 +2483,30 @@ packages:
'@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@shikijs/core@3.23.0':
resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==}
'@shikijs/engine-javascript@3.23.0':
resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==}
'@shikijs/engine-oniguruma@3.23.0':
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
'@shikijs/langs@3.23.0':
resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==}
'@shikijs/themes@3.23.0':
resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==}
'@shikijs/transformers@3.23.0':
resolution: {integrity: sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==}
'@shikijs/types@3.23.0':
resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@silvia-odwyer/photon-node@0.3.4':
resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==}
@@ -2868,6 +2910,9 @@ packages:
'@types/express@5.0.6':
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
@@ -2886,6 +2931,9 @@ packages:
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
@@ -2943,6 +2991,9 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -2992,6 +3043,9 @@ packages:
resolution: {integrity: sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA==}
engines: {node: '>=20.0.0'}
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
'@urbit/aura@3.0.0':
resolution: {integrity: sha512-N8/FHc/lmlMDCumMuTXyRHCxlov5KZY6unmJ9QR2GOw+OpROZMBsXYGwE+ZMtvN21ql9+Xb8KhGNBj08IrG3Wg==}
engines: {node: '>=16', npm: '>=8'}
@@ -3077,8 +3131,8 @@ packages:
link-preview-js:
optional: true
'@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67}
'@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
resolution: {commit: 1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67, repo: https://github.com/whiskeysockets/libsignal-node.git, type: git}
version: 2.0.1
abbrev@1.1.1:
@@ -3344,6 +3398,9 @@ packages:
caseless@0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
@@ -3360,6 +3417,12 @@ packages:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
character-entities-html4@2.1.0:
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
character-entities-legacy@3.0.0:
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
chmodrp@1.0.2:
resolution: {integrity: sha512-TdngOlFV1FLTzU0o1w8MB6/BFywhtLC0SzRTGJU7T9lmdjlCWeMRt1iVo0Ki+ldwNk0BqNiKoc8xpLZEQ8mY1w==}
@@ -3425,6 +3488,9 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
command-line-args@5.2.1:
resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==}
engines: {node: '>=4.0.0'}
@@ -3553,6 +3619,10 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
destroy@1.2.0:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -3561,6 +3631,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
diff@8.0.3:
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
engines: {node: '>=0.3.1'}
@@ -3981,6 +4054,12 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
@@ -4008,6 +4087,9 @@ packages:
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
engines: {node: '>=14'}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
htmlencode@0.0.4:
resolution: {integrity: sha512-0uDvNVpzj/E2TfvLLyyXhKBRvF1y84aZsyRxRXFsQobnHaL4pcaXk+Y9cnFlvnxrBLeXDNq/VJBD+ngdBgQG1w==}
@@ -4420,6 +4502,9 @@ packages:
lru-memoizer@2.3.0:
resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==}
lru_map@0.4.1:
resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -4452,6 +4537,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mdast-util-to-hast@13.2.1:
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
@@ -4474,6 +4562,21 @@ packages:
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
engines: {node: '>= 0.6'}
micromark-util-character@2.1.1:
resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
micromark-util-encode@2.0.1:
resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
micromark-util-sanitize-uri@2.0.1:
resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
micromark-util-symbol@2.0.1:
resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
micromark-util-types@2.0.2:
resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
@@ -4685,6 +4788,12 @@ packages:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
oniguruma-parser@0.12.1:
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
oniguruma-to-es@4.3.4:
resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==}
openai@6.10.0:
resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==}
hasBin: true
@@ -4941,6 +5050,9 @@ packages:
proper-lockfile@4.1.2:
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
protobufjs@6.8.8:
resolution: {integrity: sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==}
hasBin: true
@@ -5018,6 +5130,15 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-dom@19.2.4:
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
peerDependencies:
react: ^19.2.4
react@19.2.4:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
@@ -5036,6 +5157,15 @@ packages:
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
regex-utilities@2.3.0:
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
regex@6.1.0:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
request-promise-core@1.1.3:
resolution: {integrity: sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==}
engines: {node: '>=0.10.0'}
@@ -5130,6 +5260,9 @@ packages:
sanitize-html@2.17.1:
resolution: {integrity: sha512-ehFCW+q1a4CSOWRAdX97BX/6/PDEkCqw7/0JXZAGQV57FQB3YOkTa/rrzHPeJ+Aghy4vZAFfWMYyfxIiB7F/gw==}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
selderee@0.11.0:
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
@@ -5179,6 +5312,9 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shiki@3.23.0:
resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==}
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
@@ -5268,6 +5404,9 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
@@ -5359,6 +5498,9 @@ packages:
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@@ -5453,6 +5595,9 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
ts-algebra@2.0.0:
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
@@ -5550,6 +5695,21 @@ packages:
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
engines: {node: '>=20.18.1'}
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
unist-util-position@5.0.0:
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
unist-util-stringify-position@4.0.0:
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
unist-util-visit-parents@6.0.2:
resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
unist-util-visit@5.1.0:
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
universal-github-app-jwt@2.2.2:
resolution: {integrity: sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==}
@@ -5611,6 +5771,12 @@ packages:
resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==}
engines: {'0': node >=0.6.0}
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -5796,6 +5962,9 @@ packages:
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
snapshots:
'@agentclientprotocol/sdk@0.14.1(zod@4.3.6)':
@@ -7728,6 +7897,18 @@ snapshots:
'@oxlint/binding-win32-x64-msvc@1.50.0':
optional: true
'@pierre/diffs@1.0.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@shikijs/core': 3.23.0
'@shikijs/engine-javascript': 3.23.0
'@shikijs/transformers': 3.23.0
diff: 8.0.3
hast-util-to-html: 9.0.5
lru_map: 0.4.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
shiki: 3.23.0
'@pinojs/redact@0.4.0': {}
'@pkgjs/parseargs@0.11.0':
@@ -7934,6 +8115,44 @@ snapshots:
domhandler: 5.0.3
selderee: 0.11.0
'@shikijs/core@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.4
'@shikijs/engine-oniguruma@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/themes@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/transformers@3.23.0':
dependencies:
'@shikijs/core': 3.23.0
'@shikijs/types': 3.23.0
'@shikijs/types@3.23.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/vscode-textmate@10.0.2': {}
'@silvia-odwyer/photon-node@0.3.4': {}
'@sinclair/typebox@0.34.48': {}
@@ -8509,6 +8728,10 @@ snapshots:
'@types/express-serve-static-core': 5.1.1
'@types/serve-static': 2.2.0
'@types/hast@3.0.4':
dependencies:
'@types/unist': 3.0.3
'@types/http-errors@2.0.5': {}
'@types/jsesc@2.5.1': {}
@@ -8527,6 +8750,10 @@ snapshots:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
'@types/mdurl@2.0.0': {}
'@types/mime-types@2.1.4': {}
@@ -8588,6 +8815,8 @@ snapshots:
'@types/trusted-types@2.0.7': {}
'@types/unist@3.0.3': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.3.1
@@ -8636,6 +8865,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@ungap/structured-clone@1.3.0': {}
'@urbit/aura@3.0.0': {}
'@vector-im/matrix-bot-sdk@0.8.0-element.3(@cypress/request@3.0.10)':
@@ -8776,7 +9007,7 @@ snapshots:
'@cacheable/node-cache': 1.7.6
'@hapi/boom': 9.1.4
async-mutex: 0.5.0
libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67'
libsignal: '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67'
lru-cache: 11.2.6
music-metadata: 11.12.1
p-queue: 9.1.0
@@ -8791,7 +9022,7 @@ snapshots:
- supports-color
- utf-8-validate
'@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
'@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
dependencies:
curve25519-js: 0.0.4
protobufjs: 6.8.8
@@ -9066,6 +9297,8 @@ snapshots:
caseless@0.12.0: {}
ccount@2.0.1: {}
chai@6.2.2: {}
chalk-template@0.4.0:
@@ -9079,6 +9312,10 @@ snapshots:
chalk@5.6.2: {}
character-entities-html4@2.1.0: {}
character-entities-legacy@3.0.0: {}
chmodrp@1.0.2: {}
chokidar@5.0.0:
@@ -9150,6 +9387,8 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
comma-separated-tokens@2.0.3: {}
command-line-args@5.2.1:
dependencies:
array-back: 3.1.0
@@ -9248,10 +9487,16 @@ snapshots:
depd@2.0.0: {}
dequal@2.0.3: {}
destroy@1.2.0: {}
detect-libc@2.1.2: {}
devlop@1.1.0:
dependencies:
dequal: 2.0.3
diff@8.0.3: {}
discord-api-types@0.38.37: {}
@@ -9785,6 +10030,24 @@ snapshots:
dependencies:
function-bind: 1.1.2
hast-util-to-html@9.0.5:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
comma-separated-tokens: 2.0.3
hast-util-whitespace: 3.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.1
property-information: 7.1.0
space-separated-tokens: 2.0.2
stringify-entities: 4.0.4
zwitch: 2.0.4
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
highlight.js@10.7.3: {}
hono@4.11.10:
@@ -9810,6 +10073,8 @@ snapshots:
htmlparser2: 8.0.2
selderee: 0.11.0
html-void-elements@3.0.0: {}
htmlencode@0.0.4: {}
htmlparser2@10.1.0:
@@ -10234,6 +10499,8 @@ snapshots:
lodash.clonedeep: 4.5.0
lru-cache: 6.0.0
lru_map@0.4.1: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -10268,6 +10535,18 @@ snapshots:
math-intrinsics@1.1.0: {}
mdast-util-to-hast@13.2.1:
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@ungap/structured-clone': 1.3.0
devlop: 1.1.0
micromark-util-sanitize-uri: 2.0.1
trim-lines: 3.0.1
unist-util-position: 5.0.0
unist-util-visit: 5.1.0
vfile: 6.0.3
mdurl@2.0.0: {}
media-typer@0.3.0: {}
@@ -10280,6 +10559,23 @@ snapshots:
methods@1.1.2: {}
micromark-util-character@2.1.1:
dependencies:
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-util-encode@2.0.1: {}
micromark-util-sanitize-uri@2.0.1:
dependencies:
micromark-util-character: 2.1.1
micromark-util-encode: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-symbol@2.0.1: {}
micromark-util-types@2.0.2: {}
mime-db@1.52.0: {}
mime-db@1.54.0: {}
@@ -10528,6 +10824,14 @@ snapshots:
dependencies:
mimic-function: 5.0.1
oniguruma-parser@0.12.1: {}
oniguruma-to-es@4.3.4:
dependencies:
oniguruma-parser: 0.12.1
regex: 6.1.0
regex-recursion: 6.0.2
openai@6.10.0(ws@8.19.0)(zod@4.3.6):
optionalDependencies:
ws: 8.19.0
@@ -10864,6 +11168,8 @@ snapshots:
retry: 0.12.0
signal-exit: 3.0.7
property-information@7.1.0: {}
protobufjs@6.8.8:
dependencies:
'@protobufjs/aspromise': 1.1.2
@@ -10987,6 +11293,13 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
react-dom@19.2.4(react@19.2.4):
dependencies:
react: 19.2.4
scheduler: 0.27.0
react@19.2.4: {}
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
@@ -11010,6 +11323,16 @@ snapshots:
reflect-metadata@0.2.2: {}
regex-recursion@6.0.2:
dependencies:
regex-utilities: 2.3.0
regex-utilities@2.3.0: {}
regex@6.1.0:
dependencies:
regex-utilities: 2.3.0
request-promise-core@1.1.3(@cypress/request@3.0.10):
dependencies:
lodash: 4.17.23
@@ -11143,6 +11466,8 @@ snapshots:
parse-srcset: 1.0.2
postcss: 8.5.6
scheduler@0.27.0: {}
selderee@0.11.0:
dependencies:
parseley: 0.12.1
@@ -11248,6 +11573,17 @@ snapshots:
shebang-regex@3.0.0: {}
shiki@3.23.0:
dependencies:
'@shikijs/core': 3.23.0
'@shikijs/engine-javascript': 3.23.0
'@shikijs/engine-oniguruma': 3.23.0
'@shikijs/langs': 3.23.0
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -11355,6 +11691,8 @@ snapshots:
source-map@0.6.1: {}
space-separated-tokens@2.0.2: {}
split2@4.2.0: {}
sqlite-vec-darwin-arm64@0.1.7-alpha.2:
@@ -11456,6 +11794,11 @@ snapshots:
safe-buffer: 5.2.1
optional: true
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
character-entities-legacy: 3.0.0
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
@@ -11552,6 +11895,8 @@ snapshots:
tree-kill@1.2.2: {}
trim-lines@3.0.1: {}
ts-algebra@2.0.0: {}
tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260225.1)(typescript@5.9.3):
@@ -11636,6 +11981,29 @@ snapshots:
undici@7.22.0: {}
unist-util-is@6.0.1:
dependencies:
'@types/unist': 3.0.3
unist-util-position@5.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-stringify-position@4.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-visit-parents@6.0.2:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
unist-util-visit@5.1.0:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
universal-github-app-jwt@2.2.2: {}
universal-user-agent@7.0.3: {}
@@ -11675,6 +12043,16 @@ snapshots:
core-util-is: 1.0.2
extsprintf: 1.3.0
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
unist-util-stringify-position: 4.0.0
vfile@6.0.3:
dependencies:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
esbuild: 0.27.3
@@ -11829,3 +12207,5 @@ snapshots:
zod@3.25.76: {}
zod@4.3.6: {}
zwitch@2.0.4: {}

View File

@@ -7,6 +7,7 @@ export function createMockServerResponse(): ServerResponse & { body?: string } {
statusCode: number;
body?: string;
setHeader: (key: string, value: string) => unknown;
getHeader: (key: string) => string | undefined;
end: (body?: string) => unknown;
} = {
headersSent: false,
@@ -15,6 +16,7 @@ export function createMockServerResponse(): ServerResponse & { body?: string } {
headers[key.toLowerCase()] = value;
return res;
},
getHeader: (key: string) => headers[key.toLowerCase()],
end: (body?: string) => {
res.headersSent = true;
res.body = body;