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