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. --- warming/config.lua | 156 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 warming/config.lua (limited to 'warming/config.lua') diff --git a/warming/config.lua b/warming/config.lua new file mode 100644 index 0000000..27cafd8 --- /dev/null +++ b/warming/config.lua @@ -0,0 +1,156 @@ +-- The layered `[warming]` settings from config.toml. +-- +-- Warming is off unless configured, like opencode's. Both spellings work: +-- +-- warming = true -- on with every default +-- warming = false -- explicitly off +-- +-- [warming] -- on, with overrides +-- prompt = "..." -- transient user message +-- interval = "4 minutes" -- idle time between model requests +-- duration = "30 minutes" -- window from the latest real request +-- enabled = false -- opt out here instead of `warming = false` +-- +-- Settings merge across the host's config layers (base, user, project, +-- local -- lowest precedence first): later layers win per key, and a layer +-- that omits a key keeps whatever earlier layers set. Anything unrecognized +-- is an activation error: a typo in a feature that silently does nothing is +-- worse than a loud failure. + +local M = {} + +M.DEFAULT_PROMPT = "Cache keep-alive. Do not perform any work and do not call any tools. Reply with exactly: OK" +M.DEFAULT_INTERVAL_MS = 4 * 60 * 1000 +M.DEFAULT_DURATION_MS = 30 * 60 * 1000 + +local UNITS = { + s = 1000, sec = 1000, secs = 1000, second = 1000, seconds = 1000, + m = 60000, min = 60000, mins = 60000, minute = 60000, minutes = 60000, + h = 3600000, hr = 3600000, hrs = 3600000, hour = 3600000, hours = 3600000, +} + +function M.defaults() + return { + enabled = false, + prompt = M.DEFAULT_PROMPT, + interval_ms = M.DEFAULT_INTERVAL_MS, + duration_ms = M.DEFAULT_DURATION_MS, + } +end + +-- parse_duration("4 minutes") -> 240000 | nil, err +function M.parse_duration(text) + if type(text) ~= "string" then + return nil, "must be a duration string like \"4 minutes\"" + end + local amount, unit = text:match("^%s*(%d+)%s*(%a+)%s*$") + local ms = amount and UNITS[unit:lower()] or nil + if not ms then + return nil, string.format( + "cannot read %q as a duration (want e.g. \"30 seconds\", \"4 minutes\", \"1 hour\")", text) + end + if tonumber(amount) < 1 then + return nil, "must be greater than zero" + end + return tonumber(amount) * ms +end + +-- apply_layer(settings, value, path) -> true | nil, err +-- +-- `value` is one config.toml layer's `warming` entry: a boolean or a table +-- of overrides. Mutates and returns `settings`. +function M.apply_layer(settings, value, path) + if type(value) == "boolean" then + settings.enabled = value + return true + end + if type(value) ~= "table" then + return nil, string.format("[warming] must be true, false, or a table in %s", path) + end + -- The table form is opencode's object form: its presence enables warming. + settings.enabled = true + for key, raw in pairs(value) do + if key == "enabled" then + if type(raw) ~= "boolean" then + return nil, string.format("warming.enabled must be a boolean in %s", path) + end + settings.enabled = raw + elseif key == "prompt" then + if type(raw) ~= "string" or raw == "" then + return nil, string.format("warming.prompt must be a non-empty string in %s", path) + end + settings.prompt = raw + elseif key == "interval" or key == "duration" then + local ms, err = M.parse_duration(raw) + if not ms then + return nil, string.format("warming.%s %s in %s", key, err, path) + end + settings[key .. "_ms"] = ms + else + return nil, string.format("unknown setting [warming] %s in %s", tostring(key), path) + end + end + return true +end + +-- read(ext, io) -> settings | nil, err +-- +-- Walks `ext.dirs.layers` and merges each layer's `warming` entry. +-- `io = { read_file(path) -> text|nil, parse(text) -> table }` is injected +-- so the layer walk runs under the selfcheck without the toml rock or a +-- filesystem. +function M.read(ext, io) + local settings = M.defaults() + local layers = ext.dirs and ext.dirs.layers + if type(layers) ~= "table" then + return settings + end + for _, layer in ipairs(layers) do + if type(layer) == "table" and type(layer.dir) == "string" then + local path = layer.dir .. "/config.toml" + local text = io.read_file(path) + if text then + local ok, parsed = pcall(io.parse, text) + if not ok then + return nil, string.format("could not parse %s: %s", path, tostring(parsed)) + end + if type(parsed) ~= "table" then + return nil, string.format("could not parse %s", path) + end + if parsed.warming ~= nil then + local applied, err = M.apply_layer(settings, parsed.warming, path) + if not applied then + return nil, err + end + end + end + end + end + return settings +end + +-- The production io: whole-file reads plus the toml2lua parser (`toml` +-- module). `toml.parse` is strict, so a malformed config.toml fails +-- activation instead of half-configuring warming. +function M.default_io() + return { + read_file = function(path) + local file = io.open(path, "r") + if not file then + return nil + end + local text = file:read("*a") + file:close() + return text + end, + parse = function(text) + local ok, toml = pcall(require, "toml") + if not ok or type(toml) ~= "table" or type(toml.parse) ~= "function" then + error("the 'toml2lua' rock is required to read configuration") + end + return toml.parse(text, { strict = true }) + end, + } +end + +return M -- cgit v1.3