Add artifact list and read plugin methods
This commit is contained in:
parent
d9491aaa2a
commit
88f26bdbee
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
|
||||
39
README.md
39
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.
|
||||
|
||||
9
dist/index.d.ts
vendored
9
dist/index.d.ts
vendored
@ -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
118
dist/index.js
vendored
@ -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");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
12
dist/src/exportArtifacts.d.ts
vendored
12
dist/src/exportArtifacts.d.ts
vendored
@ -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 {};
|
||||
|
||||
128
dist/src/exportArtifacts.js
vendored
128
dist/src/exportArtifacts.js
vendored
@ -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("|", "\\|");
|
||||
}
|
||||
|
||||
@ -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
138
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<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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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("|", "\\|");
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user