summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore8
-rw-r--r--LICENSE18
-rw-r--r--README.md160
-rw-r--r--_selfcheck.lua223
-rw-r--r--init.lua189
-rw-r--r--mise.toml94
-rw-r--r--panto-warming-0.1.0-1.rockspec55
-rw-r--r--warming/config.lua156
-rw-r--r--warming/policy.lua75
-rw-r--r--warming/warm.lua160
10 files changed, 1138 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a6e4a75
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+.panto/local/
+AGENTS.local.md
+CLAUDE.local.md
+
+# Build artifact left by `luarocks upload` / `mise run release`.
+*.src.rock
+# Local luarocks tree created by `mise run deps`.
+.rocks/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..876f03c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,18 @@
+MIT License
+
+Copyright (c) 2026 Travis Parker
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+associated documentation files (the "Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial
+portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
+LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
+EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d57d9f4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,160 @@
+# panto-warming
+
+Session warming for [Pantograph](https://github.com/travisp/pantograph),
+modeled on [opencode's warming
+feature](https://opencode.ai/v2/docs/warming/). While you pause between
+prompts, the extension periodically sends a **minimal transient prompt** on
+top of the session's current context so the provider-side prompt cache stays
+warm — then throws the prompt and the response away. Nothing is added to the
+context window or the session transcript.
+
+Disabled by default. Warming requests are real provider requests: they
+consume tokens, cost money, and count against rate limits.
+
+## Install
+
+```toml
+[extensions]
+rocks = ["panto-warming"]
+```
+
+For a local checkout, use `paths = ["/path/to/panto-warming"]` instead. A
+`paths` entry loads the code but installs nothing, so put the dependencies
+in panto's own rocks tree first (`mise run deps`, or the `luarocks` line it
+contains).
+
+## Configuration
+
+Enable with defaults in any layered `config.toml` (the built-in base layer,
+`${XDG_CONFIG_HOME:-$HOME/.config}/panto/`, `.panto/`, and `.panto/local/`):
+
+```toml
+warming = true
+```
+
+Or configure it as a table — its presence enables warming:
+
+```toml
+[warming]
+prompt = "Do not perform any work. Reply with exactly: OK"
+interval = "5 minutes"
+duration = "1 hour"
+```
+
+| Field | Default | Purpose |
+| ---------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------- |
+| `prompt` | keep-alive instruction (do no work, no tools, reply `OK`) | appended as a transient user message for each warming request |
+| `interval` | `"4 minutes"` | idle time before the next warming request |
+| `duration` | `"30 minutes"` | warming window measured from the latest non-warming model request |
+| `enabled` | `true` when the table is present | opt out in one layer (`enabled = false`, or the scalar `warming = false`) |
+
+Omitted fields keep their defaults. `interval` and `duration` take duration
+strings (`"30 seconds"`, `"4 minutes"`, `"1 hour"`; singular and a few
+abbreviations work too) and must be greater than zero — anything else fails
+activation with a message naming the file, because a silently misread setting
+in a feature that spends money is worse than a loud error.
+
+Settings merge across the config layers, lowest precedence first; later
+layers win per key. A higher layer can therefore shorten the interval, extend
+the window, or turn warming off entirely.
+
+### Timing
+
+Warming starts after session start or any non-warming model request, and any
+new real activity resets both timers. A warming request never extends the
+window; after each attempt (success or failure) the next one waits out a full
+interval:
+
+```
+00:00 real activity window = [00:00, 00:30]
+00:04 warming request
+00:08 warming request
+...
+00:30 warming stops
+```
+
+If `interval >= duration`, the window always expires before a warming
+request is due and none is ever sent. Failures are silent (extensions have
+no logging channel): the session is unchanged and the next interval retries.
+Nothing warms while a real turn is in flight.
+
+## How a warming request works
+
+```mermaid
+sequenceDiagram
+ participant Timer
+ participant Warm as warm agent (throwaway)
+ participant Store as session store
+ participant Provider
+ Timer->>Store: replay transcript (same as /resume)
+ Timer->>Warm: panto.agent { conversation, model, tools, null_store }
+ Timer->>Warm: run_async { transient prompt, dispatch_tools = false }
+ Warm->>Provider: [session prefix] + [transient user message]
+ Provider-->>Warm: one response
+ Warm--)Timer: settled; response discarded
+```
+
+The warm request shares the session's prompt prefix and differs only by the
+transient user message, so a provider that caches prefixes (Anthropic with
+its `cache_control` breakpoints, OpenAI's prompt caching) sees the shared
+prefix again and keeps it hot. Then everything is dropped:
+
+- **Transcript** — reloaded from the session's own JSONL store with the same
+ machinery `/resume` uses. System blocks, tool inputs, and thinking
+ signatures replay verbatim, so the messages half of the prefix is
+ byte-identical to the session's own requests.
+- **Model** — `panto.ext.resolve_model` for the session's current
+ model/reasoning label, inheriting the live credentialed transport.
+- **Tools** — the session agent's declarations, copied onto the warm agent.
+- **Turn** — one `run_async` on its own pump thread (the UI never blocks),
+ with `dispatch_tools = false`: a misbehaving model cannot run real tools
+ from a keep-alive prompt, and tool calls are handed back unresolved and
+ discarded with the rest.
+
+The warm turn's user message and response live only in the throwaway
+agent's conversation and a `null_store` — the live session, its transcript,
+and its persistence are never touched.
+
+### Fidelity caveat: tool schemas
+
+Tool declarations cross a decode/re-encode seam when copied to the warm
+agent (`agent:tools()` decodes the session's schema JSON, `set_tools`
+re-serializes it), and Lua table iteration can reorder JSON object keys
+along the way. Both Anthropic and OpenAI key their caches on the **exact**
+rendered prefix — tools included, at position 0 — so when the re-encoded
+schema bytes differ from the session's own, that warming request cannot
+write a cache entry the session's next request will hit, and buys nothing.
+
+This is unfixable from an extension: no binding surface passes raw schema
+bytes to a second agent. A small `schema_json` passthrough in
+`libpanto-lua` (`agent:tools()` exposing the raw bytes, `set_tools`
+accepting them) would make the copy exact. Until that lands, the self-check
+measures how often re-encoding drifts on the local codec:
+
+```
+$ panto lua _selfcheck.lua
+probe: schema re-encode drift 3/4 (tool prefix fidelity degraded; see README)
+```
+
+The drift is a property of the shared JSON codec's table iteration, not of
+this rock; the messages half of the prefix is unaffected.
+
+## Costs
+
+Warming requests are billed like any other. Shorter intervals increase
+request volume; longer durations allow more of them. Enable warming only
+when the provider-side benefit is worth the added usage — with the defaults,
+one uninterrupted 30-minute window sends at most seven warming requests.
+
+## Development
+
+```sh
+mise trust # once
+mise run deps # install toml2lua into panto's rocks tree (for `paths` use)
+mise run check # panto lua _selfcheck.lua
+```
+
+`warming/policy.lua` and `warming/config.lua` are pure (durations, layer
+merging, the timing state machine) and fully covered by the self-check;
+`warming/warm.lua` is the binding glue. Release plumbing: `mise run version
+<x.y.z>`, commit, `mise run release`.
diff --git a/_selfcheck.lua b/_selfcheck.lua
new file mode 100644
index 0000000..fa6a0e2
--- /dev/null
+++ b/_selfcheck.lua
@@ -0,0 +1,223 @@
+-- Self-check for panto-warming. Run with:
+--
+-- panto lua _selfcheck.lua
+--
+-- Every module under test is pure Lua (no session, no panto module needed),
+-- so plain lua5.4 runs it too. The fidelity probe at the bottom uses the
+-- host's real JSON codec when the interpreter provides one.
+
+local here = (arg and arg[0] or "_selfcheck.lua"):match("^(.*)/") or "."
+package.path = here .. "/?.lua;" .. here .. "/?/init.lua;" .. package.path
+
+local config = require("warming.config")
+local policy = require("warming.policy")
+
+local SEC, MIN, HOUR = 1000, 60000, 3600000
+
+-- ---------------------------------------------------------------------------
+-- Duration strings
+-- ---------------------------------------------------------------------------
+
+assert(config.parse_duration("4 minutes") == 4 * MIN, "4 minutes")
+assert(config.parse_duration("30 seconds") == 30 * SEC, "30 seconds")
+assert(config.parse_duration("1 hour") == HOUR, "1 hour")
+assert(config.parse_duration("2h") == 2 * HOUR, "2h")
+assert(config.parse_duration(" 10 mins ") == 10 * MIN, "whitespace and synonyms")
+
+local function duration_fails(text, why)
+ local ms, err = config.parse_duration(text)
+ assert(ms == nil, "parse_duration accepted " .. why)
+ assert(type(err) == "string", "parse_duration explains " .. why)
+end
+duration_fails("soon", "a non-duration")
+duration_fails("4", "a bare number")
+duration_fails("0 minutes", "a zero duration")
+duration_fails("-1 minutes", "a negative duration")
+duration_fails("1 fortnight", "an unknown unit")
+duration_fails(4, "a non-string")
+
+-- ---------------------------------------------------------------------------
+-- Layer merging
+-- ---------------------------------------------------------------------------
+
+local s = config.defaults()
+assert(s.enabled == false, "warming is off unless configured")
+assert(s.interval_ms == 4 * MIN and s.duration_ms == 30 * MIN, "opencode defaults")
+assert(s.prompt:find("OK"), "default prompt asks for OK")
+
+assert(config.apply_layer(s, true, "x") == true, "scalar true applies")
+assert(s.enabled == true, "scalar true enables")
+assert(config.apply_layer(s, { interval = "2 minutes" }, "x") == true, "table applies")
+assert(s.interval_ms == 2 * MIN, "interval overridden")
+assert(s.duration_ms == 30 * MIN, "omitted keys keep earlier values")
+assert(config.apply_layer(s, false, "x") == true, "scalar false applies")
+assert(s.enabled == false, "scalar false disables")
+assert(config.apply_layer(s, { enabled = false, duration = "10 minutes" }, "x") == true, "enabled key applies")
+assert(s.enabled == false, "warming.enabled = false disables")
+assert(s.duration_ms == 10 * MIN, "duration still merged while disabled")
+assert(config.apply_layer(s, { prompt = "stay put" }, "x") == true, "prompt applies")
+assert(s.prompt == "stay put" and s.enabled == true, "table presence enables")
+
+local function layer_fails(value, why)
+ local ok, err = config.apply_layer(config.defaults(), value, "/x/config.toml")
+ assert(ok == nil, "apply_layer accepted " .. why)
+ assert(type(err) == "string" and err:find("/x/config.toml"), "error names the file for " .. why)
+end
+layer_fails("always", "a string scalar")
+layer_fails({ bogus = 1 }, "an unknown key")
+layer_fails({ prompt = "" }, "an empty prompt")
+layer_fails({ interval = "1 fortnight" }, "a bad duration")
+layer_fails({ enabled = "yes" }, "a non-boolean enabled")
+
+-- Layer walk over fake config layers (base < user < project < local).
+local docs = {
+ ["/base/config.toml"] = { warming = true },
+ ["/user/config.toml"] = { warming = { interval = "2 minutes" } },
+ ["/project/config.toml"] = { warming = { duration = "10 minutes" } },
+ ["/local/config.toml"] = { warming = false },
+}
+local fake_io = {
+ read_file = function(path)
+ return docs[path] and path or nil -- the "file contents" are its path
+ end,
+ parse = function(text)
+ return docs[text]
+ end,
+}
+local fake_ext = {
+ dirs = { layers = {
+ { name = "base", dir = "/base" },
+ { name = "user", dir = "/user" },
+ { name = "project", dir = "/project" },
+ { name = "local", dir = "/local" },
+ { name = "missing", dir = "/nowhere" },
+ } },
+}
+local merged = assert(config.read(fake_ext, fake_io))
+assert(merged.interval_ms == 2 * MIN and merged.duration_ms == 10 * MIN, "layers merge per key")
+assert(merged.enabled == false, "the highest layer's scalar wins")
+
+docs["/local/config.toml"] = { warming = { interval = "90 seconds" } }
+merged = assert(config.read(fake_ext, fake_io))
+assert(merged.enabled == true and merged.interval_ms == 90 * SEC, "later layers enable and override")
+
+docs["/local/config.toml"] = { warming = { typo = true } }
+local bad, err = config.read(fake_ext, fake_io)
+assert(bad == nil and err:find("/local/config.toml"), "a bad layer fails with its path")
+
+docs["/local/config.toml"] = { warming = true }
+fake_io.parse = function() error("boom") end
+bad, err = config.read(fake_ext, fake_io)
+assert(bad == nil and err:find("boom"), "a parse error surfaces its reason")
+
+-- ---------------------------------------------------------------------------
+-- Timing policy: the opencode timelines
+-- ---------------------------------------------------------------------------
+
+local function fire_times(p, from, to, step)
+ local fired = {}
+ local t = from
+ while t <= to do
+ if p:due(t) then
+ fired[#fired + 1] = t / MIN
+ p:attempted(t)
+ end
+ t = t + step
+ end
+ return fired
+end
+
+-- 00:00 real request; warm every 4 minutes; stop at 30 minutes.
+do
+ local p = policy.new(4 * MIN, 30 * MIN)
+ assert(p:expired(0), "no window before any activity")
+ p:activity(0)
+ assert(not p:due(0), "no warm request at the moment of activity")
+ local fired = fire_times(p, 15 * SEC, 40 * MIN, 15 * SEC)
+ assert(#fired == 7, "seven warm attempts in the default window, got " .. #fired)
+ for i, at in ipairs(fired) do
+ assert(at == i * 4, "warm attempt " .. i .. " at " .. at .. "m, want " .. (i * 4) .. "m")
+ end
+ assert(p:expired(30 * MIN), "window closes at 30m")
+ assert(not p:expired(29 * MIN), "window still open at 29m")
+end
+
+-- 00:00 real request; 00:20 real request restarts both timers; warms at
+-- 00:24; stops at 00:50.
+do
+ local p = policy.new(4 * MIN, 30 * MIN)
+ p:turn_started(0)
+ assert(not p:due(4 * MIN), "a turn in flight blocks warming")
+ p:turn_ended(20 * MIN)
+ assert(not p:due(20 * MIN), "interval restarts after the turn")
+ assert(not p:due(23 * MIN), "not due at 23m")
+ assert(p:due(24 * MIN), "due at 24m")
+ p:attempted(24 * MIN)
+ assert(not p:due(27 * MIN + 59 * SEC), "next attempt is a full interval later")
+ assert(p:due(28 * MIN), "due at 28m")
+ assert(not p:expired(49 * MIN), "window runs to 50m")
+ assert(p:expired(50 * MIN), "window closes at 50m")
+end
+
+-- An interval as long as the window: the window always expires first.
+do
+ local p = policy.new(30 * MIN, 30 * MIN)
+ p:activity(0)
+ local fired = fire_times(p, 0, 60 * MIN, 15 * SEC)
+ assert(#fired == 0, "interval >= duration means no warm request ever")
+end
+
+-- Entry eval contract: loading the extension chunk is side-effect-free and
+-- returns the one activation entry.
+local entry = assert(loadfile(here .. "/init.lua"))()
+assert(type(entry) == "table" and entry.name == "warming" and type(entry.activate) == "function",
+ "init.lua returns the warming entry")
+
+-- ---------------------------------------------------------------------------
+-- Tool-schema fidelity probe (report-only)
+--
+-- Warming copies the session's tool declarations to the warm request
+-- through the platform JSON codec: `agent:tools()` decodes the session's
+-- schema bytes and `set_tools` re-encodes them. If encode(decode(x)) is not
+-- byte-identical to x, the warm request's tool prefix diverges from the
+-- session's own requests and cannot write a cache entry they will hit (see
+-- the README). This probe measures how often that drift happens with the
+-- codec in this interpreter. It reports, but does not fail: the drift is a
+-- property of the codec, not of this rock.
+-- ---------------------------------------------------------------------------
+
+local ok_panto, panto = pcall(require, "panto")
+local json = ok_panto and type(panto) == "table" and panto.ext and panto.ext.json
+if json and type(json.encode) == "function" then
+ local schemas = {
+ { type = "object", properties = {}, additionalProperties = false },
+ { type = "object", properties = { query = { type = "string" } }, required = { "query" } },
+ { type = "object", title = "run", properties = {
+ command = { type = "string" }, cwd = { type = "string" },
+ timeout = { type = "integer", minimum = 1 },
+ env = { type = "array", items = { type = "string" } },
+ }, required = { "command" }, additionalProperties = false },
+ { type = "object", properties = {
+ name = { type = "string" }, description = { type = "string" },
+ schema = { type = "object", additionalProperties = true },
+ labels = { type = "array", items = { type = "string" } },
+ nested = { type = "object", properties = {
+ a = { type = "number" }, b = { type = "boolean" }, c = { type = "null" },
+ } },
+ } },
+ }
+ local drift = 0
+ for _, schema in ipairs(schemas) do
+ local encoded = json.encode(schema)
+ if json.encode(json.decode(encoded)) ~= encoded then
+ drift = drift + 1
+ end
+ end
+ print(string.format(
+ "probe: schema re-encode drift %d/%d (%s)",
+ drift, #schemas, drift == 0 and "byte-stable" or "tool prefix fidelity degraded; see README"))
+else
+ print("probe: skipped (no panto.ext.json in this interpreter)")
+end
+
+print("panto-warming selfcheck: OK")
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,
+}
diff --git a/mise.toml b/mise.toml
new file mode 100644
index 0000000..d773f80
--- /dev/null
+++ b/mise.toml
@@ -0,0 +1,94 @@
+# Release plumbing. `mise run version <x.y.z>` rewrites the rockspec, then you
+# commit it and `mise run release` tags, pushes and uploads.
+#
+# Why not `luarocks new_version`: it regenerates the rockspec from the parsed
+# table, so it silently drops every comment (the dependency rationale below the
+# description here) and reflows the tables. Two sed lines keep the file intact.
+
+[tasks.deps]
+description = "Install this rock's dependencies into panto's own rocks tree"
+dir = "{{config_root}}"
+run = """
+set -eu
+# A `rocks` entry installs these automatically; a `paths` checkout does not, so
+# the extension needs them put in panto's tree by hand. Pure Lua, so any
+# LuaRocks targeting 5.4 can do it. `luv` ships pinned with panto already.
+TREE="$(echo ~/.local/share/panto/rocks/lua-*)"
+luarocks --lua-version 5.4 --tree "$TREE" install toml2lua
+"""
+
+[tasks.check]
+description = "Run the self-check inside panto's interpreter"
+dir = "{{config_root}}"
+run = "panto lua _selfcheck.lua"
+
+[tasks.version]
+description = "Rewrite the rockspec for a new version (then commit it)"
+dir = "{{config_root}}"
+run = """
+set -eu
+NEW="{{arg(name='version')}}"
+SPEC="$(ls -1 ./*.rockspec)"
+case "$SPEC" in *"
+"*) echo "expected exactly one rockspec, found:" >&2; echo "$SPEC" >&2; exit 1;; esac
+
+PKG="$(sed -n 's/^package = "\\(.*\\)"/\\1/p' "$SPEC")"
+NEWSPEC="./$PKG-$NEW-1.rockspec"
+
+# Write through a temp file: without it, a re-run at the same version would
+# have the redirect truncate the file sed is still reading.
+TMP="$(mktemp)"
+sed -e "s/^version = \\".*\\"/version = \\"$NEW-1\\"/" \\
+ -e "s/^\\( *\\)tag = \\"v[^\\"]*\\"/\\1tag = \\"v$NEW\\"/" "$SPEC" > "$TMP"
+mv "$TMP" "$NEWSPEC"
+[ "$SPEC" = "$NEWSPEC" ] || rm -f "$SPEC"
+
+luarocks lint "$NEWSPEC"
+echo "wrote $NEWSPEC -- commit it, then: mise run release"
+"""
+
+[tasks.release]
+description = "Tag the committed version, push the tag, upload to luarocks.org"
+depends = ["check"]
+dir = "{{config_root}}"
+run = """
+set -eu
+SPEC="$(ls -1 ./*.rockspec)"
+case "$SPEC" in *"
+"*) echo "expected exactly one rockspec, found:" >&2; echo "$SPEC" >&2; exit 1;; esac
+
+VER="$(sed -n 's/^version = "\\(.*\\)-[0-9]*"/\\1/p' "$SPEC")"
+[ -n "$VER" ] || { echo "could not read version from $SPEC" >&2; exit 1; }
+TAG="v$VER"
+
+# jj's working copy is itself a commit, so @ is normally the empty commit a
+# `jj new` just made and the release content sits in @-. Override with
+# REV=@ (or any revset) when that is not the case.
+REV="${REV:-@-}"
+
+# The tag is what `luarocks upload` clones to build the .src.rock, so the
+# rockspec being published has to already be in the revision being tagged.
+# Uncommitted, they diverge silently: the upload would ship a rockspec that
+# says $VER over sources from a commit that never had it.
+if ! jj file show -r "$REV" "$SPEC" 2>/dev/null | diff -q - "$SPEC" >/dev/null 2>&1; then
+ echo "$SPEC at $REV differs from the working copy -- commit it first" >&2
+ exit 1
+fi
+
+if jj tag list | grep -q "^$TAG:"; then
+ echo "tag $TAG already exists -- bump the version first" >&2
+ exit 1
+fi
+
+# jj 0.42 creates tags natively and exports them to git in a colocated repo,
+# but `jj git push` only pushes bookmarks -- tags still need git.
+jj tag set "$TAG" -r "$REV"
+jj tag list | grep "^$TAG:"
+for remote in $(git remote); do
+ git push "$remote" "$TAG"
+done
+
+# Reads the API key from ~/.luarocks/upload_config.lua. Packs a .src.rock by
+# cloning source.url at the tag just pushed, so the push has to come first.
+luarocks upload "$SPEC"
+"""
diff --git a/panto-warming-0.1.0-1.rockspec b/panto-warming-0.1.0-1.rockspec
new file mode 100644
index 0000000..ab41931
--- /dev/null
+++ b/panto-warming-0.1.0-1.rockspec
@@ -0,0 +1,55 @@
+-- LuaRocks rockspec for `panto-warming`: the session-warming extension for
+-- Pantograph -- pure Lua, no compiled artifact of its own.
+--
+-- Module naming. Pantograph requires the first whitespace-delimited token of
+-- an `[extensions] rocks = [...]` entry, so `require("panto-warming")` must
+-- resolve to this rock's entry point; that is what the first `modules` line
+-- maps. The remaining modules keep the same `warming.<name>` paths a local
+-- checkout gets from `paths = ["/path/to/panto-warming"]`, so both load
+-- paths resolve identical requires.
+--
+-- Dependencies. `toml2lua` (module name `toml`) reads the layered
+-- `config.toml` at activation. It is pure Lua and needs nothing from the
+-- system: the obvious alternative for full-spec TOML would bind a C library
+-- and put a system package between a user and a working extension. `luv`
+-- owns the warming timer and ships with panto as a pinned battery, so it is
+-- normally already present in the tree this rock installs into.
+
+rockspec_format = "3.0"
+package = "panto-warming"
+version = "0.1.0-1"
+
+source = {
+ url = "git+https://code.tjp.lol/public/panto-warming.git",
+ tag = "v0.1.0",
+}
+
+description = {
+ summary = "Session warming for Pantograph: keep the provider prompt cache warm while you pause.",
+ detailed = [[
+ Periodically sends a minimal transient prompt on top of the session's
+ current context so provider-side prompt caches stay warm between turns,
+ then discards the prompt and the response -- nothing enters the context
+ window or the session transcript. Configured through a [warming]
+ section in config.toml, mirroring opencode's warming feature.
+ ]],
+ homepage = "https://code.tjp.lol/public/panto-warming",
+ license = "MIT",
+ labels = { "ai", "llm", "agent", "panto", "pantograph", "extension" },
+}
+
+dependencies = {
+ "lua >= 5.4, < 5.5",
+ "toml2lua >= 3.0, < 4.0",
+ "luv >= 1.48",
+}
+
+build = {
+ type = "builtin",
+ modules = {
+ ["panto-warming"] = "init.lua",
+ ["warming.config"] = "warming/config.lua",
+ ["warming.policy"] = "warming/policy.lua",
+ ["warming.warm"] = "warming/warm.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
diff --git a/warming/policy.lua b/warming/policy.lua
new file mode 100644
index 0000000..e129fb0
--- /dev/null
+++ b/warming/policy.lua
@@ -0,0 +1,75 @@
+-- Timing policy for session warming: WHEN a transient request may fire.
+--
+-- The semantics mirror opencode's warming feature. `interval` is the idle
+-- time before a warm request fires after real activity (and again after each
+-- warm attempt); `duration` is the warming window measured from the latest
+-- non-warming activity. Warm attempts never extend the window:
+--
+-- 00:00 real activity window = [00:00, 00:30]
+-- 00:04 warm next due 00:08
+-- 00:08 warm ...
+-- 00:30 window expired, warming stops
+--
+-- A turn in flight (`turn_started` without `turn_ended`) blocks warm
+-- requests: the live conversation is changing under us. With
+-- `interval >= duration` the window always expires before the first warm
+-- request is due, so warming never fires -- a documented consequence of the
+-- opencode semantics, not a special case.
+--
+-- Every entry point takes `now` (monotonic milliseconds) so the policy is
+-- pure and selfcheck-able; scheduling and IO live elsewhere.
+
+local M = {}
+
+local Policy = {}
+Policy.__index = Policy
+
+-- interval_ms and duration_ms are validated positive by config parsing.
+function M.new(interval_ms, duration_ms)
+ return setmetatable({
+ interval = interval_ms,
+ duration = duration_ms,
+ last_real = nil, -- ms of the latest non-warming activity
+ next_warm = nil, -- ms before which no warm request may fire
+ deadline = nil, -- ms at which the warming window closes
+ busy = false, -- a real turn is in flight
+ }, Policy)
+end
+
+function Policy:activity(now)
+ self.last_real = now
+ self.next_warm = now + self.interval
+ self.deadline = now + self.duration
+end
+
+function Policy:turn_started(now)
+ self.busy = true
+ self:activity(now)
+end
+
+function Policy:turn_ended(now)
+ self.busy = false
+ self:activity(now)
+end
+
+-- May a warm request start right now?
+function Policy:due(now)
+ return (not self.busy)
+ and self.next_warm ~= nil
+ and now >= self.next_warm
+ and now < self.deadline
+end
+
+-- A warm attempt settled (success or failure): schedule the next one. A
+-- failure waits out the same interval and the original window keeps running.
+function Policy:attempted(now)
+ self.next_warm = now + self.interval
+end
+
+-- Nothing left to do until real activity reopens the window. Lets the
+-- caller stop its timer instead of ticking forever.
+function Policy:expired(now)
+ return self.deadline == nil or now >= self.deadline
+end
+
+return M
diff --git a/warming/warm.lua b/warming/warm.lua
new file mode 100644
index 0000000..e5e41fc
--- /dev/null
+++ b/warming/warm.lua
@@ -0,0 +1,160 @@
+-- One transient warming request: the session's current prompt, a minimal
+-- extra user message, one model response -- and none of it kept.
+--
+-- The request runs on a throwaway agent so the live session is never
+-- touched. The pieces:
+--
+-- transcript `panto.file_system_jsonl_store` over the session's own
+-- store dir, then `load` -- the same reconstruction `/resume`
+-- uses, so system blocks, tool inputs, and thinking
+-- signatures come back verbatim and the request shares the
+-- session's prompt prefix byte for byte.
+-- config `panto.ext.resolve_model` for the session's current
+-- model/reasoning label, which inherits the live credentialed
+-- transport when it resolves the session's own model.
+-- tools the session agent's declarations, copied with `set_tools`
+-- so the tools array matches the session's own requests (see
+-- the README's fidelity notes on schema re-encoding).
+-- store `panto.null_store()` -- the warm turn's user message and
+-- response are appended to the throwaway conversation and
+-- persisted nowhere.
+-- turn `agent:run_async { prompt, dispatch_tools = false }` on its
+-- own pump thread, so the UI thread never blocks. Tools are
+-- never dispatched: a misbehaving model cannot run real
+-- tools from a keep-alive prompt. The response is drained
+-- and dropped.
+--
+-- Nothing here raises: the binding reports failures by raising, and every
+-- call goes through `try` so callers get one `nil, message` shape.
+
+local M = {}
+
+local function try(fn, ...)
+ local ok, value = pcall(fn, ...)
+ if not ok then
+ return false, tostring(value)
+ end
+ return true, value
+end
+
+-- start(prompt) -> handle | nil, err
+--
+-- Everything that can fail before the turn starts fails here: a caller
+-- either gets a handle to poll or a message.
+function M.start(prompt)
+ local panto = require("panto")
+ local ext = panto.ext
+
+ local ok, info = try(ext.session_info)
+ if not ok then
+ return nil, "session_info: " .. tostring(info)
+ end
+ if type(info) ~= "table" or type(info.session_dir) ~= "string" or type(info.session_id) ~= "string" then
+ return nil, "no session information available"
+ end
+
+ local opened, store = try(panto.file_system_jsonl_store, { dir = info.session_dir })
+ if not opened then
+ return nil, "session store: " .. tostring(store)
+ end
+ if store == nil then
+ return nil, "the session store could not be opened"
+ end
+
+ local loaded, conv = try(store.load, store, info.session_id)
+ if not loaded then
+ return nil, "session store: " .. tostring(conv)
+ end
+ if conv == nil then
+ return nil, "the session transcript could not be loaded"
+ end
+
+ local asked, cfg, resolve_err = pcall(ext.resolve_model, {
+ model = info.model,
+ reasoning = info.reasoning,
+ })
+ if not asked then
+ return nil, tostring(cfg)
+ end
+ if cfg == nil then
+ return nil, resolve_err and tostring(resolve_err) or "the session model could not be resolved"
+ end
+
+ local made, no_store = try(panto.null_store)
+ if not made then
+ return nil, tostring(no_store)
+ end
+
+ local built, agent = try(panto.agent, {
+ config = cfg,
+ store = no_store,
+ conversation = conv,
+ })
+ if not built then
+ return nil, "warm agent: " .. tostring(agent)
+ end
+ if agent == nil then
+ return nil, "the warm agent could not be created"
+ end
+
+ local primary = ext.agent
+ if primary ~= nil then
+ local got, decls = try(primary.tools, primary)
+ if not got then
+ return nil, "tools: " .. tostring(decls)
+ end
+ if type(decls) == "table" and #decls > 0 then
+ local armed, tools_err = try(agent.set_tools, agent, decls)
+ if not armed then
+ return nil, "warm tools: " .. tostring(tools_err)
+ end
+ end
+ end
+
+ local started, job = try(agent.run_async, agent, {
+ prompt = prompt,
+ dispatch_tools = false,
+ })
+ if not started then
+ return nil, "warm turn: " .. tostring(job)
+ end
+ if job == nil then
+ return nil, "the warm turn could not be started"
+ end
+
+ return { agent = agent, job = job, store = store, conv = conv }
+end
+
+-- poll(handle) -> nil while the pump runs, then a settled
+-- { status = "completed"|"failed"|"cancelled", err = ... }. Drains buffered
+-- events as it goes so a chatty response cannot grow the queue. The result
+-- content is deliberately discarded; `close` joins the (settled) pump.
+function M.poll(handle)
+ local job = handle.job
+ while true do
+ local ok, event = pcall(job.next_event, job)
+ if not ok or event == nil then
+ break
+ end
+ end
+ local ok, result = pcall(job.result, job)
+ if not ok then
+ pcall(job.close, job)
+ return { status = "failed", err = tostring(result) }
+ end
+ if type(result) ~= "table" then
+ return nil
+ end
+ pcall(job.close, job)
+ return { status = result.status or "failed", err = result.err }
+end
+
+-- abandon(handle): cancel and join the pump, on session teardown. The
+-- cancel half shuts the response socket down, so the join returns promptly
+-- even mid-request.
+function M.abandon(handle)
+ pcall(handle.job.request_cancel, handle.job)
+ pcall(handle.job.close, handle.job)
+end
+
+return M