openclaw/src/agents/skills/workspace.ts
Peter Steinberger c379191f80 chore: migrate to oxlint and oxfmt
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
2026-01-14 15:02:19 +00:00

229 lines
7.1 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 { CONFIG_DIR, resolveUserPath } from "../../utils.js";
import { resolveBundledSkillsDir } from "./bundled-dir.js";
import { shouldIncludeSkill } from "./config.js";
import { parseFrontmatter, resolveClawdbotMetadata } from "./frontmatter.js";
import { serializeByKey } from "./serialize.js";
import type { ParsedSkillFrontmatter, SkillEntry, SkillSnapshot } from "./types.js";
const fsp = fs.promises;
function filterSkillEntries(
entries: SkillEntry[],
config?: ClawdbotConfig,
skillFilter?: string[],
): SkillEntry[] {
let filtered = entries.filter((entry) => shouldIncludeSkill({ entry, config }));
// 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;
}
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 bundledSkills = bundledSkillsDir
? loadSkills({
dir: bundledSkillsDir,
source: "clawdbot-bundled",
})
: [];
const extraSkills = extraDirs.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),
};
});
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[];
},
): SkillSnapshot {
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
const eligible = filterSkillEntries(skillEntries, opts?.config, opts?.skillFilter);
const resolvedSkills = eligible.map((entry) => entry.skill);
return {
prompt: formatSkillsForPrompt(resolvedSkills),
skills: eligible.map((entry) => ({
name: entry.skill.name,
primaryEnv: entry.clawdbot?.primaryEnv,
})),
resolvedSkills,
};
}
export function buildWorkspaceSkillsPrompt(
workspaceDir: string,
opts?: {
config?: ClawdbotConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
entries?: SkillEntry[];
/** If provided, only include skills with these names */
skillFilter?: string[];
},
): string {
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
const eligible = filterSkillEntries(skillEntries, opts?.config, opts?.skillFilter);
return formatSkillsForPrompt(eligible.map((entry) => entry.skill));
}
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);
}