summaryrefslogtreecommitdiff
path: root/panto-agent/skill.lua
diff options
context:
space:
mode:
Diffstat (limited to 'panto-agent/skill.lua')
-rw-r--r--panto-agent/skill.lua194
1 files changed, 194 insertions, 0 deletions
diff --git a/panto-agent/skill.lua b/panto-agent/skill.lua
new file mode 100644
index 0000000..e26f8ac
--- /dev/null
+++ b/panto-agent/skill.lua
@@ -0,0 +1,194 @@
+-- agent.skill: on-demand Agent Skills (agentskills.io) via an
+-- `agent.skill` tool.
+--
+-- Discovery happens once, at activation. Skill directories are searched
+-- lowest priority first:
+--
+-- base: <XDG_DATA_HOME>/panto/agent/skills
+-- user: <XDG_CONFIG_HOME>/panto/skills
+-- ~/.agent/skills
+-- project: <cwd>/.panto/skills
+-- <cwd>/.agent/skills
+--
+-- The `.agent/skills` variants outrank their own layer's normal directory
+-- but nothing above it (`~/.agent/skills` does not outrank
+-- `<cwd>/.panto/skills`). A skill is any subdirectory containing a
+-- SKILL.md with YAML frontmatter; the frontmatter `name` (not the
+-- directory name) is the skill's identity, and a later directory's skill
+-- shadows an earlier one with the same name.
+--
+-- The surviving names and descriptions are baked into one registered
+-- tool: the description lists every skill, and the input schema
+-- constrains `skill` to the discovered names as an enum. Calling the tool
+-- re-reads the skill's SKILL.md (so edits mid-session are picked up),
+-- strips the frontmatter, and returns the instructions behind a short
+-- preamble telling the model to resolve the skill's relative paths
+-- (scripts/, references/, assets/) against the skill's real directory.
+--
+-- Frontmatter parsing is deliberately minimal: top-level `key: value`
+-- lines only (quoted values unquoted). Nested mappings like `metadata:`
+-- are ignored, which is all this extension needs (`name`, `description`).
+
+local uv = require("luv")
+local panto = require("panto")
+
+local M = { name = "agent.skill", skills = {} }
+
+-- Coroutine-aware libuv await (same shape as agent.rules): arm the
+-- callback form of a uv request, yield, resume with its results. Panto
+-- runs every user-Lua entrypoint (activate, event handlers, tools)
+-- inside a coroutine with the uv loop driven, so this just works.
+local function await(label, arm)
+ local co = assert(coroutine.running(), label .. ": await must run inside a coroutine")
+ local res
+ arm(function(...)
+ res = table.pack(...)
+ local ok, err = coroutine.resume(co)
+ if not ok then error(err, 0) end
+ end)
+ coroutine.yield()
+ return table.unpack(res, 1, res.n)
+end
+
+local function fs_realpath(p) return await("fs_realpath", function(r) uv.fs_realpath(p, r) end) end
+local function fs_open(p, flags, mode) return await("fs_open", function(r) uv.fs_open(p, flags, mode, r) end) end
+local function fs_fstat(fd) return await("fs_fstat", function(r) uv.fs_fstat(fd, r) end) end
+local function fs_read(fd, size, off) return await("fs_read", function(r) uv.fs_read(fd, size, off, r) end) end
+local function fs_close(fd) return await("fs_close", function(r) uv.fs_close(fd, r) end) end
+local function fs_scandir(p) return await("fs_scandir", function(r) uv.fs_scandir(p, r) end) end
+
+--- Read a whole file (nil on any failure).
+local function read_all(path)
+ local oerr, fd = fs_open(path, "r", 0)
+ if not fd then return nil, oerr end
+ local serr, stat = fs_fstat(fd)
+ local rerr, text
+ if stat then
+ rerr, text = fs_read(fd, stat.size, 0)
+ else
+ rerr = serr
+ end
+ fs_close(fd)
+ if not text then return nil, rerr end
+ return text
+end
+
+--- Entry names of a directory ({} when it doesn't exist / isn't readable).
+local function list_dir(dir)
+ local _, req = fs_scandir(dir)
+ if not req then return {} end
+ local names = {}
+ while true do
+ local name = uv.fs_scandir_next(req)
+ if not name then break end
+ names[#names + 1] = name
+ end
+ return names
+end
+
+--- Split SKILL.md into (meta, body). Returns nil unless the file opens
+--- with a `---` frontmatter block containing a non-empty `name`.
+function M.parse_skill_md(text)
+ local fm, body = text:match("^%-%-%-\r?\n(.-)\r?\n%-%-%-%s*\r?\n?(.*)$")
+ if not fm then return nil end
+ local meta = {}
+ for line in fm:gmatch("[^\r\n]+") do
+ local k, v = line:match("^([%w%-]+):%s*(.-)%s*$")
+ if k then
+ meta[k] = v:match('^"(.*)"$') or v:match("^'(.*)'$") or v
+ end
+ end
+ if not meta.name or meta.name == "" then return nil end
+ return meta, body
+end
+
+--- The skill directories to search, lowest priority first. `cwd` is the
+--- project root (falls back to the process cwd).
+function M.skill_dirs(cwd)
+ local dirs = {}
+ local home = os.getenv("HOME")
+ local data = os.getenv("XDG_DATA_HOME") or (home and home .. "/.local/share")
+ if data then dirs[#dirs + 1] = data .. "/panto/agent/skills" end
+ local cfg = os.getenv("XDG_CONFIG_HOME") or (home and home .. "/.config")
+ if cfg then dirs[#dirs + 1] = cfg .. "/panto/skills" end
+ if home then dirs[#dirs + 1] = home .. "/.agent/skills" end
+ local root = cwd or uv.cwd()
+ dirs[#dirs + 1] = root .. "/.panto/skills"
+ dirs[#dirs + 1] = root .. "/.agent/skills"
+ return dirs
+end
+
+--- Scan `dirs` (lowest priority first) and return a map of skill name ->
+--- { dir = <resolved skill directory>, description }. Same-named skills
+--- from later directories shadow earlier ones. Awaits, so it must run
+--- inside a panto entrypoint (or an equivalent harness).
+function M.discover(dirs)
+ local skills = {}
+ for _, dir in ipairs(dirs) do
+ for _, entry in ipairs(list_dir(dir)) do
+ local _, real = fs_realpath(dir .. "/" .. entry)
+ if real then
+ local text = read_all(real .. "/SKILL.md")
+ local meta = text and M.parse_skill_md(text)
+ if meta then
+ skills[meta.name] = { dir = real, description = meta.description or "" }
+ end
+ end
+ end
+ end
+ return skills
+end
+
+--- Tool handler: return the named skill's SKILL.md instructions
+--- (frontmatter stripped) behind a path-resolution preamble. Unknown
+--- names come back as a plain-text error the model can react to.
+function M.on_call(input)
+ local s = type(input) == "table" and type(input.skill) == "string" and M.skills[input.skill]
+ if not s then
+ local names = {}
+ for name in pairs(M.skills) do names[#names + 1] = name end
+ table.sort(names)
+ return "unknown skill: " .. tostring(type(input) == "table" and input.skill or input)
+ .. ". Available skills: " .. table.concat(names, ", ")
+ end
+ local text = read_all(s.dir .. "/SKILL.md")
+ if not text then return "failed to read " .. s.dir .. "/SKILL.md" end
+ local meta, body = M.parse_skill_md(text)
+ if not meta then body = text end
+ return "Skill '" .. input.skill .. "', loaded from " .. s.dir .. "/SKILL.md.\n"
+ .. "Any relative path these instructions reference (scripts/, references/, assets/, ...)"
+ .. " lives under that directory: resolve it against " .. s.dir .. "/.\n\n"
+ .. body
+end
+
+function M.activate()
+ M.skills = M.discover(M.skill_dirs(uv.cwd()))
+ local names = {}
+ for name in pairs(M.skills) do names[#names + 1] = name end
+ if #names == 0 then return end -- nothing to offer: don't register the tool
+ table.sort(names)
+ local lines = {}
+ for _, n in ipairs(names) do
+ lines[#lines + 1] = "- " .. n .. ": " .. M.skills[n].description
+ end
+ panto.ext.register_tool {
+ name = "agent.skill",
+ description = "Load a skill: expert instructions for a specific kind of task."
+ .. " Call this before starting work a skill covers.\nAvailable skills:\n"
+ .. table.concat(lines, "\n"),
+ schema = {
+ type = "object",
+ properties = {
+ skill = {
+ type = "string",
+ enum = names,
+ description = "Name of the skill to load.",
+ },
+ },
+ required = { "skill" },
+ },
+ handler = M.on_call,
+ }
+end
+
+return M