summaryrefslogtreecommitdiff
path: root/subagents/frontmatter.lua
blob: 87408709656b2f97b4c79e1f37bfae1a1c11291c (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
-- Split a Markdown profile into its YAML frontmatter and its body.
--
-- The format is the common one other agent harnesses use: if the very first
-- line of the file is exactly `---`, everything up to the next line that is
-- exactly `---` is a YAML mapping, and everything after that closing fence is
-- the body. Trailing carriage returns are tolerated so CRLF files parse.
--
-- Anything unusual degrades to "no metadata, body only" with a warning rather
-- than an error, because the body is the part the user cannot afford to lose:
--
--   * no opening fence          -> the whole file is the body, no warning
--   * unterminated opening fence-> the whole file is the body, no warning
--     (a lone `---` at the top of a prose file is a horizontal rule, not a
--      broken header, so this case is deliberately silent)
--   * empty fenced block        -> empty mapping, no warning
--   * parser missing or erroring-> body after the fence, warning returned
--   * YAML document not a map   -> body after the fence, warning returned
--   * unresolved anchor/alias   -> that key dropped, warning returned
--
-- In the warning cases the fenced block is dropped rather than folded back
-- into the body: an unparseable header is noise the child agent should not be
-- asked to read. The body itself is never rewritten — no trimming, no
-- normalisation — so a prompt round-trips verbatim.

local M = {}

local function trim(s)
    return (s:gsub("^%s+", ""):gsub("%s+$", ""))
end

-- tinyyaml parses a subset of YAML: it does not resolve anchors (`&a`) or
-- aliases (`*a`). Rather than failing on them it hands back the raw token as
-- a plain string, so `name: *base` arrives here as the literal "*base" and
-- would sail through any `type(v) == "string"` check to become a profile's
-- actual name. Detect that shape and drop those keys so the caller falls back
-- to its defaults instead of adopting a bogus value.
--
-- The match is deliberately narrow -- a whole value that is exactly `*word`,
-- or one opening with `&word ` -- so ordinary prose containing `*` or `&`
-- (`'fetch & parse'`, `'a *b* c'`) is left alone.
local function strip_unresolved_aliases(data)
    local hits = {}
    for k, v in pairs(data) do
        if type(v) == "string"
            and (v:match("^%*[%w_%-]+$") or v:match("^&[%w_%-]+%s")) then
            hits[#hits + 1] = tostring(k)
        end
    end
    if #hits == 0 then
        return nil
    end
    table.sort(hits)
    for _, k in ipairs(hits) do
        data[k] = nil
    end
    return table.concat(hits, ", ")
end

-- Iterate lines, yielding the line plus its start offset and the offset just
-- past its newline, so the caller can slice the original text exactly.
local function lines(text)
    local pos = 1
    return function()
        if pos > #text then
            return nil
        end
        local start = pos
        local nl = text:find("\n", pos, true)
        local line
        if nl then
            line = text:sub(start, nl - 1)
            pos = nl + 1
        else
            line = text:sub(start)
            pos = #text + 1
        end
        return line, start, pos
    end
end

-- parse(text) -> data|nil, body, warning|nil
function M.parse(text)
    if type(text) ~= "string" or text == "" then
        return nil, "", nil
    end

    local next_line = lines(text)
    local first, _, after_first = next_line()
    if first == nil or trim(first) ~= "---" then
        return nil, text, nil
    end

    local block_stop, body_start
    for line, start, after in next_line do
        if trim(line) == "---" then
            block_stop = start - 1
            body_start = after
            break
        end
    end
    if body_start == nil then
        return nil, text, nil
    end

    local block = text:sub(after_first, block_stop)
    local body = text:sub(body_start)
    if trim(block) == "" then
        return {}, body, nil
    end

    local ok_yaml, yaml = pcall(require, "tinyyaml")
    if not ok_yaml then
        return nil, body, "tinyyaml is not installed; ignoring the YAML frontmatter"
    end
    local ok, data = pcall(yaml.parse, block)
    if not ok then
        return nil, body, "YAML frontmatter did not parse: " .. tostring(data)
    end
    if type(data) ~= "table" then
        return nil, body, "YAML frontmatter is not a mapping; ignoring it"
    end
    local aliased = strip_unresolved_aliases(data)
    if aliased then
        return data, body,
            "YAML anchors/aliases are not supported; ignoring: " .. aliased
    end
    return data, body, nil
end

return M