diff --git a/.gitignore b/.gitignore index 0546820..ff0a23e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +node_modules/ coverage/ *.tsbuildinfo diff --git a/README.md b/README.md index 321fa56..9dd838b 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,12 @@ XWorkmate talks to OpenClaw through `xworkmate-bridge` using the existing can then sync generated files into its local thread workspace without changing the UI or adding provider-specific routes. -It registers one Gateway method: +It registers three Gateway methods: ```text xworkmate.artifacts.export +xworkmate.artifacts.list +xworkmate.artifacts.read ``` The method scans the resolved OpenClaw workspace after a run finishes and returns safe, relative artifact entries that XWorkmate Bridge can normalize into the APP `artifacts[]` contract. @@ -91,6 +93,41 @@ Response payload: Files at or below `maxInlineBytes` also include `encoding: "base64"` and `content`. +## View And Download + +After installation, enable the optional agent tool if you want OpenClaw chat to +show a quick artifact table: + +```json5 +{ + "agents": { + "list": [ + { + "id": "main", + "tools": { + "allow": ["xworkmate_artifacts"] + } + } + ] + } +} +``` + +Then ask OpenClaw to list artifacts in the current workspace. The tool returns a +Markdown table with the workspace path, relative file paths, content types, file +sizes, and hash prefixes. Files are still stored in the OpenClaw workspace, so +local users can open or download them directly from that workspace path. + +Gateway clients can use: + +- `xworkmate.artifacts.list` for a metadata-only manifest and Markdown table. +- `xworkmate.artifacts.read` with `relativePath` for one inline base64 file. +- `xworkmate.artifacts.export` after `agent.wait` for the XWorkmate APP sync path. + +Large files are intentionally metadata-only in v1. XWorkmate Bridge can add a +hosted artifact cache/download endpoint later if remote APP clients need direct +links for large PPT/PDF/DOCX files. + ## Limits - Only files inside the resolved OpenClaw workspace are exported. diff --git a/dist/index.d.ts b/dist/index.d.ts index 7b6ff93..ec0114e 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,2 +1,9 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -export default function register(api: OpenClawPluginApi): void; +declare const plugin: { + id: string; + name: string; + description: string; + register: typeof register; +}; +export default plugin; +declare function register(api: OpenClawPluginApi): void; diff --git a/dist/index.js b/dist/index.js index 2814b35..61db4be 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,5 +1,12 @@ -import { exportXWorkmateArtifacts } from "./src/exportArtifacts.js"; -export default function register(api) { +import { exportXWorkmateArtifacts, readXWorkmateArtifact, } from "./src/exportArtifacts.js"; +const plugin = { + id: "xworkmate-artifacts", + name: "XWorkmate Artifacts", + description: "Exports structured artifact manifests from the OpenClaw workspace for XWorkmate.", + register, +}; +export default plugin; +function register(api) { api.registerGatewayMethod("xworkmate.artifacts.export", async (opts) => { try { const payload = await exportXWorkmateArtifacts({ @@ -16,4 +23,111 @@ export default function register(api) { }); } }); + api.registerGatewayMethod("xworkmate.artifacts.list", async (opts) => { + try { + const payload = await exportXWorkmateArtifacts({ + params: { ...opts.params, includeContent: false }, + config: api.config, + pluginConfig: api.pluginConfig, + }); + opts.respond(true, payload, undefined); + } + catch (error) { + opts.respond(false, undefined, { + code: "INVALID_REQUEST", + message: error instanceof Error ? error.message : String(error), + }); + } + }); + api.registerGatewayMethod("xworkmate.artifacts.read", async (opts) => { + try { + const payload = await readXWorkmateArtifact({ + params: opts.params, + config: api.config, + pluginConfig: api.pluginConfig, + }); + opts.respond(true, payload, undefined); + } + catch (error) { + opts.respond(false, undefined, { + code: "INVALID_REQUEST", + message: error instanceof Error ? error.message : String(error), + }); + } + }); + api.registerTool((ctx) => createXWorkmateArtifactsTool(api, ctx), { + names: ["xworkmate_artifacts"], + optional: true, + }); +} +function createXWorkmateArtifactsTool(api, ctx) { + return { + name: "xworkmate_artifacts", + label: "XWorkmate Artifacts", + description: "List generated artifacts in the current OpenClaw workspace or read one small artifact as base64 for XWorkmate.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + action: { + type: "string", + enum: ["list", "read"], + description: "Use list to show workspace artifacts, or read to return one small file.", + }, + relativePath: { + type: "string", + description: "Artifact path relative to the workspace. Required for action=read.", + }, + sinceUnixMs: { + type: "number", + description: "Only list files changed at or after this Unix timestamp in milliseconds.", + }, + maxFiles: { + type: "number", + description: "Maximum number of files to list.", + }, + maxInlineBytes: { + type: "number", + description: "Maximum bytes to inline when reading an artifact.", + }, + }, + required: ["action"], + }, + async execute(_id, params) { + const action = typeof params.action === "string" ? params.action : ""; + const baseParams = { + ...params, + sessionKey: ctx.sessionKey || "agent:main:main", + runId: typeof params.runId === "string" ? params.runId : "tool", + workspaceDir: ctx.workspaceDir, + }; + if (action === "list") { + const payload = await exportXWorkmateArtifacts({ + params: { ...baseParams, includeContent: false }, + config: ctx.config ?? api.config, + pluginConfig: api.pluginConfig, + }); + return { content: [{ type: "text", text: payload.manifestMarkdown }] }; + } + if (action === "read") { + const payload = await readXWorkmateArtifact({ + params: baseParams, + config: ctx.config ?? api.config, + pluginConfig: api.pluginConfig, + }); + const artifact = payload.artifacts[0]; + const text = artifact + ? [ + payload.manifestMarkdown, + "", + artifact.content + ? `Base64 content for \`${artifact.relativePath}\`:\n\n\`\`\`base64\n${artifact.content}\n\`\`\`` + : `\`${artifact.relativePath}\` is larger than maxInlineBytes; use the workspace path to download it directly.`, + ].join("\n") + : payload.manifestMarkdown; + return { content: [{ type: "text", text }] }; + } + throw new Error("action must be list or read"); + }, + }; } diff --git a/dist/src/exportArtifacts.d.ts b/dist/src/exportArtifacts.d.ts index 87147ba..8c4fd88 100644 --- a/dist/src/exportArtifacts.d.ts +++ b/dist/src/exportArtifacts.d.ts @@ -14,11 +14,23 @@ export type XWorkmateArtifactExport = { remoteWorkspaceRefKind: "remotePath"; artifacts: XWorkmateArtifact[]; warnings: string[]; + manifestMarkdown: string; }; type ExportInput = { params: Record; config?: unknown; pluginConfig?: Record; }; +type ReadInput = { + params: Record; + config?: unknown; + pluginConfig?: Record; +}; export declare function exportXWorkmateArtifacts(input: ExportInput): Promise; +export declare function readXWorkmateArtifact(input: ReadInput): Promise; +export declare function formatArtifactManifestMarkdown(input: { + remoteWorkingDirectory: string; + artifacts: XWorkmateArtifact[]; + warnings: string[]; +}): string; export {}; diff --git a/dist/src/exportArtifacts.js b/dist/src/exportArtifacts.js index b2f38ae..b71c45d 100644 --- a/dist/src/exportArtifacts.js +++ b/dist/src/exportArtifacts.js @@ -27,8 +27,9 @@ export async function exportXWorkmateArtifacts(input) { throw new Error("sessionKey required"); } const maxFiles = positiveInteger(params.maxFiles, pluginConfig.maxFiles, DEFAULT_MAX_FILES); - const maxInlineBytes = positiveInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES); + const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES); const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0); + const includeContent = optionalBoolean(params.includeContent, true); const workspaceDir = resolveWorkspaceDir({ config: input.config, pluginConfig, @@ -62,16 +63,16 @@ export async function exportXWorkmateArtifacts(input) { sizeBytes: bytes.byteLength, sha256: createHash("sha256").update(bytes).digest("hex"), }; - if (bytes.byteLength <= maxInlineBytes) { + if (includeContent && bytes.byteLength <= maxInlineBytes) { artifact.encoding = "base64"; artifact.content = bytes.toString("base64"); } - else { + else if (includeContent) { warnings.push(`${candidate.relativePath} exceeds maxInlineBytes and was not inlined`); } artifacts.push(artifact); } - return { + const result = { runId, sessionKey, remoteWorkingDirectory: workspaceRoot, @@ -79,6 +80,96 @@ export async function exportXWorkmateArtifacts(input) { artifacts, warnings, }; + return { + ...result, + manifestMarkdown: formatArtifactManifestMarkdown(result), + }; +} +export async function readXWorkmateArtifact(input) { + const params = input.params ?? {}; + const pluginConfig = input.pluginConfig ?? {}; + const runId = optionalString(params.runId) || "read"; + const sessionKey = optionalString(params.sessionKey); + if (!sessionKey) { + throw new Error("sessionKey required"); + } + const relativePath = optionalString(params.relativePath); + if (!relativePath) { + throw new Error("relativePath required"); + } + if (relativePath.split(/[\\/]/).some((part) => part === ".." || part === "")) { + throw new Error("relativePath must stay inside the workspace"); + } + const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES); + const workspaceDir = resolveWorkspaceDir({ + config: input.config, + pluginConfig, + params, + sessionKey, + }); + const workspaceRoot = await fs.realpath(workspaceDir); + const absolutePath = path.join(workspaceRoot, relativePath.split("/").join(path.sep)); + const realPath = await fs.realpath(absolutePath); + if (!isWithinRoot(workspaceRoot, realPath)) { + throw new Error("relativePath must stay inside the workspace"); + } + const stat = await fs.stat(realPath); + if (!stat.isFile()) { + throw new Error("relativePath must point to a file"); + } + const bytes = await fs.readFile(realPath); + const artifact = { + relativePath: safeRelativePath(workspaceRoot, realPath), + label: path.posix.basename(relativePath), + contentType: contentTypeForPath(relativePath), + sizeBytes: bytes.byteLength, + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const warnings = []; + if (bytes.byteLength <= maxInlineBytes) { + artifact.encoding = "base64"; + artifact.content = bytes.toString("base64"); + } + else { + warnings.push(`${artifact.relativePath} exceeds maxInlineBytes and was not inlined`); + } + const result = { + runId, + sessionKey, + remoteWorkingDirectory: workspaceRoot, + remoteWorkspaceRefKind: "remotePath", + artifacts: [artifact], + warnings, + }; + return { + ...result, + manifestMarkdown: formatArtifactManifestMarkdown(result), + }; +} +export function formatArtifactManifestMarkdown(input) { + const lines = [ + "## XWorkmate artifacts", + "", + `Workspace: \`${input.remoteWorkingDirectory}\``, + "", + ]; + if (input.artifacts.length === 0) { + lines.push("No artifacts found."); + } + else { + lines.push("| File | Type | Size | SHA-256 | Inline |"); + lines.push("| --- | --- | ---: | --- | --- |"); + for (const artifact of input.artifacts) { + lines.push(`| \`${escapeMarkdownCell(artifact.relativePath)}\` | ${escapeMarkdownCell(artifact.contentType)} | ${formatBytes(artifact.sizeBytes)} | \`${artifact.sha256.slice(0, 12)}\` | ${artifact.encoding === "base64" ? "yes" : "no"} |`); + } + } + if (input.warnings.length > 0) { + lines.push("", "Warnings:"); + for (const warning of input.warnings) { + lines.push(`- ${warning}`); + } + } + return lines.join("\n"); } async function collectCandidates(input) { const candidates = []; @@ -234,6 +325,12 @@ function objectRecord(value) { function optionalString(value) { return typeof value === "string" ? value.trim() : ""; } +function optionalBoolean(value, fallback) { + if (typeof value === "boolean") { + return value; + } + return fallback; +} function positiveInteger(primary, secondary, fallback) { for (const value of [primary, secondary]) { const numeric = Number(value); @@ -243,6 +340,15 @@ function positiveInteger(primary, secondary, fallback) { } return fallback; } +function nonNegativeInteger(primary, secondary, fallback) { + for (const value of [primary, secondary]) { + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric >= 0) { + return Math.floor(numeric); + } + } + return fallback; +} function nonNegativeNumber(value, fallback) { const numeric = Number(value); if (Number.isFinite(numeric) && numeric >= 0) { @@ -259,3 +365,17 @@ function expandUserPath(value) { } return path.resolve(value); } +function formatBytes(sizeBytes) { + if (sizeBytes < 1024) { + return `${sizeBytes} B`; + } + const kib = sizeBytes / 1024; + if (kib < 1024) { + return `${Math.round(kib)} KB`; + } + const mib = kib / 1024; + return `${mib.toFixed(mib >= 10 ? 0 : 1)} MB`; +} +function escapeMarkdownCell(value) { + return value.replaceAll("|", "\\|"); +} diff --git a/index.test.ts b/index.test.ts index 5f5953e..526d782 100644 --- a/index.test.ts +++ b/index.test.ts @@ -1,24 +1,32 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { describe, expect, it } from "vitest"; -import register from "./index.js"; +import plugin from "./index.js"; type GatewayMethodHandler = Parameters[1]; describe("plugin registration", () => { it("registers the xworkmate artifact export gateway method", () => { const methods: Array<{ method: string; handler: GatewayMethodHandler }> = []; + const tools: unknown[] = []; const api = { config: {}, pluginConfig: {}, registerGatewayMethod: (method: string, handler: GatewayMethodHandler) => { methods.push({ method, handler }); }, + registerTool: (tool: unknown) => { + tools.push(tool); + }, } as unknown as OpenClawPluginApi; - register(api); + plugin.register(api); - expect(methods).toHaveLength(1); - expect(methods[0]?.method).toBe("xworkmate.artifacts.export"); - expect(typeof methods[0]?.handler).toBe("function"); + expect(methods.map((entry) => entry.method)).toEqual([ + "xworkmate.artifacts.export", + "xworkmate.artifacts.list", + "xworkmate.artifacts.read", + ]); + expect(methods.every((entry) => typeof entry.handler === "function")).toBe(true); + expect(tools).toHaveLength(1); }); }); diff --git a/index.ts b/index.ts index 9c6ff4b..9859ed4 100644 --- a/index.ts +++ b/index.ts @@ -1,7 +1,29 @@ -import type { GatewayRequestHandlerOptions, OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { exportXWorkmateArtifacts } from "./src/exportArtifacts.js"; +import type { + AnyAgentTool, + GatewayRequestHandlerOptions, + OpenClawPluginApi, +} from "openclaw/plugin-sdk/core"; +import { + exportXWorkmateArtifacts, + readXWorkmateArtifact, +} from "./src/exportArtifacts.js"; -export default function register(api: OpenClawPluginApi) { +type XWorkmateToolContext = { + config?: unknown; + workspaceDir?: string; + sessionKey?: string; +}; + +const plugin = { + id: "xworkmate-artifacts", + name: "XWorkmate Artifacts", + description: "Exports structured artifact manifests from the OpenClaw workspace for XWorkmate.", + register, +}; + +export default plugin; + +function register(api: OpenClawPluginApi) { api.registerGatewayMethod("xworkmate.artifacts.export", async (opts: GatewayRequestHandlerOptions) => { try { const payload = await exportXWorkmateArtifacts({ @@ -17,4 +39,114 @@ export default function register(api: OpenClawPluginApi) { }); } }); + api.registerGatewayMethod("xworkmate.artifacts.list", async (opts: GatewayRequestHandlerOptions) => { + try { + const payload = await exportXWorkmateArtifacts({ + params: { ...opts.params, includeContent: false }, + config: api.config, + pluginConfig: api.pluginConfig, + }); + opts.respond(true, payload, undefined); + } catch (error) { + opts.respond(false, undefined, { + code: "INVALID_REQUEST", + message: error instanceof Error ? error.message : String(error), + }); + } + }); + api.registerGatewayMethod("xworkmate.artifacts.read", async (opts: GatewayRequestHandlerOptions) => { + try { + const payload = await readXWorkmateArtifact({ + params: opts.params, + config: api.config, + pluginConfig: api.pluginConfig, + }); + opts.respond(true, payload, undefined); + } catch (error) { + opts.respond(false, undefined, { + code: "INVALID_REQUEST", + message: error instanceof Error ? error.message : String(error), + }); + } + }); + api.registerTool((ctx) => createXWorkmateArtifactsTool(api, ctx), { + names: ["xworkmate_artifacts"], + optional: true, + }); +} + +function createXWorkmateArtifactsTool( + api: OpenClawPluginApi, + ctx: XWorkmateToolContext, +): AnyAgentTool { + return { + name: "xworkmate_artifacts", + label: "XWorkmate Artifacts", + description: + "List generated artifacts in the current OpenClaw workspace or read one small artifact as base64 for XWorkmate.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + action: { + type: "string", + enum: ["list", "read"], + description: "Use list to show workspace artifacts, or read to return one small file.", + }, + relativePath: { + type: "string", + description: "Artifact path relative to the workspace. Required for action=read.", + }, + sinceUnixMs: { + type: "number", + description: "Only list files changed at or after this Unix timestamp in milliseconds.", + }, + maxFiles: { + type: "number", + description: "Maximum number of files to list.", + }, + maxInlineBytes: { + type: "number", + description: "Maximum bytes to inline when reading an artifact.", + }, + }, + required: ["action"], + }, + async execute(_id: string, params: Record) { + const action = typeof params.action === "string" ? params.action : ""; + const baseParams = { + ...params, + sessionKey: ctx.sessionKey || "agent:main:main", + runId: typeof params.runId === "string" ? params.runId : "tool", + workspaceDir: ctx.workspaceDir, + }; + if (action === "list") { + const payload = await exportXWorkmateArtifacts({ + params: { ...baseParams, includeContent: false }, + config: ctx.config ?? api.config, + pluginConfig: api.pluginConfig, + }); + return { content: [{ type: "text", text: payload.manifestMarkdown }] }; + } + if (action === "read") { + const payload = await readXWorkmateArtifact({ + params: baseParams, + config: ctx.config ?? api.config, + pluginConfig: api.pluginConfig, + }); + const artifact = payload.artifacts[0]; + const text = artifact + ? [ + payload.manifestMarkdown, + "", + artifact.content + ? `Base64 content for \`${artifact.relativePath}\`:\n\n\`\`\`base64\n${artifact.content}\n\`\`\`` + : `\`${artifact.relativePath}\` is larger than maxInlineBytes; use the workspace path to download it directly.`, + ].join("\n") + : payload.manifestMarkdown; + return { content: [{ type: "text", text }] }; + } + throw new Error("action must be list or read"); + }, + } as AnyAgentTool; } diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 17aeb6d..d235ef8 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -2,6 +2,9 @@ "id": "xworkmate-artifacts", "name": "XWorkmate Artifacts", "description": "Exports structured artifact manifests from the OpenClaw workspace for XWorkmate.", + "activation": { + "onStartup": true + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/package.json b/package.json index afd8d0b..5405592 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xworkmate-artifacts", - "version": "0.1.2", + "version": "0.1.3", "description": "XWorkmate artifact export plugin for OpenClaw Gateway", "type": "module", "license": "MIT", diff --git a/src/exportArtifacts.test.ts b/src/exportArtifacts.test.ts index 0c49f36..cec328c 100644 --- a/src/exportArtifacts.test.ts +++ b/src/exportArtifacts.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { exportXWorkmateArtifacts } from "./exportArtifacts.js"; +import { exportXWorkmateArtifacts, readXWorkmateArtifact } from "./exportArtifacts.js"; describe("exportXWorkmateArtifacts", () => { it("exports changed files with metadata and base64 content", async () => { @@ -34,6 +34,8 @@ describe("exportXWorkmateArtifacts", () => { encoding: "base64", content: Buffer.from("# Done\n").toString("base64"), }); + expect(result.manifestMarkdown).toContain("reports/final.md"); + expect(result.manifestMarkdown).toContain("text/markdown"); }); it("filters old files by sinceUnixMs", async () => { @@ -92,6 +94,25 @@ describe("exportXWorkmateArtifacts", () => { expect(result.warnings).toContain("large.pdf exceeds maxInlineBytes and was not inlined"); }); + it("can list artifacts without inline content", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-")); + await fs.writeFile(path.join(root, "small.txt"), "small"); + + const result = await exportXWorkmateArtifacts({ + params: { + sessionKey: "thread-main", + runId: "run-1", + maxInlineBytes: 0, + }, + pluginConfig: { workspaceDir: root }, + }); + + expect(result.artifacts[0]?.relativePath).toBe("small.txt"); + expect(result.artifacts[0]?.encoding).toBeUndefined(); + expect(result.artifacts[0]?.content).toBeUndefined(); + expect(result.warnings).toContain("small.txt exceeds maxInlineBytes and was not inlined"); + }); + it("limits exported files", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-")); await fs.writeFile(path.join(root, "a.txt"), "a"); @@ -131,4 +152,42 @@ describe("exportXWorkmateArtifacts", () => { expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["agent.txt"]); }); + + it("reads one artifact by relative path", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-")); + await fs.mkdir(path.join(root, "reports"), { recursive: true }); + await fs.writeFile(path.join(root, "reports", "final.txt"), "final"); + + const result = await readXWorkmateArtifact({ + params: { + sessionKey: "thread-main", + runId: "run-1", + relativePath: "reports/final.txt", + }, + pluginConfig: { workspaceDir: root }, + }); + + expect(result.artifacts).toHaveLength(1); + expect(result.artifacts[0]).toMatchObject({ + relativePath: "reports/final.txt", + contentType: "text/plain", + encoding: "base64", + content: Buffer.from("final").toString("base64"), + }); + }); + + it("rejects relative path traversal when reading artifacts", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-")); + + await expect( + readXWorkmateArtifact({ + params: { + sessionKey: "thread-main", + runId: "run-1", + relativePath: "../outside.txt", + }, + pluginConfig: { workspaceDir: root }, + }), + ).rejects.toThrow("relativePath must stay inside the workspace"); + }); }); diff --git a/src/exportArtifacts.ts b/src/exportArtifacts.ts index 7054f96..f55b53b 100644 --- a/src/exportArtifacts.ts +++ b/src/exportArtifacts.ts @@ -35,6 +35,7 @@ export type XWorkmateArtifactExport = { remoteWorkspaceRefKind: "remotePath"; artifacts: XWorkmateArtifact[]; warnings: string[]; + manifestMarkdown: string; }; type ExportInput = { @@ -43,6 +44,12 @@ type ExportInput = { pluginConfig?: Record; }; +type ReadInput = { + params: Record; + config?: unknown; + pluginConfig?: Record; +}; + type Candidate = { absolutePath: string; relativePath: string; @@ -63,12 +70,13 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise { + const params = input.params ?? {}; + const pluginConfig = input.pluginConfig ?? {}; + const runId = optionalString(params.runId) || "read"; + const sessionKey = optionalString(params.sessionKey); + if (!sessionKey) { + throw new Error("sessionKey required"); + } + const relativePath = optionalString(params.relativePath); + if (!relativePath) { + throw new Error("relativePath required"); + } + if (relativePath.split(/[\\/]/).some((part) => part === ".." || part === "")) { + throw new Error("relativePath must stay inside the workspace"); + } + const maxInlineBytes = nonNegativeInteger( + params.maxInlineBytes, + pluginConfig.maxInlineBytes, + DEFAULT_MAX_INLINE_BYTES, + ); + const workspaceDir = resolveWorkspaceDir({ + config: input.config, + pluginConfig, + params, + sessionKey, + }); + const workspaceRoot = await fs.realpath(workspaceDir); + const absolutePath = path.join(workspaceRoot, relativePath.split("/").join(path.sep)); + const realPath = await fs.realpath(absolutePath); + if (!isWithinRoot(workspaceRoot, realPath)) { + throw new Error("relativePath must stay inside the workspace"); + } + const stat = await fs.stat(realPath); + if (!stat.isFile()) { + throw new Error("relativePath must point to a file"); + } + const bytes = await fs.readFile(realPath); + const artifact: XWorkmateArtifact = { + relativePath: safeRelativePath(workspaceRoot, realPath), + label: path.posix.basename(relativePath), + contentType: contentTypeForPath(relativePath), + sizeBytes: bytes.byteLength, + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const warnings: string[] = []; + if (bytes.byteLength <= maxInlineBytes) { + artifact.encoding = "base64"; + artifact.content = bytes.toString("base64"); + } else { + warnings.push(`${artifact.relativePath} exceeds maxInlineBytes and was not inlined`); + } + const result = { + runId, + sessionKey, + remoteWorkingDirectory: workspaceRoot, + remoteWorkspaceRefKind: "remotePath" as const, + artifacts: [artifact], + warnings, + }; + return { + ...result, + manifestMarkdown: formatArtifactManifestMarkdown(result), + }; +} + +export function formatArtifactManifestMarkdown(input: { + remoteWorkingDirectory: string; + artifacts: XWorkmateArtifact[]; + warnings: string[]; +}): string { + const lines = [ + "## XWorkmate artifacts", + "", + `Workspace: \`${input.remoteWorkingDirectory}\``, + "", + ]; + if (input.artifacts.length === 0) { + lines.push("No artifacts found."); + } else { + lines.push("| File | Type | Size | SHA-256 | Inline |"); + lines.push("| --- | --- | ---: | --- | --- |"); + for (const artifact of input.artifacts) { + lines.push( + `| \`${escapeMarkdownCell(artifact.relativePath)}\` | ${escapeMarkdownCell(artifact.contentType)} | ${formatBytes( + artifact.sizeBytes, + )} | \`${artifact.sha256.slice(0, 12)}\` | ${artifact.encoding === "base64" ? "yes" : "no"} |`, + ); + } + } + if (input.warnings.length > 0) { + lines.push("", "Warnings:"); + for (const warning of input.warnings) { + lines.push(`- ${warning}`); + } + } + return lines.join("\n"); } async function collectCandidates(input: { @@ -296,6 +406,13 @@ function optionalString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } +function optionalBoolean(value: unknown, fallback: boolean): boolean { + if (typeof value === "boolean") { + return value; + } + return fallback; +} + function positiveInteger(primary: unknown, secondary: unknown, fallback: number): number { for (const value of [primary, secondary]) { const numeric = Number(value); @@ -306,6 +423,16 @@ function positiveInteger(primary: unknown, secondary: unknown, fallback: number) return fallback; } +function nonNegativeInteger(primary: unknown, secondary: unknown, fallback: number): number { + for (const value of [primary, secondary]) { + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric >= 0) { + return Math.floor(numeric); + } + } + return fallback; +} + function nonNegativeNumber(value: unknown, fallback: number): number { const numeric = Number(value); if (Number.isFinite(numeric) && numeric >= 0) { @@ -323,3 +450,19 @@ function expandUserPath(value: string): string { } return path.resolve(value); } + +function formatBytes(sizeBytes: number): string { + if (sizeBytes < 1024) { + return `${sizeBytes} B`; + } + const kib = sizeBytes / 1024; + if (kib < 1024) { + return `${Math.round(kib)} KB`; + } + const mib = kib / 1024; + return `${mib.toFixed(mib >= 10 ? 0 : 1)} MB`; +} + +function escapeMarkdownCell(value: string): string { + return value.replaceAll("|", "\\|"); +}