Skills (#50)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -430,6 +430,32 @@ export const deleteGatewayAgent = async (params: {
|
||||
}
|
||||
};
|
||||
|
||||
export const removeGatewayAgentFromConfigOnly = async (params: {
|
||||
client: GatewayClient;
|
||||
agentId: string;
|
||||
}): Promise<{ removed: boolean }> => {
|
||||
const agentId = params.agentId.trim();
|
||||
if (!agentId) {
|
||||
throw new Error("Agent id is required.");
|
||||
}
|
||||
|
||||
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
|
||||
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
|
||||
const list = readConfigAgentList(baseConfig);
|
||||
const nextList = list.filter((entry) => entry.id !== agentId);
|
||||
if (nextList.length === list.length) {
|
||||
return { removed: false };
|
||||
}
|
||||
|
||||
await applyGatewayConfigPatch({
|
||||
client: params.client,
|
||||
patch: { agents: { list: nextList } },
|
||||
baseHash: snapshot.hash ?? undefined,
|
||||
exists: snapshot.exists,
|
||||
});
|
||||
return { removed: true };
|
||||
};
|
||||
|
||||
export const updateGatewayHeartbeat = async (params: {
|
||||
client: GatewayClient;
|
||||
agentId: string;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
export const OFFICE_INTERACTION_TARGETS = [
|
||||
"desk",
|
||||
"server_room",
|
||||
"meeting_room",
|
||||
"gym",
|
||||
"qa_lab",
|
||||
"sms_booth",
|
||||
"phone_booth",
|
||||
] as const;
|
||||
|
||||
export type OfficeInteractionTargetId = (typeof OFFICE_INTERACTION_TARGETS)[number];
|
||||
|
||||
export const OFFICE_SKILL_TRIGGER_MOVEMENT_TARGETS = [
|
||||
"desk",
|
||||
"github",
|
||||
"gym",
|
||||
"qa_lab",
|
||||
] as const;
|
||||
|
||||
export type OfficeSkillTriggerMovementTarget =
|
||||
(typeof OFFICE_SKILL_TRIGGER_MOVEMENT_TARGETS)[number];
|
||||
|
||||
type OfficeSkillTriggerAnimationHoldKey =
|
||||
| "deskHoldByAgentId"
|
||||
| "githubHoldByAgentId"
|
||||
| "gymHoldByAgentId"
|
||||
| "qaHoldByAgentId";
|
||||
|
||||
export const OFFICE_SKILL_TRIGGER_PLACE_REGISTRY: Record<
|
||||
OfficeSkillTriggerMovementTarget,
|
||||
{
|
||||
label: string;
|
||||
interactionTarget: OfficeInteractionTargetId;
|
||||
animationHoldKey: OfficeSkillTriggerAnimationHoldKey;
|
||||
alsoSetsSkillGymHold?: boolean;
|
||||
}
|
||||
> = {
|
||||
desk: {
|
||||
label: "Desk",
|
||||
interactionTarget: "desk",
|
||||
animationHoldKey: "deskHoldByAgentId",
|
||||
},
|
||||
github: {
|
||||
label: "GitHub / Server Room",
|
||||
interactionTarget: "server_room",
|
||||
animationHoldKey: "githubHoldByAgentId",
|
||||
},
|
||||
gym: {
|
||||
label: "Gym",
|
||||
interactionTarget: "gym",
|
||||
animationHoldKey: "gymHoldByAgentId",
|
||||
alsoSetsSkillGymHold: true,
|
||||
},
|
||||
qa_lab: {
|
||||
label: "QA Lab",
|
||||
interactionTarget: "qa_lab",
|
||||
animationHoldKey: "qaHoldByAgentId",
|
||||
},
|
||||
};
|
||||
|
||||
export const isOfficeSkillTriggerMovementTarget = (
|
||||
value: unknown,
|
||||
): value is OfficeSkillTriggerMovementTarget =>
|
||||
typeof value === "string" && value in OFFICE_SKILL_TRIGGER_PLACE_REGISTRY;
|
||||
|
||||
export type DefaultSkillTriggerFallback = {
|
||||
anyPhrases: string[];
|
||||
movementTarget: OfficeSkillTriggerMovementTarget;
|
||||
skipIfAlreadyThere?: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_SKILL_TRIGGER_FALLBACKS_BY_SKILL_KEY: Record<
|
||||
string,
|
||||
DefaultSkillTriggerFallback
|
||||
> = {
|
||||
"todo-board": {
|
||||
anyPhrases: [
|
||||
"todo",
|
||||
"todo list",
|
||||
"blocked task",
|
||||
"blocked tasks",
|
||||
"add to my todo",
|
||||
"show my todo",
|
||||
],
|
||||
movementTarget: "desk",
|
||||
skipIfAlreadyThere: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const buildOfficeSkillTriggerHoldMaps = (
|
||||
movementTargetByAgentId: Record<string, OfficeSkillTriggerMovementTarget>,
|
||||
): {
|
||||
deskHoldByAgentId: Record<string, boolean>;
|
||||
githubHoldByAgentId: Record<string, boolean>;
|
||||
gymHoldByAgentId: Record<string, boolean>;
|
||||
qaHoldByAgentId: Record<string, boolean>;
|
||||
skillGymHoldByAgentId: Record<string, boolean>;
|
||||
} => {
|
||||
const next = {
|
||||
deskHoldByAgentId: {} as Record<string, boolean>,
|
||||
githubHoldByAgentId: {} as Record<string, boolean>,
|
||||
gymHoldByAgentId: {} as Record<string, boolean>,
|
||||
qaHoldByAgentId: {} as Record<string, boolean>,
|
||||
skillGymHoldByAgentId: {} as Record<string, boolean>,
|
||||
};
|
||||
|
||||
for (const [agentId, target] of Object.entries(movementTargetByAgentId)) {
|
||||
const place = OFFICE_SKILL_TRIGGER_PLACE_REGISTRY[target];
|
||||
next[place.animationHoldKey][agentId] = true;
|
||||
if (place.alsoSetsSkillGymHold) {
|
||||
next.skillGymHoldByAgentId[agentId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { RemovableSkillSource, SkillStatusEntry } from "@/lib/skills/types";
|
||||
|
||||
export type PackagedSkillId = "todo-board";
|
||||
|
||||
export type PackagedSkillDefinition = {
|
||||
packageId: PackagedSkillId;
|
||||
skillKey: string;
|
||||
name: string;
|
||||
description: string;
|
||||
installSource: RemovableSkillSource;
|
||||
creatorName?: string;
|
||||
creatorUrl?: string;
|
||||
};
|
||||
|
||||
const EMPTY_REQUIREMENTS = {
|
||||
bins: [],
|
||||
anyBins: [],
|
||||
env: [],
|
||||
config: [],
|
||||
os: [],
|
||||
};
|
||||
|
||||
const PACKAGED_SKILLS: PackagedSkillDefinition[] = [
|
||||
{
|
||||
packageId: "todo-board",
|
||||
skillKey: "todo-board",
|
||||
name: "todo",
|
||||
description: "Maintain a shared workspace TODO list with blocked tasks.",
|
||||
installSource: "openclaw-workspace",
|
||||
creatorName: "iamlukethedev",
|
||||
creatorUrl: "http://x.com/iamlukethedev/",
|
||||
},
|
||||
];
|
||||
|
||||
export const listPackagedSkills = (): PackagedSkillDefinition[] => [...PACKAGED_SKILLS];
|
||||
|
||||
export const getPackagedSkillById = (packageId: string): PackagedSkillDefinition | null =>
|
||||
PACKAGED_SKILLS.find((skill) => skill.packageId === packageId) ?? null;
|
||||
|
||||
export const getPackagedSkillBySkillKey = (skillKey: string): PackagedSkillDefinition | null => {
|
||||
const normalized = skillKey.trim();
|
||||
return PACKAGED_SKILLS.find((skill) => skill.skillKey === normalized) ?? null;
|
||||
};
|
||||
|
||||
export const buildPackagedSkillStatusEntry = (
|
||||
skill: PackagedSkillDefinition
|
||||
): SkillStatusEntry => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
source: "openclaw-extra",
|
||||
bundled: false,
|
||||
filePath: "",
|
||||
baseDir: "",
|
||||
skillKey: skill.skillKey,
|
||||
always: false,
|
||||
disabled: false,
|
||||
blockedByAllowlist: false,
|
||||
eligible: false,
|
||||
requirements: { ...EMPTY_REQUIREMENTS },
|
||||
missing: { ...EMPTY_REQUIREMENTS },
|
||||
configChecks: [],
|
||||
install: [],
|
||||
});
|
||||
|
||||
export const appendPackagedSkillsToMarketplace = (skills: SkillStatusEntry[]): SkillStatusEntry[] => {
|
||||
const presentKeys = new Set(skills.map((skill) => skill.skillKey.trim()));
|
||||
const additions = PACKAGED_SKILLS.filter((skill) => !presentKeys.has(skill.skillKey)).map(
|
||||
buildPackagedSkillStatusEntry
|
||||
);
|
||||
if (additions.length === 0) {
|
||||
return skills;
|
||||
}
|
||||
return [...additions, ...skills];
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { buildAgentMainSessionKey, type GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
removeGatewayAgentFromConfigOnly,
|
||||
updateGatewayAgentOverrides,
|
||||
} from "@/lib/gateway/agentConfig";
|
||||
import { getPackagedSkillById } from "@/lib/skills/catalog";
|
||||
import { readPackagedSkillFiles } from "@/lib/skills/packaged";
|
||||
import type { PackagedSkillInstallRequest, PackagedSkillInstallResult } from "@/lib/skills/types";
|
||||
|
||||
const normalizeRequired = (value: string, field: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`${field} is required.`);
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const escapeForJsonString = (value: string) => JSON.stringify(value);
|
||||
|
||||
const buildInstallerMessage = (params: {
|
||||
skillKey: string;
|
||||
files: Array<{ relativePath: string; content: string }>;
|
||||
}) => {
|
||||
const fileEntries = params.files
|
||||
.map(
|
||||
(file) =>
|
||||
`- path: ${escapeForJsonString(`skills/${params.skillKey}/${file.relativePath}`)}\n content: ${escapeForJsonString(file.content)}`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return [
|
||||
"Create these exact skill files inside the current workspace.",
|
||||
"You must use the file tools and write the files exactly as provided.",
|
||||
"Do not modify filenames, frontmatter, spacing, or content.",
|
||||
"Create parent directories if they do not exist.",
|
||||
"After writing the files, verify they exist and then reply only with: INSTALLED",
|
||||
"",
|
||||
"Files:",
|
||||
fileEntries,
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
const resolveRunId = (payload: unknown): string => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("Gateway returned an invalid chat.send response.");
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
const runId = typeof record.runId === "string" ? record.runId.trim() : "";
|
||||
if (!runId) {
|
||||
throw new Error("Gateway returned an invalid chat.send response (missing runId).");
|
||||
}
|
||||
return runId;
|
||||
};
|
||||
|
||||
const resolveMainKey = async (client: GatewayClient): Promise<string> => {
|
||||
const result = (await client.call("agents.list", {})) as { mainKey?: unknown };
|
||||
return typeof result?.mainKey === "string" && result.mainKey.trim() ? result.mainKey.trim() : "main";
|
||||
};
|
||||
|
||||
export const installPackagedSkillViaGatewayAgent = async (params: {
|
||||
client: GatewayClient;
|
||||
request: PackagedSkillInstallRequest;
|
||||
}): Promise<PackagedSkillInstallResult> => {
|
||||
const packageId = normalizeRequired(params.request.packageId, "packageId");
|
||||
const packagedSkill = getPackagedSkillById(packageId);
|
||||
if (!packagedSkill) {
|
||||
throw new Error(`Unknown packaged skill: ${packageId}`);
|
||||
}
|
||||
if (params.request.source !== "openclaw-workspace") {
|
||||
throw new Error("Gateway-native packaged install currently supports workspace skills only.");
|
||||
}
|
||||
|
||||
const workspaceDir = normalizeRequired(params.request.workspaceDir, "workspaceDir");
|
||||
const files = readPackagedSkillFiles(packagedSkill.packageId);
|
||||
const installerName = `Skill Installer ${Date.now()}`;
|
||||
|
||||
let installerAgentId: string | null = null;
|
||||
try {
|
||||
const created = (await params.client.call("agents.create", {
|
||||
name: installerName,
|
||||
workspace: workspaceDir,
|
||||
})) as { agentId?: unknown };
|
||||
installerAgentId =
|
||||
typeof created?.agentId === "string" ? created.agentId.trim() : "";
|
||||
if (!installerAgentId) {
|
||||
throw new Error("Gateway returned an invalid agents.create response (missing agentId).");
|
||||
}
|
||||
|
||||
await updateGatewayAgentOverrides({
|
||||
client: params.client,
|
||||
agentId: installerAgentId,
|
||||
overrides: {
|
||||
tools: {
|
||||
alsoAllow: ["group:runtime", "group:fs"],
|
||||
deny: ["group:web"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mainKey = await resolveMainKey(params.client);
|
||||
const sessionKey = buildAgentMainSessionKey(installerAgentId, mainKey);
|
||||
const sendResult = await params.client.call("chat.send", {
|
||||
sessionKey,
|
||||
message: buildInstallerMessage({ skillKey: packagedSkill.skillKey, files }),
|
||||
deliver: false,
|
||||
idempotencyKey: `skill-install:${packagedSkill.skillKey}:${Date.now()}`,
|
||||
});
|
||||
const runId = resolveRunId(sendResult);
|
||||
await params.client.call("agent.wait", { runId, timeoutMs: 60_000 });
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
installedPath: `${workspaceDir.replace(/\/+$/, "")}/skills/${packagedSkill.skillKey}`,
|
||||
source: "openclaw-workspace",
|
||||
skillKey: packagedSkill.skillKey,
|
||||
};
|
||||
} finally {
|
||||
if (installerAgentId) {
|
||||
try {
|
||||
await removeGatewayAgentFromConfigOnly({
|
||||
client: params.client,
|
||||
agentId: installerAgentId,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup for temporary installer agents.
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
hasInstallableMissingBinary,
|
||||
type SkillReadinessState,
|
||||
} from "@/lib/skills/presentation";
|
||||
import { getPackagedSkillBySkillKey } from "@/lib/skills/catalog";
|
||||
import type { SkillStatusEntry } from "@/lib/skills/types";
|
||||
|
||||
export type SkillMarketplaceCollectionId =
|
||||
| "claw3d"
|
||||
| "featured"
|
||||
| "installed"
|
||||
| "setup-required"
|
||||
@@ -26,6 +28,9 @@ export type SkillMarketplaceMetadata = {
|
||||
editorBadge?: string;
|
||||
rating?: number;
|
||||
installs?: number;
|
||||
poweredByName?: string;
|
||||
poweredByUrl?: string;
|
||||
hideStats?: boolean;
|
||||
};
|
||||
|
||||
export type SkillMarketplaceEntry = {
|
||||
@@ -72,6 +77,14 @@ const SKILL_MARKETPLACE_OVERRIDES: Record<string, Partial<SkillMarketplaceMetada
|
||||
rating: 4.7,
|
||||
installs: 11980,
|
||||
},
|
||||
"todo-board": {
|
||||
category: "Productivity",
|
||||
tagline: "Gives agents a shared workspace TODO board with blocked-task tracking.",
|
||||
capabilities: ["Task capture", "Blocked tracking", "Shared workspace state"],
|
||||
featured: true,
|
||||
editorBadge: "Claw3D test",
|
||||
hideStats: true,
|
||||
},
|
||||
};
|
||||
|
||||
const hashString = (value: string): number => {
|
||||
@@ -146,24 +159,38 @@ export const resolveSkillMarketplaceMetadata = (skill: SkillStatusEntry): SkillM
|
||||
const normalizedKey = skill.skillKey.trim().toLowerCase();
|
||||
const fallback = buildFallbackMetadata(skill);
|
||||
const override = SKILL_MARKETPLACE_OVERRIDES[normalizedKey];
|
||||
const packagedSkill = getPackagedSkillBySkillKey(skill.skillKey);
|
||||
if (!override) {
|
||||
return fallback;
|
||||
return {
|
||||
...fallback,
|
||||
poweredByName: packagedSkill?.creatorName,
|
||||
poweredByUrl: packagedSkill?.creatorUrl,
|
||||
hideStats: Boolean(packagedSkill),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...fallback,
|
||||
...override,
|
||||
capabilities: override.capabilities ?? fallback.capabilities,
|
||||
poweredByName: packagedSkill?.creatorName,
|
||||
poweredByUrl: packagedSkill?.creatorUrl,
|
||||
hideStats: override.hideStats ?? Boolean(packagedSkill),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSkillMarketplaceEntry = (skill: SkillStatusEntry): SkillMarketplaceEntry => {
|
||||
const packagedSkill = getPackagedSkillBySkillKey(skill.skillKey);
|
||||
const missingDetails = buildSkillMissingDetails(skill);
|
||||
if (packagedSkill && !skill.baseDir.trim()) {
|
||||
missingDetails.unshift("Install this packaged Claw3D skill to make it available on the gateway.");
|
||||
}
|
||||
return {
|
||||
skill,
|
||||
readiness: deriveSkillReadinessState(skill),
|
||||
metadata: resolveSkillMarketplaceMetadata(skill),
|
||||
installable: hasInstallableMissingBinary(skill),
|
||||
removable: canRemoveSkill(skill),
|
||||
missingDetails: buildSkillMissingDetails(skill),
|
||||
missingDetails,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -187,6 +214,11 @@ export const buildSkillMarketplaceCollections = (
|
||||
collections.push({ id: "featured", label: "Featured", entries: featured });
|
||||
}
|
||||
|
||||
const claw3d = entries.filter((entry) => getPackagedSkillBySkillKey(entry.skill.skillKey));
|
||||
if (claw3d.length > 0) {
|
||||
collections.push({ id: "claw3d", label: "Claw3D", entries: claw3d });
|
||||
}
|
||||
|
||||
const installed = entries.filter((entry) => entry.readiness === "ready" || entry.skill.disabled);
|
||||
if (installed.length > 0) {
|
||||
collections.push({ id: "installed", label: "Installed", entries: installed });
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
type PackagedSkillFile = {
|
||||
relativePath: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
// Keep this string synchronized with assets/skills/todo-board/SKILL.md.
|
||||
const TODO_BOARD_SKILL_MD = `---
|
||||
name: todo
|
||||
description: Maintain a shared workspace TODO list with blocked tasks.
|
||||
metadata: {"openclaw":{"skillKey":"todo-board"}}
|
||||
---
|
||||
|
||||
# TODO Board
|
||||
|
||||
Use this skill when the user wants to manage a shared task list for the current workspace.
|
||||
|
||||
## Trigger
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"activation": {
|
||||
"anyPhrases": [
|
||||
"todo",
|
||||
"todo list",
|
||||
"blocked task",
|
||||
"blocked tasks",
|
||||
"add to my todo",
|
||||
"show my todo"
|
||||
]
|
||||
},
|
||||
"movement": {
|
||||
"target": "desk",
|
||||
"skipIfAlreadyThere": true
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
When this skill is activated, the agent should return to its assigned desk before handling the request.
|
||||
|
||||
- If the user asks from Telegram or any other external surface to add, block, unblock, remove, or read TODO items, treat that as a trigger for this skill.
|
||||
- The physical behavior for this skill is: go sit at the assigned desk, then perform the TODO board workflow.
|
||||
- If the agent is already at the desk, continue without adding extra movement narration.
|
||||
|
||||
## Storage location
|
||||
|
||||
The authoritative task file is \`todo-skill/todo-list.json\` in the workspace root.
|
||||
|
||||
- Always treat that file as the source of truth.
|
||||
- Never rely on chat memory alone for the latest task state.
|
||||
- Create the \`todo-skill\` directory and \`todo-list.json\` file if they do not exist.
|
||||
|
||||
## Required workflow
|
||||
|
||||
1. Read \`todo-skill/todo-list.json\` before answering any task-management request.
|
||||
2. If the file does not exist, create it with the schema in this document before continuing.
|
||||
3. After every add, remove, block, or unblock action, write the full updated JSON back to disk.
|
||||
4. If the file exists but is invalid JSON or does not match the schema, repair it into a valid structure, preserve any recoverable items, and mention that repair in your response.
|
||||
5. If the user request is ambiguous, ask a clarifying question instead of guessing.
|
||||
|
||||
## Supported actions
|
||||
|
||||
- Add a task.
|
||||
Create a new item unless an equivalent active item already exists.
|
||||
- Block a task.
|
||||
Change the matching item to \`status: "blocked"\`. If the task does not exist and the request is clear, create it directly as blocked.
|
||||
- Unblock a task.
|
||||
Change the matching item back to \`status: "todo"\` and clear \`blockReason\`.
|
||||
- Remove a task.
|
||||
Delete only the matching item. If multiple items could match, ask for clarification.
|
||||
- Read the list.
|
||||
Summarize tasks grouped into \`TODO\` and \`BLOCKED\`.
|
||||
|
||||
## File format
|
||||
|
||||
Use this JSON shape:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-03-22T00:00:00.000Z",
|
||||
"items": [
|
||||
{
|
||||
"id": "task-1",
|
||||
"title": "Example task",
|
||||
"status": "todo",
|
||||
"createdAt": "2026-03-22T00:00:00.000Z",
|
||||
"updatedAt": "2026-03-22T00:00:00.000Z",
|
||||
"blockReason": null
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Field rules
|
||||
|
||||
- Keep \`version\` at \`1\`.
|
||||
- Generate stable, human-readable IDs such as \`prepare-demo\` or \`task-2\`.
|
||||
- Keep titles concise and preserve the user's intent.
|
||||
- Use only \`todo\` or \`blocked\` for \`status\`.
|
||||
- Use ISO timestamps for \`createdAt\`, item \`updatedAt\`, and top-level \`updatedAt\`.
|
||||
- Keep \`blockReason\` as \`null\` unless the user gave a reason or a short precise reason is clearly implied.
|
||||
|
||||
## Mutation rules
|
||||
|
||||
- Avoid duplicate active items that describe the same work.
|
||||
- Preserve existing IDs and \`createdAt\` values for unchanged items.
|
||||
- Update the touched item's \`updatedAt\` whenever you modify it.
|
||||
- Update the top-level \`updatedAt\` on every write.
|
||||
- Keep untouched items in their original order unless there is a strong reason to reorder them.
|
||||
|
||||
## Response style
|
||||
|
||||
- After each mutation, say what changed.
|
||||
- When showing the list, group tasks into \`TODO\` and \`BLOCKED\`.
|
||||
- Include each blocked task's reason when one exists.
|
||||
`;
|
||||
|
||||
// Keep this string synchronized with assets/skills/todo-board/todo-list.example.json.
|
||||
const TODO_BOARD_EXAMPLE_JSON = `{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-03-22T00:00:00.000Z",
|
||||
"items": [
|
||||
{
|
||||
"id": "draft-roadmap",
|
||||
"title": "Draft the TODO skill roadmap",
|
||||
"status": "todo",
|
||||
"createdAt": "2026-03-22T00:00:00.000Z",
|
||||
"updatedAt": "2026-03-22T00:00:00.000Z",
|
||||
"blockReason": null
|
||||
},
|
||||
{
|
||||
"id": "gateway-access",
|
||||
"title": "Confirm gateway install access",
|
||||
"status": "blocked",
|
||||
"createdAt": "2026-03-22T00:00:00.000Z",
|
||||
"updatedAt": "2026-03-22T00:00:00.000Z",
|
||||
"blockReason": "Waiting for gateway credentials"
|
||||
}
|
||||
]
|
||||
}
|
||||
`;
|
||||
|
||||
const PACKAGED_SKILL_FILES: Record<string, PackagedSkillFile[]> = {
|
||||
"todo-board": [
|
||||
{
|
||||
relativePath: "SKILL.md",
|
||||
content: TODO_BOARD_SKILL_MD,
|
||||
},
|
||||
{
|
||||
relativePath: "todo-list.example.json",
|
||||
content: TODO_BOARD_EXAMPLE_JSON,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const readPackagedSkillFiles = (packageId: string): PackagedSkillFile[] => {
|
||||
const files = PACKAGED_SKILL_FILES[packageId];
|
||||
if (!files || files.length === 0) {
|
||||
throw new Error(`Packaged skill assets are missing: ${packageId}`);
|
||||
}
|
||||
if (!files.some((file) => file.relativePath === "SKILL.md")) {
|
||||
throw new Error(`Packaged skill is missing SKILL.md: ${packageId}`);
|
||||
}
|
||||
return files.map((file) => ({ ...file }));
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { buildAgentMainSessionKey, type GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
removeGatewayAgentFromConfigOnly,
|
||||
updateGatewayAgentOverrides,
|
||||
} from "@/lib/gateway/agentConfig";
|
||||
import type { SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types";
|
||||
|
||||
const normalizeRequired = (value: string, field: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`${field} is required.`);
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const escapeForJsonString = (value: string) => JSON.stringify(value);
|
||||
|
||||
const resolveRunId = (payload: unknown): string => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("Gateway returned an invalid chat.send response.");
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
const runId = typeof record.runId === "string" ? record.runId.trim() : "";
|
||||
if (!runId) {
|
||||
throw new Error("Gateway returned an invalid chat.send response (missing runId).");
|
||||
}
|
||||
return runId;
|
||||
};
|
||||
|
||||
const resolveMainKey = async (client: GatewayClient): Promise<string> => {
|
||||
const result = (await client.call("agents.list", {})) as { mainKey?: unknown };
|
||||
return typeof result?.mainKey === "string" && result.mainKey.trim() ? result.mainKey.trim() : "main";
|
||||
};
|
||||
|
||||
const buildSkillRemovalMessage = (params: {
|
||||
baseDir: string;
|
||||
allowedRoot: string;
|
||||
}) => {
|
||||
return [
|
||||
"Delete exactly one installed skill directory from the current workspace context.",
|
||||
"You may use the runtime tools or file tools.",
|
||||
`Target directory: ${escapeForJsonString(params.baseDir)}`,
|
||||
`Allowed root: ${escapeForJsonString(params.allowedRoot)}`,
|
||||
"",
|
||||
"Rules:",
|
||||
"1. Refuse to operate outside the allowed root.",
|
||||
"2. Refuse to delete the allowed root directory itself.",
|
||||
"3. If the target directory exists, verify it contains SKILL.md before deleting it.",
|
||||
"4. If the target directory does not exist, reply only with: REMOVED_ALREADY",
|
||||
"5. If deletion succeeds, reply only with: REMOVED",
|
||||
"6. Do not modify any other files or directories.",
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
const resolveRemovalWorkspace = (request: SkillRemoveRequest): string => {
|
||||
return request.source === "openclaw-managed" ? request.managedSkillsDir : request.workspaceDir;
|
||||
};
|
||||
|
||||
const resolveAllowedRoot = (request: SkillRemoveRequest): string => {
|
||||
return request.source === "openclaw-managed"
|
||||
? request.managedSkillsDir
|
||||
: `${request.workspaceDir.replace(/[\\/]+$/, "")}/skills`;
|
||||
};
|
||||
|
||||
export const removeSkillViaGatewayAgent = async (params: {
|
||||
client: GatewayClient;
|
||||
request: SkillRemoveRequest;
|
||||
}): Promise<SkillRemoveResult> => {
|
||||
const skillKey = normalizeRequired(params.request.skillKey, "skillKey");
|
||||
const source = params.request.source;
|
||||
const baseDir = normalizeRequired(params.request.baseDir, "baseDir");
|
||||
const workspaceDir = normalizeRequired(params.request.workspaceDir, "workspaceDir");
|
||||
const managedSkillsDir = normalizeRequired(params.request.managedSkillsDir, "managedSkillsDir");
|
||||
const workspace = resolveRemovalWorkspace({
|
||||
...params.request,
|
||||
skillKey,
|
||||
baseDir,
|
||||
workspaceDir,
|
||||
managedSkillsDir,
|
||||
});
|
||||
const allowedRoot = resolveAllowedRoot({
|
||||
...params.request,
|
||||
skillKey,
|
||||
baseDir,
|
||||
workspaceDir,
|
||||
managedSkillsDir,
|
||||
});
|
||||
const removerName = `Skill Remover ${Date.now()}`;
|
||||
|
||||
let removerAgentId: string | null = null;
|
||||
try {
|
||||
const created = (await params.client.call("agents.create", {
|
||||
name: removerName,
|
||||
workspace,
|
||||
})) as { agentId?: unknown };
|
||||
removerAgentId = typeof created?.agentId === "string" ? created.agentId.trim() : "";
|
||||
if (!removerAgentId) {
|
||||
throw new Error("Gateway returned an invalid agents.create response (missing agentId).");
|
||||
}
|
||||
|
||||
await updateGatewayAgentOverrides({
|
||||
client: params.client,
|
||||
agentId: removerAgentId,
|
||||
overrides: {
|
||||
tools: {
|
||||
alsoAllow: ["group:runtime", "group:fs"],
|
||||
deny: ["group:web"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mainKey = await resolveMainKey(params.client);
|
||||
const sessionKey = buildAgentMainSessionKey(removerAgentId, mainKey);
|
||||
const sendResult = await params.client.call("chat.send", {
|
||||
sessionKey,
|
||||
message: buildSkillRemovalMessage({ baseDir, allowedRoot }),
|
||||
deliver: false,
|
||||
idempotencyKey: `skill-remove:${skillKey}:${Date.now()}`,
|
||||
});
|
||||
const runId = resolveRunId(sendResult);
|
||||
await params.client.call("agent.wait", { runId, timeoutMs: 60_000 });
|
||||
|
||||
return {
|
||||
removed: true,
|
||||
removedPath: baseDir,
|
||||
source,
|
||||
};
|
||||
} finally {
|
||||
if (removerAgentId) {
|
||||
try {
|
||||
await removeGatewayAgentFromConfigOnly({
|
||||
client: params.client,
|
||||
agentId: removerAgentId,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup for temporary remover agents.
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
+11
-13
@@ -1,4 +1,5 @@
|
||||
import { fetchJson } from "@/lib/http";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { removeSkillViaGatewayAgent } from "@/lib/skills/remove-gateway";
|
||||
import type { SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types";
|
||||
|
||||
const normalizeRequired = (value: string, field: string): string => {
|
||||
@@ -10,20 +11,17 @@ const normalizeRequired = (value: string, field: string): string => {
|
||||
};
|
||||
|
||||
export const removeSkillFromGateway = async (
|
||||
request: SkillRemoveRequest
|
||||
params: { client: GatewayClient } & SkillRemoveRequest
|
||||
): Promise<SkillRemoveResult> => {
|
||||
const payload: SkillRemoveRequest = {
|
||||
skillKey: normalizeRequired(request.skillKey, "skillKey"),
|
||||
source: request.source,
|
||||
baseDir: normalizeRequired(request.baseDir, "baseDir"),
|
||||
workspaceDir: normalizeRequired(request.workspaceDir, "workspaceDir"),
|
||||
managedSkillsDir: normalizeRequired(request.managedSkillsDir, "managedSkillsDir"),
|
||||
skillKey: normalizeRequired(params.skillKey, "skillKey"),
|
||||
source: params.source,
|
||||
baseDir: normalizeRequired(params.baseDir, "baseDir"),
|
||||
workspaceDir: normalizeRequired(params.workspaceDir, "workspaceDir"),
|
||||
managedSkillsDir: normalizeRequired(params.managedSkillsDir, "managedSkillsDir"),
|
||||
};
|
||||
|
||||
const response = await fetchJson<{ result: SkillRemoveResult }>("/api/gateway/skills/remove", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
return removeSkillViaGatewayAgent({
|
||||
client: params.client,
|
||||
request: payload,
|
||||
});
|
||||
return response.result;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { TranscriptEntry } from "@/features/agents/state/transcript";
|
||||
import {
|
||||
DEFAULT_SKILL_TRIGGER_FALLBACKS_BY_SKILL_KEY,
|
||||
isOfficeSkillTriggerMovementTarget,
|
||||
type OfficeSkillTriggerMovementTarget,
|
||||
} from "@/lib/office/places";
|
||||
import { listPackagedSkills } from "@/lib/skills/catalog";
|
||||
import { readPackagedSkillFiles } from "@/lib/skills/packaged";
|
||||
|
||||
type SkillTriggerJsonShape = {
|
||||
activation?: {
|
||||
anyPhrases?: unknown;
|
||||
};
|
||||
movement?: {
|
||||
target?: unknown;
|
||||
skipIfAlreadyThere?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type SkillTriggerDefinition = {
|
||||
packageId: string;
|
||||
skillKey: string;
|
||||
skillName: string;
|
||||
activationPhrases: string[];
|
||||
movementTarget: OfficeSkillTriggerMovementTarget;
|
||||
skipIfAlreadyThere: boolean;
|
||||
};
|
||||
|
||||
const TRIGGER_SECTION_RE = /##\s+Trigger\s*([\s\S]*?)(?:\n##\s+|\s*$)/i;
|
||||
const JSON_CODE_BLOCK_RE = /```json\s*([\s\S]*?)```/i;
|
||||
|
||||
const normalizePhrase = (value: string): string => value.trim().toLowerCase().replace(/\s+/g, " ");
|
||||
|
||||
const normalizeMessage = (value: string | null | undefined): string =>
|
||||
normalizePhrase(value ?? "");
|
||||
|
||||
const extractTriggerJson = (markdown: string): SkillTriggerJsonShape | null => {
|
||||
const triggerSection = markdown.match(TRIGGER_SECTION_RE)?.[1] ?? "";
|
||||
if (!triggerSection) {
|
||||
return null;
|
||||
}
|
||||
const jsonBlock = triggerSection.match(JSON_CODE_BLOCK_RE)?.[1]?.trim() ?? "";
|
||||
if (!jsonBlock) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(jsonBlock) as SkillTriggerJsonShape;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseSkillTriggerDefinition = (params: {
|
||||
packageId: string;
|
||||
skillKey: string;
|
||||
skillName: string;
|
||||
markdown: string;
|
||||
}): SkillTriggerDefinition | null => {
|
||||
const parsed = extractTriggerJson(params.markdown);
|
||||
const fallback = DEFAULT_SKILL_TRIGGER_FALLBACKS_BY_SKILL_KEY[params.skillKey];
|
||||
if (!parsed && !fallback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activationPhrases = Array.isArray(parsed?.activation?.anyPhrases)
|
||||
? Array.from(
|
||||
new Set(
|
||||
parsed.activation!.anyPhrases
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.map(normalizePhrase)
|
||||
.filter((value) => value.length > 0),
|
||||
),
|
||||
)
|
||||
: fallback?.anyPhrases.map(normalizePhrase) ?? [];
|
||||
const movementTarget = parsed?.movement?.target;
|
||||
const resolvedMovementTarget = isOfficeSkillTriggerMovementTarget(movementTarget)
|
||||
? movementTarget
|
||||
: fallback?.movementTarget;
|
||||
if (activationPhrases.length === 0 || !resolvedMovementTarget) {
|
||||
return null;
|
||||
}
|
||||
const skipIfAlreadyThere =
|
||||
typeof parsed?.movement?.skipIfAlreadyThere === "boolean"
|
||||
? parsed.movement.skipIfAlreadyThere
|
||||
: fallback?.skipIfAlreadyThere ?? true;
|
||||
|
||||
return {
|
||||
packageId: params.packageId,
|
||||
skillKey: params.skillKey,
|
||||
skillName: params.skillName,
|
||||
activationPhrases,
|
||||
movementTarget: resolvedMovementTarget,
|
||||
skipIfAlreadyThere,
|
||||
};
|
||||
};
|
||||
|
||||
let packagedSkillTriggerCache: SkillTriggerDefinition[] | null = null;
|
||||
|
||||
export const listPackagedSkillTriggerDefinitions = (): SkillTriggerDefinition[] => {
|
||||
if (packagedSkillTriggerCache) {
|
||||
return packagedSkillTriggerCache.map((entry) => ({
|
||||
...entry,
|
||||
activationPhrases: [...entry.activationPhrases],
|
||||
}));
|
||||
}
|
||||
|
||||
const triggers: SkillTriggerDefinition[] = [];
|
||||
for (const skill of listPackagedSkills()) {
|
||||
const skillFile = readPackagedSkillFiles(skill.packageId).find(
|
||||
(file) => file.relativePath === "SKILL.md",
|
||||
);
|
||||
if (!skillFile) {
|
||||
continue;
|
||||
}
|
||||
const trigger = parseSkillTriggerDefinition({
|
||||
packageId: skill.packageId,
|
||||
skillKey: skill.skillKey,
|
||||
skillName: skill.name,
|
||||
markdown: skillFile.content,
|
||||
});
|
||||
if (trigger) {
|
||||
triggers.push(trigger);
|
||||
}
|
||||
}
|
||||
|
||||
packagedSkillTriggerCache = triggers;
|
||||
return triggers.map((entry) => ({
|
||||
...entry,
|
||||
activationPhrases: [...entry.activationPhrases],
|
||||
}));
|
||||
};
|
||||
|
||||
export const resolveTriggeredSkillDefinition = (params: {
|
||||
isAgentRunning: boolean;
|
||||
lastUserMessage: string | null | undefined;
|
||||
transcriptEntries: TranscriptEntry[] | undefined;
|
||||
triggers: SkillTriggerDefinition[];
|
||||
}): SkillTriggerDefinition | null => {
|
||||
if (!params.isAgentRunning || params.triggers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates: string[] = [];
|
||||
const latestMessage = params.lastUserMessage?.trim() ?? "";
|
||||
if (latestMessage) {
|
||||
candidates.push(latestMessage);
|
||||
}
|
||||
if (Array.isArray(params.transcriptEntries)) {
|
||||
for (let index = params.transcriptEntries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = params.transcriptEntries[index];
|
||||
if (!entry || entry.role !== "user") {
|
||||
continue;
|
||||
}
|
||||
const text = entry.text.trim();
|
||||
if (text) {
|
||||
candidates.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalizedCandidate = normalizeMessage(candidate);
|
||||
let bestMatch: { trigger: SkillTriggerDefinition; phraseLength: number } | null = null;
|
||||
for (const trigger of params.triggers) {
|
||||
for (const phrase of trigger.activationPhrases) {
|
||||
if (!normalizedCandidate.includes(phrase)) {
|
||||
continue;
|
||||
}
|
||||
if (!bestMatch || phrase.length > bestMatch.phraseLength) {
|
||||
bestMatch = {
|
||||
trigger,
|
||||
phraseLength: phrase.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestMatch) {
|
||||
return bestMatch.trigger;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -90,6 +90,20 @@ export type SkillRemoveResult = {
|
||||
source: RemovableSkillSource;
|
||||
};
|
||||
|
||||
export type PackagedSkillInstallRequest = {
|
||||
packageId: string;
|
||||
source: RemovableSkillSource;
|
||||
workspaceDir: string;
|
||||
managedSkillsDir: string;
|
||||
};
|
||||
|
||||
export type PackagedSkillInstallResult = {
|
||||
installed: boolean;
|
||||
installedPath: string;
|
||||
source: RemovableSkillSource;
|
||||
skillKey: string;
|
||||
};
|
||||
|
||||
const resolveAgentId = (agentId: string): string => {
|
||||
const trimmed = agentId.trim();
|
||||
if (!trimmed) {
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as childProcess from "node:child_process";
|
||||
|
||||
const SSH_TARGET_ENV = "OPENCLAW_GATEWAY_SSH_TARGET";
|
||||
const SSH_USER_ENV = "OPENCLAW_GATEWAY_SSH_USER";
|
||||
const SSH_PORT_ENV = "OPENCLAW_GATEWAY_SSH_PORT";
|
||||
const SSH_STRICT_HOST_KEY_ENV = "OPENCLAW_GATEWAY_SSH_STRICT_HOST_KEY_CHECKING";
|
||||
|
||||
export const resolveConfiguredSshTarget = (env: NodeJS.ProcessEnv = process.env): string | null => {
|
||||
const configuredTarget = env[SSH_TARGET_ENV]?.trim() ?? "";
|
||||
@@ -17,6 +19,50 @@ export const resolveConfiguredSshTarget = (env: NodeJS.ProcessEnv = process.env)
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolveConfiguredSshPort = (env: NodeJS.ProcessEnv = process.env): number | null => {
|
||||
const rawPort = env[SSH_PORT_ENV]?.trim() ?? "";
|
||||
if (!rawPort) {
|
||||
return null;
|
||||
}
|
||||
const port = Number.parseInt(rawPort, 10);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
||||
throw new Error(`${SSH_PORT_ENV} must be a valid port.`);
|
||||
}
|
||||
return port;
|
||||
};
|
||||
|
||||
export const resolveConfiguredSshStrictHostKeyChecking = (
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): "accept-new" | "yes" | "no" => {
|
||||
const rawValue = env[SSH_STRICT_HOST_KEY_ENV]?.trim().toLowerCase() ?? "";
|
||||
if (!rawValue) {
|
||||
return "accept-new";
|
||||
}
|
||||
if (rawValue === "accept-new" || rawValue === "yes" || rawValue === "no") {
|
||||
return rawValue;
|
||||
}
|
||||
throw new Error(
|
||||
`${SSH_STRICT_HOST_KEY_ENV} must be one of: accept-new, yes, no.`
|
||||
);
|
||||
};
|
||||
|
||||
export const resolvePortFromGatewayUrl = (gatewayUrl: string): number | null => {
|
||||
const trimmed = gatewayUrl.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (!parsed.port) {
|
||||
return null;
|
||||
}
|
||||
const port = Number.parseInt(parsed.port, 10);
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65_535 ? port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveGatewaySshTargetFromGatewayUrl = (
|
||||
gatewayUrl: string,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
@@ -86,6 +132,8 @@ export const parseJsonOutput = (raw: string, label: string): unknown => {
|
||||
|
||||
export const runSshJson = (params: {
|
||||
sshTarget: string;
|
||||
sshPort?: number | null;
|
||||
strictHostKeyChecking?: "accept-new" | "yes" | "no";
|
||||
argv: string[];
|
||||
label: string;
|
||||
input?: string;
|
||||
@@ -100,9 +148,18 @@ export const runSshJson = (params: {
|
||||
options.maxBuffer = params.maxBuffer;
|
||||
}
|
||||
|
||||
const result = childProcess.spawnSync("ssh", ["-o", "BatchMode=yes", params.sshTarget, ...params.argv], {
|
||||
...options,
|
||||
});
|
||||
const sshArgs = [
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
`StrictHostKeyChecking=${params.strictHostKeyChecking ?? resolveConfiguredSshStrictHostKeyChecking()}`,
|
||||
];
|
||||
if (typeof params.sshPort === "number") {
|
||||
sshArgs.push("-p", String(params.sshPort));
|
||||
}
|
||||
sshArgs.push(params.sshTarget, ...params.argv);
|
||||
|
||||
const result = childProcess.spawnSync("ssh", sshArgs, { ...options });
|
||||
if (result.error) {
|
||||
throw new Error(`Failed to execute ssh: ${result.error.message}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user