174 lines
6.2 KiB
JavaScript
174 lines
6.2 KiB
JavaScript
/**
|
|
* 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<string, string>, 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<string, string>} */
|
|
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<string, unknown>} 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<string, unknown>} 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/<id>/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<string, unknown>} 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 }
|
|
}
|
|
}
|