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:
2026-05-17 09:18:02 +00:00
parent cb37fa861a
commit 299719f4de
+159 -102
View File
@@ -1242,7 +1242,163 @@ function M.run(config)
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()
-- #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 memory then memory:close(); memory = nil end
end
@@ -1768,108 +1924,9 @@ the TASK: lines.
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 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))
-- #102: body extracted into _do_memory_summarize so
-- shutdown_session can fire it with auto=true on :q.
_do_memory_summarize({ auto = false })
else
renderer.status("usage: :memory {list|add|forget|clear|inject|summarize}")
end