/** * Agent skills: discover SKILL.md under workspace/skills/ (highest precedence) then ~/.agent/skills/. * Depends on bareAgentWorkspaceDecode from agent-workspace.js (same preamble order). */ /** * @param {string} text * @returns {{ front: Record, body: string }} */ function bareAgentParseSkillFrontmatter(text) { const t = String(text || '') if (!t.startsWith('---')) return { front: {}, body: t.trim() } const nl = t.indexOf('\n') const afterFirst = nl === -1 ? '' : t.slice(nl + 1) const end = afterFirst.search(/\n---\s*(?:\n|$)/) if (end === -1) return { front: {}, body: t.trim() } const yamlBlock = afterFirst.slice(0, end) const body = afterFirst.slice(end + 1).replace(/^---\s*/, '').replace(/^\r?\n/, '') /** @type {Record} */ const front = {} for (const line of yamlBlock.split(/\r?\n/)) { const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/) if (m) front[m[1]] = m[2].trim() } return { front, body: body.trim() } } /** * @param {string} full SKILL.md text * @param {string} folderName directory basename */ function bareAgentSkillMetaFromMarkdown(full, folderName) { const { front } = bareAgentParseSkillFrontmatter(full) const name = (front.name || folderName || 'unnamed').trim() || folderName const descFromFront = front.description && String(front.description).trim() ? String(front.description).trim() : '' const descFromBody = full .split(/\r?\n/) .find((l) => { const x = l.trim() return x && !x.startsWith('---') && !x.startsWith('#') }) ?.trim() || '' const description = (descFromFront || descFromBody || 'Skill').slice(0, 400) return { name, description } } /** * @param {Record} ctx * @param {{ workspaceSkills: string, skillsGlobal: string }} paths * @returns {Promise<{ id: string, name: string, description: string, path: string, source: string }[]>} */ async function bareAgentDiscoverSkills(ctx, paths) { const vfs = ctx.vfs if (!vfs || typeof vfs.readdir !== 'function' || typeof vfs.readFile !== 'function') return [] /** @type {{ id: string, name: string, description: string, path: string, source: string }[]} */ const out = [] const seen = new Set() /** * @param {string} root * @param {string} source */ async function scanRoot(root, source) { let names = [] try { names = await vfs.readdir(root) } catch { return } if (!Array.isArray(names)) return for (const raw of names) { const entry = String(raw) if (!entry || entry.startsWith('.')) continue const skillMd = root.replace(/\/+$/, '') + '/' + entry + '/SKILL.md' try { const buf = await vfs.readFile(skillMd) if (!buf || !buf.length) continue const full = bareAgentWorkspaceDecode(ctx, buf) const meta = bareAgentSkillMetaFromMarkdown(full, entry) const keys = [entry.toLowerCase(), meta.name.toLowerCase()] let dup = false for (const k of keys) { if (seen.has(k)) dup = true } if (dup) continue for (const k of keys) seen.add(k) out.push({ id: entry, name: meta.name, description: meta.description, path: skillMd, source }) } catch { /* not a skill dir */ } } } await scanRoot(paths.workspaceSkills, 'workspace') await scanRoot(paths.skillsGlobal, 'global') return out } /** * Compact Markdown block for system prompt (names + short descriptions only). * @param {Record} ctx * @param {{ workspaceSkills: string, skillsGlobal: string }} paths * @param {number} [maxChars] */ async function bareAgentSkillsCompactPrompt(ctx, paths, maxChars) { const cap = Math.min(Math.max(Number(maxChars) || 4000, 500), 12000) const skills = await bareAgentDiscoverSkills(ctx, paths) let block = '## Available skills (compact index)\n' + 'Each skill is a directory with **SKILL.md** (optional YAML frontmatter: `name`, `description`, …).\n' + '**Workspace skills** (`~/.agent/workspace/skills/`) override **global** (`~/.agent/skills/`) when names match.\n' + 'To run one: call the **read_skill** tool with the skill id or frontmatter `name` before following its instructions.\n\n' if (!skills.length) { block += '(No skills discovered yet — add folders under `workspace/skills//SKILL.md`.)\n' return block.length > cap ? block.slice(0, cap) + '\n…\n' : block } block += '| id | name | source | description |\n| --- | --- | --- | --- |\n' for (const s of skills) { const desc = s.description.replace(/\|/g, '/').replace(/\r?\n/g, ' ').slice(0, 160) block += '| `' + s.id.replace(/`/g, "'") + '` | ' + s.name.replace(/\|/g, '/').replace(/\r?\n/g, ' ') + ' | ' + s.source + ' | ' + desc + ' |\n' } if (block.length > cap) block = block.slice(0, cap) + '\n… truncated\n' return block } /** * Load full SKILL.md for a skill matched by folder id or frontmatter name (case-insensitive). * @param {Record} ctx * @param {{ workspaceSkills: string, skillsGlobal: string }} paths * @param {string} skillQuery */ async function bareAgentLoadSkillMarkdown(ctx, paths, skillQuery) { const q = String(skillQuery || '') .trim() .toLowerCase() if (!q) return { ok: false, error: 'empty_skill', content: '', path: '' } const vfs = ctx.vfs if (!vfs || typeof vfs.readFile !== 'function') return { ok: false, error: 'vfs unavailable', content: '', path: '' } const skills = await bareAgentDiscoverSkills(ctx, paths) const hit = skills.find((s) => s.id.toLowerCase() === q) || skills.find((s) => s.name.toLowerCase() === q) if (!hit) return { ok: false, error: 'skill_not_found', content: '', path: '' } try { const buf = await vfs.readFile(hit.path) if (!buf || !buf.length) return { ok: false, error: 'empty_file', content: '', path: hit.path } const t = bareAgentWorkspaceDecode(ctx, buf) return { ok: true, skill: hit.name, id: hit.id, path: hit.path, source: hit.source, content: t } } catch (e) { const msg = e && typeof e === 'object' && 'message' in e ? String(e.message) : String(e) return { ok: false, error: msg, content: '', path: hit.path } } }