diff options
| -rw-r--r-- | README.md | 69 | ||||
| -rw-r--r-- | _selfcheck.lua | 236 | ||||
| -rw-r--r-- | panto-agent-scm-1.rockspec | 31 | ||||
| -rw-r--r-- | panto-agent/init.lua | 20 | ||||
| -rw-r--r-- | panto-agent/rules.lua | 215 | ||||
| -rw-r--r-- | panto-agent/skill.lua | 194 |
6 files changed, 765 insertions, 0 deletions
diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bd4b57 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# panto-agent + +The `agent.*` extension suite for [pantograph](https://github.com/travisp/pantograph)'s +`panto` CLI: coding-agent conveniences packaged as a luarocks rock. + +## Extensions + +- **`agent.rules`** — at session start, injects the contents of `AGENTS.md` + (or `CLAUDE.md` when no `AGENTS.md` exists — never both) into the system + prompt, from each of: the base layer agent dir + (`${XDG_DATA_HOME:-~/.local/share}/panto/agent`), the user layer dir + (`${XDG_CONFIG_HOME:-~/.config}/panto`), and the project root (cwd). + Symlinks are resolved; each file becomes one system message headed + `context from <path>:`. + + It also hooks `std.read` results (a `tool_result` event handler using + the writable `output` field): reading a file (or directory) below the + project root appends any `AGENTS.md`/`CLAUDE.md` found between the read + location and the root (root excluded — the system prompt covers it) to + the tool result as `---`-separated `context from <path>:` sections. + Each real file is injected at most once per session. The transcript + keeps the read's own output, with a `+ rules: <path>` line per attached + file below it. + +- **`agent.skill`** — an `agent.skill` tool for loading + [Agent Skills](https://agentskills.io) on demand. At activation it + discovers skills (subdirectories containing a `SKILL.md` with YAML + frontmatter) from, lowest priority first: + `${XDG_DATA_HOME:-~/.local/share}/panto/agent/skills`, + `${XDG_CONFIG_HOME:-~/.config}/panto/skills`, `~/.agent/skills`, + `<cwd>/.panto/skills`, and `<cwd>/.agent/skills`. The frontmatter + `name` (not the directory name) is the skill's identity; later + directories shadow same-named skills from earlier ones. Surviving + names and descriptions are baked into the tool's description and its + input schema's enum. Calling the tool returns the skill's `SKILL.md` + instructions (frontmatter stripped) behind a preamble that tells the + model to resolve the skill's relative paths against its directory. + With no skills discovered, the tool is not registered. + +## Install + +Via the rock (once published): + +```toml +[extensions] +rocks = ["panto-agent"] +``` + +Or as a local checkout: + +```toml +[extensions] +paths = ["/path/to/panto-agent"] +``` + +Individual extensions can be toggled with the usual policy globs, e.g. +`deny = ["agent.rules"]`. + +## Development + +Run the self-check (needs `panto` for its embedded Lua + luv): + +```sh +panto lua _selfcheck.lua +``` + +Layout note: the sources live under `panto-agent/` (matching the installed +module tree) so `require` works identically installed and from a checkout; +`_selfcheck.lua` is `_`-prefixed so panto's directory scan skips it. diff --git a/_selfcheck.lua b/_selfcheck.lua new file mode 100644 index 0000000..adf02f1 --- /dev/null +++ b/_selfcheck.lua @@ -0,0 +1,236 @@ +-- Self-check for the panto-agent suite. Run with: +-- +-- panto lua panto-agent/_selfcheck.lua +-- +-- Uses a stub `panto` module, so it exercises the pure extension logic +-- (file selection, symlink resolution, message format) without a session. + +local uv = require("luv") + +-- Stub panto module: capture agent:add_system_message calls and `on` +-- subscriptions (ext.agent stands in for the session agent). Must be +-- seeded before rules.lua is required. +local captured = {} +local handlers = {} +local registered = {} +package.loaded["panto"] = { + ext = { + agent = { + add_system_message = function(_, text) captured[#captured + 1] = text end, + }, + on = function(event, handler) handlers[event] = handler end, + register_tool = function(t) registered[#registered + 1] = t end, + json = { + -- Just enough JSON for the inputs this selfcheck fabricates + -- (a flat object with a "path" string, no escapes). + decode = function(s) return { path = s:match('"path":"(.-)"') } end, + }, + }, +} + +-- Make the suite requirable relative to this file, wherever cwd is. +local here = debug.getinfo(1, "S").source:match("^@(.*)/[^/]+$") or "." +package.path = here .. "/panto-agent/?.lua;" .. here .. "/panto-agent/?/init.lua;" .. package.path + +local rules = require("rules") +assert(rules.name == "agent.rules", "entry name") + +-- Harness pump: panto runs entrypoints inside a coroutine with the uv +-- loop driven; this standalone script has to provide the same +-- environment itself. +local function run(fn) + local results + local co = coroutine.create(function() results = table.pack(fn()) end) + local ok, err = coroutine.resume(co) + if not ok then error(err, 0) end + while coroutine.status(co) ~= "dead" do + uv.run("once") + end + return table.unpack(results, 1, results.n) +end + +local function write(path, text) + local f = assert(io.open(path, "w")) + f:write(text) + f:close() +end + +local tmp = assert(uv.fs_mkdtemp("/tmp/panto-agent-selfcheck-XXXXXX")) +local function dir(name) + local d = tmp .. "/" .. name + assert(uv.fs_mkdir(d, 493)) -- 0755 + return d +end + +-- both: AGENTS.md wins, CLAUDE.md ignored +local both = dir("both") +write(both .. "/AGENTS.md", "agents rules") +write(both .. "/CLAUDE.md", "claude rules") +-- claude-only: falls back to CLAUDE.md +local claude_only = dir("claude-only") +write(claude_only .. "/CLAUDE.md", "claude only") +-- empty: contributes nothing +local empty = dir("empty") +-- linked: AGENTS.md is a symlink; resolved path reported +local target_dir = dir("target") +write(target_dir .. "/real-rules.md", "linked rules") +local linked = dir("linked") +assert(uv.fs_symlink(target_dir .. "/real-rules.md", linked .. "/AGENTS.md")) + +-- activate(): subscribes to session_start; each firing resets the +-- session-global `seen` set and injects one message per file, using the +-- event's cwd as the project root. Activate first so firing session_start +-- doubles as the seen-reset between the scenarios below. +rules.layer_dirs = function(cwd) return { cwd } end +rules.activate() +local on_session_start = handlers["session_start"] +assert(type(on_session_start) == "function", "session_start handler registered") +assert(#captured == 0, "nothing injected before session_start fires") + +-- collect() awaits libuv callbacks, so it runs under the harness pump. +local got = run(function() return rules.collect({ both, claude_only, empty, linked }) end) +assert(#got == 3, "expected 3 collected, got " .. #got) +assert(got[1].path == uv.fs_realpath(both .. "/AGENTS.md"), "AGENTS.md over CLAUDE.md") +assert(got[1].text == "agents rules") +assert(got[2].path == uv.fs_realpath(claude_only .. "/CLAUDE.md"), "CLAUDE.md fallback") +assert(got[2].text == "claude only") +assert(got[3].path == uv.fs_realpath(target_dir .. "/real-rules.md"), "symlink resolved") +assert(got[3].text == "linked rules") + +-- Session-global dedupe: a repeat collect of already-seen files is empty. +local again = run(function() return rules.collect({ both }) end) +assert(#again == 0, "seen files are not collected twice in a session") + +-- A session boundary resets `seen` (empty dir: injects nothing). +run(function() on_session_start({ cwd = empty }) end) +assert(#captured == 0, "empty root injects nothing") +-- Two dirs reaching the same real file within one collect: used once. +local dedup = run(function() return rules.collect({ linked, target_dir }) end) +assert(#dedup == 1, "same real file deduped") + +run(function() on_session_start({ cwd = both }) end) +assert(#captured == 1, "one system message") +local want = "context from " .. uv.fs_realpath(both .. "/AGENTS.md") .. ":\n\nagents rules" +assert(captured[1] == want, "message format:\n" .. captured[1]) +-- A second session boundary (e.g. /new) re-injects for the new cwd. +run(function() on_session_start({ cwd = claude_only }) end) +assert(#captured == 2 and captured[2]:find("claude only"), "re-injects on next session") + +-- tool_result handler: rules files between a read and the session root +-- ride on the result via the writable `output` field, and the component +-- wrapper lists them. Tree: proj/ (root, covered by the system prompt), +-- proj/sub (AGENTS.md), proj/sub/deep (CLAUDE.md only). +local proj = dir("proj") +write(proj .. "/AGENTS.md", "root rules") +assert(uv.fs_mkdir(proj .. "/sub", 493)) +write(proj .. "/sub/AGENTS.md", "sub rules") +assert(uv.fs_mkdir(proj .. "/sub/deep", 493)) +write(proj .. "/sub/deep/CLAUDE.md", "deep rules") +write(proj .. "/sub/deep/file.txt", "hello") + +run(function() on_session_start({ cwd = proj }) end) +local on_tool_result = handlers["tool_result"] +assert(type(on_tool_result) == "function", "tool_result handler registered") + +-- Fabricate a `tool_result` event: a plain table stands in for the real +-- event userdata (assignment and method calls look the same). +local fake_comp = { render = function(_, _) return { "read line" } end } +local function read_event(path) + local ev = { + tool_name = "std.read", + input = '{"path":"' .. path .. '"}', + output = "FILE", + } + ev.get_component = function() return fake_comp end + ev.set_component = function(_, t) ev.component = t end + return ev +end + +local ev = read_event(proj .. "/sub/deep/file.txt") +run(function() on_tool_result(ev) end) +local sub_real = uv.fs_realpath(proj .. "/sub/AGENTS.md") +local deep_real = uv.fs_realpath(proj .. "/sub/deep/CLAUDE.md") +assert(ev.output == "FILE" + .. "\n\n---\ncontext from " .. sub_real .. ":\n\nsub rules" + .. "\n\n---\ncontext from " .. deep_real .. ":\n\ndeep rules", + "walk order + format:\n" .. ev.output) +-- The component wrapper passes the original lines through and lists the +-- attached files below them. +local lines = ev.component.render(ev.component, 80) +assert(lines[1] == "read line", "wrapper renders through the original") +assert(lines[2] == "+ rules: " .. sub_real and lines[3] == "+ rules: " .. deep_real, + "wrapper lists attached files") + +-- Same read again: everything already seen, result and component untouched. +local ev2 = read_event(proj .. "/sub/deep/file.txt") +run(function() on_tool_result(ev2) end) +assert(ev2.output == "FILE" and ev2.component == nil, "already-seen rules are not re-injected") + +-- Reading a directory directly starts the walk at that directory. +assert(uv.fs_mkdir(proj .. "/other", 493)) +write(proj .. "/other/AGENTS.md", "other rules") +local ev3 = read_event(proj .. "/other") +run(function() on_tool_result(ev3) end) +assert(ev3.output:find("other rules", 1, true), "directory read includes its own rules") + +-- Reads outside the session root add nothing; other tools are ignored. +local ev4 = read_event(both .. "/AGENTS.md") +run(function() on_tool_result(ev4) end) +assert(ev4.output == "FILE", "outside-root reads are untouched") +local ev5 = read_event(proj .. "/sub/deep/file.txt") +ev5.tool_name = "std.shell" +run(function() on_tool_result(ev5) end) +assert(ev5.output == "FILE", "non-read tools are ignored") + +-- agent.skill: layered discovery, frontmatter-name shadowing, enum/desc +-- registration, frontmatter-stripped load with a path preamble. +local skill = require("skill") +assert(skill.name == "agent.skill", "skill entry name") + +local function write_skill(layer, dirname, name, desc, body) + local d = layer .. "/" .. dirname + assert(uv.fs_mkdir(d, 493)) + write(d .. "/SKILL.md", "---\nname: " .. name .. "\ndescription: " .. desc + .. "\nmetadata:\n author: selfcheck\n---\n" .. body) + return d +end + +local sk_low = dir("skills-low") +local sk_high = dir("skills-high") +-- alpha in both layers; the high layer's directory name differs from the +-- frontmatter name, which is the identity that shadows. +write_skill(sk_low, "alpha", "alpha", "low alpha", "low alpha body\n") +local alpha_high = write_skill(sk_high, "alpha-dir", "alpha", '"high alpha"', "high alpha body\n") +write_skill(sk_low, "beta", "beta", "beta desc", "beta body\n") +-- Not skills: no SKILL.md; SKILL.md without frontmatter. +assert(uv.fs_mkdir(sk_low .. "/noskill", 493)) +assert(uv.fs_mkdir(sk_low .. "/badfm", 493)) +write(sk_low .. "/badfm/SKILL.md", "just markdown, no frontmatter\n") + +skill.skill_dirs = function() return { sk_low, sk_high } end +run(function() skill.activate() end) +assert(#registered == 1, "one tool registered") +local tool = registered[1] +assert(tool.name == "agent.skill", "tool name") +local enum = tool.schema.properties.skill.enum +assert(#enum == 2 and enum[1] == "alpha" and enum[2] == "beta", "enum is the surviving names, sorted") +assert(tool.description:find("- alpha: high alpha", 1, true), "shadowing layer's description wins") +assert(tool.description:find("- beta: beta desc", 1, true), "description lists every skill") + +local alpha_real = uv.fs_realpath(alpha_high) +local out = run(function() return tool.handler({ skill = "alpha" }) end) +assert(out:find("loaded from " .. alpha_real .. "/SKILL.md", 1, true), "preamble names the real path") +assert(out:find("resolve it against " .. alpha_real .. "/", 1, true), "preamble covers relative paths") +assert(out:find("high alpha body", 1, true), "higher layer shadows lower") +assert(not out:find("name: alpha", 1, true), "frontmatter stripped") + +local miss = run(function() return tool.handler({ skill = "nope" }) end) +assert(miss:find("unknown skill: nope", 1, true) and miss:find("alpha, beta", 1, true), + "unknown skill lists the available ones") + +-- No skills anywhere: the tool is not registered at all. +skill.skill_dirs = function() return { empty } end +run(function() skill.activate() end) +assert(#registered == 1, "no skills, no tool") + +print("panto-agent selfcheck: OK") diff --git a/panto-agent-scm-1.rockspec b/panto-agent-scm-1.rockspec new file mode 100644 index 0000000..bd3e459 --- /dev/null +++ b/panto-agent-scm-1.rockspec @@ -0,0 +1,31 @@ +rockspec_format = "3.0" +package = "panto-agent" +version = "scm-1" + +source = { + -- Placeholder until the suite moves to its own repository. + url = "git+https://example.invalid/panto-agent.git", +} + +description = { + summary = "The agent.* extension suite for pantograph", + detailed = [[ +Coding-agent extensions for the panto CLI: rules-file context injection +(agent.rules), with skills and subagents to follow. Load by adding +"panto-agent" to `extensions.rocks` in panto's config.toml. +]], + license = "MIT", +} + +dependencies = { + "lua >= 5.1", +} + +build = { + type = "builtin", + modules = { + ["panto-agent"] = "panto-agent/init.lua", + ["panto-agent.rules"] = "panto-agent/rules.lua", + ["panto-agent.skill"] = "panto-agent/skill.lua", + }, +} diff --git a/panto-agent/init.lua b/panto-agent/init.lua new file mode 100644 index 0000000..017f4ee --- /dev/null +++ b/panto-agent/init.lua @@ -0,0 +1,20 @@ +-- panto-agent: the agent.* extension suite for pantograph. +-- +-- Returns the list of extension entries this rock provides. Works both +-- installed as a rock (`require("panto-agent")`, submodules namespaced) +-- and as a directory source via `extensions.paths` (siblings on +-- `package.path`). + +local function sub(name) + -- Truncate to one value: require returns (module, loaderdata) and a + -- trailing multi-value would expand inside the entries list below. + local ok, mod = pcall(require, "panto-agent." .. name) + if ok then return mod end + local fallback = require(name) + return fallback +end + +return { + sub("rules"), + sub("skill"), +} diff --git a/panto-agent/rules.lua b/panto-agent/rules.lua new file mode 100644 index 0000000..77c234f --- /dev/null +++ b/panto-agent/rules.lua @@ -0,0 +1,215 @@ +-- agent.rules: inject AGENTS.md (or CLAUDE.md) context into the system +-- prompt at the start of every session, and subdirectory rules files into +-- `std.read` results as the agent explores. +-- +-- Activation subscribes to `session_start`, so the rules re-inject on any +-- future in-process session boundary (e.g. a `/new` command), not just at +-- startup. The handler looks in each config layer's directory — base +-- (<data home>/panto/agent), user (~/.config/panto), and the project root +-- (the session's cwd) — for an AGENTS.md, falling back to CLAUDE.md when +-- AGENTS.md is absent (never both). Each file found becomes one system +-- message: +-- +-- context from <resolved path>: +-- +-- <file contents> +-- +-- Symlinks are resolved; the header shows the real path. The same real +-- file reachable from two layers is used once. +-- +-- A `tool_result` handler covers `std.read`: after each read, walk from +-- the read file's directory (or the directory itself, when one is read +-- directly) up to — but excluding — the session root, and append each +-- not-yet-seen rules file to the tool result via the writable event field +-- (`ev.output = ...`) as a plaintext section: +-- +-- --- +-- context from <resolved path>: +-- +-- <file contents> +-- +-- Plaintext (not a protocol attachment) because panto attachments are +-- binary media (image/PDF) with no filename channel, and OpenAI-style +-- providers can't carry non-text in tool results at all. One session-wide +-- `seen` set (shared with the system-prompt pass) guarantees each real +-- file is injected at most once per session. +-- +-- The transcript keeps the read's original output; a component wrapper +-- (get_component → wrap → set_component) adds one "+ rules: <path>" line +-- per attached file below it as a visual note. + +local uv = require("luv") +local panto = require("panto") + +local M = { name = "agent.rules" } + +local candidates = { "AGENTS.md", "CLAUDE.md" } + +-- Coroutine-aware libuv await, same shape as the std.* tools' _std.await +-- (not requirable from a rock): arm the callback form of a uv request, +-- yield, and let the callback resume us with its results. Every fs op +-- below routes through libuv's thread pool instead of blocking the shared +-- loop. Panto runs every user-Lua entrypoint (activate, event handlers, +-- commands, tools) inside a coroutine with the uv loop driven, so this +-- just works — no pumping on our side. +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 + +-- Awaited fs wrappers; all return (err, value) like the luv callbacks. +local function fs_realpath(p) return await("fs_realpath", function(r) uv.fs_realpath(p, r) end) end +local function fs_stat(p) return await("fs_stat", function(r) uv.fs_stat(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 + +-- Session state: the resolved session root and the set of rules files +-- (by real path) already injected this session — via the system prompt +-- or a read result. Reset at every session boundary. +local root = nil +local seen = {} + +--- The three layer directories to search, lowest first. `cwd` is the +--- project root (falls back to the process cwd). +function M.layer_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" end + local cfg = os.getenv("XDG_CONFIG_HOME") or (home and home .. "/.config") + if cfg then dirs[#dirs + 1] = cfg .. "/panto" end + dirs[#dirs + 1] = cwd or uv.cwd() + return dirs +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 + +--- Collect the rules files under `dirs`: a list of { path, text }, where +--- `path` is the symlink-resolved absolute path. AGENTS.md beats CLAUDE.md +--- within a directory; a real file already injected this session (the +--- module-global `seen`, reset at each session boundary) is skipped, and +--- newly collected files are marked. Awaits, so it must run inside a +--- panto entrypoint (or an equivalent harness). +function M.collect(dirs) + local out = {} + for _, dir in ipairs(dirs) do + for _, fname in ipairs(candidates) do + -- fs_realpath doubles as an existence check (err if missing + -- or a broken symlink) and resolves symlinks. + local _, real = fs_realpath(dir .. "/" .. fname) + if real then + if not seen[real] then + seen[real] = true + local text = read_all(real) + if text then + out[#out + 1] = { path = real, text = text } + end + end + break -- AGENTS.md present: never also use CLAUDE.md + end + end + end + return out +end + +local function dirname(p) + local d = p:match("^(.*)/[^/]+$") + if d == "" then return "/" end + return d +end + +--- Directories to check for a read of `path`: the containing directory +--- (or `path` itself when it is a directory), then each parent, up to but +--- EXCLUDING `base` (the session root — its rules are already in the +--- system prompt). Returned outermost-first, so broader rules land before +--- more specific ones. Empty when `path` doesn't exist or isn't strictly +--- under `base`. +function M.walk_dirs(path, base) + if not base then return {} end + local _, real = fs_realpath(path) + if not real then return {} end + local _, st = fs_stat(real) + if not st then return {} end + local dir = (st.type == "directory") and real or dirname(real) + local dirs = {} + while dir ~= base and #dir > #base do + dirs[#dirs + 1] = dir + dir = dirname(dir) + end + if dir ~= base then return {} end -- never reached the root: outside it + -- Reverse: deepest-first -> outermost-first. + for i = 1, #dirs // 2 do + dirs[i], dirs[#dirs - i + 1] = dirs[#dirs - i + 1], dirs[i] + end + return dirs +end + +--- `tool_result` handler for `std.read`: append any not-yet-seen rules +--- files between the read location and the session root to the result +--- (via the writable `output` field), and note them in the transcript by +--- wrapping the read's component. +function M.on_read_result(ev) + if ev.tool_name ~= "std.read" or not root then return end + local ok, input = pcall(panto.ext.json.decode, ev.input or "") + if not ok or type(input) ~= "table" or type(input.path) ~= "string" then return end + local extra = M.collect(M.walk_dirs(input.path, root)) + if #extra == 0 then return end + + local sections = {} + for _, m in ipairs(extra) do + sections[#sections + 1] = "\n\n---\ncontext from " .. m.path .. ":\n\n" .. m.text + end + ev.output = (ev.output or "") .. table.concat(sections) + + -- Visual note: the transcript keeps the read's own output; list the + -- attached files below it. + local comp = ev:get_component() + if comp then + ev:set_component({ + render = function(_, width) + local lines = comp:render(width) + for _, m in ipairs(extra) do + lines[#lines + 1] = "+ rules: " .. m.path + end + return lines + end, + }) + end +end + +function M.activate() + panto.ext.on("session_start", function(ev) + seen = {} + local _, real_cwd = fs_realpath(ev.cwd or uv.cwd()) + root = real_cwd + for _, m in ipairs(M.collect(M.layer_dirs(ev.cwd))) do + panto.ext.agent:add_system_message("context from " .. m.path .. ":\n\n" .. m.text) + end + end) + panto.ext.on("tool_result", M.on_read_result) +end + +return M 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 |
