557 lines
24 KiB
Python
557 lines
24 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 ---
|
||
|
|
|
||
|
|
# 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()
|