diff --git a/repl.lua b/repl.lua index 8446f36..848807d 100644 --- a/repl.lua +++ b/repl.lua @@ -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