diff --git a/repl.lua b/repl.lua index 1253096..b3add7d 100644 --- a/repl.lua +++ b/repl.lua @@ -43,6 +43,7 @@ Meta commands: :memory forget append a tombstone for :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)