419 lines
13 KiB
TypeScript
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;
|
|
}
|