repl: memory handle + :remember + :memory meta (Phase 4 commit #3)

Phase 4 commit #3 per docs/PHASE4.md §12. End-to-end memory wiring.

Startup:
  - Opens memory handle at <history.dir>/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 <text>       — shortcut for :memory add fact <text>;
                            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 <kind> <t> — fact|pref|context required; rejects other
                            kinds
  :memory forget <id>    — 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 05:11:48 +00:00
parent c1a5c736ec
commit 3b074afaee
+119
View File
@@ -37,6 +37,12 @@ Meta commands:
:norris off exit Norris mode (rare — usually 'abort' at halt) :norris off exit Norris mode (rare — usually 'abort' at halt)
:safety patterns list active destructive-op patterns :safety patterns list active destructive-op patterns
:safety check <cmd> probe is_destructive against <cmd> without running :safety check <cmd> probe is_destructive against <cmd> without running
:remember <text> shortcut: :memory add fact <text>
:memory list show active memory items (id, ts, kind, content)
:memory add <kind> <t> add a memory item (kind: fact | pref | context)
:memory forget <id> append a tombstone for <id>
:memory clear forget all active items (confirms first)
:memory inject reload memory.jsonl into ctx (after manual edits)
:help this message :help this message
]] ]]
@@ -185,6 +191,43 @@ function M.run(config)
renderer.status("session log disabled: " .. tostring(serr)) renderer.status("session log disabled: " .. tostring(serr))
end end
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) local function log_turn(turn)
if session then session:append(turn) end if session then session:append(turn) end
end end
@@ -363,6 +406,7 @@ function M.run(config)
local function shutdown_session() local function shutdown_session()
if session then session:close(); session = nil end if session then session:close(); session = nil end
if memory then memory:close(); memory = nil end
end end
-- ---------------------------------------------------------------- Norris driver -- ---------------------------------------------------------------- Norris driver
@@ -673,6 +717,81 @@ function M.run(config)
end end
run_norris(goal) run_norris(goal)
end, end,
remember = function(args)
local text = args:match("^%s*(.-)%s*$")
if not text or text == "" then
renderer.status("usage: :remember <text>"); 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 <fact|pref|context> <text>"); 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 <id>"); 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) safety = function(args)
local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$") local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$")
if sub == "patterns" then if sub == "patterns" then