From 87316f83453d1f46c915644e5c53a7e2297cf86a Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sun, 10 May 2026 19:19:21 +0000 Subject: [PATCH] =?UTF-8?q?history:=20JSONL=20session=20log=20=E2=80=94=20?= =?UTF-8?q?open,=20append,=20load,=20list=5Fsessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- history.lua | 121 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 111 insertions(+), 10 deletions(-) diff --git a/history.lua b/history.lua index 7578304..ee777f9 100644 --- a/history.lua +++ b/history.lua @@ -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 /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