@@ -195,6 +195,10 @@ def __init__(
195195 self .todos : list [dict ] = []
196196 self .pending_user_prompts : list [str ] = []
197197 self ._save_error : str | None = None
198+ # auto_save debounce state (see config.AUTO_SAVE_*): the last
199+ # successful save's wall time and conversation length
200+ self ._last_save_at : float | None = None
201+ self ._last_save_len : int = 0
198202 self .last_messages : list = []
199203 self .cancel_event = threading .Event ()
200204 # Named LLM profiles for runtime switching via /model command
@@ -680,6 +684,39 @@ def remember_user_text(self, messages: list) -> None:
680684 def auto_save (self , messages : list , system : str | None ) -> None :
681685 if not config .AUTO_SAVE_SESSION :
682686 return
687+ # Debounce the full-conversation rewrite: auto_save runs after
688+ # every round, but the file only needs refreshing when enough
689+ # time has passed AND the conversation grew. The growth check
690+ # is on the message count (cheap) before the expensive
691+ # _conversation_text serialization.
692+ now = time .monotonic ()
693+ # getattr: test doubles built via __new__ skip __init__ and never
694+ # set the debounce state; treat a missing state as "never saved".
695+ last = getattr (self , "_last_save_at" , None )
696+ if last is not None :
697+ elapsed = now - last
698+ grew = (
699+ len (messages ) - getattr (self , "_last_save_len" , 0 )
700+ >= config .AUTO_SAVE_MIN_GROWTH_MSGS
701+ )
702+ # skip while inside the min interval and the conversation has
703+ # not grown enough; a big round (grew) or the interval
704+ # elapsing forces a save
705+ if elapsed < config .AUTO_SAVE_MIN_INTERVAL and not grew :
706+ return
707+ self ._do_save (messages )
708+
709+ def flush_auto_save (self , messages : list ) -> None :
710+ """Save immediately, bypassing the auto_save debounce.
711+
712+ Called at run end / cancel / compaction, where the conversation
713+ must reach the session file regardless of the debounce window.
714+ """
715+ if not config .AUTO_SAVE_SESSION :
716+ return
717+ self ._do_save (messages )
718+
719+ def _do_save (self , messages : list ) -> None :
683720 text = self ._conversation_text (messages )
684721 # retry once: transient failures (NFS hiccup, brief lock) clear
685722 # on the second attempt; permanent ones (disk full, read-only)
@@ -689,6 +726,8 @@ def auto_save(self, messages: list, system: str | None) -> None:
689726 try :
690727 self .store .save (text )
691728 self ._save_error = None
729+ self ._last_save_at = time .monotonic ()
730+ self ._last_save_len = len (messages )
692731 return
693732 except (OSError , UnicodeError ) as e :
694733 # UnicodeError too: a save failure must never escape into
@@ -983,7 +1022,7 @@ def compact_conversation(self) -> tuple[bool, str]:
9831022 # preserved verbatim after the frame, so the model keeps
9841023 # the actual requests.
9851024 self .last_messages = compacted_messages (summary , user_prompt_texts (messages ))
986- self .auto_save (self .last_messages , self . system_prompt )
1025+ self .flush_auto_save (self .last_messages )
9871026 self .notify ("compact" )
9881027 return True , "Session compacted successfully."
9891028 except Exception as e : # noqa: BLE001 - compaction failure is non-fatal
@@ -1019,7 +1058,7 @@ def summarize_conversation(self) -> str:
10191058 if not summary :
10201059 return "Summary failed: empty response."
10211060 self .last_messages .append (Msg (role = "assistant" , content = summary ))
1022- self .auto_save (self .last_messages , self . system_prompt )
1061+ self .flush_auto_save (self .last_messages )
10231062 return "Summary appended."
10241063
10251064
0 commit comments