history: JSONL session log — open, append, load, list_sessions
Phase 1 persistence per PHASE1.md §6.
history.open(path, meta?) -> session | (nil, err)
parent dir auto-created; meta line written iff file is new/empty so
reopening a session doesn't duplicate the header
session:append(turn)
JSON-encoded line, fh:flush after every write (no fsync — Q16
tracks the policy if it ever bites)
session:close()
history.load(path) -> meta, turns | (nil, err)
skips unparseable lines (e.g. partial trailing write from a crash);
distinguishes the meta-header line from role/content turn lines
history.list_sessions(dir) -> [basename, ...]
sorted (ISO 8601 names lex-sort chronologically); no mtime / turn
counts in Phase 1 — that's a Phase 4 :sessions UI concern
Smoke:
- open, append 3 turns, close, list_sessions sees 1 file
- load returns meta (model="fast") and 3 turns in order
- corrupt tail (partial JSON line appended) is silently skipped on load
- reopen with different meta does NOT duplicate the header line
Repl wiring (`:save`, `:resume`, `:sessions`, auto-write on quit) lands
in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+111
-10
@@ -1,20 +1,121 @@
|
||||
-- history.lua — persistent session log + memory.jsonl.
|
||||
-- Phase 0: NO disk I/O. This module is a stub placeholder so module names are
|
||||
-- stable when Phase 1 lands the persistence layer.
|
||||
-- See docs/PHASE0.md §11 (Phase 1).
|
||||
-- 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 = {}
|
||||
|
||||
function M.open_session(dir)
|
||||
error("history.open_session: not implemented (Phase 1)")
|
||||
local Session = {}
|
||||
Session.__index = Session
|
||||
|
||||
-- Best-effort mkdir -p. Failures are surfaced by io.open below.
|
||||
local function ensure_dir(path)
|
||||
if not path or path == "" then return end
|
||||
os.execute(string.format("mkdir -p %q", path))
|
||||
end
|
||||
|
||||
function M.append_turn(session, turn)
|
||||
error("history.append_turn: not implemented (Phase 1)")
|
||||
local function parent_dir(path)
|
||||
return path:match("^(.*)/[^/]+$")
|
||||
end
|
||||
|
||||
function M.summarize_and_close(session, broker)
|
||||
error("history.summarize_and_close: not implemented (Phase 3)")
|
||||
-- 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:
|
||||
-- meta : the {meta={...}} table from the first line, or nil if absent
|
||||
-- turns : array of {role, content, ...} for each parseable subsequent line
|
||||
-- nil, err : on file open failure
|
||||
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 meta, turns
|
||||
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.
|
||||
local p = io.popen(string.format("ls -1 %q 2>/dev/null", dir))
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user