From 94568d9f70b12fa348040a7998dac7b0808b0258 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 3 Jul 2026 09:49:13 -0600 Subject: Initial changes: agent.rules and agent.skill extensions 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. --- panto-agent/rules.lua | 215 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 panto-agent/rules.lua (limited to 'panto-agent/rules.lua') 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 +-- (/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 : +-- +-- +-- +-- 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 : +-- +-- +-- +-- 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: " 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 -- cgit v1.3