repl: :memory summarize — LLM candidate extraction (Phase 4 commit #4)

Phase 4 commit #4 per docs/PHASE4.md §6.

:memory summarize:
  1. Source-of-truth: session log file via history.load(session_path),
     NOT ctx:to_messages() (R-C2). Skips turns tagged meta="summarize"
     so prior summarize exchanges don't self-amplify across multiple
     calls within the same session.
  2. Pick summarizer model from cfg.memory.summarizer_model (default
     active model).
  3. Build a transcript string ("role: content" per turn, 800 chars max
     per turn) and feed it as a single user turn alongside a system
     instruction asking for "(fact|pref|context): <content>" lines.
  4. broker.chat with max_tokens=1024 + timeout_ms=90000 (the deep
     model can take a while; we don't want a 15s probe-cap here).
  5. Log the response as an assistant turn with meta="summarize" so the
     next :memory summarize call filters it out.
  6. Parse response lines tolerating markdown bullets and bold markup:
     ^%s*[-*]?%s*[*_]*(fact|pref|context)[*_]*:%s*(.+)$
  7. Per-candidate prompt: y / N / edit.
       y    → memory:add(kind, content)
       edit → readline prompt for replacement text
       any other → drop
  8. status: "summarize: added N / M candidates".

Live-tested against hossenfelder/fast:
  Pipeline correct end-to-end. Model emitted one candidate; user
  confirmation prompt fired; item persisted; :memory list showed it.
  Candidate quality from the 1.5B model is poor — typical
  small-model behavior; deep/cloud models would do better but this
  isn't an aish bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 07:53:36 +00:00
parent 3b074afaee
commit f22d21d754
+95 -1
View File
@@ -43,6 +43,7 @@ Meta commands:
: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)
:memory summarize LLM-extract candidate items from this session
:help this message
]]
@@ -788,8 +789,101 @@ function M.run(config)
inject_memory()
renderer.status(("re-injected %d items"):format(
(ctx.memory_items and #ctx.memory_items) or 0))
elseif sub == "summarize" then
if not memory then renderer.status("memory unavailable"); return end
if not session_path then
renderer.status("no session log to summarize"); return
end
-- Source of truth is the session log file (R-C2).
-- Exclude prior summarize exchanges to avoid drift.
local turns, _meta = history.load(session_path)
if not turns or #turns == 0 then
renderer.status("session log empty; nothing to summarize"); return
end
local filtered = {}
for _, t in ipairs(turns) do
if t.meta ~= "summarize" then
filtered[#filtered + 1] =
("%s: %s"):format(t.role,
(t.content or ""):gsub("\n", " "):sub(1, 800))
end
end
local transcript = table.concat(filtered, "\n")
if #transcript < 50 then
renderer.status("session content too short to summarize"); return
end
-- Pick summarizer model.
local sum_name = (config.memory and config.memory.summarizer_model)
or active_name
local sum_cfg = config.models[sum_name]
if not sum_cfg then
renderer.status("summarizer model not found: " .. sum_name); return
end
renderer.status(("summarizing via %s ..."):format(sum_name))
local reply, err = broker.chat(sum_cfg, {
{ role = "system", content =
"Read the following conversation transcript. Extract "
.. "facts, preferences, or context worth remembering "
.. "across future sessions. Output ONE candidate per "
.. "line, prefixed with the kind: \"fact: ...\", "
.. "\"pref: ...\", or \"context: ...\". Maximum 10 "
.. "candidates. No commentary outside candidate lines."
},
{ role = "user", content = transcript },
}, { max_tokens = 1024, timeout_ms = 90000 })
if not reply then
renderer.status("summarize failed: " .. tostring(err))
return
end
-- Persist the summarize-tagged assistant turn so future
-- :memory summarize filters it out (R-C2).
log_turn({ role = "assistant", content = reply, meta = "summarize" })
-- Parse candidates: tolerate bullets and bold markup.
local candidates = {}
for line in (reply .. "\n"):gmatch("([^\n]*)\n") do
local kind, body = line:match("^%s*[-*]?%s*[*_]*(%a+)[*_]*%s*:%s*(.+)$")
if kind then
kind = kind:lower()
if kind == "fact" or kind == "pref" or kind == "context" then
candidates[#candidates + 1] = { kind = kind,
content = body:gsub("%s+$", "") }
end
end
end
if #candidates == 0 then
renderer.status("no candidates parsed from response"); return
end
local added = 0
for _, cand in ipairs(candidates) do
io.write(("\n[memory] candidate (%s): %s\n")
:format(cand.kind, cand.content))
local ans = rl.readline("keep? [y/N/edit] ") or ""
local first = ans:lower():sub(1, 1)
if first == "y" then
memory:add(cand.kind, cand.content)
added = added + 1
elseif first == "e" then
local edited = rl.readline("edit: ") or ""
edited = edited:gsub("^%s+", ""):gsub("%s+$", "")
if edited ~= "" then
memory:add(cand.kind, edited)
added = added + 1
end
end
end
inject_memory()
renderer.status(("summarize: added %d / %d candidates")
:format(added, #candidates))
else
renderer.status("usage: :memory {list|add|forget|clear|inject}")
renderer.status("usage: :memory {list|add|forget|clear|inject|summarize}")
end
end,
safety = function(args)