diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 0b49368d..fb4be067 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -186,6 +186,33 @@ memory: # For exit/reset, only fires if the session had at least this many user turns. flush_min_turns: 6 # Min user turns to trigger flush on exit/reset (0 = disabled) +# ============================================================================= +# Session Reset Policy (Messaging Platforms) +# ============================================================================= +# Controls when messaging sessions (Telegram, Discord, WhatsApp, Slack) are +# automatically cleared. Without resets, conversation context grows indefinitely +# which increases API costs with every message. +# +# When a reset triggers, the agent first saves important information to its +# persistent memory — but the conversation context is wiped. The agent starts +# fresh but retains learned facts via its memory system. +# +# Users can always manually reset with /reset or /new in chat. +# +# Modes: +# "both" - Reset on EITHER inactivity timeout or daily boundary (recommended) +# "idle" - Reset only after N minutes of inactivity +# "daily" - Reset only at a fixed hour each day +# "none" - Never auto-reset; context lives until /reset or compression kicks in +# +# When a reset triggers, the agent gets one turn to save important memories and +# skills before the context is wiped. Persistent memory carries across sessions. +# +session_reset: + mode: both # "both", "idle", "daily", or "none" + idle_minutes: 1440 # Inactivity timeout in minutes (default: 1440 = 24 hours) + at_hour: 4 # Daily reset hour, 0-23 local time (default: 4 AM) + # ============================================================================= # Skills Configuration # ============================================================================= diff --git a/gateway/config.py b/gateway/config.py index 16eceda6..32b623ea 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -65,8 +65,9 @@ class SessionResetPolicy: - "daily": Reset at a specific hour each day - "idle": Reset after N minutes of inactivity - "both": Whichever triggers first (daily boundary OR idle timeout) + - "none": Never auto-reset (context managed only by compression) """ - mode: str = "both" # "daily", "idle", or "both" + mode: str = "both" # "daily", "idle", "both", or "none" at_hour: int = 4 # Hour for daily reset (0-23, local time) idle_minutes: int = 1440 # Minutes of inactivity before reset (24 hours) @@ -264,6 +265,21 @@ def load_gateway_config() -> GatewayConfig: except Exception as e: print(f"[gateway] Warning: Failed to load {gateway_config_path}: {e}") + # Bridge session_reset from config.yaml (the user-facing config file) + # into the gateway config. config.yaml takes precedence over gateway.json + # for session reset policy since that's where hermes setup writes it. + try: + import yaml + config_yaml_path = Path.home() / ".hermes" / "config.yaml" + if config_yaml_path.exists(): + with open(config_yaml_path) as f: + yaml_cfg = yaml.safe_load(f) or {} + sr = yaml_cfg.get("session_reset") + if sr and isinstance(sr, dict): + config.default_reset_policy = SessionResetPolicy.from_dict(sr) + except Exception: + pass + # Override with environment variables _apply_env_overrides(config) diff --git a/gateway/run.py b/gateway/run.py index 352a8208..f59374ea 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -134,6 +134,7 @@ class GatewayRunner: self.session_store = SessionStore( self.config.sessions_dir, self.config, has_active_processes_fn=lambda key: process_registry.has_active_for_session(key), + on_auto_reset=self._flush_memories_before_reset, ) self.delivery_router = DeliveryRouter(self.config) self._running = False @@ -148,6 +149,66 @@ class GatewayRunner: # Key: session_key, Value: {"command": str, "pattern_key": str} self._pending_approvals: Dict[str, Dict[str, str]] = {} + def _flush_memories_before_reset(self, old_entry): + """Prompt the agent to save memories/skills before an auto-reset. + + Called synchronously by SessionStore before destroying an expired session. + Loads the transcript, gives the agent a real turn with memory + skills + tools, and explicitly asks it to preserve anything worth keeping. + """ + try: + history = self.session_store.load_transcript(old_entry.session_id) + if not history or len(history) < 4: + return + + from run_agent import AIAgent + _flush_api_key = os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY", "") + _flush_base_url = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") + _flush_model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL", "anthropic/claude-opus-4.6") + + if not _flush_api_key: + return + + tmp_agent = AIAgent( + model=_flush_model, + api_key=_flush_api_key, + base_url=_flush_base_url, + max_iterations=8, + quiet_mode=True, + enabled_toolsets=["memory", "skills"], + session_id=old_entry.session_id, + ) + + # Build conversation history from transcript + msgs = [ + {"role": m.get("role"), "content": m.get("content")} + for m in history + if m.get("role") in ("user", "assistant") and m.get("content") + ] + + # Give the agent a real turn to think about what to save + flush_prompt = ( + "[System: This session is about to be automatically reset due to " + "inactivity or a scheduled daily reset. The conversation context " + "will be cleared after this turn.\n\n" + "Review the conversation above and:\n" + "1. Save any important facts, preferences, or decisions to memory " + "(user profile or your notes) that would be useful in future sessions.\n" + "2. If you discovered a reusable workflow or solved a non-trivial " + "problem, consider saving it as a skill.\n" + "3. If nothing is worth saving, that's fine — just skip.\n\n" + "Do NOT respond to the user. Just use the memory and skill_manage " + "tools if needed, then stop.]" + ) + + tmp_agent.run_conversation( + user_message=flush_prompt, + conversation_history=msgs, + ) + logger.info("Pre-reset save completed for session %s", old_entry.session_id) + except Exception as e: + logger.debug("Pre-reset save failed for session %s: %s", old_entry.session_id, e) + # DM pairing store for code-based user authorization from gateway.pairing import PairingStore self.pairing_store = PairingStore() diff --git a/gateway/session.py b/gateway/session.py index f89700ee..eaa8d289 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -277,12 +277,14 @@ class SessionStore: """ def __init__(self, sessions_dir: Path, config: GatewayConfig, - has_active_processes_fn=None): + has_active_processes_fn=None, + on_auto_reset=None): self.sessions_dir = sessions_dir self.config = config self._entries: Dict[str, SessionEntry] = {} self._loaded = False self._has_active_processes_fn = has_active_processes_fn + self._on_auto_reset = on_auto_reset # callback(old_entry) before auto-reset # Initialize SQLite session database self._db = None @@ -345,6 +347,9 @@ class SessionStore: session_type=source.chat_type ) + if policy.mode == "none": + return False + now = datetime.now() if policy.mode in ("idle", "both"): @@ -396,8 +401,13 @@ class SessionStore: self._save() return entry else: - # Session is being reset -- end the old one in SQLite + # Session is being auto-reset — flush memories before destroying was_auto_reset = True + if self._on_auto_reset: + try: + self._on_auto_reset(entry) + except Exception as e: + logger.debug("Auto-reset callback failed: %s", e) if self._db: try: self._db.end_session(entry.session_id, "session_reset") diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 8b725b72..6828311f 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1086,6 +1086,82 @@ def run_setup_wizard(args): print_success(f"Context compression threshold set to {config['compression'].get('threshold', 0.85)}") + # ========================================================================= + # Step 6b: Session Reset Policy (Messaging) + # ========================================================================= + print_header("Session Reset Policy") + print_info("Messaging sessions (Telegram, Discord, etc.) accumulate context over time.") + print_info("Each message adds to the conversation history, which means growing API costs.") + print_info("") + print_info("To manage this, sessions can automatically reset after a period of inactivity") + print_info("or at a fixed time each day. When a reset happens, the agent saves important") + print_info("things to its persistent memory first — but the conversation context is cleared.") + print_info("") + print_info("You can also manually reset anytime by typing /reset in chat.") + print_info("") + + reset_choices = [ + "Inactivity + daily reset (recommended — reset whichever comes first)", + "Inactivity only (reset after N minutes of no messages)", + "Daily only (reset at a fixed hour each day)", + "Never auto-reset (context lives until /reset or context compression)", + "Keep current settings", + ] + + current_policy = config.get('session_reset', {}) + current_mode = current_policy.get('mode', 'both') + current_idle = current_policy.get('idle_minutes', 1440) + current_hour = current_policy.get('at_hour', 4) + + default_reset = {"both": 0, "idle": 1, "daily": 2, "none": 3}.get(current_mode, 0) + + reset_idx = prompt_choice("Session reset mode:", reset_choices, default_reset) + + config.setdefault('session_reset', {}) + + if reset_idx == 0: # Both + config['session_reset']['mode'] = 'both' + idle_str = prompt(" Inactivity timeout (minutes)", str(current_idle)) + try: + idle_val = int(idle_str) + if idle_val > 0: + config['session_reset']['idle_minutes'] = idle_val + except ValueError: + pass + hour_str = prompt(" Daily reset hour (0-23, local time)", str(current_hour)) + try: + hour_val = int(hour_str) + if 0 <= hour_val <= 23: + config['session_reset']['at_hour'] = hour_val + except ValueError: + pass + print_success(f"Sessions reset after {config['session_reset'].get('idle_minutes', 1440)} min idle or daily at {config['session_reset'].get('at_hour', 4)}:00") + elif reset_idx == 1: # Idle only + config['session_reset']['mode'] = 'idle' + idle_str = prompt(" Inactivity timeout (minutes)", str(current_idle)) + try: + idle_val = int(idle_str) + if idle_val > 0: + config['session_reset']['idle_minutes'] = idle_val + except ValueError: + pass + print_success(f"Sessions reset after {config['session_reset'].get('idle_minutes', 1440)} min of inactivity") + elif reset_idx == 2: # Daily only + config['session_reset']['mode'] = 'daily' + hour_str = prompt(" Daily reset hour (0-23, local time)", str(current_hour)) + try: + hour_val = int(hour_str) + if 0 <= hour_val <= 23: + config['session_reset']['at_hour'] = hour_val + except ValueError: + pass + print_success(f"Sessions reset daily at {config['session_reset'].get('at_hour', 4)}:00") + elif reset_idx == 3: # None + config['session_reset']['mode'] = 'none' + print_info("Sessions will never auto-reset. Context is managed only by compression.") + print_warning("Long conversations will grow in cost. Use /reset manually when needed.") + # else: keep current (idx == 4) + # ========================================================================= # Step 7: Messaging Platforms (Optional) # =========================================================================