From 8da8849e71a8dac7ed81d45d7674e6a3f38cbb7c Mon Sep 17 00:00:00 2001 From: t Date: Fri, 25 Sep 2026 14:58:55 -0600 Subject: Add panto-warming session cache warming extension Send a minimal transient prompt on idle intervals to keep the provider-side prompt cache warm, then discard prompt and response so the context window and transcript stay untouched. Disabled by default; enable via warming table with layered interval, duration, and prompt settings. --- init.lua | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 init.lua (limited to 'init.lua') diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..17e0ed7 --- /dev/null +++ b/init.lua @@ -0,0 +1,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, +} -- cgit v1.3