feat: save conversation backups before compaction

Nicolas Leão committed Mar 26, 2026 at 15:43 UTC d42301a9b1fb8d7a28c21d13bff3d400c7b0efa5
1 file changed +46 -11
plugins/_chat_compaction/helpers/compactor.py
+46 -11
@@ -1,16 +1,44 @@
1 """Core compaction logic for the compaction plugin."""
2 import asyncio
3 +import os
4 +from datetime import datetime
5 from typing import Callable
6
7 from agent import Agent
6 -from helpers import history, tokens
8 +from helpers import files, history, tokens
9 from helpers.history import History, output_text
10 from helpers.log import Log
9 -from helpers.persist_chat import save_tmp_chat, remove_msg_files
11 +from helpers.persist_chat import (
12 + export_json_chat,
13 + get_chat_folder_path,
14 + save_tmp_chat,
15 + remove_msg_files,
16 +)
17 from helpers.state_monitor_integration import mark_dirty_all
18 from plugins._model_config.helpers.model_config import get_chat_model_config
19
20
21 +def _save_pre_compaction_backup(context, full_text: str) -> dict[str, str]:
22 + """Save the original chat as JSON and plain text before compaction.
23 +
24 + Returns dict with 'json' and 'txt' absolute file paths.
25 + """
26 + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
27 + backup_dir = os.path.join(get_chat_folder_path(context.id), "backups")
28 + os.makedirs(backup_dir, exist_ok=True)
29 +
30 + json_path = os.path.join(backup_dir, f"pre-compact-{timestamp}.json")
31 + txt_path = os.path.join(backup_dir, f"pre-compact-{timestamp}.txt")
32 +
33 + json_content = export_json_chat(context)
34 + with open(json_path, "w", encoding="utf-8") as f:
35 + f.write(json_content)
36 +
37 + with open(txt_path, "w", encoding="utf-8") as f:
38 + f.write(full_text)
39 +
40 + return {"json": json_path, "txt": txt_path}
41 +
42
43 async def run_compaction(context, use_chat_model: bool = True) -> None:
44 """
@@ -74,31 +102,38 @@ async def run_compaction(context, use_chat_model: bool = True) -> None:
102 if not summary or not summary.strip():
103 raise ValueError("Compaction produced empty summary")
104
77 - # Step 5: Replace history with compacted version
78 - agent.history = History(agent=agent)
79 - agent.history.add_message(
80 - ai=True,
81 - content=f"## Context compacted\n\n{summary}"
105 + # Step 5: Save pre-compaction backup before destroying history
106 + backup_paths = _save_pre_compaction_backup(context, full_text)
107 +
108 + # Step 6: Replace history with compacted version
109 + backup_note = (
110 + f"\n\n---\n"
111 + f"*Pre-compaction backup of the full original conversation:*\n"
112 + f"- `{backup_paths['txt']}`"
113 )
114 + compacted_content = f"## Context compacted\n\n{summary}{backup_note}"
115 +
116 + agent.history = History(agent=agent)
117 + agent.history.add_message(ai=True, content=compacted_content)
118
119 # Clear subordinate chain
120 agent.data.pop(Agent.DATA_NAME_SUBORDINATE, None)
121 context.streaming_agent = None
122
88 - # Step 6: Reset log and create response
123 + # Step 7: Reset log and create response
124 context.log.reset()
125 context.log.log(
126 type="response",
127 heading="Context compacted",
93 - content=f"## Context compacted\n\n{summary}",
128 + content=compacted_content,
129 update_progress="none",
130 )
131
97 - # Step 7: Persist and notify
132 + # Step 8: Persist and notify
133 save_tmp_chat(context)
134 remove_msg_files(context.id)
135
101 - # Step 8: Force progress bar to inactive state LAST
136 + # Step 9: Force progress bar to inactive state LAST
137 # This must happen after all log operations and persist
138 context.log.set_progress("Waiting for input", 0, False)
139 mark_dirty_all(reason="plugins.compaction.compact_chat")