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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user