-- history.lua — persistent session log (JSONL). -- Phase 1: append-only JSONL per session under /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