Files
openclaw/src/agents/skills/workspace.ts
2026-02-17 13:36:48 +09:00

757 lines
23 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
formatSkillsForPrompt,
loadSkillsFromDir,
type Skill,
} from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
import { resolveSandboxPath } from "../sandbox-paths.js";
import { resolveBundledSkillsDir } from "./bundled-dir.js";
import { shouldIncludeSkill } from "./config.js";
import { normalizeSkillFilter } from "./filter.js";
import {
parseFrontmatter,
resolveOpenClawMetadata,
resolveSkillInvocationPolicy,
} from "./frontmatter.js";
import { resolvePluginSkillDirs } from "./plugin-skills.js";
import { serializeByKey } from "./serialize.js";
import type {
ParsedSkillFrontmatter,
SkillEligibilityContext,
SkillCommandSpec,
SkillEntry,
SkillSnapshot,
} from "./types.js";
const fsp = fs.promises;
const skillsLogger = createSubsystemLogger("skills");
const skillCommandDebugOnce = new Set<string>();
function debugSkillCommandOnce(
messageKey: string,
message: string,
meta?: Record<string, unknown>,
) {
if (skillCommandDebugOnce.has(messageKey)) {
return;
}
skillCommandDebugOnce.add(messageKey);
skillsLogger.debug(message, meta);
}
function filterSkillEntries(
entries: SkillEntry[],
config?: OpenClawConfig,
skillFilter?: string[],
eligibility?: SkillEligibilityContext,
): SkillEntry[] {
let filtered = entries.filter((entry) => shouldIncludeSkill({ entry, config, eligibility }));
// If skillFilter is provided, only include skills in the filter list.
if (skillFilter !== undefined) {
const normalized = normalizeSkillFilter(skillFilter) ?? [];
const label = normalized.length > 0 ? normalized.join(", ") : "(none)";
skillsLogger.debug(`Applying skill filter: ${label}`);
filtered =
normalized.length > 0
? filtered.filter((entry) => normalized.includes(entry.skill.name))
: [];
skillsLogger.debug(
`After skill filter: ${filtered.map((entry) => entry.skill.name).join(", ") || "(none)"}`,
);
}
return filtered;
}
const SKILL_COMMAND_MAX_LENGTH = 32;
const SKILL_COMMAND_FALLBACK = "skill";
// Discord command descriptions must be ≤100 characters
const SKILL_COMMAND_DESCRIPTION_MAX_LENGTH = 100;
const DEFAULT_MAX_CANDIDATES_PER_ROOT = 300;
const DEFAULT_MAX_SKILLS_LOADED_PER_SOURCE = 200;
const DEFAULT_MAX_SKILLS_IN_PROMPT = 150;
const DEFAULT_MAX_SKILLS_PROMPT_CHARS = 30_000;
const DEFAULT_MAX_SKILL_FILE_BYTES = 256_000;
function sanitizeSkillCommandName(raw: string): string {
const normalized = raw
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
const trimmed = normalized.slice(0, SKILL_COMMAND_MAX_LENGTH);
return trimmed || SKILL_COMMAND_FALLBACK;
}
function resolveUniqueSkillCommandName(base: string, used: Set<string>): string {
const normalizedBase = base.toLowerCase();
if (!used.has(normalizedBase)) {
return base;
}
for (let index = 2; index < 1000; index += 1) {
const suffix = `_${index}`;
const maxBaseLength = Math.max(1, SKILL_COMMAND_MAX_LENGTH - suffix.length);
const trimmedBase = base.slice(0, maxBaseLength);
const candidate = `${trimmedBase}${suffix}`;
const candidateKey = candidate.toLowerCase();
if (!used.has(candidateKey)) {
return candidate;
}
}
const fallback = `${base.slice(0, Math.max(1, SKILL_COMMAND_MAX_LENGTH - 2))}_x`;
return fallback;
}
type ResolvedSkillsLimits = {
maxCandidatesPerRoot: number;
maxSkillsLoadedPerSource: number;
maxSkillsInPrompt: number;
maxSkillsPromptChars: number;
maxSkillFileBytes: number;
};
function resolveSkillsLimits(config?: OpenClawConfig): ResolvedSkillsLimits {
const limits = config?.skills?.limits;
return {
maxCandidatesPerRoot: limits?.maxCandidatesPerRoot ?? DEFAULT_MAX_CANDIDATES_PER_ROOT,
maxSkillsLoadedPerSource:
limits?.maxSkillsLoadedPerSource ?? DEFAULT_MAX_SKILLS_LOADED_PER_SOURCE,
maxSkillsInPrompt: limits?.maxSkillsInPrompt ?? DEFAULT_MAX_SKILLS_IN_PROMPT,
maxSkillsPromptChars: limits?.maxSkillsPromptChars ?? DEFAULT_MAX_SKILLS_PROMPT_CHARS,
maxSkillFileBytes: limits?.maxSkillFileBytes ?? DEFAULT_MAX_SKILL_FILE_BYTES,
};
}
function listChildDirectories(dir: string): string[] {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const dirs: string[] = [];
for (const entry of entries) {
if (entry.name.startsWith(".")) continue;
if (entry.name === "node_modules") continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
dirs.push(entry.name);
continue;
}
if (entry.isSymbolicLink()) {
try {
if (fs.statSync(fullPath).isDirectory()) {
dirs.push(entry.name);
}
} catch {
// ignore broken symlinks
}
}
}
return dirs;
} catch {
return [];
}
}
function resolveNestedSkillsRoot(
dir: string,
opts?: {
maxEntriesToScan?: number;
},
): { baseDir: string; note?: string } {
const nested = path.join(dir, "skills");
try {
if (!fs.existsSync(nested) || !fs.statSync(nested).isDirectory()) {
return { baseDir: dir };
}
} catch {
return { baseDir: dir };
}
// Heuristic: if `dir/skills/*/SKILL.md` exists for any entry, treat `dir/skills` as the real root.
// Note: don't stop at 25, but keep a cap to avoid pathological scans.
const nestedDirs = listChildDirectories(nested);
const scanLimit = Math.max(0, opts?.maxEntriesToScan ?? 100);
const toScan = scanLimit === 0 ? [] : nestedDirs.slice(0, Math.min(nestedDirs.length, scanLimit));
for (const name of toScan) {
const skillMd = path.join(nested, name, "SKILL.md");
if (fs.existsSync(skillMd)) {
return { baseDir: nested, note: `Detected nested skills root at ${nested}` };
}
}
return { baseDir: dir };
}
function unwrapLoadedSkills(loaded: unknown): Skill[] {
if (Array.isArray(loaded)) {
return loaded as Skill[];
}
if (loaded && typeof loaded === "object" && "skills" in loaded) {
const skills = (loaded as { skills?: unknown }).skills;
if (Array.isArray(skills)) {
return skills as Skill[];
}
}
return [];
}
function loadSkillEntries(
workspaceDir: string,
opts?: {
config?: OpenClawConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
},
): SkillEntry[] {
const limits = resolveSkillsLimits(opts?.config);
const loadSkills = (params: { dir: string; source: string }): Skill[] => {
const resolved = resolveNestedSkillsRoot(params.dir, {
maxEntriesToScan: limits.maxCandidatesPerRoot,
});
const baseDir = resolved.baseDir;
// If the root itself is a skill directory, just load it directly (but enforce size cap).
const rootSkillMd = path.join(baseDir, "SKILL.md");
if (fs.existsSync(rootSkillMd)) {
try {
const size = fs.statSync(rootSkillMd).size;
if (size > limits.maxSkillFileBytes) {
skillsLogger.warn("Skipping skills root due to oversized SKILL.md.", {
dir: baseDir,
filePath: rootSkillMd,
size,
maxSkillFileBytes: limits.maxSkillFileBytes,
});
return [];
}
} catch {
return [];
}
const loaded = loadSkillsFromDir({ dir: baseDir, source: params.source });
return unwrapLoadedSkills(loaded);
}
const childDirs = listChildDirectories(baseDir);
const suspicious = childDirs.length > limits.maxCandidatesPerRoot;
const maxCandidates = Math.max(0, limits.maxSkillsLoadedPerSource);
const limitedChildren = childDirs.slice().sort().slice(0, maxCandidates);
if (suspicious) {
skillsLogger.warn("Skills root looks suspiciously large, truncating discovery.", {
dir: params.dir,
baseDir,
childDirCount: childDirs.length,
maxCandidatesPerRoot: limits.maxCandidatesPerRoot,
maxSkillsLoadedPerSource: limits.maxSkillsLoadedPerSource,
});
} else if (childDirs.length > maxCandidates) {
skillsLogger.warn("Skills root has many entries, truncating discovery.", {
dir: params.dir,
baseDir,
childDirCount: childDirs.length,
maxSkillsLoadedPerSource: limits.maxSkillsLoadedPerSource,
});
}
const loadedSkills: Skill[] = [];
// Only consider immediate subfolders that look like skills (have SKILL.md) and are under size cap.
for (const name of limitedChildren) {
const skillDir = path.join(baseDir, name);
const skillMd = path.join(skillDir, "SKILL.md");
if (!fs.existsSync(skillMd)) {
continue;
}
try {
const size = fs.statSync(skillMd).size;
if (size > limits.maxSkillFileBytes) {
skillsLogger.warn("Skipping skill due to oversized SKILL.md.", {
skill: name,
filePath: skillMd,
size,
maxSkillFileBytes: limits.maxSkillFileBytes,
});
continue;
}
} catch {
continue;
}
const loaded = loadSkillsFromDir({ dir: skillDir, source: params.source });
loadedSkills.push(...unwrapLoadedSkills(loaded));
if (loadedSkills.length >= limits.maxSkillsLoadedPerSource) {
break;
}
}
if (loadedSkills.length > limits.maxSkillsLoadedPerSource) {
return loadedSkills
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, limits.maxSkillsLoadedPerSource);
}
return loadedSkills;
};
const managedSkillsDir = opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
const workspaceSkillsDir = path.resolve(workspaceDir, "skills");
const bundledSkillsDir = opts?.bundledSkillsDir ?? resolveBundledSkillsDir();
const extraDirsRaw = opts?.config?.skills?.load?.extraDirs ?? [];
const extraDirs = extraDirsRaw
.map((d) => (typeof d === "string" ? d.trim() : ""))
.filter(Boolean);
const pluginSkillDirs = resolvePluginSkillDirs({
workspaceDir,
config: opts?.config,
});
const mergedExtraDirs = [...extraDirs, ...pluginSkillDirs];
const bundledSkills = bundledSkillsDir
? loadSkills({
dir: bundledSkillsDir,
source: "openclaw-bundled",
})
: [];
const extraSkills = mergedExtraDirs.flatMap((dir) => {
const resolved = resolveUserPath(dir);
return loadSkills({
dir: resolved,
source: "openclaw-extra",
});
});
const managedSkills = loadSkills({
dir: managedSkillsDir,
source: "openclaw-managed",
});
const personalAgentsSkillsDir = path.resolve(os.homedir(), ".agents", "skills");
const personalAgentsSkills = loadSkills({
dir: personalAgentsSkillsDir,
source: "agents-skills-personal",
});
const projectAgentsSkillsDir = path.resolve(workspaceDir, ".agents", "skills");
const projectAgentsSkills = loadSkills({
dir: projectAgentsSkillsDir,
source: "agents-skills-project",
});
const workspaceSkills = loadSkills({
dir: workspaceSkillsDir,
source: "openclaw-workspace",
});
const merged = new Map<string, Skill>();
// Precedence: extra < bundled < managed < agents-skills-personal < agents-skills-project < workspace
for (const skill of extraSkills) {
merged.set(skill.name, skill);
}
for (const skill of bundledSkills) {
merged.set(skill.name, skill);
}
for (const skill of managedSkills) {
merged.set(skill.name, skill);
}
for (const skill of personalAgentsSkills) {
merged.set(skill.name, skill);
}
for (const skill of projectAgentsSkills) {
merged.set(skill.name, skill);
}
for (const skill of workspaceSkills) {
merged.set(skill.name, skill);
}
const skillEntries: SkillEntry[] = Array.from(merged.values()).map((skill) => {
let frontmatter: ParsedSkillFrontmatter = {};
try {
const raw = fs.readFileSync(skill.filePath, "utf-8");
frontmatter = parseFrontmatter(raw);
} catch {
// ignore malformed skills
}
return {
skill,
frontmatter,
metadata: resolveOpenClawMetadata(frontmatter),
invocation: resolveSkillInvocationPolicy(frontmatter),
};
});
return skillEntries;
}
function applySkillsPromptLimits(params: { skills: Skill[]; config?: OpenClawConfig }): {
skillsForPrompt: Skill[];
truncated: boolean;
truncatedReason: "count" | "chars" | null;
} {
const limits = resolveSkillsLimits(params.config);
const total = params.skills.length;
const byCount = params.skills.slice(0, Math.max(0, limits.maxSkillsInPrompt));
let skillsForPrompt = byCount;
let truncated = total > byCount.length;
let truncatedReason: "count" | "chars" | null = truncated ? "count" : null;
const fits = (skills: Skill[]): boolean => {
const block = formatSkillsForPrompt(skills);
return block.length <= limits.maxSkillsPromptChars;
};
if (!fits(skillsForPrompt)) {
// Binary search the largest prefix that fits in the char budget.
let lo = 0;
let hi = skillsForPrompt.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (fits(skillsForPrompt.slice(0, mid))) {
lo = mid;
} else {
hi = mid - 1;
}
}
skillsForPrompt = skillsForPrompt.slice(0, lo);
truncated = true;
truncatedReason = "chars";
}
return { skillsForPrompt, truncated, truncatedReason };
}
export function buildWorkspaceSkillSnapshot(
workspaceDir: string,
opts?: {
config?: OpenClawConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
entries?: SkillEntry[];
/** If provided, only include skills with these names */
skillFilter?: string[];
eligibility?: SkillEligibilityContext;
snapshotVersion?: number;
},
): SkillSnapshot {
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
const eligible = filterSkillEntries(
skillEntries,
opts?.config,
opts?.skillFilter,
opts?.eligibility,
);
const promptEntries = eligible.filter(
(entry) => entry.invocation?.disableModelInvocation !== true,
);
const resolvedSkills = promptEntries.map((entry) => entry.skill);
const remoteNote = opts?.eligibility?.remote?.note?.trim();
const { skillsForPrompt, truncated } = applySkillsPromptLimits({
skills: resolvedSkills,
config: opts?.config,
});
const truncationNote = truncated
? `⚠️ Skills truncated: included ${skillsForPrompt.length} of ${resolvedSkills.length}. Run \`openclaw skills check\` to audit.`
: "";
const prompt = [remoteNote, truncationNote, formatSkillsForPrompt(skillsForPrompt)]
.filter(Boolean)
.join("\n");
const skillFilter = normalizeSkillFilter(opts?.skillFilter);
return {
prompt,
skills: eligible.map((entry) => ({
name: entry.skill.name,
primaryEnv: entry.metadata?.primaryEnv,
})),
...(skillFilter === undefined ? {} : { skillFilter }),
resolvedSkills,
version: opts?.snapshotVersion,
};
}
export function buildWorkspaceSkillsPrompt(
workspaceDir: string,
opts?: {
config?: OpenClawConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
entries?: SkillEntry[];
/** If provided, only include skills with these names */
skillFilter?: string[];
eligibility?: SkillEligibilityContext;
},
): string {
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
const eligible = filterSkillEntries(
skillEntries,
opts?.config,
opts?.skillFilter,
opts?.eligibility,
);
const promptEntries = eligible.filter(
(entry) => entry.invocation?.disableModelInvocation !== true,
);
const remoteNote = opts?.eligibility?.remote?.note?.trim();
const resolvedSkills = promptEntries.map((entry) => entry.skill);
const { skillsForPrompt, truncated } = applySkillsPromptLimits({
skills: resolvedSkills,
config: opts?.config,
});
const truncationNote = truncated
? `⚠️ Skills truncated: included ${skillsForPrompt.length} of ${resolvedSkills.length}. Run \`openclaw skills check\` to audit.`
: "";
return [remoteNote, truncationNote, formatSkillsForPrompt(skillsForPrompt)]
.filter(Boolean)
.join("\n");
}
export function resolveSkillsPromptForRun(params: {
skillsSnapshot?: SkillSnapshot;
entries?: SkillEntry[];
config?: OpenClawConfig;
workspaceDir: string;
}): string {
const snapshotPrompt = params.skillsSnapshot?.prompt?.trim();
if (snapshotPrompt) {
return snapshotPrompt;
}
if (params.entries && params.entries.length > 0) {
const prompt = buildWorkspaceSkillsPrompt(params.workspaceDir, {
entries: params.entries,
config: params.config,
});
return prompt.trim() ? prompt : "";
}
return "";
}
export function loadWorkspaceSkillEntries(
workspaceDir: string,
opts?: {
config?: OpenClawConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
},
): SkillEntry[] {
return loadSkillEntries(workspaceDir, opts);
}
function resolveUniqueSyncedSkillDirName(base: string, used: Set<string>): string {
if (!used.has(base)) {
used.add(base);
return base;
}
for (let index = 2; index < 10_000; index += 1) {
const candidate = `${base}-${index}`;
if (!used.has(candidate)) {
used.add(candidate);
return candidate;
}
}
let fallbackIndex = 10_000;
let fallback = `${base}-${fallbackIndex}`;
while (used.has(fallback)) {
fallbackIndex += 1;
fallback = `${base}-${fallbackIndex}`;
}
used.add(fallback);
return fallback;
}
function resolveSyncedSkillDestinationPath(params: {
targetSkillsDir: string;
entry: SkillEntry;
usedDirNames: Set<string>;
}): string | null {
const sourceDirName = path.basename(params.entry.skill.baseDir).trim();
if (!sourceDirName || sourceDirName === "." || sourceDirName === "..") {
return null;
}
const uniqueDirName = resolveUniqueSyncedSkillDirName(sourceDirName, params.usedDirNames);
return resolveSandboxPath({
filePath: uniqueDirName,
cwd: params.targetSkillsDir,
root: params.targetSkillsDir,
}).resolved;
}
export async function syncSkillsToWorkspace(params: {
sourceWorkspaceDir: string;
targetWorkspaceDir: string;
config?: OpenClawConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
}) {
const sourceDir = resolveUserPath(params.sourceWorkspaceDir);
const targetDir = resolveUserPath(params.targetWorkspaceDir);
if (sourceDir === targetDir) {
return;
}
await serializeByKey(`syncSkills:${targetDir}`, async () => {
const targetSkillsDir = path.join(targetDir, "skills");
const entries = loadSkillEntries(sourceDir, {
config: params.config,
managedSkillsDir: params.managedSkillsDir,
bundledSkillsDir: params.bundledSkillsDir,
});
await fsp.rm(targetSkillsDir, { recursive: true, force: true });
await fsp.mkdir(targetSkillsDir, { recursive: true });
const usedDirNames = new Set<string>();
for (const entry of entries) {
let dest: string | null = null;
try {
dest = resolveSyncedSkillDestinationPath({
targetSkillsDir,
entry,
usedDirNames,
});
} catch (error) {
const message = error instanceof Error ? error.message : JSON.stringify(error);
console.warn(
`[skills] Failed to resolve safe destination for ${entry.skill.name}: ${message}`,
);
continue;
}
if (!dest) {
console.warn(
`[skills] Failed to resolve safe destination for ${entry.skill.name}: invalid source directory name`,
);
continue;
}
try {
await fsp.cp(entry.skill.baseDir, dest, {
recursive: true,
force: true,
});
} catch (error) {
const message = error instanceof Error ? error.message : JSON.stringify(error);
console.warn(`[skills] Failed to copy ${entry.skill.name} to sandbox: ${message}`);
}
}
});
}
export function filterWorkspaceSkillEntries(
entries: SkillEntry[],
config?: OpenClawConfig,
): SkillEntry[] {
return filterSkillEntries(entries, config);
}
export function buildWorkspaceSkillCommandSpecs(
workspaceDir: string,
opts?: {
config?: OpenClawConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
entries?: SkillEntry[];
skillFilter?: string[];
eligibility?: SkillEligibilityContext;
reservedNames?: Set<string>;
},
): SkillCommandSpec[] {
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
const eligible = filterSkillEntries(
skillEntries,
opts?.config,
opts?.skillFilter,
opts?.eligibility,
);
const userInvocable = eligible.filter((entry) => entry.invocation?.userInvocable !== false);
const used = new Set<string>();
for (const reserved of opts?.reservedNames ?? []) {
used.add(reserved.toLowerCase());
}
const specs: SkillCommandSpec[] = [];
for (const entry of userInvocable) {
const rawName = entry.skill.name;
const base = sanitizeSkillCommandName(rawName);
if (base !== rawName) {
debugSkillCommandOnce(
`sanitize:${rawName}:${base}`,
`Sanitized skill command name "${rawName}" to "/${base}".`,
{ rawName, sanitized: `/${base}` },
);
}
const unique = resolveUniqueSkillCommandName(base, used);
if (unique !== base) {
debugSkillCommandOnce(
`dedupe:${rawName}:${unique}`,
`De-duplicated skill command name for "${rawName}" to "/${unique}".`,
{ rawName, deduped: `/${unique}` },
);
}
used.add(unique.toLowerCase());
const rawDescription = entry.skill.description?.trim() || rawName;
const description =
rawDescription.length > SKILL_COMMAND_DESCRIPTION_MAX_LENGTH
? rawDescription.slice(0, SKILL_COMMAND_DESCRIPTION_MAX_LENGTH - 1) + "…"
: rawDescription;
const dispatch = (() => {
const kindRaw = (
entry.frontmatter?.["command-dispatch"] ??
entry.frontmatter?.["command_dispatch"] ??
""
)
.trim()
.toLowerCase();
if (!kindRaw) {
return undefined;
}
if (kindRaw !== "tool") {
return undefined;
}
const toolName = (
entry.frontmatter?.["command-tool"] ??
entry.frontmatter?.["command_tool"] ??
""
).trim();
if (!toolName) {
debugSkillCommandOnce(
`dispatch:missingTool:${rawName}`,
`Skill command "/${unique}" requested tool dispatch but did not provide command-tool. Ignoring dispatch.`,
{ skillName: rawName, command: unique },
);
return undefined;
}
const argModeRaw = (
entry.frontmatter?.["command-arg-mode"] ??
entry.frontmatter?.["command_arg_mode"] ??
""
)
.trim()
.toLowerCase();
const argMode = !argModeRaw || argModeRaw === "raw" ? "raw" : null;
if (!argMode) {
debugSkillCommandOnce(
`dispatch:badArgMode:${rawName}:${argModeRaw}`,
`Skill command "/${unique}" requested tool dispatch but has unknown command-arg-mode. Falling back to raw.`,
{ skillName: rawName, command: unique, argMode: argModeRaw },
);
}
return { kind: "tool", toolName, argMode: "raw" } as const;
})();
specs.push({
name: unique,
skillName: rawName,
description,
...(dispatch ? { dispatch } : {}),
});
}
return specs;
}