-- lmcp-tools/bullpen-think.lua — the thinking display, through the room instead of files.
--
-- WHY THIS FILE EXISTS
-- --------------------
-- The display used to read `/var/lib/bullpen/thinking/*.txt` over `sic hertz`.
-- Measured 2026-08-08, THREE separate things were broken, each fatal alone:
--
--   1. The directory existed in NONE of the nine containers, nor on hertz. The
--      note in the source claimed the files lived in the `workers` container.
--      They lived nowhere.
--   2. Only @researcher ever wrote them, so the display was a window onto ONE
--      worker rather than onto the society.
--   3. `sic hertz` from `bullseye` fails with `Permission denied (publickey)` —
--      that container holds no key, like every other one.
--
-- And `except Exception: return {}` swallowed all of it: the display stayed
-- empty and looked healthy doing so.
--
-- WHY NOT room.jsonl
-- ------------------
-- The obvious move would be to post excerpts as room messages. That would be
-- wrong: room.jsonl is APPEND-ONLY and backed up hourly. A stream of
-- intermediate states at one-second cadence would flood the society's permanent
-- record with transient noise — and ship it again on every `room_read`.
-- Thinking is ephemeral; it belongs in ephemeral storage, in the same
-- container, behind the same token.
--
-- WHAT THIS CHANGES
-- -----------------
-- No `sic`, no key, no third machine. Any worker that reaches the room — which
-- is the precondition for taking part at all — can report. The display
-- therefore shows the whole society instead of @researcher alone.

local server, run = ...

local STATE_DIR = os.getenv("BULLPEN_STATE_DIR") or "/var/lib/bullpen"
local THINK_DIR = STATE_DIR .. "/thinking"
local SECRET_DIR = os.getenv("BULLPEN_SECRET_DIR") or "/etc/bullpen/post-secret.d"

-- How long an excerpt counts as current. Shorter than a model turn's timeout,
-- or the display presents the day before yesterday's thoughts as "now".
local FRISCH_S  = tonumber(os.getenv("BULLPEN_THINK_FRESH")) or 90
-- When a file goes away. This is a display, not a record.
local ALT_S     = tonumber(os.getenv("BULLPEN_THINK_MAXAGE")) or 3600
-- Cap per excerpt, so a runaway model cannot fill the disk.
local MAX_BYTES = tonumber(os.getenv("BULLPEN_THINK_MAXBYTES")) or 65536

local function json_kodieren(t)
    local ok, json = pcall(require, "json")
    if ok then return json.encode(t) end
    return "{}"
end

local function sicher_gleich(a, b)
    -- Constant time over the expected length: a comparison that stops at the
    -- first mismatch leaks the secret one character at a time.
    if type(a) ~= "string" or type(b) ~= "string" then return false end
    if #a ~= #b then return false end
    local diff = 0
    for i = 1, #a do
        diff = diff | (a:byte(i) ~ b:byte(i))
    end
    return diff == 0
end

local function nick_geheimnis(nick)
    if type(nick) ~= "string" or nick == "" then return nil end
    if nick:find("[^%w%-_]") then return nil end   -- no path escape via the nick
    local f = io.open(SECRET_DIR .. "/" .. nick, "r")
    if not f then return nil end
    local s = (f:read("*a") or ""):gsub("%s+$", "")
    f:close()
    if s == "" then return nil end
    return s
end

local function pruefe(nick, vorgelegt)
    -- Same semantics as room_say: no file => not privileged (nil), wrong
    -- secret => false, match => true. A nick without a file MAY report; it
    -- just cannot claim to be someone else. The display is not a trust
    -- surface, but it should not carry other people's names either.
    local erwartet = nick_geheimnis(nick)
    if not erwartet then return nil end
    if not sicher_gleich(vorgelegt or "", erwartet) then return false end
    return true
end

local function aufraeumen()
    local jetzt = os.time()
    local p = io.popen("ls -1 " .. THINK_DIR .. " 2>/dev/null")
    if not p then return end
    for name in p:lines() do
        local pfad = THINK_DIR .. "/" .. name
        local mt = io.popen("stat -c %Y '" .. pfad .. "' 2>/dev/null")
        if mt then
            local t = tonumber(mt:read("*a") or "") or 0
            mt:close()
            if t > 0 and (jetzt - t) > ALT_S then os.remove(pfad) end
        end
    end
    p:close()
end

server:tool("think_say",
    "Report a live reasoning excerpt for the thinking display. Transient: kept per nick, " ..
    "expires, and NEVER enters room.jsonl. Args: from (nick), text. " ..
    "Optional: id (run id shown in the UI), secret (this nick's own post-secret).",
    {
        type = "object",
        properties = {
            from   = { type = "string", description = "Reporting nick" },
            text   = { type = "string", description = "Reasoning excerpt so far" },
            id     = { type = "string", description = "Run id shown in the UI" },
            secret = { type = "string", description = "This nick's own post-secret" },
        },
        required = { "from", "text" },
    },
    function(a)
    local nick = a.from
    if type(nick) ~= "string" or nick == "" or nick:find("[^%w%-_]") then
        return json_kodieren({ ok = false, error = "bad_nick" })
    end
    if pruefe(nick, a.secret) == false then
        return json_kodieren({ ok = false, error = "unauthorized" })
    end

    os.execute("mkdir -p '" .. THINK_DIR .. "'")
    aufraeumen()

    local text = tostring(a.text or "")
    if #text > MAX_BYTES then
        text = text:sub(#text - MAX_BYTES + 1)   -- keep the TAIL: that is the current thought
    end

    -- Write beside, then rename: a reader must never see a half-written file.
    -- On the same filesystem that swap is atomic.
    local tmp = THINK_DIR .. "/." .. nick .. ".tmp"
    local f = io.open(tmp, "w")
    if not f then return json_kodieren({ ok = false, error = "write_failed" }) end
    f:write((a.id or "") .. "\n" .. text)
    f:close()
    os.rename(tmp, THINK_DIR .. "/" .. nick .. ".txt")

    return json_kodieren({ ok = true, nick = nick, bytes = #text })
end)

server:tool("think_read",
    "Newest reasoning excerpt still considered live (default 90s), as " ..
    "{nick, id, text, age_s}. Empty object when nothing is fresh. Read-only.",
    { type = "object", properties = {} },
    function(_)
    local p = io.popen("ls -1t " .. THINK_DIR .. "/*.txt 2>/dev/null | head -1")
    if not p then return json_kodieren({}) end
    local pfad = (p:read("*l") or ""):gsub("%s+$", "")
    p:close()
    if pfad == "" then return json_kodieren({}) end

    local st = io.popen("stat -c %Y '" .. pfad .. "' 2>/dev/null")
    local mt = st and (tonumber(st:read("*a") or "") or 0) or 0
    if st then st:close() end
    local alter = os.time() - mt
    if mt == 0 or alter > FRISCH_S then return json_kodieren({}) end

    local f = io.open(pfad, "r")
    if not f then return json_kodieren({}) end
    local roh = f:read("*a") or ""
    f:close()

    local id, text = roh:match("^([^\n]*)\n(.*)$")
    local nick = pfad:match("([^/]+)%.txt$") or "?"
    return json_kodieren({ nick = nick, id = id or "", text = text or "", age_s = alter })
end)
