Files
aish/history.lua
T
marfrit 199dd87eaa history: memory.jsonl store + flock (Phase 4 commit #1)
Phase 4 commit #1 per docs/PHASE4.md §12. Two file changes bundled
because R-B1 (flock for race-free single-writer enforcement) cannot
be deferred — adding it retroactively means reopening the memory
handle.

ffi/libc.lua extensions:
  - cdef flock(int fd, int op), open(...), lseek(int, long, int)
  - constants LOCK_EX=2, LOCK_NB=4, LOCK_UN=8
  - M.flock(fd, op) wrapper returning (true) on success or
    (false, errmsg) — errmsg is the strerror text so callers can
    surface "Resource temporarily unavailable" cleanly to the user.

history.lua additions (Phase 4 section appended at end):
  - M.open_memory(path) -> handle | nil, err
    Opens the file via libc.open(2) (need integer fd for flock —
    io.open's FILE* doesn't expose it), takes flock(LOCK_EX | LOCK_NB).
    Returns "memory.jsonl held by another aish process" on lock-held.
    Scans existing content for max id; caches as handle.next_id.
    Writes meta header on first creation (no id, ignored at load).
  - handle:add(kind, content, tags?, source?) -> id
    Assigns next id; appends one JSONL item with auto-timestamp.
    kind ∈ {fact, pref, context} enforced via assert.
  - handle:forget(target_id)
    Appends a tombstone {id, ts, kind:"forget", target}.
  - handle:close()
    Releases fd (flock auto-released on close).
  - M.load_memory(path) -> items_table
    Reads all lines, builds forget-target set from kind=="forget"
    entries, returns active items as an array sorted by ts desc.
    Items without id (meta header) silently dropped. Tombstones with
    non-matching targets are no-ops (N3 invariant).

Round-trip test passes:
  - open empty file → next_id=1
  - add 3 items → ids 1, 2, 3
  - forget id 2 (appends tombstone)
  - reopen → next_id correctly advances past the tombstone (=5)
  - load_memory → 2 active items (id 1 + id 3); tombstone resolved
  - lock-held detection: second open while first held → fails with
    "memory.jsonl held by another aish process" message
  - close releases the lock; reopen after release succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:52:03 +00:00

313 lines
11 KiB
Lua

-- history.lua — persistent session log + cross-session memory store.
-- Phase 1: append-only JSONL per session under <config.history.dir>/sessions/.
-- Phase 4: cross-session memory.jsonl at <config.history.dir>/memory.jsonl,
-- single-writer enforced via flock(LOCK_EX | LOCK_NB) per PHASE4 R-B1.
-- See docs/PHASE0.md §11, docs/PHASE1.md §6, docs/PHASE4.md §4.
local json = require("dkjson")
local libc = require("ffi.libc")
local ffi = require("ffi")
local M = {}
local Session = {}
Session.__index = Session
local Memory = {}
Memory.__index = Memory
-- 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
-- ============================================================================
-- Phase 4: memory.jsonl — cross-session memory store.
-- Same JSONL convention as session logs, but a single shared file rather
-- than per-session. Single-writer enforced via flock advisory lock.
-- See docs/PHASE4.md §2 / §4.
-- ============================================================================
-- We need an integer fd for flock. io.open returns a Lua FILE*; LuaJIT
-- has no portable way to extract the underlying fd from that. Workaround:
-- open via libc directly using open(2). Already exposed close() in libc;
-- need to declare open() and read/write via the existing fd interface.
ffi.cdef[[
int open(const char *pathname, int flags, int mode);
long lseek(int fd, long offset, int whence);
]]
local O_RDWR = 2
local O_CREAT = 64 -- 0100 octal on Linux/glibc
local O_APPEND = 1024 -- 02000 octal on Linux/glibc
local SEEK_SET = 0
local FILE_MODE = 0x180 -- 0600 octal — owner rw only
-- ---------------------------------------------------------------- M.open_memory
-- Opens memory.jsonl at `path` for append, takes an exclusive non-blocking
-- flock on the fd, scans existing content for max id, writes a meta header
-- if the file is new. Returns:
-- handle, nil on success
-- nil, err on lock-held / open failure
function M.open_memory(path)
ensure_dir(parent_dir(path))
-- Open via libc open(2) so we have an integer fd for flock.
local fd = ffi.C.open(path,
bit and bit.bor(O_RDWR, O_CREAT, O_APPEND)
or (O_RDWR + O_CREAT + O_APPEND),
FILE_MODE)
-- bit lib may not be loaded; fall back to numeric add (flags don't
-- overlap so OR == add here).
if fd < 0 then
return nil, "open " .. path .. " failed: "
.. libc.strerror(libc.errno())
end
local ok, err = libc.flock(fd, libc.LOCK_EX + libc.LOCK_NB)
if not ok then
libc.close(fd)
return nil, "memory.jsonl held by another aish process ("
.. tostring(err) .. ")"
end
-- Scan existing content for max id. lseek back to start, read all.
local max_id = 0
local was_empty = true
ffi.C.lseek(fd, 0, SEEK_SET)
while true do
-- Read in 4K chunks. Use libc.read which returns string+len.
local chunk, n = libc.read(fd, 4096)
if not chunk or n == 0 then break end
was_empty = false
-- Accumulate into a buffer; on first scan we may straddle lines.
-- Simple approach: keep a tail and split on newlines.
for line in chunk:gmatch("[^\n]+") do
local obj = json.decode(line)
if obj and obj.id and obj.id > max_id then max_id = obj.id end
end
end
-- Seek to end so subsequent libc.write appends.
ffi.C.lseek(fd, 0, 2) -- SEEK_END
local handle = setmetatable({
path = path,
fd = fd,
next_id = max_id + 1,
closed = false,
}, Memory)
if was_empty then
-- Write meta header. No id; load_memory skips lines without id.
handle:_write_raw({
meta = {
aish_version = "phase4",
created = os.date("!%Y-%m-%dT%H:%M:%SZ"),
}
})
end
return handle
end
-- Internal: append one JSON line to the fd.
function Memory:_write_raw(obj)
local line = json.encode(obj) .. "\n"
libc.write(self.fd, line)
end
-- Append a memory item. Returns the assigned id.
function Memory:add(kind, content, tags, source)
assert(not self.closed, "memory:add on closed handle")
assert(kind == "fact" or kind == "pref" or kind == "context",
"memory:add: kind must be fact|pref|context (got " .. tostring(kind) .. ")")
assert(content and content ~= "", "memory:add: content required")
local id = self.next_id
self.next_id = id + 1
local item = {
id = id,
ts = os.date("!%Y-%m-%dT%H:%M:%SZ"),
kind = kind,
content = content,
}
if tags then item.tags = tags end
if source then item.source = source end
self:_write_raw(item)
return id
end
-- Append a tombstone for `target_id`. Idempotent at the file level; the
-- caller (e.g. `:memory forget` meta handler) may want to check
-- M.load_memory first to surface a "not active" status to the user (N1).
function Memory:forget(target_id)
assert(not self.closed, "memory:forget on closed handle")
self:_write_raw({
id = self.next_id,
ts = os.date("!%Y-%m-%dT%H:%M:%SZ"),
kind = "forget",
target = target_id,
})
self.next_id = self.next_id + 1
end
function Memory:close()
if self.closed then return end
-- flock is released automatically on fd close.
libc.close(self.fd)
self.fd = nil
self.closed = true
end
-- ---------------------------------------------------------------- M.load_memory
-- Read all items, resolve tombstones, return active set sorted by ts desc.
-- Items without an `id` field (e.g. the meta header) are silently dropped.
-- Tombstones with non-matching targets are no-ops.
-- Returns:
-- items_table array of {id, ts, kind, content, tags?, source?}
-- may be empty if file doesn't exist or contains only meta/tombstones
function M.load_memory(path)
local fh = io.open(path, "r")
if not fh then return {} end
local items = {} -- by id
local forget = {} -- set of target ids
for line in fh:lines() do
if #line > 0 then
local obj = json.decode(line)
if obj and obj.id then
if obj.kind == "forget" then
if obj.target then forget[obj.target] = true end
elseif obj.kind == "fact" or obj.kind == "pref"
or obj.kind == "context" then
items[obj.id] = obj
end
end
end
end
fh:close()
local active = {}
for id, item in pairs(items) do
if not forget[id] then active[#active + 1] = item end
end
-- Sort by ts descending (most recent first). Strings sort right when
-- they're ISO 8601 — ASCII order = chronological.
table.sort(active, function(a, b) return a.ts > b.ts end)
return active
end
return M