repl: session persistence wiring — auto-log, :save, :resume, :sessions
Phase 1 session log integration per PHASE1.md §6.
On every M.run(), open a session file at
<config.history.dir>/sessions/<utc-iso8601>.jsonl
with a meta header (started, model, aish_version). If history.dir is
unset or unwritable, status-log the disable and continue without
persistence.
ask_ai logs the merged user turn (after pending exec output is folded
in) and the assistant turn (after streaming completes). run_shell does
NOT log [exec output] — that becomes part of the next user turn when
ctx.pending_exec_output is flushed.
New meta commands:
:sessions list session files; "*" marks the active one
:save <name> rename current session log to <name>.jsonl (auto-
appends .jsonl); reopens for continued append
:resume <name> load <name>.jsonl into ctx (replaces current turns
via ctx:reset + append loop). The current process's
own session log is unaffected — Phase 1 chooses
per-process logs over chained continuations.
:quit and EOF (Ctrl-D) both close the session file via shutdown_session
before exiting.
HELP text updated (no longer "Phase 0:" header since meta set has
grown). Q15 noted in PHASE1.md §10 (resume into non-empty context) is
resolved by the ctx:reset() in :resume — silent overwrite for Phase 1,
revisit if anyone cares.
End-to-end live verified: chat -> auto-log; :save renames; :sessions
listings; :resume + :history shows the round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,12 +8,13 @@ local executor = require("executor")
|
|||||||
local broker = require("broker")
|
local broker = require("broker")
|
||||||
local renderer = require("renderer")
|
local renderer = require("renderer")
|
||||||
local Context = require("context")
|
local Context = require("context")
|
||||||
|
local history = require("history")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
local HELP = [[
|
local HELP = [[
|
||||||
Meta commands (Phase 0):
|
Meta commands:
|
||||||
:quit / :q exit aish
|
:quit / :q exit aish (current session is flushed and closed)
|
||||||
:clear clear screen (history kept)
|
:clear clear screen (history kept)
|
||||||
:reset clear in-memory conversation history
|
:reset clear in-memory conversation history
|
||||||
:model <name> switch active model
|
:model <name> switch active model
|
||||||
@@ -21,6 +22,9 @@ Meta commands (Phase 0):
|
|||||||
:history show conversation turns
|
:history show conversation turns
|
||||||
:exec <cmd> force shell execution
|
:exec <cmd> force shell execution
|
||||||
:ask <text> force AI query
|
:ask <text> force AI query
|
||||||
|
:sessions list session log files
|
||||||
|
:save <name> rename current session log to <name>.jsonl
|
||||||
|
:resume <name> load <name>.jsonl turns into the in-memory context
|
||||||
:help this message
|
:help this message
|
||||||
]]
|
]]
|
||||||
|
|
||||||
@@ -35,6 +39,30 @@ function M.run(config)
|
|||||||
|
|
||||||
local ctx = Context.new(config.context or {})
|
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()
|
local function prompt()
|
||||||
return ("[aish:%s]> "):format(active_name)
|
return ("[aish:%s]> "):format(active_name)
|
||||||
end
|
end
|
||||||
@@ -74,6 +102,7 @@ function M.run(config)
|
|||||||
local function ask_ai(text)
|
local function ask_ai(text)
|
||||||
local prev_pending = ctx.pending_exec_output
|
local prev_pending = ctx.pending_exec_output
|
||||||
ctx:append_user(text) -- flushes any pending [exec output] as prefix
|
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 parts = {}
|
||||||
local ok, err = broker.chat_stream(active_cfg, ctx:to_messages(),
|
local ok, err = broker.chat_stream(active_cfg, ctx:to_messages(),
|
||||||
@@ -91,6 +120,7 @@ function M.run(config)
|
|||||||
end
|
end
|
||||||
local resp = table.concat(parts)
|
local resp = table.concat(parts)
|
||||||
ctx:append({ role = "assistant", content = resp })
|
ctx:append({ role = "assistant", content = resp })
|
||||||
|
log_turn(ctx.turns[#ctx.turns])
|
||||||
status_evictions(ctx:enforce_budget())
|
status_evictions(ctx:enforce_budget())
|
||||||
|
|
||||||
for _, cmd in ipairs(executor.extract_cmd_lines(resp)) do
|
for _, cmd in ipairs(executor.extract_cmd_lines(resp)) do
|
||||||
@@ -105,10 +135,14 @@ function M.run(config)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function shutdown_session()
|
||||||
|
if session then session:close(); session = nil end
|
||||||
|
end
|
||||||
|
|
||||||
-- Meta dispatch table.
|
-- Meta dispatch table.
|
||||||
local meta = {
|
local meta = {
|
||||||
quit = function() os.exit(0) end,
|
quit = function() shutdown_session(); os.exit(0) end,
|
||||||
q = function() os.exit(0) end,
|
q = function() shutdown_session(); os.exit(0) end,
|
||||||
clear = function() io.write("\27[H\27[2J"); io.flush() end,
|
clear = function() io.write("\27[H\27[2J"); io.flush() end,
|
||||||
reset = function()
|
reset = function()
|
||||||
ctx:reset(); renderer.status("context reset")
|
ctx:reset(); renderer.status("context reset")
|
||||||
@@ -149,6 +183,56 @@ function M.run(config)
|
|||||||
if args == "" then renderer.status("usage: :ask <text>"); return end
|
if args == "" then renderer.status("usage: :ask <text>"); return end
|
||||||
ask_ai(args)
|
ask_ai(args)
|
||||||
end,
|
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 <name>"); 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 <name>"); 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,
|
help = function() io.write(HELP) end,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +240,9 @@ function M.run(config)
|
|||||||
while true do
|
while true do
|
||||||
local line = rl.readline(prompt())
|
local line = rl.readline(prompt())
|
||||||
if line == nil then -- EOF (Ctrl-D on empty line)
|
if line == nil then -- EOF (Ctrl-D on empty line)
|
||||||
io.write("\n"); break
|
io.write("\n")
|
||||||
|
shutdown_session()
|
||||||
|
break
|
||||||
end
|
end
|
||||||
if line:gsub("%s", "") == "" then
|
if line:gsub("%s", "") == "" then
|
||||||
-- empty / whitespace-only: skip silently
|
-- empty / whitespace-only: skip silently
|
||||||
|
|||||||
Reference in New Issue
Block a user