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