-- repl.lua — readline loop, input dispatch, prompt rendering. -- Wires ffi/readline + router + executor + broker + context + renderer. -- See docs/PHASE0.md §5 (dispatch), §9 (prompt + readline). local rl = require("ffi.readline") local router = require("router") local executor = require("executor") local broker = require("broker") local renderer = require("renderer") local Context = require("context") local history = require("history") local M = {} local HELP = [[ Meta commands: :quit / :q exit aish (current session is flushed and closed) :clear clear screen (history kept) :reset clear in-memory conversation history :model switch active model :models list configured models (* = active) :history show conversation turns :exec force shell execution :ask force AI query :sessions list session log files :save rename current session log to .jsonl :resume load .jsonl turns into the in-memory context :help this message ]] function M.run(config) assert(config and config.models, "repl.run: config.models required") local active_name = config.default_model or next(config.models) local active_cfg = config.models[active_name] if not active_cfg then error("aish: default_model '" .. tostring(active_name) .. "' not found in config.models") end local ctx = Context.new(config.context or {}) -- Session log (PHASE1.md §6). Always open one on startup; auto-write -- every user/assistant turn; close on :quit. If history.dir is set but -- unwritable, log a status and continue without persistence. local history_dir = (config.history and config.history.dir) or nil local sessions_dir = history_dir and (history_dir .. "/sessions") or nil local session_path = sessions_dir and (sessions_dir .. "/" .. os.date("!%Y-%m-%dT%H-%M-%SZ") .. ".jsonl") local session if session_path then local sess, serr = history.open(session_path, { started = os.date("!%Y-%m-%dT%H:%M:%SZ"), model = active_name, aish_version = "phase1", }) if sess then session = sess else renderer.status("session log disabled: " .. tostring(serr)) end end local function log_turn(turn) if session then session:append(turn) end end local function prompt() return ("[aish:%s]> "):format(active_name) end -- Phase 1 reserved-key wiring (PHASE1.md §7). The mechanism is real; the -- handlers are placeholders that emit a status. Phase 3 (Norris) is the -- first consumer that replaces the body with real work. rl.bind("\\C-n", function() renderer.status("Norris mode not yet implemented (Phase 3)") end) local function status_evictions(n) if n and n > 0 then renderer.status(("oldest %d turns evicted"):format(n)) end end -- Run a shell command, framing output and (per config.shell.capture_output) -- buffering it for the NEXT user turn — context.append_exec_output keeps -- a [exec output] block pending until ask_ai flushes it via append_user. -- Direct user-role injection violated chat-template alternation (mistral- -- nemo's Jinja rejects user/user back-to-back); see PHASE0.md §6. local function run_shell(cmd) local chd, err = executor.maybe_chdir(cmd) if chd ~= nil then if chd then local pwd = io.popen("pwd"):read("*l") or "?" renderer.status("cwd -> " .. pwd) else renderer.status("cd: " .. tostring(err)) end return end renderer.exec_begin() local out, code = executor.exec(cmd) renderer.exec_end(code) if config.shell and config.shell.capture_output then ctx:append_exec_output(out) end end -- Send user text to the active model, render the response token-by-token -- via broker.chat_stream, and (per §6 + config.shell.confirm_cmd) optionally -- execute extracted CMD: lines on the reassembled full text. local function ask_ai(text) local prev_pending = ctx.pending_exec_output ctx:append_user(text) -- flushes any pending [exec output] as prefix log_turn(ctx.turns[#ctx.turns]) -- merged user turn (may include exec) local parts = {} local ok, err = broker.chat_stream(active_cfg, ctx:to_messages(), function(delta) parts[#parts + 1] = delta renderer.assistant_delta(delta) end) renderer.assistant_flush() if not ok then renderer.status("broker error: " .. tostring(err)) table.remove(ctx.turns) -- back out the merged user turn ctx.pending_exec_output = prev_pending -- restore buffered exec output return end local resp = table.concat(parts) ctx:append({ role = "assistant", content = resp }) log_turn(ctx.turns[#ctx.turns]) status_evictions(ctx:enforce_budget()) for _, cmd in ipairs(executor.extract_cmd_lines(resp)) do local doit if config.shell and config.shell.confirm_cmd then local ans = rl.readline(("execute '%s'? [y/N] "):format(cmd)) or "" doit = (ans:lower():sub(1, 1) == "y") else doit = true end if doit then run_shell(cmd) end end end local function shutdown_session() if session then session:close(); session = nil end end -- Meta dispatch table. local meta = { quit = function() shutdown_session(); os.exit(0) end, q = function() shutdown_session(); os.exit(0) end, clear = function() io.write("\27[H\27[2J"); io.flush() end, reset = function() ctx:reset(); renderer.status("context reset") end, model = function(args) local name = args:match("^%s*(%S+)") if not name or not config.models[name] then renderer.status("usage: :model ; not found: " .. tostring(name)) return end active_name, active_cfg = name, config.models[name] renderer.status("model -> " .. name) end, models = function() renderer.status(("models (active: %s):"):format(active_name)) for name, cfg in pairs(config.models) do local mark = (name == active_name) and "*" or " " io.write((" %s %-8s %s @ %s\n"):format( mark, name, cfg.model or "?", cfg.endpoint or "?")) end end, history = function() if #ctx.turns == 0 then renderer.status("(empty)"); return end for i, t in ipairs(ctx.turns) do io.write(("[%d] %s: %s\n"):format( i, t.role, t.content:gsub("\n", " "))) end end, exec = function(args) args = (args or ""):match("^%s*(.-)%s*$") if args == "" then renderer.status("usage: :exec "); return end run_shell(args) end, ask = function(args) args = (args or ""):match("^%s*(.-)%s*$") if args == "" then renderer.status("usage: :ask "); return end ask_ai(args) end, sessions = function() if not sessions_dir then renderer.status("(no history.dir configured)"); return end local names = history.list_sessions(sessions_dir) if #names == 0 then renderer.status("(no sessions in " .. sessions_dir .. ")"); return end for _, n in ipairs(names) do local mark = (session_path and session_path:match("[^/]+$") == n) and "*" or " " io.write((" %s %s\n"):format(mark, n)) end end, save = function(args) local name = args:match("^%s*(%S+)") if not name then renderer.status("usage: :save "); return end if not (session and session_path and sessions_dir) then renderer.status("no active session to save") return end name = name:gsub("%.jsonl$", "") local new_path = sessions_dir .. "/" .. name .. ".jsonl" if new_path == session_path then renderer.status("already named " .. name) return end session:close() local ok, rerr = os.rename(session_path, new_path) if not ok then renderer.status("rename failed: " .. tostring(rerr)) -- best-effort reopen of original path so logging continues session = history.open(session_path) return end session_path = new_path session = history.open(session_path) -- reopen for continued append renderer.status("saved as " .. name .. ".jsonl") end, resume = function(args) local name = args:match("^%s*(%S+)") if not name then renderer.status("usage: :resume "); return end if not sessions_dir then renderer.status("(no history.dir configured)"); return end name = name:gsub("%.jsonl$", "") local path = sessions_dir .. "/" .. name .. ".jsonl" local meta_hdr, turns = history.load(path) if not (meta_hdr or turns) then renderer.status("resume failed: cannot load " .. path) return end ctx:reset() for _, t in ipairs(turns or {}) do ctx:append(t) end renderer.status(("resumed %d turns from %s"):format(#(turns or {}), name)) end, help = function() io.write(HELP) end, } -- Main loop. while true do local line = rl.readline(prompt()) if line == nil then -- EOF (Ctrl-D on empty line) io.write("\n") shutdown_session() break end if line:gsub("%s", "") == "" then -- empty / whitespace-only: skip silently else rl.add_history(line) local kind, payload = router.classify(line, config) if kind == "meta" then local name, rest = payload:match("^(%S+)%s*(.*)$") local handler = name and meta[name] if handler then handler(rest or "") else renderer.status("unknown meta command: :" .. tostring(name)) end elseif kind == "shell" then run_shell(payload) else -- "ai" ask_ai(payload) end end end end -- Phase 0 module export. Meta-command list shown above lives in HELP and -- is implemented inline in run(). return M