diff --git a/repl.lua b/repl.lua index e398022..9c82ad2 100644 --- a/repl.lua +++ b/repl.lua @@ -8,12 +8,13 @@ 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 (Phase 0): - :quit / :q exit aish +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 @@ -21,6 +22,9 @@ Meta commands (Phase 0): :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 ]] @@ -35,6 +39,30 @@ function M.run(config) 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 @@ -74,6 +102,7 @@ function M.run(config) 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(), @@ -91,6 +120,7 @@ function M.run(config) 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 @@ -105,10 +135,14 @@ function M.run(config) end end + local function shutdown_session() + if session then session:close(); session = nil end + end + -- Meta dispatch table. local meta = { - quit = function() os.exit(0) end, - q = function() os.exit(0) end, + 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") @@ -149,6 +183,56 @@ function M.run(config) 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, } @@ -156,7 +240,9 @@ function M.run(config) while true do local line = rl.readline(prompt()) if line == nil then -- EOF (Ctrl-D on empty line) - io.write("\n"); break + io.write("\n") + shutdown_session() + break end if line:gsub("%s", "") == "" then -- empty / whitespace-only: skip silently