diff --git a/repl.lua b/repl.lua index e81e369..70c18a8 100644 --- a/repl.lua +++ b/repl.lua @@ -1,25 +1,164 @@ -- repl.lua — readline loop, input dispatch, prompt rendering. --- See docs/PHASE0.md §5, §9. +-- 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 M = {} --- Phase 0 stub. +local HELP = [[ +Meta commands (Phase 0): + :quit / :q exit aish + :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 + :help this message +]] + function M.run(config) - error("repl.run: not implemented (Phase 0 pending)") + 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 {}) + + local function prompt() + return ("[aish:%s]> "):format(active_name) + 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) + -- injecting it back into context as a `[exec output]`-tagged user turn. + 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 + local out, code = executor.exec(cmd) + renderer.exec_output(out, code) + if config.shell and config.shell.capture_output then + ctx:append({ role = "user", content = "[exec output]\n" .. out }) + status_evictions(ctx:enforce_budget()) + end + end + + -- Send user text to the active model, render the response, and (per + -- §6 + config.shell.confirm_cmd) optionally execute extracted CMD: lines. + local function ask_ai(text) + ctx:append({ role = "user", content = text }) + local resp, err = broker.chat(active_cfg, ctx:to_messages()) + if not resp then + renderer.status("broker error: " .. tostring(err)) + table.remove(ctx.turns) -- back out the user turn we just added + return + end + ctx:append({ role = "assistant", content = resp }) + status_evictions(ctx:enforce_budget()) + renderer.assistant(resp) + + 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 + + -- Meta dispatch table. + local meta = { + quit = function() os.exit(0) end, + q = function() 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) run_shell(args) end, + ask = function(args) ask_ai(args) 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"); 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 --- Meta command table per PHASE0.md §5.2. -M.meta_commands = { - quit = function(ctx) os.exit(0) end, - q = function(ctx) os.exit(0) end, - clear = nil, -- TODO Phase 0 impl - reset = nil, - model = nil, - models = nil, - history = nil, - exec = nil, - ask = nil, - help = nil, -} - +-- Phase 0 module export. Meta-command list shown above lives in HELP and +-- is implemented inline in run(). return M