repl: :every recurring prompts via pre-prompt due-check (closes #11)

In-session timer that re-injects a prompt every N seconds. "Watch this
thing" workflows (`:every 5m "check journalctl -u nginx for errors"`)
without spawning a separate aish process.

Approach: minimum viable. check_every_due() runs at the top of each
main-loop iteration — timers fire BETWEEN user inputs, not during
readline waits or active broker calls. Mid-stream firing would require
rewriting ffi/readline to callback mode (substantial scope). If the
on-the-fly firing requirement matters in practice it can land as a
follow-up issue against the readline FFI.

Meta:
  :every <interval> <prompt>   schedule (interval: 30s | 5m | 2h | bare int)
  :every list                  show jobs (id, interval, time-until-next, model, prompt)
  :every cancel <id>           remove

Defaults:
  - Model: "fast" preset if defined in config.models, else active model
    (per the issue's "recurring prompts should default to fast preset").
  - In-memory only — jobs don't persist across restarts.
  - Suppressed while ctx.norris_active (planner stays on goal anchor).
  - Quotes around the prompt are stripped if present.
  - Each tick fires the job once, re-schedules next_fire = now + interval
    (no catch-up if the interval elapsed multiple times during a long
    user input).

Tested: 11 interval-parse cases (30s, 5m, 2h, bare int, malformed),
load via require, end-to-end :every list / cancel surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 21:23:07 +00:00
parent 17e62c0326
commit 67d80e1047
+93
View File
@@ -139,6 +139,9 @@ Meta commands:
:route check <text> report which class <text> would route to (debug)
:fallback on/off toggle cloud retry when local transport fails
:skills list user-defined skills loaded from ~/.config/aish/skills/
:every <i> <prompt> schedule a recurring prompt (i: 30s | 5m | 2h)
:every list show scheduled recurring prompts
:every cancel <id> remove a scheduled prompt
:help this message
]]
@@ -1448,8 +1451,98 @@ function M.run(config)
end
load_skills()
-- Issue #11: in-session recurring prompts (:every). Pre-prompt due-check
-- model: timers fire between user inputs, not during readline waits or
-- broker calls. This is the minimum viable approach without rewriting
-- ffi/readline to callback-mode. Suppressed during Norris.
local every_jobs = {} -- { {id, interval_s, next_fire, prompt, model_name}, ... }
local next_every_id = 1
local function _parse_interval(s)
s = (s or ""):gsub("%s+", "")
local num, unit = s:match("^(%d+)([smh]?)$")
if not num then return nil end
local mult = ({ s = 1, m = 60, h = 3600, [""] = 1 })[unit]
return tonumber(num) * mult
end
local function _every_fire(job)
renderer.status(("[every #%d tick: %s]")
:format(job.id, job.prompt))
-- Temporarily swap to the job's chosen model so the recurring prompt
-- hits the preset selected at :every time (defaulted to "fast").
local saved_name, saved_cfg = active_name, active_cfg
if config.models[job.model_name] then
active_name, active_cfg = job.model_name, config.models[job.model_name]
end
local ok, err = pcall(ask_ai, job.prompt)
active_name, active_cfg = saved_name, saved_cfg
if not ok then
renderer.status(("[every #%d failed: %s]"):format(job.id, tostring(err)))
end
end
local function check_every_due()
if ctx.norris_active then return end
local now = os.time()
-- Snapshot the due jobs so a long-running tick doesn't compound.
local due = {}
for _, j in ipairs(every_jobs) do
if now >= j.next_fire then due[#due + 1] = j end
end
for _, j in ipairs(due) do
j.next_fire = os.time() + j.interval_s
_every_fire(j)
end
end
meta.every = function(args)
local sub = args:match("^%s*(%S*)") or ""
if sub == "list" or sub == "" and args:match("^%s*$") then
if #every_jobs == 0 then
renderer.status("(no recurring prompts)"); return
end
local now = os.time()
renderer.status(("recurring prompts (%d):"):format(#every_jobs))
for _, j in ipairs(every_jobs) do
io.write((" #%d every %ds (next in %ds, model=%s) %s\n")
:format(j.id, j.interval_s, j.next_fire - now, j.model_name, j.prompt))
end
return
end
if sub == "cancel" then
local id = tonumber(args:match("cancel%s+(%d+)"))
if not id then renderer.status("usage: :every cancel <id>"); return end
for i, j in ipairs(every_jobs) do
if j.id == id then
table.remove(every_jobs, i)
renderer.status(("cancelled #%d"):format(id)); return
end
end
renderer.status(("no such job: #%d"):format(id)); return
end
-- :every <interval> <prompt...> (prompt may be quoted; quotes stripped)
local interval_s, rest = args:match("^%s*(%S+)%s+(.+)$")
local secs = _parse_interval(interval_s)
if not secs or secs < 1 then
renderer.status("usage: :every <interval> <prompt> (interval: 30s | 5m | 2h | bare int)")
return
end
local p = rest:gsub("^%s+", ""):gsub("%s+$", "")
p = p:match("^\"(.*)\"$") or p:match("^'(.*)'$") or p
if p == "" then renderer.status("usage: :every <interval> <prompt>"); return end
local job_model = (config.models and config.models.fast) and "fast" or active_name
local id = next_every_id; next_every_id = next_every_id + 1
every_jobs[#every_jobs + 1] = {
id = id,
interval_s = secs,
next_fire = os.time() + secs,
prompt = p,
model_name = job_model,
}
renderer.status(("scheduled #%d every %ds (model=%s): %s")
:format(id, secs, job_model, p))
end
-- Main loop.
while true do
check_every_due()
local line = rl.readline(prompt())
if line == nil then -- EOF (Ctrl-D on empty line)
io.write("\n")