# autonomous_engine.py """ Truly autonomous decision-making engine for Miku. Makes decisions based on context signals without constant LLM polling. """ import time import random from datetime import datetime, timedelta from dataclasses import dataclass, field from typing import Dict, List, Optional from collections import deque import discord from .autonomous_persistence import save_autonomous_context, load_autonomous_context, apply_context_to_signals @dataclass class ContextSignals: """Lightweight context tracking without storing message content""" # Activity metrics messages_last_5min: int = 0 messages_last_hour: int = 0 unique_users_active: int = 0 conversation_momentum: float = 0.0 # 0-1 score based on message frequency # User presence users_joined_recently: int = 0 users_status_changed: int = 0 users_started_activity: List[tuple] = field(default_factory=list) # (activity_name, timestamp) tuples # Miku's state time_since_last_action: float = 0.0 # seconds time_since_last_interaction: float = 0.0 # seconds since someone talked to her messages_since_last_appearance: int = 0 # Time context hour_of_day: int = 0 is_weekend: bool = False # Emotional influence current_mood: str = "neutral" mood_energy_level: float = 0.5 # 0-1, affects likelihood of action @dataclass class ActionThresholds: """Dynamic thresholds that change based on mood and context""" # How long to wait before considering action (seconds) min_silence_for_general: float = 1800 # 30 min min_silence_for_engagement: float = 3600 # 1 hour # Activity level needed to join conversation (0-1) conversation_join_threshold: float = 0.6 # How many messages before feeling "left out" messages_before_fomo: int = 25 # Mood-based multipliers mood_action_multiplier: float = 1.0 # Higher = more likely to act class AutonomousEngine: """ Decision engine that determines WHEN Miku should act, then delegates to existing autonomous functions for WHAT to do. """ def __init__(self): self.server_contexts: Dict[int, ContextSignals] = {} self.server_message_times: Dict[int, deque] = {} # Track message timestamps self.server_last_action: Dict[int, float] = {} self.bot_startup_time: float = time.time() # Track when bot started # Mood personality profiles self.mood_profiles = { "bubbly": {"energy": 0.9, "sociability": 0.95, "impulsiveness": 0.8}, "sleepy": {"energy": 0.2, "sociability": 0.3, "impulsiveness": 0.1}, "curious": {"energy": 0.7, "sociability": 0.6, "impulsiveness": 0.7}, "shy": {"energy": 0.4, "sociability": 0.2, "impulsiveness": 0.2}, "serious": {"energy": 0.6, "sociability": 0.5, "impulsiveness": 0.3}, "excited": {"energy": 0.95, "sociability": 0.9, "impulsiveness": 0.9}, "silly": {"energy": 0.8, "sociability": 0.85, "impulsiveness": 0.95}, "melancholy": {"energy": 0.3, "sociability": 0.4, "impulsiveness": 0.2}, "flirty": {"energy": 0.75, "sociability": 0.85, "impulsiveness": 0.7}, "romantic": {"energy": 0.6, "sociability": 0.7, "impulsiveness": 0.5}, "irritated": {"energy": 0.5, "sociability": 0.3, "impulsiveness": 0.6}, "angry": {"energy": 0.7, "sociability": 0.2, "impulsiveness": 0.8}, "neutral": {"energy": 0.5, "sociability": 0.5, "impulsiveness": 0.5}, "asleep": {"energy": 0.0, "sociability": 0.0, "impulsiveness": 0.0}, } # Load persisted context on initialization self._load_persisted_context() def _load_persisted_context(self): """Load saved context data on bot startup""" context_data, last_action = load_autonomous_context() # Restore last action timestamps self.server_last_action = last_action # Restore context signals for guild_id, data in context_data.items(): self.server_contexts[guild_id] = ContextSignals() self.server_message_times[guild_id] = deque(maxlen=100) apply_context_to_signals(data, self.server_contexts[guild_id]) def save_context(self): """Save current context to disk""" save_autonomous_context(self.server_contexts, self.server_last_action) def track_message(self, guild_id: int, author_is_bot: bool = False): """Track a message without storing content""" if guild_id not in self.server_contexts: self.server_contexts[guild_id] = ContextSignals() self.server_message_times[guild_id] = deque(maxlen=100) if author_is_bot: return # Don't count bot messages now = time.time() self.server_message_times[guild_id].append(now) ctx = self.server_contexts[guild_id] ctx.messages_since_last_appearance += 1 # Cap at 100 to prevent massive buildup during sleep/inactivity # This prevents inappropriate FOMO triggers after long periods if ctx.messages_since_last_appearance > 100: ctx.messages_since_last_appearance = 100 # Update time-based metrics self._update_activity_metrics(guild_id) def track_user_event(self, guild_id: int, event_type: str, data: dict = None): """Track user presence events (joins, status changes, etc.)""" if guild_id not in self.server_contexts: self.server_contexts[guild_id] = ContextSignals() self.server_message_times[guild_id] = deque(maxlen=100) ctx = self.server_contexts[guild_id] if event_type == "user_joined": ctx.users_joined_recently += 1 elif event_type == "status_changed": ctx.users_status_changed += 1 elif event_type == "activity_started" and data: activity_name = data.get("activity_name") if activity_name: now = time.time() # Remove duplicate activities (same name) ctx.users_started_activity = [ (name, ts) for name, ts in ctx.users_started_activity if name != activity_name ] # Add new activity with timestamp ctx.users_started_activity.append((activity_name, now)) # Keep only last 5 activities if len(ctx.users_started_activity) > 5: ctx.users_started_activity.pop(0) def _clean_old_activities(self, guild_id: int, max_age_seconds: float = 3600): """Remove activities older than max_age (default 1 hour)""" if guild_id not in self.server_contexts: return ctx = self.server_contexts[guild_id] now = time.time() # Filter out old activities ctx.users_started_activity = [ (name, ts) for name, ts in ctx.users_started_activity if now - ts < max_age_seconds ] def update_mood(self, guild_id: int, mood: str): """Update mood and recalculate energy level""" if guild_id not in self.server_contexts: self.server_contexts[guild_id] = ContextSignals() self.server_message_times[guild_id] = deque(maxlen=100) ctx = self.server_contexts[guild_id] ctx.current_mood = mood # Get mood personality profile profile = self.mood_profiles.get(mood, self.mood_profiles["neutral"]) ctx.mood_energy_level = profile["energy"] def _update_activity_metrics(self, guild_id: int): """Update activity metrics based on message timestamps""" ctx = self.server_contexts[guild_id] times = self.server_message_times[guild_id] now = time.time() # Count messages in time windows ctx.messages_last_5min = sum(1 for t in times if now - t < 300) ctx.messages_last_hour = sum(1 for t in times if now - t < 3600) # Calculate conversation momentum (0-1 scale) # High momentum = consistent messages in last 5 minutes if ctx.messages_last_5min >= 10: ctx.conversation_momentum = min(1.0, ctx.messages_last_5min / 20) else: ctx.conversation_momentum = ctx.messages_last_5min / 10 # Time since last action if guild_id in self.server_last_action: ctx.time_since_last_action = now - self.server_last_action[guild_id] else: ctx.time_since_last_action = float('inf') # Time context ctx.hour_of_day = datetime.now().hour ctx.is_weekend = datetime.now().weekday() >= 5 def should_take_action(self, guild_id: int, debug: bool = False, triggered_by_message: bool = False) -> Optional[str]: """ Determine if Miku should take action and what type. Returns action type or None. This is the CORE decision logic - no LLM needed! Args: guild_id: Server ID debug: If True, print detailed decision reasoning triggered_by_message: If True, this check was triggered immediately after someone sent a message """ if guild_id not in self.server_contexts: return None ctx = self.server_contexts[guild_id] # STARTUP COOLDOWN: Don't act for first 2 minutes after bot startup # This prevents rapid-fire messages when bot restarts time_since_startup = time.time() - self.bot_startup_time if time_since_startup < 120: # 2 minutes if debug: print(f"ā³ [V2 Debug] Startup cooldown active ({time_since_startup:.0f}s / 120s)") return None # Never act when asleep if ctx.current_mood == "asleep": if debug: print(f"šŸ’¤ [V2 Debug] Mood is 'asleep' - no action taken") return None # Get mood personality profile = self.mood_profiles.get(ctx.current_mood, self.mood_profiles["neutral"]) # Update metrics self._update_activity_metrics(guild_id) if debug: print(f"\nšŸ” [V2 Debug] Decision Check for Guild {guild_id}") print(f" Triggered by message: {triggered_by_message}") print(f" Mood: {ctx.current_mood} (energy={profile['energy']:.2f}, sociability={profile['sociability']:.2f}, impulsiveness={profile['impulsiveness']:.2f})") print(f" Momentum: {ctx.conversation_momentum:.2f}") print(f" Messages (5min/1hr): {ctx.messages_last_5min}/{ctx.messages_last_hour}") print(f" Messages since appearance: {ctx.messages_since_last_appearance}") print(f" Time since last action: {ctx.time_since_last_action:.0f}s") print(f" Active activities: {len(ctx.users_started_activity)}") # --- Decision Logic --- # 1. CONVERSATION JOIN (high priority when momentum is high) if self._should_join_conversation(ctx, profile, debug): if debug: print(f"āœ… [V2 Debug] DECISION: join_conversation") return "join_conversation" # 2. USER ENGAGEMENT (someone interesting appeared) if self._should_engage_user(ctx, profile, debug): if debug: print(f"āœ… [V2 Debug] DECISION: engage_user") return "engage_user" # 3. FOMO RESPONSE (lots of activity without her) # When FOMO triggers, join the conversation instead of saying something random if self._should_respond_to_fomo(ctx, profile, debug): if debug: print(f"āœ… [V2 Debug] DECISION: join_conversation (FOMO)") return "join_conversation" # Jump in and respond to what's being said # 4. BORED/LONELY (quiet for too long, depending on mood) # CRITICAL FIX: If this check was triggered by a message, convert "general" to "join_conversation" # This ensures Miku responds to the message instead of saying something random if self._should_break_silence(ctx, profile, debug): if triggered_by_message: if debug: print(f"āœ… [V2 Debug] DECISION: join_conversation (break silence, but message just sent)") return "join_conversation" # Respond to the message instead of random general statement else: if debug: print(f"āœ… [V2 Debug] DECISION: general (break silence)") return "general" # 5. SHARE TWEET (low activity, wants to share something) if self._should_share_content(ctx, profile, debug): if debug: print(f"āœ… [V2 Debug] DECISION: share_tweet") return "share_tweet" # 6. CHANGE PROFILE PICTURE (very rare, once per day) if self._should_change_profile_picture(ctx, profile, debug): if debug: print(f"āœ… [V2 Debug] DECISION: change_profile_picture") return "change_profile_picture" if debug: print(f"āŒ [V2 Debug] DECISION: None (no conditions met)") return None def _should_join_conversation(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool: """Decide if Miku should join an active conversation""" # High conversation momentum + sociable mood + hasn't spoken recently base_threshold = 0.6 mood_adjusted = base_threshold * (2.0 - profile["sociability"]) # Lower threshold if sociable conditions = { "momentum_check": ctx.conversation_momentum > mood_adjusted, "messages_check": ctx.messages_since_last_appearance >= 5, "cooldown_check": ctx.time_since_last_action > 300, "impulsiveness_roll": random.random() < profile["impulsiveness"] } result = all(conditions.values()) if debug: print(f" [Join Conv] momentum={ctx.conversation_momentum:.2f} > {mood_adjusted:.2f}? {conditions['momentum_check']}") print(f" [Join Conv] messages={ctx.messages_since_last_appearance} >= 5? {conditions['messages_check']}") print(f" [Join Conv] cooldown={ctx.time_since_last_action:.0f}s > 300s? {conditions['cooldown_check']}") print(f" [Join Conv] impulsive roll? {conditions['impulsiveness_roll']} | Result: {result}") return result def _should_engage_user(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool: """Decide if Miku should engage with a user (status change/activity)""" # Someone started a new activity or status changed + enough time passed has_activities = len(ctx.users_started_activity) > 0 cooldown_ok = ctx.time_since_last_action > 1800 roll = random.random() threshold = profile["sociability"] * profile["impulsiveness"] roll_ok = roll < threshold result = has_activities and cooldown_ok and roll_ok if debug and has_activities: activities = [name for name, ts in ctx.users_started_activity] print(f" [Engage] activities={activities}, cooldown={ctx.time_since_last_action:.0f}s > 1800s? {cooldown_ok}") print(f" [Engage] roll={roll:.2f} < {threshold:.2f}? {roll_ok} | Result: {result}") return result def _should_respond_to_fomo(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool: """Decide if Miku feels left out (FOMO)""" # Lots of messages but she hasn't participated fomo_threshold = 25 * (2.0 - profile["sociability"]) # Social moods have lower threshold msgs_check = ctx.messages_since_last_appearance > fomo_threshold momentum_check = ctx.conversation_momentum > 0.3 cooldown_check = ctx.time_since_last_action > 900 result = msgs_check and momentum_check and cooldown_check if debug: print(f" [FOMO] messages={ctx.messages_since_last_appearance} > {fomo_threshold:.0f}? {msgs_check}") print(f" [FOMO] momentum={ctx.conversation_momentum:.2f} > 0.3? {momentum_check}") print(f" [FOMO] cooldown={ctx.time_since_last_action:.0f}s > 900s? {cooldown_check} | Result: {result}") return result def _should_break_silence(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool: """Decide if Miku should break a long silence""" # Low activity + long time + mood-dependent min_silence = 1800 * (2.0 - profile["energy"]) # High energy = shorter wait quiet_check = ctx.messages_last_hour < 5 silence_check = ctx.time_since_last_action > min_silence energy_roll = random.random() energy_ok = energy_roll < profile["energy"] result = quiet_check and silence_check and energy_ok if debug: print(f" [Silence] msgs_last_hour={ctx.messages_last_hour} < 5? {quiet_check}") print(f" [Silence] time={ctx.time_since_last_action:.0f}s > {min_silence:.0f}s? {silence_check}") print(f" [Silence] energy roll={energy_roll:.2f} < {profile['energy']:.2f}? {energy_ok} | Result: {result}") return result def _should_share_content(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool: """Decide if Miku should share a tweet/content""" # Quiet period + curious/excited mood quiet_check = ctx.messages_last_hour < 10 cooldown_check = ctx.time_since_last_action > 3600 energy_roll = random.random() energy_threshold = profile["energy"] * 0.5 energy_ok = energy_roll < energy_threshold mood_ok = ctx.current_mood in ["curious", "excited", "bubbly", "neutral"] result = quiet_check and cooldown_check and energy_ok and mood_ok if debug: print(f" [Share] msgs_last_hour={ctx.messages_last_hour} < 10? {quiet_check}") print(f" [Share] cooldown={ctx.time_since_last_action:.0f}s > 3600s? {cooldown_check}") print(f" [Share] energy roll={energy_roll:.2f} < {energy_threshold:.2f}? {energy_ok}") print(f" [Share] mood '{ctx.current_mood}' appropriate? {mood_ok} | Result: {result}") return result def _should_change_profile_picture(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool: """ Decide if Miku should change her profile picture. This is a rare, once-per-day action. """ # Check if we've changed recently (track globally, not per-server) from datetime import datetime, timedelta import os import json metadata_path = "memory/profile_pictures/metadata.json" # Load last change time try: if os.path.exists(metadata_path): with open(metadata_path, 'r') as f: metadata = json.load(f) last_change = metadata.get("changed_at") if last_change: last_change_dt = datetime.fromisoformat(last_change) hours_since_change = (datetime.now() - last_change_dt).total_seconds() / 3600 if hours_since_change < 20: # At least 20 hours between changes if debug: print(f" [PFP] Last change {hours_since_change:.1f}h ago, waiting...") return False except Exception as e: if debug: print(f" [PFP] Error checking last change: {e}") # Only consider changing during certain hours (10 AM - 10 PM) hour = ctx.hour_of_day time_check = 10 <= hour <= 22 # Require low activity + long cooldown quiet_check = ctx.messages_last_hour < 5 cooldown_check = ctx.time_since_last_action > 5400 # 1.5 hours # Mood influences decision (more likely when bubbly, curious, excited) mood_boost = ctx.current_mood in ["bubbly", "curious", "excited", "silly"] # Very low base chance (roughly once per day) base_chance = 0.02 if mood_boost else 0.01 roll = random.random() roll_ok = roll < base_chance result = time_check and quiet_check and cooldown_check and roll_ok if debug: print(f" [PFP] hour={hour}, time_ok={time_check}") print(f" [PFP] msgs_last_hour={ctx.messages_last_hour} < 5? {quiet_check}") print(f" [PFP] cooldown={ctx.time_since_last_action:.0f}s > 5400s? {cooldown_check}") print(f" [PFP] mood_boost={mood_boost}, roll={roll:.4f} < {base_chance:.4f}? {roll_ok}") print(f" [PFP] Result: {result}") return result def should_react_to_message(self, guild_id: int, message_age_seconds: float = 0) -> bool: """ Decide if Miku should react to a message with an emoji. Called when new messages arrive OR by periodic scheduler. Args: guild_id: Server ID message_age_seconds: How old the message is (0 = brand new) Returns: True if should react, False otherwise """ if guild_id not in self.server_contexts: return False ctx = self.server_contexts[guild_id] # Never react when asleep if ctx.current_mood == "asleep": return False profile = self.mood_profiles.get(ctx.current_mood, self.mood_profiles["neutral"]) # Brand new message (real-time reaction) if message_age_seconds < 10: # Base 30% chance, modified by mood base_chance = 0.30 mood_multiplier = (profile["impulsiveness"] + profile["sociability"]) / 2 reaction_chance = base_chance * mood_multiplier # More likely to react to messages in active conversations if ctx.conversation_momentum > 0.5: reaction_chance *= 1.5 # Boost in active chats # Less likely if just reacted recently if ctx.time_since_last_action < 300: # 5 minutes reaction_chance *= 0.3 # Reduce significantly return random.random() < reaction_chance # Older message (scheduled reaction check) else: # Base 20% chance for scheduled reactions base_chance = 0.20 mood_multiplier = (profile["impulsiveness"] + profile["energy"]) / 2 reaction_chance = base_chance * mood_multiplier # Don't react to very old messages if chat is active if message_age_seconds > 1800 and ctx.messages_last_5min > 5: # 30 min old + active chat return False return random.random() < reaction_chance def record_action(self, guild_id: int): """Record that Miku took an action""" self.server_last_action[guild_id] = time.time() if guild_id in self.server_contexts: self.server_contexts[guild_id].messages_since_last_appearance = 0 # Clear some event counters self.server_contexts[guild_id].users_joined_recently = 0 self.server_contexts[guild_id].users_status_changed = 0 def decay_events(self, guild_id: int): """ Decay event counters over time (call periodically every 15 minutes). Uses proper exponential decay with 1-hour half-life. Also cleans up old activities. """ if guild_id not in self.server_contexts: return ctx = self.server_contexts[guild_id] # Decay user events (half-life of 1 hour) # For 15-minute intervals: decay_factor = 0.5^(1/4) ā‰ˆ 0.841 decay_factor = 0.5 ** (1/4) # ā‰ˆ 0.8408964... ctx.users_joined_recently = int(ctx.users_joined_recently * decay_factor) ctx.users_status_changed = int(ctx.users_status_changed * decay_factor) # Clean up old activities (older than 1 hour) self._clean_old_activities(guild_id, max_age_seconds=3600) # Global instance autonomous_engine = AutonomousEngine()