diff --git a/docs/tools/diffs.md b/docs/tools/diffs.md index 6207366034e..3e0289dd05d 100644 --- a/docs/tools/diffs.md +++ b/docs/tools/diffs.md @@ -111,6 +111,7 @@ All fields are optional unless noted: - `lang` (`string`): language override hint for before and after mode. - `title` (`string`): viewer title override. - `mode` (`"view" | "file" | "both"`): output mode. Defaults to plugin default `defaults.mode`. + Deprecated alias: `"image"` behaves like `"file"` and is still accepted for backward compatibility. - `theme` (`"light" | "dark"`): viewer theme. Defaults to plugin default `defaults.theme`. - `layout` (`"unified" | "split"`): diff layout. Defaults to plugin default `defaults.layout`. - `expandUnchanged` (`boolean`): expand unchanged sections when full context is available. Per-call option only (not a plugin default key). @@ -150,9 +151,12 @@ Shared fields for modes that create a viewer: - `inputKind` - `fileCount` - `mode` +- `context` (`agentId`, `sessionId`, `messageChannel`, `agentAccountId` when available) File fields when PNG or PDF is rendered: +- `artifactId` +- `expiresAt` - `filePath` - `path` (same value as `filePath`, for message tool compatibility) - `fileBytes` diff --git a/extensions/diffs/README.md b/extensions/diffs/README.md index f1af1792cb8..961d0db9289 100644 --- a/extensions/diffs/README.md +++ b/extensions/diffs/README.md @@ -15,6 +15,8 @@ The tool can return: - `details.viewerUrl`: a gateway URL that can be opened in the canvas - `details.filePath`: a local rendered artifact path when file rendering is requested - `details.fileFormat`: the rendered file format (`png` or `pdf`) +- `details.artifactId` and `details.expiresAt`: artifact identity and TTL metadata +- `details.context`: available routing metadata such as `agentId`, `sessionId`, `messageChannel`, and `agentAccountId` When the plugin is enabled, it also ships a companion skill from `skills/` and prepends stable tool-usage guidance into system-prompt space via `before_prompt_build`. The hook uses `prependSystemContext`, so the guidance stays out of user-prompt space while still being available every turn. @@ -49,6 +51,7 @@ Patch: Useful options: - `mode`: `view`, `file`, or `both` + Deprecated alias: `image` behaves like `file` and is still accepted for backward compatibility. - `layout`: `unified` or `split` - `theme`: `light` or `dark` (default: `dark`) - `fileFormat`: `png` or `pdf` (default: `png`) diff --git a/extensions/diffs/index.test.ts b/extensions/diffs/index.test.ts index 02ce339e47c..4a73905f0c0 100644 --- a/extensions/diffs/index.test.ts +++ b/extensions/diffs/index.test.ts @@ -2,7 +2,7 @@ import type { IncomingMessage } from "node:http"; import { describe, expect, it, vi } from "vitest"; import { createMockServerResponse } from "../../test/helpers/extensions/mock-http-response.js"; import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; -import type { OpenClawPluginApi } from "./api.js"; +import type { OpenClawPluginApi, OpenClawPluginToolContext } from "./api.js"; import plugin from "./index.js"; describe("diffs plugin registration", () => { @@ -48,7 +48,9 @@ describe("diffs plugin registration", () => { }; type RegisteredHttpRouteParams = Parameters[0]; - let registeredTool: RegisteredTool | undefined; + let registeredToolFactory: + | ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined) + | undefined; let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined; const api = createTestPluginApi({ @@ -75,7 +77,7 @@ describe("diffs plugin registration", () => { }, runtime: {} as never, registerTool(tool: Parameters[0]) { - registeredTool = typeof tool === "function" ? undefined : tool; + registeredToolFactory = typeof tool === "function" ? tool : () => tool; }, registerHttpRoute(params: RegisteredHttpRouteParams) { registeredHttpRouteHandler = params.handler; @@ -84,6 +86,12 @@ describe("diffs plugin registration", () => { plugin.register?.(api as unknown as OpenClawPluginApi); + const registeredTool = registeredToolFactory?.({ + agentId: "main", + sessionId: "session-123", + messageChannel: "discord", + agentAccountId: "default", + }) as RegisteredTool | undefined; const result = await registeredTool?.execute?.("tool-1", { before: "one\n", after: "two\n", @@ -108,6 +116,14 @@ describe("diffs plugin registration", () => { expect(String(res.body)).toContain('"disableLineNumbers":true'); expect(String(res.body)).toContain('"diffIndicators":"classic"'); expect(String(res.body)).toContain("--diffs-line-height: 30px;"); + expect((result as { details?: Record } | undefined)?.details?.context).toEqual( + { + agentId: "main", + sessionId: "session-123", + messageChannel: "discord", + agentAccountId: "default", + }, + ); }); }); diff --git a/extensions/diffs/index.ts b/extensions/diffs/index.ts index 5ce8c94fabd..e9dfe7d5de7 100644 --- a/extensions/diffs/index.ts +++ b/extensions/diffs/index.ts @@ -1,6 +1,9 @@ import path from "node:path"; -import type { OpenClawPluginApi } from "./api.js"; -import { resolvePreferredOpenClawTmpDir } from "./api.js"; +import { + definePluginEntry, + resolvePreferredOpenClawTmpDir, + type OpenClawPluginApi, +} from "./api.js"; import { diffsPluginConfigSchema, resolveDiffsPluginDefaults, @@ -11,7 +14,7 @@ import { DIFFS_AGENT_GUIDANCE } from "./src/prompt-guidance.js"; import { DiffArtifactStore } from "./src/store.js"; import { createDiffsTool } from "./src/tool.js"; -const plugin = { +export default definePluginEntry({ id: "diffs", name: "Diffs", description: "Read-only diff viewer and PNG/PDF renderer for agents.", @@ -24,7 +27,9 @@ const plugin = { logger: api.logger, }); - api.registerTool(createDiffsTool({ api, store, defaults })); + api.registerTool((ctx) => createDiffsTool({ api, store, defaults, context: ctx }), { + name: "diffs", + }); api.registerHttpRoute({ path: "/plugins/diffs", auth: "plugin", @@ -39,6 +44,4 @@ const plugin = { prependSystemContext: DIFFS_AGENT_GUIDANCE, })); }, -}; - -export default plugin; +}); diff --git a/extensions/diffs/src/config.test.ts b/extensions/diffs/src/config.test.ts index b7845326483..0c6055199d7 100644 --- a/extensions/diffs/src/config.test.ts +++ b/extensions/diffs/src/config.test.ts @@ -1,7 +1,9 @@ +import fs from "node:fs"; import { describe, expect, it } from "vitest"; import { DEFAULT_DIFFS_PLUGIN_SECURITY, DEFAULT_DIFFS_TOOL_DEFAULTS, + diffsPluginConfigSchema, resolveDiffImageRenderOptions, resolveDiffsPluginDefaults, resolveDiffsPluginSecurity, @@ -165,3 +167,13 @@ describe("resolveDiffsPluginSecurity", () => { }); }); }); + +describe("diffs plugin schema surfaces", () => { + it("keeps the runtime json schema in sync with the manifest config schema", () => { + const manifest = JSON.parse( + fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"), + ) as { configSchema?: unknown }; + + expect(diffsPluginConfigSchema.jsonSchema).toEqual(manifest.configSchema); + }); +}); diff --git a/extensions/diffs/src/store.test.ts b/extensions/diffs/src/store.test.ts index 8039865b71b..02e0e0c8b6b 100644 --- a/extensions/diffs/src/store.test.ts +++ b/extensions/diffs/src/store.test.ts @@ -28,10 +28,22 @@ describe("DiffArtifactStore", () => { title: "Demo", inputKind: "before_after", fileCount: 1, + context: { + agentId: "main", + sessionId: "session-123", + messageChannel: "discord", + agentAccountId: "default", + }, }); const loaded = await store.getArtifact(artifact.id, artifact.token); expect(loaded?.id).toBe(artifact.id); + expect(loaded?.context).toEqual({ + agentId: "main", + sessionId: "session-123", + messageChannel: "discord", + agentAccountId: "default", + }); expect(await store.readHtml(artifact.id)).toBe("demo"); }); @@ -97,10 +109,19 @@ describe("DiffArtifactStore", () => { }); it("creates standalone file artifacts with managed metadata", async () => { - const standalone = await store.createStandaloneFileArtifact(); + const standalone = await store.createStandaloneFileArtifact({ + context: { + agentId: "main", + sessionId: "session-123", + }, + }); expect(standalone.filePath).toMatch(/preview\.png$/); expect(standalone.filePath).toContain(rootDir); expect(Date.parse(standalone.expiresAt)).toBeGreaterThan(Date.now()); + expect(standalone.context).toEqual({ + agentId: "main", + sessionId: "session-123", + }); }); it("expires standalone file artifacts using ttl metadata", async () => { diff --git a/extensions/diffs/src/store.ts b/extensions/diffs/src/store.ts index baab4757384..282c18fa743 100644 --- a/extensions/diffs/src/store.ts +++ b/extensions/diffs/src/store.ts @@ -2,7 +2,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import type { PluginLogger } from "../api.js"; -import type { DiffArtifactMeta, DiffOutputFormat } from "./types.js"; +import type { DiffArtifactContext, DiffArtifactMeta, DiffOutputFormat } from "./types.js"; const DEFAULT_TTL_MS = 30 * 60 * 1000; const MAX_TTL_MS = 6 * 60 * 60 * 1000; @@ -16,11 +16,13 @@ type CreateArtifactParams = { inputKind: DiffArtifactMeta["inputKind"]; fileCount: number; ttlMs?: number; + context?: DiffArtifactContext; }; type CreateStandaloneFileArtifactParams = { format?: DiffOutputFormat; ttlMs?: number; + context?: DiffArtifactContext; }; type StandaloneFileMeta = { @@ -29,6 +31,7 @@ type StandaloneFileMeta = { createdAt: string; expiresAt: string; filePath: string; + context?: DiffArtifactContext; }; type ArtifactMetaFileName = "meta.json" | "file-meta.json"; @@ -69,6 +72,7 @@ export class DiffArtifactStore { expiresAt: expiresAt.toISOString(), viewerPath: `${VIEWER_PREFIX}/${id}/${token}`, htmlPath, + ...(params.context ? { context: params.context } : {}), }; await fs.mkdir(artifactDir, { recursive: true }); @@ -127,7 +131,7 @@ export class DiffArtifactStore { async createStandaloneFileArtifact( params: CreateStandaloneFileArtifactParams = {}, - ): Promise<{ id: string; filePath: string; expiresAt: string }> { + ): Promise<{ id: string; filePath: string; expiresAt: string; context?: DiffArtifactContext }> { await this.ensureRoot(); const id = crypto.randomBytes(10).toString("hex"); @@ -143,6 +147,7 @@ export class DiffArtifactStore { createdAt: createdAt.toISOString(), expiresAt, filePath: this.normalizeStoredPath(filePath, "filePath"), + ...(params.context ? { context: params.context } : {}), }; await fs.mkdir(artifactDir, { recursive: true }); @@ -152,6 +157,7 @@ export class DiffArtifactStore { id, filePath: meta.filePath, expiresAt: meta.expiresAt, + ...(meta.context ? { context: meta.context } : {}), }; } @@ -268,6 +274,7 @@ export class DiffArtifactStore { createdAt: value.createdAt, expiresAt: value.expiresAt, filePath: this.normalizeStoredPath(value.filePath, "filePath"), + ...(value.context ? { context: normalizeArtifactContext(value.context) } : {}), }; } catch (error) { this.logger?.warn(`Failed to normalize standalone diff metadata for ${id}: ${String(error)}`); @@ -356,3 +363,23 @@ function isExpired(meta: { expiresAt: string }): boolean { function isFileNotFound(error: unknown): boolean { return error instanceof Error && "code" in error && error.code === "ENOENT"; } + +function normalizeArtifactContext(value: unknown): DiffArtifactContext | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + + const raw = value as Record; + const context = { + agentId: normalizeOptionalString(raw.agentId), + sessionId: normalizeOptionalString(raw.sessionId), + messageChannel: normalizeOptionalString(raw.messageChannel), + agentAccountId: normalizeOptionalString(raw.agentAccountId), + }; + + return Object.values(context).some((entry) => entry !== undefined) ? context : undefined; +} + +function normalizeOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index f79098dd907..949113b9be5 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createTestPluginApi } from "../../../test/helpers/extensions/plugin-api.js"; -import type { OpenClawPluginApi } from "../api.js"; +import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js"; import type { DiffScreenshotter } from "./browser.js"; import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js"; import { DiffArtifactStore } from "./store.js"; @@ -137,6 +137,8 @@ describe("diffs tool", () => { }); expectArtifactOnlyFileResult(screenshotter, result); + expect((result?.details as Record).artifactId).toEqual(expect.any(String)); + expect((result?.details as Record).expiresAt).toEqual(expect.any(String)); }); it("honors ttlSeconds for artifact-only file output", async () => { @@ -316,6 +318,12 @@ describe("diffs tool", () => { fontFamily: "JetBrains Mono", fontSize: 17, }, + context: { + agentId: "main", + sessionId: "session-123", + messageChannel: "discord", + agentAccountId: "default", + }, }); const result = await tool.execute?.("tool-5", { @@ -326,6 +334,12 @@ describe("diffs tool", () => { expect(readTextContent(result, 0)).toContain("Diff viewer ready."); expect((result?.details as Record).mode).toBe("view"); + expect((result?.details as Record).context).toEqual({ + agentId: "main", + sessionId: "session-123", + messageChannel: "discord", + agentAccountId: "default", + }); const viewerPath = String((result?.details as Record).viewerPath); const [id] = viewerPath.split("/").filter(Boolean).slice(-2); @@ -381,6 +395,29 @@ describe("diffs tool", () => { const html = await store.readHtml(id); expect(html).toContain('body data-theme="dark"'); }); + + it("routes tool context into artifact details for file mode", async () => { + const screenshotter = createPngScreenshotter(); + const tool = createToolWithScreenshotter(store, screenshotter, DEFAULT_DIFFS_TOOL_DEFAULTS, { + agentId: "reviewer", + sessionId: "session-456", + messageChannel: "telegram", + agentAccountId: "work", + }); + + const result = await tool.execute?.("tool-context-file", { + before: "one\n", + after: "two\n", + mode: "file", + }); + + expect((result?.details as Record).context).toEqual({ + agentId: "reviewer", + sessionId: "session-456", + messageChannel: "telegram", + agentAccountId: "work", + }); + }); }); function createApi(): OpenClawPluginApi { @@ -403,12 +440,19 @@ function createToolWithScreenshotter( store: DiffArtifactStore, screenshotter: DiffScreenshotter, defaults = DEFAULT_DIFFS_TOOL_DEFAULTS, + context: OpenClawPluginToolContext | undefined = { + agentId: "main", + sessionId: "session-123", + messageChannel: "discord", + agentAccountId: "default", + }, ) { return createDiffsTool({ api: createApi(), store, defaults, screenshotter, + context, }); } diff --git a/extensions/diffs/src/tool.ts b/extensions/diffs/src/tool.ts index b20f11fee15..761d0284d7b 100644 --- a/extensions/diffs/src/tool.ts +++ b/extensions/diffs/src/tool.ts @@ -1,11 +1,11 @@ import fs from "node:fs/promises"; import { Static, Type } from "@sinclair/typebox"; -import type { AnyAgentTool, OpenClawPluginApi } from "../api.js"; +import type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js"; import { PlaywrightDiffScreenshotter, type DiffScreenshotter } from "./browser.js"; import { resolveDiffImageRenderOptions } from "./config.js"; import { renderDiffDocument } from "./render.js"; import type { DiffArtifactStore } from "./store.js"; -import type { DiffRenderOptions, DiffToolDefaults } from "./types.js"; +import type { DiffArtifactContext, DiffRenderOptions, DiffToolDefaults } from "./types.js"; import { DIFF_IMAGE_QUALITY_PRESETS, DIFF_LAYOUTS, @@ -64,7 +64,10 @@ const DiffsToolSchema = Type.Object( }), ), mode: Type.Optional( - stringEnum(DIFF_MODES, "Output mode: view, file, image, or both. Default: both."), + stringEnum( + DIFF_MODES, + "Output mode: view, file, image (deprecated alias for file), or both. Default: both.", + ), ), theme: Type.Optional(stringEnum(DIFF_THEMES, "Viewer theme. Default: dark.")), layout: Type.Optional(stringEnum(DIFF_LAYOUTS, "Diff layout. Default: unified.")), @@ -135,6 +138,7 @@ export function createDiffsTool(params: { store: DiffArtifactStore; defaults: DiffToolDefaults; screenshotter?: DiffScreenshotter; + context?: OpenClawPluginToolContext; }): AnyAgentTool { return { name: "diffs", @@ -144,6 +148,7 @@ export function createDiffsTool(params: { parameters: DiffsToolSchema, execute: async (_toolCallId, rawParams) => { const toolParams = rawParams as DiffsToolRawParams; + const artifactContext = buildArtifactContext(params.context); const input = normalizeDiffInput(toolParams); const mode = normalizeMode(toolParams.mode, params.defaults.mode); const theme = normalizeTheme(toolParams.theme, params.defaults.theme); @@ -181,6 +186,7 @@ export function createDiffsTool(params: { theme, image, ttlMs, + context: artifactContext, }); return { @@ -195,10 +201,13 @@ export function createDiffsTool(params: { ], details: buildArtifactDetails({ baseDetails: { + ...(artifactFile.artifactId ? { artifactId: artifactFile.artifactId } : {}), + ...(artifactFile.expiresAt ? { expiresAt: artifactFile.expiresAt } : {}), title: rendered.title, inputKind: rendered.inputKind, fileCount: rendered.fileCount, mode, + ...(artifactContext ? { context: artifactContext } : {}), }, artifactFile, image, @@ -212,6 +221,7 @@ export function createDiffsTool(params: { inputKind: rendered.inputKind, fileCount: rendered.fileCount, ttlMs, + context: artifactContext, }); const viewerUrl = buildViewerUrl({ @@ -229,6 +239,7 @@ export function createDiffsTool(params: { inputKind: artifact.inputKind, fileCount: artifact.fileCount, mode, + ...(artifactContext ? { context: artifactContext } : {}), }; if (mode === "view") { @@ -351,15 +362,18 @@ async function renderDiffArtifactFile(params: { theme: DiffTheme; image: DiffRenderOptions["image"]; ttlMs?: number; -}): Promise<{ path: string; bytes: number }> { + context?: DiffArtifactContext; +}): Promise<{ path: string; bytes: number; artifactId?: string; expiresAt?: string }> { + const standaloneArtifact = params.artifactId + ? undefined + : await params.store.createStandaloneFileArtifact({ + format: params.image.format, + ttlMs: params.ttlMs, + context: params.context, + }); const outputPath = params.artifactId ? params.store.allocateFilePath(params.artifactId, params.image.format) - : ( - await params.store.createStandaloneFileArtifact({ - format: params.image.format, - ttlMs: params.ttlMs, - }) - ).filePath; + : standaloneArtifact!.filePath; await params.screenshotter.screenshotHtml({ html: params.html, @@ -372,9 +386,35 @@ async function renderDiffArtifactFile(params: { return { path: outputPath, bytes: stats.size, + ...(standaloneArtifact?.id ? { artifactId: standaloneArtifact.id } : {}), + ...(standaloneArtifact?.expiresAt ? { expiresAt: standaloneArtifact.expiresAt } : {}), }; } +function buildArtifactContext( + context: OpenClawPluginToolContext | undefined, +): DiffArtifactContext | undefined { + if (!context) { + return undefined; + } + + const artifactContext = { + agentId: normalizeContextString(context.agentId), + sessionId: normalizeContextString(context.sessionId), + messageChannel: normalizeContextString(context.messageChannel), + agentAccountId: normalizeContextString(context.agentAccountId), + }; + + return Object.values(artifactContext).some((value) => value !== undefined) + ? artifactContext + : undefined; +} + +function normalizeContextString(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + function normalizeDiffInput(params: DiffsToolParams): DiffInput { const patch = params.patch?.trim(); const before = params.before; diff --git a/extensions/diffs/src/types.ts b/extensions/diffs/src/types.ts index ff389688839..856ea7d729d 100644 --- a/extensions/diffs/src/types.ts +++ b/extensions/diffs/src/types.ts @@ -99,6 +99,13 @@ export type RenderedDiffDocument = { inputKind: DiffInput["kind"]; }; +export type DiffArtifactContext = { + agentId?: string; + sessionId?: string; + messageChannel?: string; + agentAccountId?: string; +}; + export type DiffArtifactMeta = { id: string; token: string; @@ -109,6 +116,7 @@ export type DiffArtifactMeta = { fileCount: number; viewerPath: string; htmlPath: string; + context?: DiffArtifactContext; filePath?: string; imagePath?: string; }; diff --git a/src/plugin-sdk/diffs.ts b/src/plugin-sdk/diffs.ts index 918536230d7..9884781be8d 100644 --- a/src/plugin-sdk/diffs.ts +++ b/src/plugin-sdk/diffs.ts @@ -1,11 +1,13 @@ // Narrow plugin-sdk surface for the bundled diffs plugin. // Keep this list additive and scoped to symbols used under extensions/diffs. +export { definePluginEntry } from "./core.js"; export type { OpenClawConfig } from "../config/config.js"; export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; export type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginConfigSchema, + OpenClawPluginToolContext, PluginLogger, } from "../plugins/types.js";