summaryrefslogtreecommitdiff
path: root/panto-agent/rules.lua
diff options
context:
space:
mode:
Diffstat (limited to 'panto-agent/rules.lua')
-rw-r--r--panto-agent/rules.lua215
1 files changed, 215 insertions, 0 deletions
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