diff --git a/repl.lua b/repl.lua index fb86c63..887e530 100644 --- a/repl.lua +++ b/repl.lua @@ -139,6 +139,9 @@ Meta commands: :route check report which class 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 schedule a recurring prompt (i: 30s | 5m | 2h) + :every list show scheduled recurring prompts + :every cancel 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 "); 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 (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: 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 "); 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")