Files
aish/history.lua
T
marfrit 7d62eb5659 review followups: pcall shield, :resume guard, shell quoting, nits
CONCERNs from the Phase 1 review pass:

ffi/curl.lua:
  - SSE write_cb body is now pcall-wrapped. A Lua error in on_event (or
    in the parse loop itself) is captured into cb_error and surfaced
    after curl_easy_perform rather than propagating across the FFI
    callback boundary (which LuaJIT documents as process-fatal). The
    EOS flush path gets the same shield. Errors return
    (nil, "callback: <msg>") from post_sse.

history.lua:
  - sh_singlequote() escapes shell metacharacters; the mkdir -p and
    ls -1 shell-outs no longer double-quote (where $(...) and $VAR
    still expand) — single-quote with embedded-' escaping is the
    safe form.
  - M.load now returns (turns, meta) instead of (meta, turns). turns
    is ALWAYS a table on success, never nil-when-no-header; failure
    path is the unambiguous (nil, err). Callers can `if not turns
    then` without the previous ambiguity. repl.lua :resume updated
    to the new shape.

repl.lua :resume:
  - Refuse to resume into a non-empty ctx — silent overwrite was the
    Q15 default, but the review surfaced the no-undo / no-warning
    failure mode. User must :reset (or :save then re-launch) to
    express intent. The current session's on-disk log is unaffected
    either way.

NITs:
  - ffi/libc.lua READ_BUF: comment noting it's module-shared and
    Phase 1 has no reentrant readers; revisit when that changes.
  - PHASE1.md §7: \C-x\C-c reservation pinned to Phase 3 ("deferred
    from Phase 1 — no consumer here") rather than the previous
    dangling "(or here)".

Regression suite verifies:
  - history.load new signature on success + failure paths
  - shell-quoted history.dir with $ doesn't trip
  - aish scripted run: ctx with 2 turns refuses :resume anchor with
    a clear status; user must :reset first

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:05:23 +00:00

131 lines
4.4 KiB
Lua

-- history.lua — persistent session log (JSONL).
-- Phase 1: append-only JSONL per session under <config.history.dir>/sessions/.
-- Phase 3 will add memory.jsonl summarization (separate from session logs).
-- See docs/PHASE0.md §11 and docs/PHASE1.md §6.
local json = require("dkjson")
local M = {}
local Session = {}
Session.__index = Session
-- Best-effort mkdir -p. Failures are surfaced by io.open below. Uses
-- single-quote escaping (Lua's %q double-quotes, which still expands $(...)
-- and $VAR inside) so a path containing shell metacharacters doesn't trip.
local function sh_singlequote(s)
return "'" .. s:gsub("'", "'\\''") .. "'"
end
local function ensure_dir(path)
if not path or path == "" then return end
os.execute("mkdir -p " .. sh_singlequote(path))
end
local function parent_dir(path)
return path:match("^(.*)/[^/]+$")
end
-- Open `path` for append. Creates parent dirs if missing. Returns the session
-- handle, or (nil, errmsg) on open failure.
-- path : absolute path to the .jsonl file
-- meta : optional table written as the first line ONLY if the file is new /
-- empty. Use this for the {started, model, version, ...} header per
-- PHASE1.md §6.
function M.open(path, meta)
ensure_dir(parent_dir(path))
-- Detect new-or-empty before opening for append (append + read does not
-- give a portable way to inspect size on every libc). Simple two-step.
local existing = io.open(path, "r")
local is_empty = true
if existing then
local first = existing:read("*l")
if first and #first > 0 then is_empty = false end
existing:close()
end
local fh, err = io.open(path, "a")
if not fh then return nil, err end
local sess = setmetatable({ path = path, fh = fh, closed = false }, Session)
if is_empty and meta then
sess:append({ meta = meta })
end
return sess
end
function Session:append(turn)
if self.closed then return false, "session closed" end
local line = json.encode(turn)
-- write + flush so a crash mid-session preserves all turns up to the
-- last full append. Phase 1 default: no fsync per line (would dominate
-- runtime on slow disks). Q16 tracks fsync policy if it ever bites.
self.fh:write(line, "\n")
self.fh:flush()
return true
end
function Session:close()
if self.closed then return end
self.fh:close()
self.fh = nil
self.closed = true
end
-- Load a session file. Returns:
-- turns, meta : turns is ALWAYS a table on success (possibly empty);
-- meta is the {meta={...}} header value or nil if absent
-- nil, err : on file open failure (turns-first means callers can
-- test `if not turns then` without ambiguity vs a missing
-- meta-header line)
function M.load(path)
local fh, err = io.open(path, "r")
if not fh then return nil, err end
local meta, turns = nil, {}
local first = true
for line in fh:lines() do
if #line > 0 then
local obj = json.decode(line)
if obj then
if first and obj.meta then
meta = obj.meta
elseif obj.role and obj.content then
turns[#turns + 1] = obj
end
end
-- malformed lines (e.g. trailing partial write before crash) are
-- silently skipped per the §6 recovery semantic
first = false
end
end
fh:close()
return turns, meta
end
-- List session files in `dir` (just file basenames matching *.jsonl). Phase 1
-- minimum: name only. mtime / turn count are a Phase 4 concern when :sessions
-- starts wanting to surface a richer picker. Returns:
-- array of strings (basenames, no path prefix)
-- may be empty if dir doesn't exist
function M.list_sessions(dir)
local out = {}
if not dir or dir == "" then return out end
-- io.popen here is plain ls; executor.exec was swapped to PTY but
-- io.popen itself still works. Single-quote escaping for path safety
-- (see sh_singlequote rationale above).
local p = io.popen("ls -1 " .. sh_singlequote(dir) .. " 2>/dev/null")
if not p then return out end
for name in p:lines() do
if name:match("%.jsonl$") then out[#out + 1] = name end
end
p:close()
table.sort(out) -- ISO 8601 sorts lexicographically = chronologically
return out
end
return M