summaryrefslogtreecommitdiff
path: root/init.lua
blob: 17e0ed731aae64d6f59185673602e40f89aa6eb4 (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
-- panto-warming: session warming for pantograph.
--
-- Keeps the provider-side prompt cache warm while the user pauses between
-- prompts: once per `interval` of inactivity (within a `duration` window
-- from the latest real request), it sends a minimal transient prompt on top
-- of the session's current context, discards the response, and never adds
-- either one to the context window. Modeled on opencode's warming feature;
-- configured through a `[warming]` section in config.toml (warming/config).
--
-- Activation runs once per panto session (a new session, `/new`, or
-- `/resume` rebuilds the Lua state and runs this again). It reads the
-- layered settings, subscribes to the turn lifecycle that drives the timing
-- policy, and owns one luv timer that ticks while the warming window is
-- open. The eval below is side-effect-free -- pantograph evaluates every
-- candidate extension but only activates the ones its policy permits --
-- so every registration lives inside `activate`.

local config = require("warming.config")
local policy_mod = require("warming.policy")
local warm = require("warming.warm")

-- A tick is only bookkeeping (poll a job, compare timestamps), so a coarse
-- period is plenty; it is clamped to [1s, 30s].
local TICK_MAX_MS = 30000

local function host()
    return require("panto").ext
end

-- Events are optional: a host without the bus simply never calls back, and
-- warming then stays idle instead of breaking the session.
local function subscribe(ext, name, handler)
    if type(ext.on) == "function" then
        pcall(ext.on, name, handler)
    end
end

local function human_ms(ms)
    if ms % 3600000 == 0 then
        return (ms / 3600000) .. "h"
    elseif ms % 60000 == 0 then
        return (ms / 60000) .. "m"
    end
    return math.floor(ms / 1000) .. "s"
end

-- One status line in the session header, so warming -- which spends real
-- money -- is visible whenever it is on. The line re-renders with live
-- status on the next repaint.
local function install_header(ext, summary)
    subscribe(ext, "session_start", function(event)
        local inner = event:get_component()
        event:set_component({
            render = function(_, width)
                local lines = inner:render(width)
                local style = ext.style()
                local at = (#lines > 0 and lines[#lines] == "") and #lines or (#lines + 1)
                table.insert(lines, at, style.dim .. " " .. summary() .. style.reset)
                return lines
            end,
        })
    end)
end

local function activate()
    local panto = require("panto")
    local ext = host()
    if type(ext.resolve_model) ~= "function" or type(panto.agent) ~= "function" then
        error("panto-warming: this pantograph is too old for warming (panto.ext.resolve_model is missing)")
    end

    local settings, err = config.read(ext, config.default_io())
    if settings == nil then
        error("panto-warming: " .. err)
    end
    if not settings.enabled then
        return
    end

    local summary = string.format(
        "warming: every %s for %s after activity",
        human_ms(settings.interval_ms),
        human_ms(settings.duration_ms)
    )

    local ok_uv, uv = pcall(require, "luv")
    if not ok_uv then
        -- No loop handles means no schedule. Say so in the header instead of
        -- failing activation: the session is perfectly usable without us.
        install_header(ext, function()
            return "warming: unavailable (the 'luv' rock is not installed)"
        end)
        return
    end

    local policy = policy_mod.new(settings.interval_ms, settings.duration_ms)
    local current = nil -- in-flight warm handle
    local timer = nil
    local done = false

    local function stop_timer()
        if timer and not timer:is_closing() and timer:is_active() then
            timer:stop()
        end
    end

    local tick -- assigned below; the timer callback closes over it

    local function ensure_timer()
        if done then
            return
        end
        if timer == nil then
            timer = uv.new_timer()
        end
        if not timer:is_active() then
            local period = math.max(1000, math.min(settings.interval_ms, TICK_MAX_MS))
            timer:start(0, period, function()
                -- Containment: a tick must never take the process down
                -- through luv's default error behavior.
                pcall(tick)
            end)
        end
    end

    tick = function()
        local now = uv.now()
        if current then
            local result = warm.poll(current)
            if result then
                current = nil
                policy:attempted(now)
            end
            return
        end
        if policy:expired(now) then
            stop_timer()
            return
        end
        if policy:due(now) then
            local handle = warm.start(settings.prompt)
            if handle then
                current = handle
            else
                -- Failed attempt (model resolution, store, ...): the next
                -- try waits out the same interval, window still running.
                policy:attempted(now)
            end
        end
    end

    subscribe(ext, "session_start", function()
        policy:activity(uv.now())
        ensure_timer()
    end)
    subscribe(ext, "turn_start", function()
        policy:turn_started(uv.now())
        ensure_timer()
    end)
    subscribe(ext, "turn_end", function()
        policy:turn_ended(uv.now())
        ensure_timer()
    end)
    subscribe(ext, "turn_interrupt", function()
        policy:turn_ended(uv.now())
        ensure_timer()
    end)
    subscribe(ext, "session_end", function()
        done = true
        stop_timer()
        if timer then
            pcall(timer.close, timer)
            timer = nil
        end
        if current then
            warm.abandon(current)
            current = nil
        end
    end)

    install_header(ext, function()
        return summary
    end)
end

return {
    name = "warming",
    activate = activate,
}