mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 12:20:43 +00:00
257 lines
8.5 KiB
TypeScript
257 lines
8.5 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { Type } from "typebox";
|
|
import { jsonResult, type OpenClawPluginApi } from "../api.js";
|
|
import type { SkillWorkshopConfig } from "./config.js";
|
|
import {
|
|
applyProposalToWorkspace,
|
|
normalizeSkillName,
|
|
prepareProposalWrite,
|
|
writeSupportFile,
|
|
} from "./skills.js";
|
|
import type { SkillChange, SkillProposal, SkillWorkshopStatus } from "./types.js";
|
|
import { createStoreForContext, resolveWorkspaceDir } from "./workshop.js";
|
|
|
|
type ToolParams = {
|
|
action?: string;
|
|
id?: string;
|
|
status?: SkillWorkshopStatus;
|
|
skillName?: string;
|
|
title?: string;
|
|
reason?: string;
|
|
description?: string;
|
|
body?: string;
|
|
section?: string;
|
|
oldText?: string;
|
|
newText?: string;
|
|
relativePath?: string;
|
|
apply?: boolean;
|
|
};
|
|
|
|
function readString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
}
|
|
|
|
function buildProposal(params: {
|
|
workspaceDir: string;
|
|
raw: ToolParams;
|
|
source: "tool";
|
|
}): SkillProposal {
|
|
const skillName = normalizeSkillName(readString(params.raw.skillName) ?? "");
|
|
if (!skillName) {
|
|
throw new Error("skillName required");
|
|
}
|
|
const now = Date.now();
|
|
const title = readString(params.raw.title) ?? `Skill update: ${skillName}`;
|
|
const reason = readString(params.raw.reason) ?? "Tool-created skill update";
|
|
const body = readString(params.raw.body);
|
|
const description = readString(params.raw.description) ?? title;
|
|
let change: SkillChange;
|
|
if (params.raw.oldText !== undefined || params.raw.newText !== undefined) {
|
|
const oldText = readString(params.raw.oldText);
|
|
const newText = readString(params.raw.newText);
|
|
if (!oldText || !newText) {
|
|
throw new Error("oldText and newText required for replace");
|
|
}
|
|
change = { kind: "replace", oldText, newText };
|
|
} else if (readString(params.raw.section)) {
|
|
if (!body) {
|
|
throw new Error("body required");
|
|
}
|
|
change = {
|
|
kind: "append",
|
|
section: readString(params.raw.section) ?? "Workflow",
|
|
body,
|
|
description,
|
|
};
|
|
} else {
|
|
if (!body) {
|
|
throw new Error("body required");
|
|
}
|
|
change = { kind: "create", description, body };
|
|
}
|
|
return {
|
|
id: randomUUID(),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
workspaceDir: params.workspaceDir,
|
|
skillName,
|
|
title,
|
|
reason,
|
|
source: params.source,
|
|
status: "pending",
|
|
change,
|
|
};
|
|
}
|
|
|
|
export function createSkillWorkshopTool(params: {
|
|
api: OpenClawPluginApi;
|
|
config: SkillWorkshopConfig;
|
|
ctx: { workspaceDir?: string };
|
|
}) {
|
|
return {
|
|
name: "skill_workshop",
|
|
label: "Skill Workshop",
|
|
description:
|
|
"Create, queue, inspect, approve, or safely apply workspace skill updates for repeatable workflows.",
|
|
parameters: Type.Object({
|
|
action: Type.String({
|
|
enum: [
|
|
"status",
|
|
"list_pending",
|
|
"list_quarantine",
|
|
"inspect",
|
|
"suggest",
|
|
"apply",
|
|
"reject",
|
|
"write_support_file",
|
|
],
|
|
}),
|
|
id: Type.Optional(Type.String()),
|
|
status: Type.Optional(
|
|
Type.String({ enum: ["pending", "applied", "rejected", "quarantined"] }),
|
|
),
|
|
skillName: Type.Optional(Type.String()),
|
|
title: Type.Optional(Type.String()),
|
|
reason: Type.Optional(Type.String()),
|
|
description: Type.Optional(Type.String()),
|
|
body: Type.Optional(Type.String()),
|
|
section: Type.Optional(Type.String()),
|
|
oldText: Type.Optional(Type.String()),
|
|
newText: Type.Optional(Type.String()),
|
|
relativePath: Type.Optional(Type.String()),
|
|
apply: Type.Optional(Type.Boolean()),
|
|
}),
|
|
async execute(_toolCallId: string, rawParams: Record<string, unknown>) {
|
|
const raw = rawParams as ToolParams;
|
|
const action = raw.action ?? "status";
|
|
const workspaceDir = resolveWorkspaceDir(params);
|
|
const store = createStoreForContext(params);
|
|
if (action === "status") {
|
|
const all = await store.list();
|
|
return jsonResult({
|
|
workspaceDir,
|
|
pending: all.filter((item) => item.status === "pending").length,
|
|
quarantined: all.filter((item) => item.status === "quarantined").length,
|
|
applied: all.filter((item) => item.status === "applied").length,
|
|
rejected: all.filter((item) => item.status === "rejected").length,
|
|
});
|
|
}
|
|
if (action === "list_pending") {
|
|
return jsonResult(await store.list(raw.status ?? "pending"));
|
|
}
|
|
if (action === "list_quarantine") {
|
|
return jsonResult(await store.list("quarantined"));
|
|
}
|
|
if (action === "inspect") {
|
|
if (!raw.id) {
|
|
throw new Error("id required");
|
|
}
|
|
return jsonResult(await store.get(raw.id));
|
|
}
|
|
if (action === "suggest") {
|
|
const proposal = buildProposal({ workspaceDir, raw, source: "tool" });
|
|
const shouldApply =
|
|
raw.apply === true || (raw.apply !== false && params.config.approvalPolicy === "auto");
|
|
if (shouldApply) {
|
|
const prepared = await prepareProposalWrite({
|
|
proposal,
|
|
maxSkillBytes: params.config.maxSkillBytes,
|
|
});
|
|
const critical = prepared.findings.find((finding) => finding.severity === "critical");
|
|
if (critical) {
|
|
const stored = await store.add(
|
|
{
|
|
...proposal,
|
|
status: "quarantined",
|
|
updatedAt: Date.now(),
|
|
scanFindings: prepared.findings,
|
|
quarantineReason: critical.message,
|
|
},
|
|
params.config.maxPending,
|
|
);
|
|
return jsonResult({ status: "quarantined", proposal: stored });
|
|
}
|
|
const applied = await applyProposalToWorkspace({
|
|
proposal,
|
|
maxSkillBytes: params.config.maxSkillBytes,
|
|
});
|
|
const stored = await store.add(
|
|
{
|
|
...proposal,
|
|
status: "applied",
|
|
updatedAt: Date.now(),
|
|
scanFindings: applied.findings,
|
|
},
|
|
params.config.maxPending,
|
|
);
|
|
return jsonResult({ status: "applied", skillPath: applied.skillPath, proposal: stored });
|
|
}
|
|
const prepared = await prepareProposalWrite({
|
|
proposal,
|
|
maxSkillBytes: params.config.maxSkillBytes,
|
|
});
|
|
const critical = prepared.findings.find((finding) => finding.severity === "critical");
|
|
if (critical) {
|
|
const stored = await store.add(
|
|
{
|
|
...proposal,
|
|
status: "quarantined",
|
|
updatedAt: Date.now(),
|
|
scanFindings: prepared.findings,
|
|
quarantineReason: critical.message,
|
|
},
|
|
params.config.maxPending,
|
|
);
|
|
return jsonResult({ status: "quarantined", proposal: stored });
|
|
}
|
|
const stored = await store.add(
|
|
{ ...proposal, scanFindings: prepared.findings },
|
|
params.config.maxPending,
|
|
);
|
|
return jsonResult({ status: "pending", proposal: stored });
|
|
}
|
|
if (action === "apply") {
|
|
if (!raw.id) {
|
|
throw new Error("id required");
|
|
}
|
|
const proposal = await store.get(raw.id);
|
|
if (!proposal) {
|
|
throw new Error(`proposal not found: ${raw.id}`);
|
|
}
|
|
if (proposal.status === "quarantined") {
|
|
throw new Error("quarantined proposal cannot be applied");
|
|
}
|
|
const applied = await applyProposalToWorkspace({
|
|
proposal,
|
|
maxSkillBytes: params.config.maxSkillBytes,
|
|
});
|
|
const updated = await store.updateStatus(raw.id, "applied");
|
|
return jsonResult({ status: "applied", skillPath: applied.skillPath, proposal: updated });
|
|
}
|
|
if (action === "reject") {
|
|
if (!raw.id) {
|
|
throw new Error("id required");
|
|
}
|
|
return jsonResult(await store.updateStatus(raw.id, "rejected"));
|
|
}
|
|
if (action === "write_support_file") {
|
|
const skillName = readString(raw.skillName);
|
|
const relativePath = readString(raw.relativePath);
|
|
const body = raw.body;
|
|
if (!skillName || !relativePath || typeof body !== "string") {
|
|
throw new Error("skillName, relativePath, and body required");
|
|
}
|
|
const filePath = await writeSupportFile({
|
|
workspaceDir,
|
|
skillName,
|
|
relativePath,
|
|
content: body,
|
|
maxBytes: params.config.maxSkillBytes,
|
|
});
|
|
return jsonResult({ status: "written", filePath });
|
|
}
|
|
throw new Error(`unknown action: ${action}`);
|
|
},
|
|
};
|
|
}
|