From 3b074afaee72ee0f82f1a731e4302a78a00ddc37 Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Wed, 13 May 2026 05:11:48 +0000 Subject: [PATCH] repl: memory handle + :remember + :memory meta (Phase 4 commit #3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 commit #3 per docs/PHASE4.md §12. End-to-end memory wiring. Startup: - Opens memory handle at /memory.jsonl via history.open_memory(). Status-logs failure (e.g. flock held by another aish) and continues without memory. - inject_memory(): loads via history.load_memory(), truncates by cfg.memory.inject_max_chars (default 2000), populates ctx.memory_items. Status line announces N items injected. - shutdown_session() now also closes memory (releases flock). Meta commands: :remember — shortcut for :memory add fact ; auto-refreshes ctx.memory_items so the next AI turn sees the new item without restart :memory list — show id / ts / kind / content (truncated at 80 chars per line) :memory add — fact|pref|context required; rejects other kinds :memory forget — N1: checks active-set first, surfaces "id N not active (already forgotten or never existed)" without appending if the id isn't live :memory clear — [y/N] confirm prompt; tombstones every active item :memory inject — N4: reload memory.jsonl into ctx.memory_items, replacing existing. Useful after manual file edits. Help block extended with the new commands. End-to-end verified: Boot 1 → :remember×2 + :memory add → 3 items, :memory list shows all three with timestamps Boot 2 → memory: 3 items injected (startup status); :memory list same three; ctx.turns empty (history is sessions/, memory is separate) Boot 3 → :memory forget 2 succeeds; :memory forget 99 → "not active" status without writing a tombstone; :memory list shows 2 items; :memory clear → confirm prompt → "cleared 2 items"; :memory list → "(no memory items)" Co-Authored-By: Claude Opus 4.7 (1M context) --- repl.lua | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/repl.lua b/repl.lua index 23492fb..1253096 100644 --- a/repl.lua +++ b/repl.lua @@ -37,6 +37,12 @@ Meta commands: :norris off exit Norris mode (rare — usually 'abort' at halt) :safety patterns list active destructive-op patterns :safety check probe is_destructive against without running + :remember shortcut: :memory add fact + :memory list show active memory items (id, ts, kind, content) + :memory add add a memory item (kind: fact | pref | context) + :memory forget append a tombstone for + :memory clear forget all active items (confirms first) + :memory inject reload memory.jsonl into ctx (after manual edits) :help this message ]] @@ -185,6 +191,43 @@ function M.run(config) renderer.status("session log disabled: " .. tostring(serr)) end end + -- Phase 4: memory.jsonl handle. Sibling of sessions/ in the history dir. + -- Single-writer enforced via flock; if held by another aish process, + -- status-log once and run without memory (Phase 3 behavior). + local memory_path = history_dir and (history_dir .. "/memory.jsonl") or nil + local memory -- handle or nil + local inject_max_chars = + (config.memory and config.memory.inject_max_chars) or 2000 + + -- Inject the top-N items into ctx.memory_items, capped by char budget. + local function inject_memory() + if not memory_path then ctx.memory_items = nil; return end + local items = history.load_memory(memory_path) + if #items == 0 then ctx.memory_items = nil; return end + local picked, total = {}, 0 + for _, it in ipairs(items) do -- already sorted by ts desc + local cost = #(it.content or "") + 16 -- rough overhead per line + if total + cost > inject_max_chars then break end + picked[#picked + 1] = it + total = total + cost + end + ctx.memory_items = picked + end + + if memory_path then + local m, merr = history.open_memory(memory_path) + if m then + memory = m + inject_memory() + if ctx.memory_items and #ctx.memory_items > 0 then + renderer.status(("memory: %d items injected"):format( + #ctx.memory_items)) + end + else + renderer.status("memory disabled: " .. tostring(merr)) + end + end + local function log_turn(turn) if session then session:append(turn) end end @@ -363,6 +406,7 @@ function M.run(config) local function shutdown_session() if session then session:close(); session = nil end + if memory then memory:close(); memory = nil end end -- ---------------------------------------------------------------- Norris driver @@ -673,6 +717,81 @@ function M.run(config) end run_norris(goal) end, + remember = function(args) + local text = args:match("^%s*(.-)%s*$") + if not text or text == "" then + renderer.status("usage: :remember "); return + end + if not memory then renderer.status("memory unavailable"); return end + local id = memory:add("fact", text) + inject_memory() -- refresh live ctx so the next AI turn sees it + renderer.status(("remembered as id=%d (fact)"):format(id)) + end, + memory = function(args) + local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$") + if sub == "" or sub == "list" then + if not memory_path then + renderer.status("memory unavailable (no history.dir)"); return + end + local items = history.load_memory(memory_path) + if #items == 0 then + renderer.status("(no memory items)"); return + end + for _, it in ipairs(items) do + io.write((" %3d %s %-7s %s\n"):format( + it.id, it.ts, it.kind, + (it.content or ""):gsub("\n", " "):sub(1, 80))) + end + elseif sub == "add" then + if not memory then renderer.status("memory unavailable"); return end + local kind, body = sub_args:match("^%s*(%S+)%s+(.+)$") + if not kind or not body then + renderer.status("usage: :memory add "); return + end + if kind ~= "fact" and kind ~= "pref" and kind ~= "context" then + renderer.status("kind must be fact, pref, or context"); return + end + local id = memory:add(kind, body:gsub("^%s+", ""):gsub("%s+$", "")) + inject_memory() + renderer.status(("added id=%d (%s)"):format(id, kind)) + elseif sub == "forget" then + if not memory then renderer.status("memory unavailable"); return end + local id = tonumber(sub_args:match("^%s*(%d+)")) + if not id then renderer.status("usage: :memory forget "); return end + -- N1: check active set first; surface status if id isn't active + local items = history.load_memory(memory_path) + local found = false + for _, it in ipairs(items) do + if it.id == id then found = true; break end + end + if not found then + renderer.status(("id %d not active (already forgotten or never existed)"):format(id)) + return + end + memory:forget(id) + inject_memory() + renderer.status(("forgot id=%d"):format(id)) + elseif sub == "clear" then + if not memory then renderer.status("memory unavailable"); return end + local items = history.load_memory(memory_path) + if #items == 0 then renderer.status("(no items to clear)"); return end + local ans = rl.readline( + ("forget all %d active memory items? [y/N] "):format(#items)) + or "" + if ans:lower():sub(1,1) ~= "y" then + renderer.status("clear cancelled"); return + end + for _, it in ipairs(items) do memory:forget(it.id) end + inject_memory() + renderer.status(("cleared %d items"):format(#items)) + elseif sub == "inject" then + inject_memory() + renderer.status(("re-injected %d items"):format( + (ctx.memory_items and #ctx.memory_items) or 0)) + else + renderer.status("usage: :memory {list|add|forget|clear|inject}") + end + end, safety = function(args) local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$") if sub == "patterns" then