summaryrefslogtreecommitdiff
path: root/panto-agent/rules.lua
blob: 77c234f47fd5a2c3bd944a366b4b9b6619ad0bf6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
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