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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user