repl: auto-summarize on :q into memory.jsonl (closes #102)
Closes #102 (FR-B from the 2026-05-17 German strategy analysis, small-model improvement strategy 5: "History-Zusammenfassung via local"). Today the `:memory summarize` distill flow is a manual meta — users have to remember to run it before quitting. This commit wires the same flow into shutdown_session under an opt-in cfg flag, so the local fast model can absorb each non-trivial session into the persistent memory.jsonl without user burden. Next-session startup's [background] block picks the new entries up automatically (Phase 4). Implementation: - Extract the `:memory summarize` body into _do_memory_summarize(opts). opts.auto = true: skip the per-candidate readline keep?[y/N/edit] loop and auto-add every parsed candidate (trust the model + the explicit opt-in via cfg.memory.auto_summarize_on_quit). opts.min_turns is the silent-no-op cutoff. Status messages suppressed for fast-path no-ops so :q stays quiet on trivial sessions. - :memory summarize meta now one line: _do_memory_summarize({ auto=false }). - shutdown_session checks cfg.memory.auto_summarize_on_quit; if set, pcall(_do_memory_summarize, { auto=true, min_turns=N }). pcall so a broker failure NEVER blocks :q (memory is best-effort). New config keys (all opt-in; default behavior unchanged): memory = { enabled = true, auto_summarize_on_quit = true, min_turns_for_summary = 5, -- skip trivial sessions summary_model = "fast", -- cfg.memory.summarizer_model is -- still honored for back-compat } E2E verified on hossenfelder:8082 with qwen-coder-7b as summary_model: 3 user turns ("remember venus...", "remember mars...", "remember pluto..."): :q -> "[aish] summarizing session for memory via fast ..." -> "[aish] auto-summarize: added 3 memory items" -> memory.jsonl gained 3 fact: entries (correctly extracted) Below threshold (1 user turn, min=10): :q -> silent, no broker call, no memory.jsonl change Flag off (default behavior, 4 turns): :q -> silent, identical to pre-#102 behavior Regression: 87/87 safety, 31/31 router_model, repl loads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1242,7 +1242,163 @@ function M.run(config)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- #102: extracted distill body from `:memory summarize` so
|
||||||
|
-- shutdown_session can fire it non-interactively on :q when
|
||||||
|
-- cfg.memory.auto_summarize_on_quit is set. opts.auto = true
|
||||||
|
-- skips per-candidate readline prompts and auto-adds every
|
||||||
|
-- parsed candidate; opts.min_turns is the cutoff below which
|
||||||
|
-- auto mode is a no-op.
|
||||||
|
--
|
||||||
|
-- Returns (added_count, total_candidates) or (nil, err_msg).
|
||||||
|
-- Status messages are only emitted in interactive (non-auto)
|
||||||
|
-- mode so :q doesn't print noise on a no-op.
|
||||||
|
local function _do_memory_summarize(opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local auto = opts.auto
|
||||||
|
if not memory then
|
||||||
|
if not auto then renderer.status("memory unavailable") end
|
||||||
|
return nil, "memory unavailable"
|
||||||
|
end
|
||||||
|
if not session_path then
|
||||||
|
if not auto then renderer.status("no session log to summarize") end
|
||||||
|
return nil, "no session log"
|
||||||
|
end
|
||||||
|
|
||||||
|
local turns, _meta = history.load(session_path)
|
||||||
|
if not turns or #turns == 0 then
|
||||||
|
if not auto then renderer.status("session log empty; nothing to summarize") end
|
||||||
|
return nil, "session empty"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Auto mode: skip below-threshold sessions silently.
|
||||||
|
if auto and opts.min_turns and #turns < opts.min_turns then
|
||||||
|
return nil, "below threshold"
|
||||||
|
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
|
||||||
|
if not auto then renderer.status("session content too short to summarize") end
|
||||||
|
return nil, "too short"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pick summarizer model. #102: cfg.memory.summary_model is the
|
||||||
|
-- per-#102 alias; fall back to the existing summarizer_model
|
||||||
|
-- key (Phase 5 / Phase 4 parity), then active model name.
|
||||||
|
local sum_name = (config.memory and (config.memory.summary_model
|
||||||
|
or config.memory.summarizer_model))
|
||||||
|
or active_name
|
||||||
|
local sum_cfg = config.models[sum_name]
|
||||||
|
if not sum_cfg then
|
||||||
|
if not auto then renderer.status("summarizer model not found: " .. sum_name) end
|
||||||
|
return nil, "summarizer model not found: " .. sum_name
|
||||||
|
end
|
||||||
|
|
||||||
|
if not auto then
|
||||||
|
renderer.status(("summarizing via %s ..."):format(sum_name))
|
||||||
|
else
|
||||||
|
renderer.status(("summarizing session for memory via %s ...")
|
||||||
|
:format(sum_name))
|
||||||
|
end
|
||||||
|
local sum_msgs = scrub_messages({
|
||||||
|
{ 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 },
|
||||||
|
}, secrets_mode_for(sum_cfg))
|
||||||
|
local reply, second = broker.chat(sum_cfg, sum_msgs,
|
||||||
|
{ max_tokens = 1024, timeout_ms = 90000,
|
||||||
|
category = "memory_summarize" })
|
||||||
|
|
||||||
|
if not reply then
|
||||||
|
if not auto then
|
||||||
|
renderer.status("summarize failed: " .. tostring(second))
|
||||||
|
end
|
||||||
|
return nil, "broker failed: " .. tostring(second)
|
||||||
|
end
|
||||||
|
if second then -- usage payload
|
||||||
|
_record_usage(second.model, second.category, second)
|
||||||
|
end
|
||||||
|
if secrets_session then
|
||||||
|
reply = secrets_session:rehydrate(reply)
|
||||||
|
end
|
||||||
|
|
||||||
|
log_turn({ role = "assistant", content = reply, meta = "summarize" })
|
||||||
|
|
||||||
|
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
|
||||||
|
if not auto then renderer.status("no candidates parsed from response") end
|
||||||
|
return 0, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local added = 0
|
||||||
|
if auto then
|
||||||
|
-- Auto mode: trust the model + opt-in (cfg.memory.auto_summarize_on_quit).
|
||||||
|
-- Add every parsed candidate; no interactive prompt.
|
||||||
|
for _, cand in ipairs(candidates) do
|
||||||
|
memory:add(cand.kind, cand.content)
|
||||||
|
added = added + 1
|
||||||
|
end
|
||||||
|
inject_memory()
|
||||||
|
renderer.status(("auto-summarize: added %d memory item%s")
|
||||||
|
:format(added, added == 1 and "" or "s"))
|
||||||
|
else
|
||||||
|
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))
|
||||||
|
end
|
||||||
|
return added, #candidates
|
||||||
|
end
|
||||||
|
|
||||||
local function shutdown_session()
|
local function shutdown_session()
|
||||||
|
-- #102: auto-summarize on :q. Gate on cfg.memory.auto_summarize_on_quit
|
||||||
|
-- AND a turn count threshold (default 5) so trivial sessions don't
|
||||||
|
-- pollute memory.jsonl. pcall so a broker failure never blocks
|
||||||
|
-- shutdown.
|
||||||
|
if config.memory and config.memory.auto_summarize_on_quit then
|
||||||
|
local min = config.memory.min_turns_for_summary or 5
|
||||||
|
pcall(_do_memory_summarize, { auto = true, min_turns = min })
|
||||||
|
end
|
||||||
if session then session:close(); session = nil end
|
if session then session:close(); session = nil end
|
||||||
if memory then memory:close(); memory = nil end
|
if memory then memory:close(); memory = nil end
|
||||||
end
|
end
|
||||||
@@ -1768,108 +1924,9 @@ the TASK: lines.
|
|||||||
renderer.status(("re-injected %d items"):format(
|
renderer.status(("re-injected %d items"):format(
|
||||||
(ctx.memory_items and #ctx.memory_items) or 0))
|
(ctx.memory_items and #ctx.memory_items) or 0))
|
||||||
elseif sub == "summarize" then
|
elseif sub == "summarize" then
|
||||||
if not memory then renderer.status("memory unavailable"); return end
|
-- #102: body extracted into _do_memory_summarize so
|
||||||
if not session_path then
|
-- shutdown_session can fire it with auto=true on :q.
|
||||||
renderer.status("no session log to summarize"); return
|
_do_memory_summarize({ auto = false })
|
||||||
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 sum_msgs = scrub_messages({
|
|
||||||
{ 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 },
|
|
||||||
}, secrets_mode_for(sum_cfg))
|
|
||||||
-- Phase 7: capture (text, usage); second is err on failure.
|
|
||||||
local reply, second = broker.chat(sum_cfg, sum_msgs,
|
|
||||||
{ max_tokens = 1024, timeout_ms = 90000,
|
|
||||||
category = "memory_summarize" })
|
|
||||||
|
|
||||||
if not reply then
|
|
||||||
renderer.status("summarize failed: " .. tostring(second))
|
|
||||||
return
|
|
||||||
end
|
|
||||||
if second then -- usage payload
|
|
||||||
_record_usage(second.model, second.category, second)
|
|
||||||
end
|
|
||||||
if secrets_session then
|
|
||||||
reply = secrets_session:rehydrate(reply)
|
|
||||||
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
|
else
|
||||||
renderer.status("usage: :memory {list|add|forget|clear|inject|summarize}")
|
renderer.status("usage: :memory {list|add|forget|clear|inject|summarize}")
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user