-- 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