Initial OpenClaw XWorkmate artifacts plugin
This commit is contained in:
commit
52d8d07e66
39
.github/workflows/ci.yml
vendored
Normal file
39
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.28.2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Verify npm package contents
|
||||
run: pnpm pack:check
|
||||
44
.github/workflows/publish.yml
vendored
Normal file
44
.github/workflows/publish.yml
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.28.2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
registry-url: https://registry.npmjs.org/
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Publish
|
||||
run: npm publish --provenance
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
76
README.md
Normal file
76
README.md
Normal file
@ -0,0 +1,76 @@
|
||||
# openclaw-xworkmate-artifacts
|
||||
|
||||
OpenClaw Gateway plugin that exports structured workspace artifact manifests for XWorkmate.
|
||||
|
||||
It registers one Gateway method:
|
||||
|
||||
```text
|
||||
xworkmate.artifacts.export
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Install locally
|
||||
|
||||
Link this directory into OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugins install --link /Users/shenlan/workspaces/cloud-neutral-toolkit/openclaw-xworkmate-artifacts
|
||||
openclaw plugins enable openclaw-xworkmate-artifacts
|
||||
```
|
||||
|
||||
Equivalent config shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"load": {
|
||||
"paths": [
|
||||
"/Users/shenlan/workspaces/cloud-neutral-toolkit/openclaw-xworkmate-artifacts"
|
||||
]
|
||||
},
|
||||
"entries": {
|
||||
"openclaw-xworkmate-artifacts": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Contract
|
||||
|
||||
Request params:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionKey": "thread-main",
|
||||
"runId": "turn-1",
|
||||
"sinceUnixMs": 1770000000000,
|
||||
"maxFiles": 64,
|
||||
"maxInlineBytes": 10485760
|
||||
}
|
||||
```
|
||||
|
||||
Response payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"runId": "turn-1",
|
||||
"sessionKey": "thread-main",
|
||||
"remoteWorkingDirectory": "/home/user/.openclaw/workspace",
|
||||
"remoteWorkspaceRefKind": "remotePath",
|
||||
"artifacts": [
|
||||
{
|
||||
"relativePath": "reports/final.md",
|
||||
"label": "final.md",
|
||||
"contentType": "text/markdown",
|
||||
"sizeBytes": 1234,
|
||||
"sha256": "..."
|
||||
}
|
||||
],
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
Files at or below `maxInlineBytes` also include `encoding: "base64"` and `content`.
|
||||
22
index.test.ts
Normal file
22
index.test.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { GatewayRequestHandler, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import register from "./index.js";
|
||||
|
||||
describe("plugin registration", () => {
|
||||
it("registers the xworkmate artifact export gateway method", () => {
|
||||
const methods: Array<{ method: string; handler: GatewayRequestHandler }> = [];
|
||||
const api = {
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => {
|
||||
methods.push({ method, handler });
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
register(api);
|
||||
|
||||
expect(methods).toHaveLength(1);
|
||||
expect(methods[0]?.method).toBe("xworkmate.artifacts.export");
|
||||
expect(typeof methods[0]?.handler).toBe("function");
|
||||
});
|
||||
});
|
||||
20
index.ts
Normal file
20
index.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import type { GatewayRequestHandlerOptions, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { exportXWorkmateArtifacts } from "./src/exportArtifacts.js";
|
||||
|
||||
export default function register(api: OpenClawPluginApi) {
|
||||
api.registerGatewayMethod("xworkmate.artifacts.export", async (opts: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const payload = await exportXWorkmateArtifacts({
|
||||
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),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
37
openclaw.plugin.json
Normal file
37
openclaw.plugin.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"id": "openclaw-xworkmate-artifacts",
|
||||
"name": "XWorkmate Artifacts",
|
||||
"description": "Exports structured artifact manifests from the OpenClaw workspace for XWorkmate.",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"workspaceDir": {
|
||||
"type": "string",
|
||||
"description": "Optional workspace directory override. Defaults to the selected OpenClaw agent workspace."
|
||||
},
|
||||
"maxFiles": {
|
||||
"type": "number",
|
||||
"description": "Default maximum number of files to include."
|
||||
},
|
||||
"maxInlineBytes": {
|
||||
"type": "number",
|
||||
"description": "Default maximum file size to inline as base64."
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"workspaceDir": {
|
||||
"label": "Workspace Directory",
|
||||
"help": "Leave blank to use the OpenClaw agent workspace."
|
||||
},
|
||||
"maxFiles": {
|
||||
"label": "Max Files",
|
||||
"help": "Upper bound for exported artifacts. Default: 64."
|
||||
},
|
||||
"maxInlineBytes": {
|
||||
"label": "Max Inline Bytes",
|
||||
"help": "Upper bound for base64 inline artifact content. Default: 10485760."
|
||||
}
|
||||
}
|
||||
}
|
||||
52
package.json
Normal file
52
package.json
Normal file
@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "openclaw-xworkmate-artifacts",
|
||||
"version": "0.1.0",
|
||||
"description": "OpenClaw Gateway artifact export plugin for XWorkmate",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"openclaw",
|
||||
"xworkmate",
|
||||
"artifacts",
|
||||
"gateway",
|
||||
"plugin"
|
||||
],
|
||||
"homepage": "https://github.com/x-evor/openclaw-xworkmate-artifacts#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/x-evor/openclaw-xworkmate-artifacts.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/x-evor/openclaw-xworkmate-artifacts/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"files": [
|
||||
"README.md",
|
||||
"index.ts",
|
||||
"openclaw.plugin.json",
|
||||
"src/exportArtifacts.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"pack:check": "npm pack --dry-run"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"openclaw": "2026.5.3-1",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
6278
pnpm-lock.yaml
generated
Normal file
6278
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
134
src/exportArtifacts.test.ts
Normal file
134
src/exportArtifacts.test.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { createHash } from "node:crypto";
|
||||
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";
|
||||
|
||||
describe("exportXWorkmateArtifacts", () => {
|
||||
it("exports changed files with metadata and base64 content", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
|
||||
await fs.mkdir(path.join(root, "reports"), { recursive: true });
|
||||
const filePath = path.join(root, "reports", "final.md");
|
||||
await fs.writeFile(filePath, "# Done\n");
|
||||
const stat = await fs.stat(filePath);
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
sinceUnixMs: stat.mtimeMs - 1,
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
expect(result.remoteWorkingDirectory).toBe(await fs.realpath(root));
|
||||
expect(result.remoteWorkspaceRefKind).toBe("remotePath");
|
||||
expect(result.artifacts).toHaveLength(1);
|
||||
expect(result.artifacts[0]).toMatchObject({
|
||||
relativePath: "reports/final.md",
|
||||
label: "final.md",
|
||||
contentType: "text/markdown",
|
||||
sizeBytes: Buffer.byteLength("# Done\n"),
|
||||
sha256: createHash("sha256").update("# Done\n").digest("hex"),
|
||||
encoding: "base64",
|
||||
content: Buffer.from("# Done\n").toString("base64"),
|
||||
});
|
||||
});
|
||||
|
||||
it("filters old files by sinceUnixMs", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
|
||||
const oldFile = path.join(root, "old.txt");
|
||||
await fs.writeFile(oldFile, "old");
|
||||
const stat = await fs.stat(oldFile);
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
sinceUnixMs: stat.mtimeMs + 10_000,
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
expect(result.artifacts).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips excluded directories and symlinks", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
|
||||
await fs.mkdir(path.join(root, ".git"), { recursive: true });
|
||||
await fs.writeFile(path.join(root, ".git", "secret.txt"), "secret");
|
||||
await fs.writeFile(path.join(root, "real.txt"), "real");
|
||||
await fs.symlink(path.join(root, "real.txt"), path.join(root, "linked.txt"));
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["real.txt"]);
|
||||
expect(result.warnings.some((entry) => entry.includes("linked.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves oversized artifacts out of inline content", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
|
||||
await fs.writeFile(path.join(root, "large.pdf"), Buffer.from("large-content"));
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
maxInlineBytes: 2,
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
expect(result.artifacts[0]?.relativePath).toBe("large.pdf");
|
||||
expect(result.artifacts[0]?.encoding).toBeUndefined();
|
||||
expect(result.artifacts[0]?.content).toBeUndefined();
|
||||
expect(result.warnings).toContain("large.pdf 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");
|
||||
await fs.writeFile(path.join(root, "b.txt"), "b");
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
maxFiles: 1,
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
expect(result.artifacts).toHaveLength(1);
|
||||
expect(result.warnings).toContain("artifact limit reached; skipped remaining files after 1");
|
||||
});
|
||||
|
||||
it("selects an agent workspace from agent session keys", async () => {
|
||||
const mainRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-main-"));
|
||||
const agentRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-agent-"));
|
||||
await fs.writeFile(path.join(mainRoot, "main.txt"), "main");
|
||||
await fs.writeFile(path.join(agentRoot, "agent.txt"), "agent");
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey: "agent:research:thread-1",
|
||||
runId: "run-1",
|
||||
},
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { workspace: mainRoot },
|
||||
list: [{ id: "research", workspace: agentRoot }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["agent.txt"]);
|
||||
});
|
||||
});
|
||||
325
src/exportArtifacts.ts
Normal file
325
src/exportArtifacts.ts
Normal file
@ -0,0 +1,325 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_MAX_FILES = 64;
|
||||
const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const SKIPPED_DIRS = new Set([
|
||||
".git",
|
||||
".openclaw",
|
||||
".pi",
|
||||
".dart_tool",
|
||||
".next",
|
||||
".turbo",
|
||||
"build",
|
||||
"dist",
|
||||
"node_modules",
|
||||
]);
|
||||
|
||||
export type XWorkmateArtifact = {
|
||||
relativePath: string;
|
||||
label: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
encoding?: "base64";
|
||||
content?: string;
|
||||
};
|
||||
|
||||
export type XWorkmateArtifactExport = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
remoteWorkingDirectory: string;
|
||||
remoteWorkspaceRefKind: "remotePath";
|
||||
artifacts: XWorkmateArtifact[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type ExportInput = {
|
||||
params: Record<string, unknown>;
|
||||
config?: unknown;
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type Candidate = {
|
||||
absolutePath: string;
|
||||
relativePath: string;
|
||||
sizeBytes: number;
|
||||
mtimeMs: number;
|
||||
};
|
||||
|
||||
export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactExport> {
|
||||
const params = input.params ?? {};
|
||||
const pluginConfig = input.pluginConfig ?? {};
|
||||
const runId = optionalString(params.runId);
|
||||
if (!runId) {
|
||||
throw new Error("runId required");
|
||||
}
|
||||
const sessionKey = optionalString(params.sessionKey);
|
||||
if (!sessionKey) {
|
||||
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 sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
|
||||
const workspaceDir = resolveWorkspaceDir({
|
||||
config: input.config,
|
||||
pluginConfig,
|
||||
params,
|
||||
sessionKey,
|
||||
});
|
||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||
const warnings: string[] = [];
|
||||
const candidates = await collectCandidates({
|
||||
workspaceRoot,
|
||||
sinceUnixMs,
|
||||
warnings,
|
||||
});
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
if (right.mtimeMs !== left.mtimeMs) {
|
||||
return right.mtimeMs - left.mtimeMs;
|
||||
}
|
||||
return left.relativePath.localeCompare(right.relativePath);
|
||||
});
|
||||
|
||||
const artifacts: XWorkmateArtifact[] = [];
|
||||
for (const candidate of candidates) {
|
||||
if (artifacts.length >= maxFiles) {
|
||||
warnings.push(`artifact limit reached; skipped remaining files after ${maxFiles}`);
|
||||
break;
|
||||
}
|
||||
const bytes = await fs.readFile(candidate.absolutePath);
|
||||
const artifact: XWorkmateArtifact = {
|
||||
relativePath: candidate.relativePath,
|
||||
label: path.posix.basename(candidate.relativePath),
|
||||
contentType: contentTypeForPath(candidate.relativePath),
|
||||
sizeBytes: bytes.byteLength,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
};
|
||||
if (bytes.byteLength <= maxInlineBytes) {
|
||||
artifact.encoding = "base64";
|
||||
artifact.content = bytes.toString("base64");
|
||||
} else {
|
||||
warnings.push(`${candidate.relativePath} exceeds maxInlineBytes and was not inlined`);
|
||||
}
|
||||
artifacts.push(artifact);
|
||||
}
|
||||
|
||||
return {
|
||||
runId,
|
||||
sessionKey,
|
||||
remoteWorkingDirectory: workspaceRoot,
|
||||
remoteWorkspaceRefKind: "remotePath",
|
||||
artifacts,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectCandidates(input: {
|
||||
workspaceRoot: string;
|
||||
sinceUnixMs: number;
|
||||
warnings: string[];
|
||||
}): Promise<Candidate[]> {
|
||||
const candidates: Candidate[] = [];
|
||||
await walk(input.workspaceRoot);
|
||||
return candidates;
|
||||
|
||||
async function walk(currentDir: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
input.warnings.push(`cannot read ${safeDisplayPath(input.workspaceRoot, currentDir)}: ${String(error)}`);
|
||||
return;
|
||||
}
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "." || entry.name === "..") {
|
||||
continue;
|
||||
}
|
||||
const absolutePath = path.join(currentDir, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
input.warnings.push(`skipped symlink ${safeDisplayPath(input.workspaceRoot, absolutePath)}`);
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIPPED_DIRS.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
await walk(absolutePath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const stat = await fs.stat(absolutePath);
|
||||
const changedAtMs = Math.max(stat.mtimeMs, stat.ctimeMs);
|
||||
if (changedAtMs < input.sinceUnixMs) {
|
||||
continue;
|
||||
}
|
||||
const realPath = await fs.realpath(absolutePath);
|
||||
if (!isWithinRoot(input.workspaceRoot, realPath)) {
|
||||
input.warnings.push(`skipped path outside workspace ${entry.name}`);
|
||||
continue;
|
||||
}
|
||||
const relativePath = safeRelativePath(input.workspaceRoot, realPath);
|
||||
if (!relativePath) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
absolutePath: realPath,
|
||||
relativePath,
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: changedAtMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorkspaceDir(input: {
|
||||
config?: unknown;
|
||||
pluginConfig: Record<string, unknown>;
|
||||
params: Record<string, unknown>;
|
||||
sessionKey: string;
|
||||
}): string {
|
||||
const explicit = optionalString(input.params.workspaceDir) || optionalString(input.pluginConfig.workspaceDir);
|
||||
if (explicit) {
|
||||
return expandUserPath(explicit);
|
||||
}
|
||||
const config = objectRecord(input.config);
|
||||
const agents = objectRecord(config.agents);
|
||||
const agentList = Array.isArray(agents.list)
|
||||
? agents.list.map(objectRecord).filter((entry) => Object.keys(entry).length > 0)
|
||||
: [];
|
||||
const agentId = agentIdFromSessionKey(input.sessionKey);
|
||||
const selected =
|
||||
(agentId ? agentList.find((entry) => optionalString(entry.id) === agentId) : undefined) ??
|
||||
agentList.find((entry) => entry.default === true) ??
|
||||
agentList[0];
|
||||
const selectedWorkspace = selected ? optionalString(selected.workspace) : "";
|
||||
if (selectedWorkspace) {
|
||||
return expandUserPath(selectedWorkspace);
|
||||
}
|
||||
const defaults = objectRecord(agents.defaults);
|
||||
const defaultWorkspace = optionalString(defaults.workspace);
|
||||
if (defaultWorkspace) {
|
||||
return expandUserPath(defaultWorkspace);
|
||||
}
|
||||
const profile = process.env.OPENCLAW_PROFILE?.trim();
|
||||
if (profile && profile.toLowerCase() !== "default") {
|
||||
return path.join(os.homedir(), ".openclaw", `workspace-${profile}`);
|
||||
}
|
||||
return path.join(os.homedir(), ".openclaw", "workspace");
|
||||
}
|
||||
|
||||
function agentIdFromSessionKey(sessionKey: string): string {
|
||||
const parts = sessionKey.split(":");
|
||||
if (parts.length >= 3 && parts[0] === "agent") {
|
||||
return parts[1]?.trim() ?? "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function safeRelativePath(root: string, target: string): string {
|
||||
const relative = path.relative(root, target);
|
||||
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return "";
|
||||
}
|
||||
const normalized = relative.split(path.sep).join(path.posix.sep);
|
||||
if (normalized.split("/").some((part) => part === ".." || part === "")) {
|
||||
return "";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function safeDisplayPath(root: string, target: string): string {
|
||||
return safeRelativePath(root, target) || path.basename(target);
|
||||
}
|
||||
|
||||
function isWithinRoot(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function contentTypeForPath(relativePath: string): string {
|
||||
switch (path.extname(relativePath).toLowerCase()) {
|
||||
case ".md":
|
||||
case ".markdown":
|
||||
return "text/markdown";
|
||||
case ".txt":
|
||||
case ".log":
|
||||
return "text/plain";
|
||||
case ".json":
|
||||
return "application/json";
|
||||
case ".csv":
|
||||
return "text/csv";
|
||||
case ".html":
|
||||
case ".htm":
|
||||
return "text/html";
|
||||
case ".pdf":
|
||||
return "application/pdf";
|
||||
case ".pptx":
|
||||
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||
case ".docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
case ".xlsx":
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
case ".png":
|
||||
return "image/png";
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return "image/jpeg";
|
||||
case ".gif":
|
||||
return "image/gif";
|
||||
case ".svg":
|
||||
return "image/svg+xml";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
function objectRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function positiveInteger(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) {
|
||||
return numeric;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function expandUserPath(value: string): string {
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(os.homedir(), value.slice(2));
|
||||
}
|
||||
return path.resolve(value);
|
||||
}
|
||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node", "vitest"]
|
||||
},
|
||||
"include": ["*.ts", "src/**/*.ts"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user