Add artifact list and read plugin methods

This commit is contained in:
Haitao Pan 2026-05-05 11:43:40 +08:00
parent d9491aaa2a
commit 88f26bdbee
12 changed files with 659 additions and 23 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
node_modules/
coverage/
*.tsbuildinfo

View File

@ -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.

9
dist/index.d.ts vendored
View File

@ -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;

118
dist/index.js vendored
View File

@ -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");
},
};
}

View File

@ -14,11 +14,23 @@ export type XWorkmateArtifactExport = {
remoteWorkspaceRefKind: "remotePath";
artifacts: XWorkmateArtifact[];
warnings: string[];
manifestMarkdown: string;
};
type ExportInput = {
params: Record<string, unknown>;
config?: unknown;
pluginConfig?: Record<string, unknown>;
};
type ReadInput = {
params: Record<string, unknown>;
config?: unknown;
pluginConfig?: Record<string, unknown>;
};
export declare function exportXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactExport>;
export declare function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmateArtifactExport>;
export declare function formatArtifactManifestMarkdown(input: {
remoteWorkingDirectory: string;
artifacts: XWorkmateArtifact[];
warnings: string[];
}): string;
export {};

View File

@ -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("|", "\\|");
}

View File

@ -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<OpenClawPluginApi["registerGatewayMethod"]>[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);
});
});

138
index.ts
View File

@ -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<string, unknown>) {
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;
}

View File

@ -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,

View File

@ -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",

View File

@ -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");
});
});

View File

@ -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<string, unknown>;
};
type ReadInput = {
params: Record<string, unknown>;
config?: unknown;
pluginConfig?: Record<string, unknown>;
};
type Candidate = {
absolutePath: string;
relativePath: string;
@ -63,12 +70,13 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
}
const maxFiles = positiveInteger(params.maxFiles, pluginConfig.maxFiles, DEFAULT_MAX_FILES);
const maxInlineBytes = positiveInteger(
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,
@ -104,23 +112,125 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
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,
remoteWorkspaceRefKind: "remotePath",
remoteWorkspaceRefKind: "remotePath" as const,
artifacts,
warnings,
};
return {
...result,
manifestMarkdown: formatArtifactManifestMarkdown(result),
};
}
export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmateArtifactExport> {
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("|", "\\|");
}