Files
clawdbot/src/agents/skills/workspace.ts
2026-01-23 00:49:40 +00:00

419 lines
13 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import {
formatSkillsForPrompt,
loadSkillsFromDir,
type Skill,
} from "@mariozechner/pi-coding-agent";
import type { ClawdbotConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
import { resolveBundledSkillsDir } from "./bundled-dir.js";
import { shouldIncludeSkill } from "./config.js";
import {
parseFrontmatter,
resolveClawdbotMetadata,
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?: ClawdbotConfig,
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 = skillFilter.map((entry) => String(entry).trim()).filter(Boolean);
const label = normalized.length > 0 ? normalized.join(", ") : "(none)";
console.log(`[skills] Applying skill filter: ${label}`);
filtered =
normalized.length > 0
? filtered.filter((entry) => normalized.includes(entry.skill.name))
: [];
console.log(`[skills] After filter: ${filtered.map((entry) => entry.skill.name).join(", ")}`);
}
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;
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;
}
function loadSkillEntries(
workspaceDir: string,
opts?: {
config?: ClawdbotConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
},
): SkillEntry[] {
const loadSkills = (params: { dir: string; source: string }): Skill[] => {
const loaded = loadSkillsFromDir(params);
if (Array.isArray(loaded)) return loaded;
if (
loaded &&
typeof loaded === "object" &&
"skills" in loaded &&
Array.isArray((loaded as { skills?: unknown }).skills)
) {
return (loaded as { skills: Skill[] }).skills;
}
return [];
};
const managedSkillsDir = opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
const workspaceSkillsDir = path.join(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: "clawdbot-bundled",
})
: [];
const extraSkills = mergedExtraDirs.flatMap((dir) => {
const resolved = resolveUserPath(dir);
return loadSkills({
dir: resolved,
source: "clawdbot-extra",
});
});
const managedSkills = loadSkills({
dir: managedSkillsDir,
source: "clawdbot-managed",
});
const workspaceSkills = loadSkills({
dir: workspaceSkillsDir,
source: "clawdbot-workspace",
});
const merged = new Map<string, Skill>();
// Precedence: extra < bundled < managed < 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 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,
clawdbot: resolveClawdbotMetadata(frontmatter),
invocation: resolveSkillInvocationPolicy(frontmatter),
};
});
return skillEntries;
}
export function buildWorkspaceSkillSnapshot(
workspaceDir: string,
opts?: {
config?: ClawdbotConfig;
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 prompt = [remoteNote, formatSkillsForPrompt(resolvedSkills)].filter(Boolean).join("\n");
return {
prompt,
skills: eligible.map((entry) => ({
name: entry.skill.name,
primaryEnv: entry.clawdbot?.primaryEnv,
})),
resolvedSkills,
version: opts?.snapshotVersion,
};
}
export function buildWorkspaceSkillsPrompt(
workspaceDir: string,
opts?: {
config?: ClawdbotConfig;
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();
return [remoteNote, formatSkillsForPrompt(promptEntries.map((entry) => entry.skill))]
.filter(Boolean)
.join("\n");
}
export function resolveSkillsPromptForRun(params: {
skillsSnapshot?: SkillSnapshot;
entries?: SkillEntry[];
config?: ClawdbotConfig;
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?: ClawdbotConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
},
): SkillEntry[] {
return loadSkillEntries(workspaceDir, opts);
}
export async function syncSkillsToWorkspace(params: {
sourceWorkspaceDir: string;
targetWorkspaceDir: string;
config?: ClawdbotConfig;
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 });
for (const entry of entries) {
const dest = path.join(targetSkillsDir, entry.skill.name);
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?: ClawdbotConfig,
): SkillEntry[] {
return filterSkillEntries(entries, config);
}
export function buildWorkspaceSkillCommandSpecs(
workspaceDir: string,
opts?: {
config?: ClawdbotConfig;
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;
}