summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md69
-rw-r--r--_selfcheck.lua236
-rw-r--r--panto-agent-scm-1.rockspec31
-rw-r--r--panto-agent/init.lua20
-rw-r--r--panto-agent/rules.lua215
-rw-r--r--panto-agent/skill.lua194
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