diff options
| -rw-r--r-- | .gitignore | 8 | ||||
| -rw-r--r-- | LICENSE | 18 | ||||
| -rw-r--r-- | README.md | 160 | ||||
| -rw-r--r-- | _selfcheck.lua | 223 | ||||
| -rw-r--r-- | init.lua | 189 | ||||
| -rw-r--r-- | mise.toml | 94 | ||||
| -rw-r--r-- | panto-warming-0.1.0-1.rockspec | 55 | ||||
| -rw-r--r-- | warming/config.lua | 156 | ||||
| -rw-r--r-- | warming/policy.lua | 75 | ||||
| -rw-r--r-- | warming/warm.lua | 160 |
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/ @@ -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 |
