- Moved on_message_event() call to END of message processing in bot.py - Only track messages for autonomous when NOT addressed to Miku - Fixed autonomous_engine.py to convert all message-triggered actions to join_conversation - Prevent inappropriate autonomous actions (general, share_tweet, change_profile_picture) when triggered by user messages - Ensures Miku responds to user messages FIRST before any autonomous action fires This fixes the issue where autonomous actions would fire before Miku's response to user messages, and ensures the 'detect and join conversation' safeguard works properly.
568 lines
25 KiB
Python
568 lines
25 KiB
Python
# 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 ---
|
|
|
|
# CRITICAL: If triggered by a message, we should ONLY do join_conversation
|
|
# This ensures Miku responds to what's being said, not random autonomous actions
|
|
# Exception: Reactions are handled separately and are allowed
|
|
|
|
# 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 triggered_by_message:
|
|
# Convert to join_conversation when message-triggered
|
|
if debug:
|
|
print(f"✅ [V2 Debug] DECISION: join_conversation (engage_user converted due to message trigger)")
|
|
return "join_conversation"
|
|
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)
|
|
# Skip this entirely when triggered by message - would be inappropriate to ignore user's message
|
|
if not triggered_by_message and 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)
|
|
# Skip this entirely when triggered by message
|
|
if not triggered_by_message and 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()
|