Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Luke The Dev
2026-03-23 11:44:25 -05:00
committed by GitHub
parent c2cbdeec44
commit 5e7812c352
30 changed files with 2213 additions and 128 deletions
+26
View File
@@ -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;
+116
View File
@@ -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;
};
+74
View File
@@ -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];
};
+129
View File
@@ -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.
}
}
}
};
+34 -2
View File
@@ -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 });
+165
View File
@@ -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 }));
};
+140
View 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
View File
@@ -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;
};
+183
View File
@@ -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;
};
+14
View File
@@ -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) {
+60 -3
View File
@@ -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}`);
}