import * as fs from 'node:fs'; import * as path from 'node:path'; import type { Skill, LoadSkillsOptions, LoadSkillsResult, SkillDefinition } from './skills.types.js'; const SKILL_FILENAME = 'SKILL.md'; const FALLBACK_SKILLS_DIR = '~/.pi/agent/skills'; function expandTilde(p: string): string { if (p.startsWith('~/')) { const home = process.env.HOME || process.env.USERPROFILE || ''; return path.join(home, p.slice(2)); } if (p === '~') { return process.env.HOME || process.env.USERPROFILE || ''; } return p; } /** 从 SKILL.md 内容解析 name、description、triggers(frontmatter)。供 loader 与 ZIP 解析共用。 */ export function parseSkillFrontmatter(content: string): { name: string; description: string; triggers?: string; } { const match = content.match(/^---\s*\n([\s\S]*?)\n---/); if (!match) { return { name: 'unknown', description: '' }; } const block = match[1]; const nameMatch = block.match(/name:\s*(.+)/); const descMatch = block.match(/description:\s*(.+)/); const triggersMatch = block.match(/triggers:\s*(.+)/); return { name: nameMatch ? nameMatch[1].trim().replace(/^['"]|['"]$/g, '') : 'unknown', description: descMatch ? descMatch[1].trim().replace(/^['"]|['"]$/g, '').slice(0, 1024) : '', triggers: triggersMatch ? triggersMatch[1].trim().replace(/^['"]|['"]$/g, '').slice(0, 512) : undefined, }; } /** * 从 SKILL.md 内容解析能力定义。 * 同时支持新格式 `## Skill Definition` 和旧格式 `## Role Definition`(向后兼容)。 * 只解析能力字段(tools, prompt_extension),不再解析身份字段。 */ export function parseSkillDefinition(content: string): SkillDefinition | undefined { // Support both new and legacy section headers const sectionMatch = content.match(/## (?:Skill|Role) Definition\s*\n([\s\S]*?)(?=\n##|$)/); if (!sectionMatch) return undefined; const block = sectionMatch[1].trim(); // Must have at least tools or prompt_extension to be a valid skill definition // Parse tools list const toolsMatch = block.match(/tools:\s*\n((?:\s+-\s+.+\n?)*)/); const tools: string[] = []; if (toolsMatch) { const listText = toolsMatch[1]; const items = listText.match(/^\s+-\s+(.+)$/gm) ?? []; for (const item of items) { const val = item.replace(/^\s+-\s+/, '').trim().replace(/^['"]|['"]$/g, ''); if (val) tools.push(val); } } // Parse prompt_extension (block scalar) const promptExtMatch = block.match(/prompt_extension:\s*\|\s*\n([\s\S]*?)(?=\n\S|$)/); const prompt_extension = promptExtMatch ? promptExtMatch[1] .split('\n') .map((line) => line.replace(/^ /, '')) .join('\n') .trimEnd() : ''; if (tools.length === 0 && !prompt_extension) return undefined; return { tools, prompt_extension }; } /** * @deprecated 向后兼容别名,请使用 parseSkillDefinition */ export function parseRoleDefinition(content: string): SkillDefinition | undefined { return parseSkillDefinition(content); } /** 从沙箱加载 skills */ export async function loadSkillsFromSandbox(opts: LoadSkillsOptions): Promise { const { sandboxId, skillsPath, sandboxClient } = opts; if (!sandboxId || !skillsPath || !sandboxClient) return []; try { const entries = await sandboxClient.listFiles(sandboxId, skillsPath); const skills: Skill[] = []; for (const entry of entries) { const isDir = (entry as { type?: string }).type === 'dir' || !entry.path.includes('.'); if (!isDir) continue; const skillDirPath = `${skillsPath}/${entry.name}`.replace(/\/+/g, '/'); const skillFilePath = `${skillDirPath}/${SKILL_FILENAME}`; try { const raw = await sandboxClient.readFile(sandboxId, skillFilePath, { format: 'text' }); const content = typeof raw === 'string' ? raw : new TextDecoder().decode(raw); const { name, description, triggers } = parseSkillFrontmatter(content); const skillDefinition = parseSkillDefinition(content); skills.push({ name, description, ...(triggers ? { triggers } : {}), filePath: skillFilePath, ...(skillDefinition ? { skillDefinition, roleDefinition: skillDefinition } : {}), }); } catch { // 跳过无法读取的 SKILL.md } } return skills; } catch { return []; } } /** 从本地目录加载 skills */ export function loadSkillsFromDir(dirPath: string): Skill[] { const expanded = expandTilde(dirPath.trim()); if (!fs.existsSync(expanded) || !fs.statSync(expanded).isDirectory()) { return []; } const skills: Skill[] = []; const entries = fs.readdirSync(expanded, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; const skillFilePath = path.join(expanded, entry.name, SKILL_FILENAME); if (!fs.existsSync(skillFilePath)) continue; try { const content = fs.readFileSync(skillFilePath, 'utf-8'); const { name, description, triggers } = parseSkillFrontmatter(content); const skillDefinition = parseSkillDefinition(content); skills.push({ name, description, ...(triggers ? { triggers } : {}), filePath: skillFilePath, ...(skillDefinition ? { skillDefinition, roleDefinition: skillDefinition } : {}), }); } catch { // 跳过无法读取的 SKILL.md } } return skills; } /** 加载 skills(优先沙箱,fallback 本地)。支持 system + session 两路径合并。 */ export async function loadSkills(opts: LoadSkillsOptions): Promise { const sandboxSkills: Skill[] = []; if (opts.skillsPath && opts.sandboxId && opts.sandboxClient) { const system = await loadSkillsFromSandbox({ ...opts, skillsPath: opts.skillsPath }); sandboxSkills.push(...system); } if (opts.sessionSkillsPath && opts.sandboxId && opts.sandboxClient) { const session = await loadSkillsFromSandbox({ ...opts, skillsPath: opts.sessionSkillsPath }); const seen = new Set(sandboxSkills.map((s) => s.name)); for (const s of session) { if (!seen.has(s.name)) { seen.add(s.name); sandboxSkills.push(s); } } } if (sandboxSkills.length > 0) { return { skills: sandboxSkills, source: 'sandbox' }; } const localPaths: string[] = opts.skillPaths ?? []; const envPaths = process.env.AGENT_SKILLS_PATHS?.split(',').map((p) => p.trim()).filter(Boolean) ?? []; const allPaths = [...new Set([...localPaths, ...envPaths, expandTilde(FALLBACK_SKILLS_DIR)])]; const seen = new Set(); const skills: Skill[] = []; for (const p of allPaths) { const fromDir = loadSkillsFromDir(p); for (const s of fromDir) { if (!seen.has(s.name)) { seen.add(s.name); skills.push(s); } } } return { skills, source: 'local' }; } /** * Format skills as system prompt section (Discovery phase: name + description only). * * 默认 `_skillsBasePath` 取自共享区(`/mnt/shared/skills`),与 AgentSandboxService.getSkillsPath() * 对齐——skills 是跨用户共享内容,物理上挂在共享区。当调用方显式传入 path 时以传入值为准。 */ export function formatSkillsForPrompt( result: LoadSkillsResult, _skillsBasePath: string = '/mnt/shared/skills', ): string { if (result.skills.length === 0) return ''; const lines = [ '', '## Available Skills', 'Use `search_skills` to find relevant skills by keyword before loading. Use `loadSkill` to load a skill when the user\'s request matches:', '', ...result.skills.map((s) => { return `- **${s.name}**: ${s.description || '(no description)'}`; }), '', ]; return lines.join('\n'); }