summaryrefslogtreecommitdiff
path: root/panto-agent/skill.lua
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-03 09:49:13 -0600
committert <t@tjp.lol>2026-07-03 10:53:10 -0600
commit94568d9f70b12fa348040a7998dac7b0808b0258 (patch)
treee177f28e534c44525ec9828edc3cab18e7c6756e /panto-agent/skill.lua
Initial changes: agent.rules and agent.skill extensionsHEADmain
panto-agent is a luarocks rock providing the agent.* extension suite for pantograph's panto CLI: - agent.rules: injects AGENTS.md/CLAUDE.md (AGENTS.md wins, never both) from the base agent dir, user config dir, and project root into the system prompt at session start. Also hooks std.read tool results to append rules files found between a read location and the project root, each injected at most once per session. - agent.skill: discovers Agent Skills (SKILL.md with YAML frontmatter) across data/config/home/project skill dirs with later dirs shadowing earlier ones, and exposes them via an agent.skill tool whose enum and description are built from the surviving skills. The tool returns the skill's instructions with frontmatter stripped. Not registered when no skills are found. Includes a rockspec, README, and a panto-runnable self-check (_selfcheck.lua, underscore-prefixed so panto's directory scan skips it). Sources live under panto-agent/ so require works the same installed or from a checkout.
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