Initial commit: Miku Discord Bot

This commit is contained in:
2025-12-07 17:15:09 +02:00
commit 8c74ad5260
206 changed files with 50125 additions and 0 deletions

View File

@@ -0,0 +1,556 @@
# 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()