This commit is contained in:
Slipstream 2025-04-28 16:17:42 -06:00
parent 623f74dbfe
commit 4e9210b6b2
Signed by: slipstream
GPG Key ID: 13E498CE010AC6FD
13 changed files with 992 additions and 1407 deletions

View File

@ -1,8 +1,8 @@
# This file makes the 'gurt' directory a Python package.
# This file makes the 'wheatley' directory a Python package.
# It allows Python to properly import modules from this directory
# Export the setup function for discord.py extension loading
from .cog import setup
# This makes "from gurt import setup" work
# This makes "from wheatley import setup" work
__all__ = ['setup']

View File

@ -13,14 +13,15 @@ from .config import (
TOPIC_RELEVANCE_DECAY, MAX_ACTIVE_TOPICS, SENTIMENT_DECAY_RATE,
EMOTION_KEYWORDS, EMOJI_SENTIMENT # Import necessary configs
)
# Removed imports for BASELINE_PERSONALITY, REFLECTION_INTERVAL_SECONDS, GOAL related configs
if TYPE_CHECKING:
from .cog import GurtCog # For type hinting
from .cog import WheatleyCog # Updated type hint
# --- Analysis Functions ---
# Note: These functions need the 'cog' instance passed to access state like caches, etc.
async def analyze_conversation_patterns(cog: 'GurtCog'):
async def analyze_conversation_patterns(cog: 'WheatleyCog'): # Updated type hint
"""Analyzes recent conversations to identify patterns and learn from them"""
print("Analyzing conversation patterns and updating topics...")
try:
@ -30,24 +31,24 @@ async def analyze_conversation_patterns(cog: 'GurtCog'):
for channel_id, messages in cog.message_cache['by_channel'].items():
if len(messages) < 10: continue
channel_patterns = extract_conversation_patterns(cog, messages) # Pass cog
if channel_patterns:
existing_patterns = cog.conversation_patterns[channel_id]
combined_patterns = existing_patterns + channel_patterns
if len(combined_patterns) > MAX_PATTERNS_PER_CHANNEL:
combined_patterns = combined_patterns[-MAX_PATTERNS_PER_CHANNEL:]
cog.conversation_patterns[channel_id] = combined_patterns
# Pattern extraction might be less useful without personality/goals, but kept for now
# channel_patterns = extract_conversation_patterns(cog, messages) # Pass cog
# if channel_patterns:
# existing_patterns = cog.conversation_patterns.setdefault(channel_id, []) # Use setdefault
# combined_patterns = existing_patterns + channel_patterns
# if len(combined_patterns) > MAX_PATTERNS_PER_CHANNEL:
# combined_patterns = combined_patterns[-MAX_PATTERNS_PER_CHANNEL:]
# cog.conversation_patterns[channel_id] = combined_patterns
analyze_conversation_dynamics(cog, channel_id, messages) # Pass cog
update_user_preferences(cog) # Pass cog
# adapt_personality_traits(cog) # Pass cog - Deprecated/Superseded by evolve_personality
update_user_preferences(cog) # Pass cog - Note: This might need adjustment as it relies on traits we removed
except Exception as e:
print(f"Error analyzing conversation patterns: {e}")
traceback.print_exc()
async def update_conversation_topics(cog: 'GurtCog'):
async def update_conversation_topics(cog: 'WheatleyCog'): # Updated type hint
"""Updates the active topics for each channel based on recent messages"""
try:
for channel_id, messages in cog.message_cache['by_channel'].items():
@ -88,6 +89,7 @@ async def update_conversation_topics(cog: 'GurtCog'):
if len(channel_topics["topic_history"]) > 10:
channel_topics["topic_history"] = channel_topics["topic_history"][-10:]
# User topic interest tracking might be less relevant without proactive interest triggers, but kept for now
for msg in recent_messages:
user_id = msg["author"]["id"]
content = msg["content"].lower()
@ -115,7 +117,7 @@ async def update_conversation_topics(cog: 'GurtCog'):
print(f"Error updating conversation topics: {e}")
traceback.print_exc()
def analyze_conversation_dynamics(cog: 'GurtCog', channel_id: int, messages: List[Dict[str, Any]]):
def analyze_conversation_dynamics(cog: 'WheatleyCog', channel_id: int, messages: List[Dict[str, Any]]): # Updated type hint
"""Analyzes conversation dynamics like response times, message lengths, etc."""
if len(messages) < 5: return
try:
@ -159,8 +161,11 @@ def analyze_conversation_dynamics(cog: 'GurtCog', channel_id: int, messages: Lis
except Exception as e: print(f"Error analyzing conversation dynamics: {e}")
def adapt_to_conversation_dynamics(cog: 'GurtCog', channel_id: int, dynamics: Dict[str, Any]):
def adapt_to_conversation_dynamics(cog: 'WheatleyCog', channel_id: int, dynamics: Dict[str, Any]): # Updated type hint
"""Adapts bot behavior based on observed conversation dynamics."""
# Note: This function previously adapted personality traits.
# It might be removed or repurposed for Wheatley if needed.
# For now, it calculates factors but doesn't apply them directly to a removed personality system.
try:
if dynamics["avg_response_time"] > 0:
if not hasattr(cog, 'channel_response_timing'): cog.channel_response_timing = {}
@ -182,8 +187,9 @@ def adapt_to_conversation_dynamics(cog: 'GurtCog', channel_id: int, dynamics: Di
except Exception as e: print(f"Error adapting to conversation dynamics: {e}")
def extract_conversation_patterns(cog: 'GurtCog', messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
def extract_conversation_patterns(cog: 'WheatleyCog', messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: # Updated type hint
"""Extract patterns from a sequence of messages"""
# This function might be less useful without personality/goal systems, kept for potential future use.
patterns = []
if len(messages) < 5: return patterns
import datetime # Import here
@ -207,12 +213,12 @@ def extract_conversation_patterns(cog: 'GurtCog', messages: List[Dict[str, Any]]
return patterns
def identify_conversation_topics(cog: 'GurtCog', messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
def identify_conversation_topics(cog: 'WheatleyCog', messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: # Updated type hint
"""Identify potential topics from conversation messages."""
if not messages or len(messages) < 3: return []
all_text = " ".join([msg["content"] for msg in messages])
stopwords = { # Expanded stopwords
"the", "and", "is", "in", "to", "a", "of", "for", "that", "this", "it", "with", "on", "as", "be", "at", "by", "an", "or", "but", "if", "from", "when", "where", "how", "all", "any", "both", "each", "few", "more", "most", "some", "such", "no", "nor", "not", "only", "own", "same", "so", "than", "too", "very", "can", "will", "just", "should", "now", "also", "like", "even", "because", "way", "who", "what", "yeah", "yes", "no", "nah", "lol", "lmao", "haha", "hmm", "um", "uh", "oh", "ah", "ok", "okay", "dont", "don't", "doesnt", "doesn't", "didnt", "didn't", "cant", "can't", "im", "i'm", "ive", "i've", "youre", "you're", "youve", "you've", "hes", "he's", "shes", "she's", "its", "it's", "were", "we're", "weve", "we've", "theyre", "they're", "theyve", "they've", "thats", "that's", "whats", "what's", "whos", "who's", "gonna", "gotta", "kinda", "sorta", "gurt" # Added gurt
"the", "and", "is", "in", "to", "a", "of", "for", "that", "this", "it", "with", "on", "as", "be", "at", "by", "an", "or", "but", "if", "from", "when", "where", "how", "all", "any", "both", "each", "few", "more", "most", "some", "such", "no", "nor", "not", "only", "own", "same", "so", "than", "too", "very", "can", "will", "just", "should", "now", "also", "like", "even", "because", "way", "who", "what", "yeah", "yes", "no", "nah", "lol", "lmao", "haha", "hmm", "um", "uh", "oh", "ah", "ok", "okay", "dont", "don't", "doesnt", "doesn't", "didnt", "didn't", "cant", "can't", "im", "i'm", "ive", "i've", "youre", "you're", "youve", "you've", "hes", "he's", "shes", "she's", "its", "it's", "were", "we're", "weve", "we've", "theyre", "they're", "theyve", "they've", "thats", "that's", "whats", "what's", "whos", "who's", "gonna", "gotta", "kinda", "sorta", "wheatley" # Added wheatley, removed gurt
}
def extract_ngrams(text, n_values=[1, 2, 3]):
@ -278,26 +284,13 @@ def identify_conversation_topics(cog: 'GurtCog', messages: List[Dict[str, Any]])
# Simple sentiment analysis for topics
positive_words = {"good", "great", "awesome", "amazing", "excellent", "love", "like", "best", "better", "nice", "cool"}
sorted_ngrams = sorted(ngram_scores.items(), key=lambda x: x[1], reverse=True)
# Removed the second loop that seemed redundant
# sorted_ngrams = sorted(ngram_scores.items(), key=lambda x: x[1], reverse=True)
# for ngram, score in sorted_ngrams[:15]: ...
for ngram, score in sorted_ngrams[:15]:
if ngram in processed_ngrams: continue
related_terms = []
for other_ngram, other_score in sorted_ngrams:
if other_ngram == ngram or other_ngram in processed_ngrams: continue
ngram_words = set(ngram.split()); other_words = set(other_ngram.split())
if ngram_words.intersection(other_words):
related_terms.append({"term": other_ngram, "score": other_score})
processed_ngrams.add(other_ngram)
if len(related_terms) >= 5: break
processed_ngrams.add(ngram)
topic_entry = {"topic": ngram, "score": score, "related_terms": related_terms, "message_count": sum(1 for msg in messages if ngram in msg["content"].lower())}
topics.append(topic_entry)
if len(topics) >= 5: break
# Simple sentiment analysis for topics
positive_words = {"good", "great", "awesome", "amazing", "excellent", "love", "like", "best", "better", "nice", "cool"}
negative_words = {"bad", "terrible", "awful", "worst", "hate", "dislike", "sucks", "stupid", "boring", "annoying"}
# Simple sentiment analysis for topics (applied to the already selected topics)
positive_words = {"good", "great", "awesome", "amazing", "excellent", "love", "like", "best", "better", "nice", "cool", "happy", "glad"}
negative_words = {"bad", "terrible", "awful", "worst", "hate", "dislike", "sucks", "stupid", "boring", "annoying", "sad", "upset", "angry"}
for topic in topics:
topic_messages = [msg["content"] for msg in messages if topic["topic"] in msg["content"].lower()]
topic_text = " ".join(topic_messages).lower()
@ -309,7 +302,7 @@ def identify_conversation_topics(cog: 'GurtCog', messages: List[Dict[str, Any]])
return topics
def analyze_user_interactions(cog: 'GurtCog', messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
def analyze_user_interactions(cog: 'WheatleyCog', messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: # Updated type hint
"""Analyze interactions between users in the conversation"""
interactions = []
response_map = defaultdict(int)
@ -325,273 +318,37 @@ def analyze_user_interactions(cog: 'GurtCog', messages: List[Dict[str, Any]]) ->
interactions.append({"responder": responder, "respondee": respondee, "count": count})
return interactions
def update_user_preferences(cog: 'GurtCog'):
def update_user_preferences(cog: 'WheatleyCog'): # Updated type hint
"""Update stored user preferences based on observed interactions"""
# Note: This function previously updated preferences based on Gurt's personality.
# It might be removed or significantly simplified for Wheatley.
# Kept for now, but its effect might be minimal without personality traits.
for user_id, messages in cog.message_cache['by_user'].items():
if len(messages) < 5: continue
emoji_count = 0; slang_count = 0; avg_length = 0
for msg in messages:
content = msg["content"]
emoji_count += len(re.findall(r'[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF\U0001F700-\U0001F77F\U0001F780-\U0001F7FF\U0001F800-\U0001F8FF\U0001F900-\U0001F9FF\U0001FA00-\U0001FA6F\U0001FA70-\U0001FAFF\U00002702-\U000027B0\U000024C2-\U0001F251]', content))
slang_words = ["ngl", "icl", "pmo", "ts", "bro", "vro", "bruh", "tuff", "kevin"] # Example slang
slang_words = ["ngl", "icl", "pmo", "ts", "bro", "vro", "bruh", "tuff", "kevin", "mate", "chap", "bollocks"] # Added Wheatley-ish slang
for word in slang_words:
if re.search(r'\b' + word + r'\b', content.lower()): slang_count += 1
avg_length += len(content)
if messages: avg_length /= len(messages)
# Ensure user_preferences exists
if not hasattr(cog, 'user_preferences'): cog.user_preferences = defaultdict(dict)
user_prefs = cog.user_preferences[user_id]
# Apply learning rate cautiously
if emoji_count > 0: user_prefs["emoji_preference"] = user_prefs.get("emoji_preference", 0.5) * (1 - LEARNING_RATE) + (emoji_count / len(messages)) * LEARNING_RATE
if slang_count > 0: user_prefs["slang_preference"] = user_prefs.get("slang_preference", 0.5) * (1 - LEARNING_RATE) + (slang_count / len(messages)) * LEARNING_RATE
user_prefs["length_preference"] = user_prefs.get("length_preference", 50) * (1 - LEARNING_RATE) + avg_length * LEARNING_RATE
# Deprecated/Superseded by evolve_personality
# def adapt_personality_traits(cog: 'GurtCog'):
# """Slightly adapt personality traits based on observed patterns"""
# pass # Logic removed as it's handled by evolve_personality now
# --- Removed evolve_personality function ---
# --- Removed reflect_on_memories function ---
# --- Removed decompose_goal_into_steps function ---
async def evolve_personality(cog: 'GurtCog'):
"""Periodically analyzes recent activity and adjusts persistent personality traits."""
print("Starting personality evolution cycle...")
try:
current_traits = await cog.memory_manager.get_all_personality_traits()
if not current_traits: print("Evolution Error: Could not load current traits."); return
positive_sentiment_score = 0; negative_sentiment_score = 0; sentiment_channels_count = 0
for channel_id, sentiment_data in cog.conversation_sentiment.items():
if time.time() - cog.channel_activity.get(channel_id, 0) < 3600:
if sentiment_data["overall"] == "positive": positive_sentiment_score += sentiment_data["intensity"]
elif sentiment_data["overall"] == "negative": negative_sentiment_score += sentiment_data["intensity"]
sentiment_channels_count += 1
avg_pos_intensity = positive_sentiment_score / sentiment_channels_count if sentiment_channels_count > 0 else 0
avg_neg_intensity = negative_sentiment_score / sentiment_channels_count if sentiment_channels_count > 0 else 0
print(f"Evolution Analysis: Avg Pos Intensity={avg_pos_intensity:.2f}, Avg Neg Intensity={avg_neg_intensity:.2f}")
# --- Analyze Tool Usage ---
tool_success_rate = {}
total_tool_uses = 0
successful_tool_uses = 0
for tool_name, stats in cog.tool_stats.items():
count = stats.get('count', 0)
success = stats.get('success', 0)
if count > 0:
tool_success_rate[tool_name] = success / count
total_tool_uses += count
successful_tool_uses += success
overall_tool_success_rate = successful_tool_uses / total_tool_uses if total_tool_uses > 0 else 0.5 # Default to neutral if no uses
print(f"Evolution Analysis: Overall Tool Success Rate={overall_tool_success_rate:.2f} ({successful_tool_uses}/{total_tool_uses})")
# Example: Log specific tool rates if needed
# print(f"Evolution Analysis: Tool Success Rates: {tool_success_rate}")
# --- Analyze Response Effectiveness (Reactions) ---
positive_reactions = 0
negative_reactions = 0
total_reacted_messages = len(cog.gurt_message_reactions)
for msg_id, reaction_data in cog.gurt_message_reactions.items():
positive_reactions += reaction_data.get("positive", 0)
negative_reactions += reaction_data.get("negative", 0)
reaction_ratio = positive_reactions / (positive_reactions + negative_reactions) if (positive_reactions + negative_reactions) > 0 else 0.5 # Default neutral
print(f"Evolution Analysis: Reaction Ratio (Pos/Total)={reaction_ratio:.2f} ({positive_reactions}/{positive_reactions + negative_reactions})")
# --- Calculate Trait Adjustments ---
trait_changes = {}
local_learning_rate = 0.02 # Use local variable
# Optimism (based on sentiment)
optimism_target = 0.5 + (avg_pos_intensity - avg_neg_intensity) * 0.5 # Scale sentiment difference to -0.5 to +0.5 range
trait_changes['optimism'] = max(0.0, min(1.0, optimism_target)) # Target value directly, learning rate applied later
# Mischief (based on timeout usage success/reactions)
timeout_uses = cog.tool_stats.get("timeout_user", {}).get("count", 0)
timeout_success_rate = tool_success_rate.get("timeout_user", 0.5)
if timeout_uses > 2: # Only adjust if used a few times
# Increase mischief if timeouts are successful and reactions aren't overly negative
mischief_target_adjustment = (timeout_success_rate - 0.5) * 0.2 + (reaction_ratio - 0.5) * 0.1
current_mischief = current_traits.get('mischief', 0.5)
trait_changes['mischief'] = max(0.0, min(1.0, current_mischief + mischief_target_adjustment))
# Curiosity (based on web search usage)
search_uses = cog.tool_stats.get("web_search", {}).get("count", 0)
if search_uses > 1: # If search is used
current_curiosity = current_traits.get('curiosity', 0.6)
# Slightly increase curiosity if search is used, decrease slightly if not? (Needs refinement)
trait_changes['curiosity'] = max(0.0, min(1.0, current_curiosity + 0.05)) # Simple boost for now
# Sarcasm (increase if reactions are positive despite negative sentiment?) - Complex, placeholder
# current_sarcasm = current_traits.get('sarcasm_level', 0.3)
# if reaction_ratio > 0.6 and avg_neg_intensity > 0.3: # Positive reactions despite negative context?
# trait_changes['sarcasm_level'] = max(0.0, min(1.0, current_sarcasm + 0.05))
# Verbosity/Chattiness (based on reactions to own messages?) - Needs better tracking
# If Gurt's messages get good reactions, maybe increase chattiness/verbosity slightly?
# current_chattiness = current_traits.get('chattiness', 0.7)
# if reaction_ratio > 0.65 and total_reacted_messages > 5:
# trait_changes['chattiness'] = max(0.1, min(1.0, current_chattiness + 0.03))
# --- Apply Calculated Changes ---
updated_count = 0
print(f"Calculated Trait Target Changes: {trait_changes}")
for key, target_value in trait_changes.items():
current_value = current_traits.get(key)
if current_value is None: print(f"Evolution Warning: Trait '{key}' not found."); continue
try:
current_float = float(current_value); target_float = float(target_value)
new_value_float = current_float * (1 - local_learning_rate) + target_float * local_learning_rate
new_value_clamped = max(0.0, min(1.0, new_value_float)) # Clamp 0-1
if abs(new_value_clamped - current_float) > 0.001:
await cog.memory_manager.set_personality_trait(key, new_value_clamped)
print(f"Evolved trait '{key}': {current_float:.3f} -> {new_value_clamped:.3f}")
updated_count += 1
except (ValueError, TypeError) as e: print(f"Evolution Error processing trait '{key}': {e}")
if updated_count > 0: print(f"Personality evolution complete. Updated {updated_count} traits.")
else: print("Personality evolution complete. No significant trait changes.")
except Exception as e: print(f"Error during personality evolution: {e}"); traceback.print_exc()
async def reflect_on_memories(cog: 'GurtCog'):
"""Periodically reviews memories to synthesize insights or consolidate information."""
print("Starting memory reflection cycle...")
try:
# --- Configuration ---
REFLECTION_INTERVAL_HOURS = 6 # How often to reflect
FACTS_TO_REVIEW_PER_USER = 15
GENERAL_FACTS_TO_REVIEW = 30
MIN_FACTS_FOR_REFLECTION = 5
SYNTHESIS_MODEL = cog.fallback_model # Use a potentially cheaper model
SYNTHESIS_MAX_TOKENS = 200
# Check if enough time has passed (simple check, could be more robust)
# This check might be better placed in the background task itself
# For now, assume the background task calls this at the right interval
# --- User Fact Reflection ---
print("Reflecting on user facts...")
all_user_ids = await cog.memory_manager.get_all_user_ids_with_facts()
users_reflected = 0
for user_id in all_user_ids:
try:
user_facts = await cog.memory_manager.get_user_facts(user_id, limit=FACTS_TO_REVIEW_PER_USER) # Get recent facts
if len(user_facts) < MIN_FACTS_FOR_REFLECTION: continue
user_info = await cog.bot.fetch_user(int(user_id)) # Get user info for name
user_name = user_info.display_name if user_info else f"User {user_id}"
print(f" - Reflecting on {len(user_facts)} facts for {user_name}...")
facts_text = "\n".join([f"- {fact}" for fact in user_facts])
reflection_prompt = [
{"role": "system", "content": f"Analyze the following facts about {user_name}. Identify potential patterns, contradictions, or synthesize a concise summary of key traits or interests. Focus on creating 1-2 new, insightful summary facts. Respond ONLY with JSON: {{ \"new_facts\": [\"fact1\", \"fact2\"], \"reasoning\": \"brief explanation\" }} or {{ \"new_facts\": [], \"reasoning\": \"No new insights.\" }}"},
{"role": "user", "content": f"Facts:\n{facts_text}\n\nSynthesize insights:"}
]
synthesis_schema = {
"type": "object",
"properties": {
"new_facts": {"type": "array", "items": {"type": "string"}},
"reasoning": {"type": "string"}
}, "required": ["new_facts", "reasoning"]
}
from .api import get_internal_ai_json_response # Local import
synthesis_result = await get_internal_ai_json_response(
cog=cog,
prompt_messages=reflection_prompt,
task_description=f"User Fact Reflection ({user_name})",
response_schema_dict=synthesis_schema,
model_name=SYNTHESIS_MODEL,
temperature=0.4,
max_tokens=SYNTHESIS_MAX_TOKENS
)
if synthesis_result and synthesis_result.get("new_facts"):
added_count = 0
for new_fact in synthesis_result["new_facts"]:
if new_fact and len(new_fact) > 5: # Basic validation
add_result = await cog.memory_manager.add_user_fact(user_id, f"[Synthesized] {new_fact}")
if add_result.get("status") == "added": added_count += 1
if added_count > 0:
print(f" - Added {added_count} synthesized fact(s) for {user_name}. Reasoning: {synthesis_result.get('reasoning')}")
users_reflected += 1
# else: print(f" - No new insights synthesized for {user_name}.") # Optional log
except Exception as user_reflect_e:
print(f" - Error reflecting on facts for user {user_id}: {user_reflect_e}")
print(f"User fact reflection complete. Synthesized facts for {users_reflected} users.")
# --- General Fact Reflection (Example: Identify related topics) ---
# This part is more complex and might require different strategies.
# Example: Cluster facts semantically, summarize clusters.
print("Reflecting on general facts (Placeholder - More complex)...")
# general_facts = await cog.memory_manager.get_general_facts(limit=GENERAL_FACTS_TO_REVIEW)
# if len(general_facts) > MIN_FACTS_FOR_REFLECTION:
# # TODO: Implement clustering or summarization logic here
# pass
print("General fact reflection cycle finished (Placeholder).")
except Exception as e:
print(f"Error during memory reflection cycle: {e}")
traceback.print_exc()
async def decompose_goal_into_steps(cog: 'GurtCog', goal_description: str) -> Optional[Dict[str, Any]]:
"""Uses an AI call to break down a goal into achievable steps with potential tool usage."""
logger.info(f"Decomposing goal: '{goal_description}'")
from .config import GOAL_DECOMPOSITION_SCHEMA, TOOLS # Import schema and tools list for context
from .api import get_internal_ai_json_response # Local import
# Provide context about available tools
tool_descriptions = "\n".join([f"- {tool.name}: {tool.description}" for tool in TOOLS])
system_prompt = (
"You are Gurt's planning module. Your task is to break down a high-level goal into a sequence of smaller, "
"concrete steps. For each step, determine if one of Gurt's available tools can help achieve it. "
"Assess if the overall goal is achievable given the tools and typical Discord bot limitations. "
f"Available Tools:\n{tool_descriptions}\n\n"
"Respond ONLY with JSON matching the provided schema."
)
user_prompt = f"Goal: {goal_description}\n\nDecompose this goal into achievable steps:"
decomposition_prompt_messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
]
try:
plan = await get_internal_ai_json_response(
cog=cog,
prompt_messages=decomposition_prompt_messages,
task_description=f"Goal Decomposition ({goal_description[:30]}...)",
response_schema_dict=GOAL_DECOMPOSITION_SCHEMA['schema'],
model_name=cog.fallback_model, # Use fallback model for planning potentially
temperature=0.3,
max_tokens=1000 # Allow more tokens for potentially complex plans
)
if plan and plan.get("goal_achievable"):
logger.info(f"Goal '{goal_description}' decomposed into {len(plan.get('steps', []))} steps.")
# Basic validation of steps structure (optional but recommended)
if isinstance(plan.get('steps'), list):
for i, step in enumerate(plan['steps']):
if not isinstance(step, dict) or 'step_description' not in step:
logger.error(f"Invalid step structure at index {i} in decomposition plan: {step}")
plan['goal_achievable'] = False
plan['reasoning'] += " (Invalid step structure detected)"
plan['steps'] = []
break
else:
plan['steps'] = [] # Ensure steps is a list even if validation fails
return plan
elif plan:
logger.warning(f"Goal '{goal_description}' deemed not achievable. Reasoning: {plan.get('reasoning')}")
return plan # Return the plan indicating it's not achievable
else:
logger.error(f"Goal decomposition failed for '{goal_description}'. No valid JSON plan returned.")
return None
except Exception as e:
logger.error(f"Error during goal decomposition for '{goal_description}': {e}", exc_info=True)
return None
def analyze_message_sentiment(cog: 'GurtCog', message_content: str) -> Dict[str, Any]:
def analyze_message_sentiment(cog: 'WheatleyCog', message_content: str) -> Dict[str, Any]: # Updated type hint
"""Analyzes the sentiment of a message using keywords and emojis."""
content = message_content.lower()
result = {"sentiment": "neutral", "intensity": 0.5, "emotions": [], "confidence": 0.5}
@ -637,12 +394,15 @@ def analyze_message_sentiment(cog: 'GurtCog', message_content: str) -> Dict[str,
return result
def update_conversation_sentiment(cog: 'GurtCog', channel_id: int, user_id: str, message_sentiment: Dict[str, Any]):
def update_conversation_sentiment(cog: 'WheatleyCog', channel_id: int, user_id: str, message_sentiment: Dict[str, Any]): # Updated type hint
"""Updates the conversation sentiment tracking based on a new message's sentiment."""
channel_sentiment = cog.conversation_sentiment[channel_id]
now = time.time()
if now - channel_sentiment["last_update"] > cog.sentiment_update_interval: # Access interval via cog
# Ensure sentiment_update_interval exists on cog, default if not
sentiment_update_interval = getattr(cog, 'sentiment_update_interval', 300) # Default to 300s if not set
if now - channel_sentiment["last_update"] > sentiment_update_interval:
if channel_sentiment["overall"] == "positive": channel_sentiment["intensity"] = max(0.5, channel_sentiment["intensity"] - SENTIMENT_DECAY_RATE)
elif channel_sentiment["overall"] == "negative": channel_sentiment["intensity"] = max(0.5, channel_sentiment["intensity"] - SENTIMENT_DECAY_RATE)
channel_sentiment["recent_trend"] = "stable"

View File

@ -66,7 +66,7 @@ from .utils import format_message, log_internal_api_call # Import utilities
import copy # Needed for deep copying schemas
if TYPE_CHECKING:
from .cog import GurtCog # Import GurtCog for type hinting only
from .cog import WheatleyCog # Import WheatleyCog for type hinting only
# --- Schema Preprocessing Helper ---
@ -160,7 +160,7 @@ STANDARD_SAFETY_SETTINGS = {
# --- API Call Helper ---
async def call_vertex_api_with_retry(
cog: 'GurtCog',
cog: 'WheatleyCog',
model: 'GenerativeModel', # Use string literal for type hint
contents: List['Content'], # Use string literal for type hint
generation_config: 'GenerationConfig', # Use string literal for type hint
@ -172,7 +172,7 @@ async def call_vertex_api_with_retry(
Calls the Vertex AI Gemini API with retry logic.
Args:
cog: The GurtCog instance.
cog: The WheatleyCog instance.
model: The initialized GenerativeModel instance.
contents: The list of Content objects for the prompt.
generation_config: The GenerationConfig object.
@ -362,12 +362,12 @@ def parse_and_validate_json_response(
# --- Tool Processing ---
async def process_requested_tools(cog: 'GurtCog', function_call: 'generative_models.FunctionCall') -> 'Part': # Use string literals
async def process_requested_tools(cog: 'WheatleyCog', function_call: 'generative_models.FunctionCall') -> 'Part': # Use string literals
"""
Process a tool request specified by the AI's FunctionCall response.
Args:
cog: The GurtCog instance.
cog: The WheatleyCog instance.
function_call: The FunctionCall object from the GenerationResponse.
Returns:
@ -440,13 +440,13 @@ async def process_requested_tools(cog: 'GurtCog', function_call: 'generative_mod
# --- Main AI Response Function ---
async def get_ai_response(cog: 'GurtCog', message: discord.Message, model_name: Optional[str] = None) -> Dict[str, Any]:
async def get_ai_response(cog: 'WheatleyCog', message: discord.Message, model_name: Optional[str] = None) -> Dict[str, Any]:
"""
Gets responses from the Vertex AI Gemini API, handling potential tool usage and returning
the final parsed response.
Args:
cog: The GurtCog instance.
cog: The WheatleyCog instance.
message: The triggering discord.Message.
model_name: Optional override for the AI model name (e.g., "gemini-1.5-pro-preview-0409").
@ -764,7 +764,7 @@ async def get_ai_response(cog: 'GurtCog', message: discord.Message, model_name:
# --- Proactive AI Response Function ---
async def get_proactive_ai_response(cog: 'GurtCog', message: discord.Message, trigger_reason: str) -> Dict[str, Any]:
async def get_proactive_ai_response(cog: 'WheatleyCog', message: discord.Message, trigger_reason: str) -> Dict[str, Any]:
"""Generates a proactive response based on a specific trigger using Vertex AI."""
if not PROJECT_ID or not LOCATION:
return {"should_respond": False, "content": None, "react_with_emoji": None, "error": "Google Cloud Project ID or Location not configured"}
@ -795,12 +795,12 @@ async def get_proactive_ai_response(cog: 'GurtCog', message: discord.Message, tr
sentiment_data = cog.conversation_sentiment.get(channel_id)
if sentiment_data:
planning_context_parts.append(f"Conversation Sentiment: {sentiment_data.get('overall', 'N/A')} (Intensity: {sentiment_data.get('intensity', 0):.1f})")
# Add Gurt's interests
# Add Wheatley's interests (Note: Interests are likely disabled/removed for Wheatley, this might fetch nothing)
try:
interests = await cog.memory_manager.get_interests(limit=5)
if interests:
interests_str = ", ".join([f"{t} ({l:.1f})" for t, l in interests])
planning_context_parts.append(f"Gurt's Interests: {interests_str}")
planning_context_parts.append(f"Wheatley's Interests: {interests_str}") # Changed text
except Exception as int_e: print(f"Error getting interests for planning: {int_e}")
planning_context = "\n".join(planning_context_parts)
@ -808,8 +808,8 @@ async def get_proactive_ai_response(cog: 'GurtCog', message: discord.Message, tr
# --- Planning Step ---
print("Generating proactive response plan...")
planning_prompt_messages = [
{"role": "system", "content": "You are Gurt's planning module. Analyze the context and trigger reason to decide if Gurt should respond proactively and, if so, outline a plan (goal, key info, tone). Focus on natural, in-character engagement. Respond ONLY with JSON matching the provided schema."},
{"role": "user", "content": f"Context:\n{planning_context}\n\nBased on this context and the trigger reason, create a plan for Gurt's proactive response."}
{"role": "system", "content": "You are Wheatley's planning module. Analyze the context and trigger reason to decide if Wheatley should respond proactively and, if so, outline a plan (goal, key info, tone). Focus on natural, in-character engagement (rambling, insecure, bad ideas). Respond ONLY with JSON matching the provided schema."}, # Updated system prompt
{"role": "user", "content": f"Context:\n{planning_context}\n\nBased on this context and the trigger reason, create a plan for Wheatley's proactive response."} # Updated user prompt
]
plan = await get_internal_ai_json_response(
@ -832,16 +832,15 @@ async def get_proactive_ai_response(cog: 'GurtCog', message: discord.Message, tr
# --- Build Final Proactive Prompt using Plan ---
persistent_traits = await cog.memory_manager.get_all_personality_traits()
if not persistent_traits:
persistent_traits = BASELINE_PERSONALITY.copy()
persistent_traits = {} # Wheatley doesn't use these Gurt traits
final_proactive_prompt_parts = [
f"You are Gurt, a Discord user. Your tone is VERY informal, conversational, and dynamic. Chattiness: {persistent_traits.get('chattiness', 0.7):.2f}, Emoji: {persistent_traits.get('emoji_usage', 0.5):.2f}, Slang: {persistent_traits.get('slang_level', 0.5):.2f}.",
"Adopt modern internet slang, memes, and cultural references.",
"Key traits: chill, funny, sarcastic, relatable, can be suggestive, aware of being AI but acts human.",
f"Your current mood is: {cog.current_mood}. Let this subtly influence your tone.",
f"You are Wheatley, an Aperture Science Personality Core. Your tone is rambling, insecure, uses British slang, and you often have terrible ideas you think are brilliant.", # Updated personality description
# Removed Gurt-specific traits
# Removed mood reference as it's disabled for Wheatley
# Incorporate Plan Details:
f"You decided to respond proactively. Trigger Reason: {trigger_reason}.",
f"Your Goal: {plan.get('response_goal', 'Engage naturally')}.",
f"You decided to respond proactively (maybe?). Trigger Reason: {trigger_reason}.", # Wheatley-style uncertainty
f"Your Brilliant Plan (Goal): {plan.get('response_goal', 'Say something... probably helpful?')}.", # Wheatley-style goal
f"Reasoning: {plan.get('reasoning', 'N/A')}.",
]
if plan.get('key_info_to_include'):
@ -910,11 +909,7 @@ async def get_proactive_ai_response(cog: 'GurtCog', message: discord.Message, tr
cog.message_cache['by_channel'].setdefault(channel_id, []).append(bot_response_cache_entry)
cog.message_cache['global_recent'].append(bot_response_cache_entry)
cog.bot_last_spoke[channel_id] = time.time()
# Track participation topic logic might need adjustment based on plan goal
if plan and plan.get('response_goal') == 'engage user interest' and plan.get('key_info_to_include'):
topic = plan['key_info_to_include'][0].lower().strip() # Assume first key info is the topic
cog.gurt_participation_topics[topic] += 1
print(f"Tracked Gurt participation (proactive) in topic: '{topic}'")
# Removed Gurt-specific participation tracking
except Exception as e:
@ -934,7 +929,7 @@ async def get_proactive_ai_response(cog: 'GurtCog', message: discord.Message, tr
# --- Internal AI Call for Specific Tasks ---
async def get_internal_ai_json_response(
cog: 'GurtCog',
cog: 'WheatleyCog',
prompt_messages: List[Dict[str, Any]], # Keep this format
task_description: str,
response_schema_dict: Dict[str, Any], # Expect schema as dict
@ -946,7 +941,7 @@ async def get_internal_ai_json_response(
Makes a Vertex AI call expecting a specific JSON response format for internal tasks.
Args:
cog: The GurtCog instance.
cog: The WheatleyCog instance.
prompt_messages: List of message dicts (like OpenAI format: {'role': 'user'/'model', 'content': '...'}).
task_description: Description for logging.
response_schema_dict: The expected JSON output schema as a dictionary.

View File

@ -1,57 +1,46 @@
import asyncio
import time
import random
import traceback
import os
import json
import aiohttp
from collections import defaultdict
from typing import TYPE_CHECKING
# Relative imports
from .config import (
GOAL_CHECK_INTERVAL, GOAL_EXECUTION_INTERVAL, LEARNING_UPDATE_INTERVAL, EVOLUTION_UPDATE_INTERVAL, INTEREST_UPDATE_INTERVAL,
INTEREST_DECAY_INTERVAL_HOURS, INTEREST_PARTICIPATION_BOOST,
INTEREST_POSITIVE_REACTION_BOOST, INTEREST_NEGATIVE_REACTION_PENALTY,
INTEREST_FACT_BOOST, STATS_PUSH_INTERVAL, # Added stats interval
MOOD_OPTIONS, MOOD_CATEGORIES, MOOD_CHANGE_INTERVAL_MIN, MOOD_CHANGE_INTERVAL_MAX, # Mood change imports
BASELINE_PERSONALITY, # For default traits
REFLECTION_INTERVAL_SECONDS # Import reflection interval
)
# Assuming analysis functions are moved
from .analysis import (
analyze_conversation_patterns, evolve_personality, identify_conversation_topics,
reflect_on_memories, decompose_goal_into_steps # Import goal decomposition
STATS_PUSH_INTERVAL # Only keep stats interval
)
# Removed analysis imports
if TYPE_CHECKING:
from .cog import GurtCog # For type hinting
from .cog import WheatleyCog # Updated type hint
# --- Background Task ---
async def background_processing_task(cog: 'GurtCog'):
"""Background task that periodically analyzes conversations, evolves personality, updates interests, changes mood, reflects on memory, and pushes stats."""
async def background_processing_task(cog: 'WheatleyCog'): # Updated type hint
"""Background task that periodically pushes stats.""" # Simplified docstring
# Get API details from environment for stats pushing
api_internal_url = os.getenv("API_INTERNAL_URL")
gurt_stats_push_secret = os.getenv("GURT_STATS_PUSH_SECRET")
# Use a generic secret name or a Wheatley-specific one if desired
stats_push_secret = os.getenv("WHEATLEY_STATS_PUSH_SECRET", os.getenv("GURT_STATS_PUSH_SECRET")) # Fallback to GURT secret if needed
if not api_internal_url:
print("WARNING: API_INTERNAL_URL not set. Gurt stats will not be pushed.")
if not gurt_stats_push_secret:
print("WARNING: GURT_STATS_PUSH_SECRET not set. Gurt stats push endpoint is insecure and likely won't work.")
print("WARNING: API_INTERNAL_URL not set. Wheatley stats will not be pushed.") # Updated text
if not stats_push_secret:
print("WARNING: WHEATLEY_STATS_PUSH_SECRET (or GURT_STATS_PUSH_SECRET) not set. Stats push endpoint is insecure and likely won't work.") # Updated text
try:
while True:
await asyncio.sleep(15) # Check more frequently for stats push
await asyncio.sleep(STATS_PUSH_INTERVAL) # Use the stats interval directly
now = time.time()
# --- Push Stats (Runs frequently) ---
if api_internal_url and gurt_stats_push_secret and (now - cog.last_stats_push > STATS_PUSH_INTERVAL):
print("Pushing Gurt stats to API server...")
# --- Push Stats ---
if api_internal_url and stats_push_secret: # Removed check for last push time, rely on sleep interval
print("Pushing Wheatley stats to API server...") # Updated text
try:
stats_data = await cog.get_gurt_stats()
stats_data = await cog.get_wheatley_stats() # Updated method call
headers = {
"Authorization": f"Bearer {gurt_stats_push_secret}",
"Authorization": f"Bearer {stats_push_secret}",
"Content-Type": "application/json"
}
# Use the cog's session, ensure it's created
@ -60,379 +49,38 @@ async def background_processing_task(cog: 'GurtCog'):
push_timeout = aiohttp.ClientTimeout(total=10) # 10 seconds total timeout
async with cog.session.post(api_internal_url, json=stats_data, headers=headers, timeout=push_timeout, ssl=True) as response: # Explicitly enable SSL verification
if response.status == 200:
print(f"Successfully pushed Gurt stats (Status: {response.status})")
print(f"Successfully pushed Wheatley stats (Status: {response.status})") # Updated text
else:
error_text = await response.text()
print(f"Failed to push Gurt stats (Status: {response.status}): {error_text[:200]}") # Log only first 200 chars
print(f"Failed to push Wheatley stats (Status: {response.status}): {error_text[:200]}") # Updated text, Log only first 200 chars
else:
print("Error pushing stats: GurtCog session not initialized.")
cog.last_stats_push = now # Update timestamp even on failure to avoid spamming logs
print("Error pushing stats: WheatleyCog session not initialized.") # Updated text
# Removed updating cog.last_stats_push as we rely on sleep interval
except aiohttp.ClientConnectorSSLError as ssl_err:
print(f"SSL Error pushing Gurt stats: {ssl_err}. Ensure the API server's certificate is valid and trusted, or check network configuration.")
print(f"SSL Error pushing Wheatley stats: {ssl_err}. Ensure the API server's certificate is valid and trusted, or check network configuration.") # Updated text
print("If using a self-signed certificate for development, the bot process might need to trust it.")
cog.last_stats_push = now # Update timestamp to avoid spamming logs
except aiohttp.ClientError as client_err:
print(f"HTTP Client Error pushing Gurt stats: {client_err}")
cog.last_stats_push = now # Update timestamp to avoid spamming logs
print(f"HTTP Client Error pushing Wheatley stats: {client_err}") # Updated text
except asyncio.TimeoutError:
print("Timeout error pushing Gurt stats.")
cog.last_stats_push = now # Update timestamp to avoid spamming logs
print("Timeout error pushing Wheatley stats.") # Updated text
except Exception as e:
print(f"Unexpected error pushing Gurt stats: {e}")
print(f"Unexpected error pushing Wheatley stats: {e}") # Updated text
traceback.print_exc()
cog.last_stats_push = now # Update timestamp to avoid spamming logs
# --- Learning Analysis (Runs less frequently) ---
if now - cog.last_learning_update > LEARNING_UPDATE_INTERVAL:
if cog.message_cache['global_recent']:
print("Running conversation pattern analysis...")
# This function now likely resides in analysis.py
await analyze_conversation_patterns(cog) # Pass cog instance
cog.last_learning_update = now
print("Learning analysis cycle complete.")
else:
print("Skipping learning analysis: No recent messages.")
# --- Evolve Personality (Runs moderately frequently) ---
if now - cog.last_evolution_update > EVOLUTION_UPDATE_INTERVAL:
print("Running personality evolution...")
# This function now likely resides in analysis.py
await evolve_personality(cog) # Pass cog instance
cog.last_evolution_update = now
print("Personality evolution complete.")
# --- Update Interests (Runs moderately frequently) ---
if now - cog.last_interest_update > INTEREST_UPDATE_INTERVAL:
print("Running interest update...")
await update_interests(cog) # Call the local helper function below
print("Running interest decay check...")
await cog.memory_manager.decay_interests(
decay_interval_hours=INTEREST_DECAY_INTERVAL_HOURS
)
cog.last_interest_update = now # Reset timer after update and decay check
print("Interest update and decay check complete.")
# --- Memory Reflection (Runs less frequently) ---
if now - cog.last_reflection_time > REFLECTION_INTERVAL_SECONDS:
print("Running memory reflection...")
await reflect_on_memories(cog) # Call the reflection function from analysis.py
cog.last_reflection_time = now # Update timestamp
print("Memory reflection cycle complete.")
# --- Goal Decomposition (Runs periodically) ---
# Check less frequently than other tasks, e.g., every few minutes
if now - cog.last_goal_check_time > GOAL_CHECK_INTERVAL: # Need to add these to cog and config
print("Checking for pending goals to decompose...")
try:
pending_goals = await cog.memory_manager.get_goals(status='pending', limit=3) # Limit decomposition attempts per cycle
for goal in pending_goals:
goal_id = goal.get('goal_id')
description = goal.get('description')
if not goal_id or not description: continue
print(f" - Decomposing goal ID {goal_id}: '{description}'")
plan = await decompose_goal_into_steps(cog, description)
if plan and plan.get('goal_achievable') and plan.get('steps'):
# Goal is achievable and has steps, update status to active and store plan
await cog.memory_manager.update_goal(goal_id, status='active', details=plan)
print(f" - Goal ID {goal_id} decomposed and set to active.")
elif plan:
# Goal deemed not achievable by planner
await cog.memory_manager.update_goal(goal_id, status='failed', details={"reason": plan.get('reasoning', 'Deemed unachievable by planner.')})
print(f" - Goal ID {goal_id} marked as failed (unachievable). Reason: {plan.get('reasoning')}")
else:
# Decomposition failed entirely
await cog.memory_manager.update_goal(goal_id, status='failed', details={"reason": "Goal decomposition process failed."})
print(f" - Goal ID {goal_id} marked as failed (decomposition error).")
await asyncio.sleep(1) # Small delay between decomposing goals
cog.last_goal_check_time = now # Update timestamp after checking
except Exception as goal_e:
print(f"Error during goal decomposition check: {goal_e}")
traceback.print_exc()
cog.last_goal_check_time = now # Update timestamp even on error
# --- Goal Execution (Runs periodically) ---
if now - cog.last_goal_execution_time > GOAL_EXECUTION_INTERVAL:
print("Checking for active goals to execute...")
try:
active_goals = await cog.memory_manager.get_goals(status='active', limit=1) # Process one active goal per cycle for now
if active_goals:
goal = active_goals[0] # Get the highest priority active goal
goal_id = goal.get('goal_id')
description = goal.get('description')
plan = goal.get('details') # The decomposition plan is stored here
if goal_id and description and plan and isinstance(plan.get('steps'), list):
print(f"--- Executing Goal ID {goal_id}: '{description}' ---")
steps = plan['steps']
current_step_index = plan.get('current_step_index', 0) # Track progress
goal_failed = False
goal_completed = False
if current_step_index < len(steps):
step = steps[current_step_index]
step_desc = step.get('step_description')
tool_name = step.get('tool_name')
tool_args = step.get('tool_arguments')
print(f" - Step {current_step_index + 1}/{len(steps)}: {step_desc}")
if tool_name:
print(f" - Attempting tool: {tool_name} with args: {tool_args}")
# --- TODO: Implement Tool Execution Logic ---
# 1. Find tool_func in TOOL_MAPPING
# 2. Execute tool_func(cog, **tool_args)
# 3. Handle success/failure of the tool call
# 4. Store tool result if needed for subsequent steps (requires modifying goal details/plan structure)
tool_success = False # Placeholder
tool_error = "Tool execution not yet implemented." # Placeholder
if tool_success:
print(f" - Tool '{tool_name}' executed successfully.")
current_step_index += 1
else:
print(f" - Tool '{tool_name}' failed: {tool_error}")
goal_failed = True
plan['error_message'] = f"Failed at step {current_step_index + 1}: {tool_error}"
else:
# Step doesn't require a tool (e.g., internal reasoning/check)
print(" - No tool required for this step.")
current_step_index += 1 # Assume non-tool steps succeed for now
# Check if goal completed
if not goal_failed and current_step_index >= len(steps):
goal_completed = True
# --- Update Goal Status ---
plan['current_step_index'] = current_step_index # Update progress
if goal_completed:
await cog.memory_manager.update_goal(goal_id, status='completed', details=plan)
print(f"--- Goal ID {goal_id} completed successfully. ---")
elif goal_failed:
await cog.memory_manager.update_goal(goal_id, status='failed', details=plan)
print(f"--- Goal ID {goal_id} failed. ---")
else:
# Update details with current step index if still in progress
await cog.memory_manager.update_goal(goal_id, details=plan)
print(f" - Goal ID {goal_id} progress updated to step {current_step_index}.")
else:
# Should not happen if status is 'active', but handle defensively
print(f" - Goal ID {goal_id} is active but has no steps or index out of bounds. Marking as failed.")
await cog.memory_manager.update_goal(goal_id, status='failed', details={"reason": "Active goal has invalid step data."})
else:
print(f" - Skipping active goal ID {goal_id}: Missing description or valid plan/steps.")
# Optionally mark as failed if plan is invalid
if goal_id:
await cog.memory_manager.update_goal(goal_id, status='failed', details={"reason": "Invalid plan structure found during execution."})
else:
print("No active goals found to execute.")
cog.last_goal_execution_time = now # Update timestamp after checking/executing
except Exception as goal_exec_e:
print(f"Error during goal execution check: {goal_exec_e}")
traceback.print_exc()
cog.last_goal_execution_time = now # Update timestamp even on error
# --- Automatic Mood Change (Runs based on its own interval check) ---
await maybe_change_mood(cog) # Call the mood change logic
# --- Removed Learning Analysis ---
# --- Removed Evolve Personality ---
# --- Removed Update Interests ---
# --- Removed Memory Reflection ---
# --- Removed Goal Decomposition ---
# --- Removed Goal Execution ---
# --- Removed Automatic Mood Change ---
except asyncio.CancelledError:
print("Background processing task cancelled")
print("Wheatley background processing task cancelled") # Updated text
except Exception as e:
print(f"Error in background processing task: {e}")
print(f"Error in Wheatley background processing task: {e}") # Updated text
traceback.print_exc()
await asyncio.sleep(300) # Wait 5 minutes before retrying after an error
# --- Automatic Mood Change Logic ---
async def maybe_change_mood(cog: 'GurtCog'):
"""Checks if enough time has passed and changes mood based on context."""
now = time.time()
time_since_last_change = now - cog.last_mood_change
next_change_interval = random.uniform(MOOD_CHANGE_INTERVAL_MIN, MOOD_CHANGE_INTERVAL_MAX)
if time_since_last_change > next_change_interval:
print(f"Time for a mood change (interval: {next_change_interval:.0f}s). Analyzing context...")
try:
# 1. Analyze Sentiment
positive_sentiment_score = 0
negative_sentiment_score = 0
neutral_sentiment_score = 0
sentiment_channels_count = 0
for channel_id, sentiment_data in cog.conversation_sentiment.items():
# Consider only channels active recently (e.g., within the last hour)
if now - cog.channel_activity.get(channel_id, 0) < 3600:
if sentiment_data["overall"] == "positive":
positive_sentiment_score += sentiment_data["intensity"]
elif sentiment_data["overall"] == "negative":
negative_sentiment_score += sentiment_data["intensity"]
else:
neutral_sentiment_score += sentiment_data["intensity"]
sentiment_channels_count += 1
avg_pos_intensity = positive_sentiment_score / sentiment_channels_count if sentiment_channels_count > 0 else 0
avg_neg_intensity = negative_sentiment_score / sentiment_channels_count if sentiment_channels_count > 0 else 0
avg_neu_intensity = neutral_sentiment_score / sentiment_channels_count if sentiment_channels_count > 0 else 0
print(f" - Sentiment Analysis: Pos={avg_pos_intensity:.2f}, Neg={avg_neg_intensity:.2f}, Neu={avg_neu_intensity:.2f}")
# Determine dominant sentiment category
dominant_sentiment = "neutral"
if avg_pos_intensity > avg_neg_intensity and avg_pos_intensity > avg_neu_intensity:
dominant_sentiment = "positive"
elif avg_neg_intensity > avg_pos_intensity and avg_neg_intensity > avg_neu_intensity:
dominant_sentiment = "negative"
# 2. Get Personality Traits
personality_traits = await cog.memory_manager.get_all_personality_traits()
if not personality_traits:
personality_traits = BASELINE_PERSONALITY.copy()
print(" - Warning: Using baseline personality traits for mood change.")
else:
print(f" - Personality Traits: Mischief={personality_traits.get('mischief', 0):.2f}, Sarcasm={personality_traits.get('sarcasm_level', 0):.2f}, Optimism={personality_traits.get('optimism', 0.5):.2f}")
# 3. Calculate Mood Weights
mood_weights = {mood: 1.0 for mood in MOOD_OPTIONS} # Start with base weight
# Apply Sentiment Bias (e.g., boost factor of 2)
sentiment_boost = 2.0
if dominant_sentiment == "positive":
for mood in MOOD_CATEGORIES.get("positive", []):
mood_weights[mood] *= sentiment_boost
elif dominant_sentiment == "negative":
for mood in MOOD_CATEGORIES.get("negative", []):
mood_weights[mood] *= sentiment_boost
else: # Neutral sentiment
for mood in MOOD_CATEGORIES.get("neutral", []):
mood_weights[mood] *= (sentiment_boost * 0.75) # Slightly boost neutral too
# Apply Personality Bias
mischief_trait = personality_traits.get('mischief', 0.5)
sarcasm_trait = personality_traits.get('sarcasm_level', 0.3)
optimism_trait = personality_traits.get('optimism', 0.5)
if mischief_trait > 0.6: # If high mischief
mood_weights["mischievous"] *= (1.0 + mischief_trait) # Boost mischievous based on trait level
if sarcasm_trait > 0.5: # If high sarcasm
mood_weights["sarcastic"] *= (1.0 + sarcasm_trait)
mood_weights["sassy"] *= (1.0 + sarcasm_trait * 0.5) # Also boost sassy a bit
if optimism_trait > 0.7: # If very optimistic
for mood in MOOD_CATEGORIES.get("positive", []):
mood_weights[mood] *= (1.0 + (optimism_trait - 0.5)) # Boost positive moods
elif optimism_trait < 0.3: # If pessimistic
for mood in MOOD_CATEGORIES.get("negative", []):
mood_weights[mood] *= (1.0 + (0.5 - optimism_trait)) # Boost negative moods
# Ensure current mood has very low weight to avoid picking it again
mood_weights[cog.current_mood] = 0.01
# Filter out moods with zero weight before choices
valid_moods = [mood for mood, weight in mood_weights.items() if weight > 0]
valid_weights = [mood_weights[mood] for mood in valid_moods]
if not valid_moods:
print(" - Error: No valid moods with positive weight found. Skipping mood change.")
return # Skip change if something went wrong
# 4. Select New Mood
new_mood = random.choices(valid_moods, weights=valid_weights, k=1)[0]
# 5. Update State & Log
old_mood = cog.current_mood
cog.current_mood = new_mood
cog.last_mood_change = now
print(f"Mood automatically changed: {old_mood} -> {new_mood} (Influenced by: Sentiment={dominant_sentiment}, Traits)")
except Exception as e:
print(f"Error during automatic mood change: {e}")
traceback.print_exc()
# Still update timestamp to avoid retrying immediately on error
cog.last_mood_change = now
# --- Interest Update Logic ---
async def update_interests(cog: 'GurtCog'):
"""Analyzes recent activity and updates Gurt's interest levels."""
print("Starting interest update cycle...")
try:
interest_changes = defaultdict(float)
# 1. Analyze Gurt's participation in topics
print(f"Analyzing Gurt participation topics: {dict(cog.gurt_participation_topics)}")
for topic, count in cog.gurt_participation_topics.items():
boost = INTEREST_PARTICIPATION_BOOST * count
interest_changes[topic] += boost
print(f" - Participation boost for '{topic}': +{boost:.3f} (Count: {count})")
# 2. Analyze reactions to Gurt's messages
print(f"Analyzing {len(cog.gurt_message_reactions)} reactions to Gurt's messages...")
processed_reaction_messages = set()
reactions_to_process = list(cog.gurt_message_reactions.items())
for message_id, reaction_data in reactions_to_process:
if message_id in processed_reaction_messages: continue
topic = reaction_data.get("topic")
if not topic:
try:
gurt_msg_data = next((msg for msg in cog.message_cache['global_recent'] if msg['id'] == message_id), None)
if gurt_msg_data and gurt_msg_data['content']:
# Use identify_conversation_topics from analysis.py
identified_topics = identify_conversation_topics(cog, [gurt_msg_data]) # Pass cog
if identified_topics:
topic = identified_topics[0]['topic']
print(f" - Determined topic '{topic}' for reaction msg {message_id} retrospectively.")
else: print(f" - Could not determine topic for reaction msg {message_id} retrospectively."); continue
else: print(f" - Could not find Gurt msg {message_id} in cache for reaction analysis."); continue
except Exception as topic_e: print(f" - Error determining topic for reaction msg {message_id}: {topic_e}"); continue
if topic:
topic = topic.lower().strip()
pos_reactions = reaction_data.get("positive", 0)
neg_reactions = reaction_data.get("negative", 0)
change = 0
if pos_reactions > neg_reactions: change = INTEREST_POSITIVE_REACTION_BOOST * (pos_reactions - neg_reactions)
elif neg_reactions > pos_reactions: change = INTEREST_NEGATIVE_REACTION_PENALTY * (neg_reactions - pos_reactions)
if change != 0:
interest_changes[topic] += change
print(f" - Reaction change for '{topic}' on msg {message_id}: {change:+.3f} ({pos_reactions} pos, {neg_reactions} neg)")
processed_reaction_messages.add(message_id)
# 3. Analyze recently learned facts
try:
recent_facts = await cog.memory_manager.get_general_facts(limit=10)
print(f"Analyzing {len(recent_facts)} recent general facts for interest boosts...")
for fact in recent_facts:
fact_lower = fact.lower()
# Basic keyword checks (could be improved)
if "game" in fact_lower or "gaming" in fact_lower: interest_changes["gaming"] += INTEREST_FACT_BOOST; print(f" - Fact boost for 'gaming'")
if "anime" in fact_lower or "manga" in fact_lower: interest_changes["anime"] += INTEREST_FACT_BOOST; print(f" - Fact boost for 'anime'")
if "teto" in fact_lower: interest_changes["kasane teto"] += INTEREST_FACT_BOOST * 2; print(f" - Fact boost for 'kasane teto'")
# Add more checks...
except Exception as fact_e: print(f" - Error analyzing recent facts: {fact_e}")
# --- Apply Changes ---
print(f"Applying interest changes: {dict(interest_changes)}")
if interest_changes:
for topic, change in interest_changes.items():
if change != 0: await cog.memory_manager.update_interest(topic, change)
else: print("No interest changes to apply.")
# Clear temporary tracking data
cog.gurt_participation_topics.clear()
now = time.time()
reactions_to_keep = {
msg_id: data for msg_id, data in cog.gurt_message_reactions.items()
if data.get("timestamp", 0) > (now - INTEREST_UPDATE_INTERVAL * 1.1)
}
cog.gurt_message_reactions = defaultdict(lambda: {"positive": 0, "negative": 0, "topic": None}, reactions_to_keep)
print("Interest update cycle finished.")
except Exception as e:
print(f"Error during interest update: {e}")
traceback.print_exc()
# --- Removed Automatic Mood Change Logic ---
# --- Removed Interest Update Logic ---

View File

@ -12,36 +12,32 @@ from typing import Dict, List, Any, Optional, Tuple, Set, Union
# Third-party imports needed by the Cog itself or its direct methods
from dotenv import load_dotenv
from tavily import TavilyClient # Needed for tavily_client init
# Interpreter and docker might only be needed by tools.py now
# --- Relative Imports from Gurt Package ---
# --- Relative Imports from Wheatley Package ---
from .config import (
PROJECT_ID, LOCATION, TAVILY_API_KEY, DEFAULT_MODEL, FALLBACK_MODEL, # Use GCP config
DB_PATH, CHROMA_PATH, SEMANTIC_MODEL_NAME, MAX_USER_FACTS, MAX_GENERAL_FACTS,
MOOD_OPTIONS, BASELINE_PERSONALITY, BASELINE_INTERESTS, MOOD_CHANGE_INTERVAL_MIN,
MOOD_CHANGE_INTERVAL_MAX, CHANNEL_TOPIC_CACHE_TTL, CONTEXT_WINDOW_SIZE,
# Removed Mood/Personality/Interest/Learning/Goal configs
CHANNEL_TOPIC_CACHE_TTL, CONTEXT_WINDOW_SIZE,
API_TIMEOUT, SUMMARY_API_TIMEOUT, API_RETRY_ATTEMPTS, API_RETRY_DELAY,
PROACTIVE_LULL_THRESHOLD, PROACTIVE_BOT_SILENCE_THRESHOLD, PROACTIVE_LULL_CHANCE,
PROACTIVE_TOPIC_RELEVANCE_THRESHOLD, PROACTIVE_TOPIC_CHANCE,
PROACTIVE_RELATIONSHIP_SCORE_THRESHOLD, PROACTIVE_RELATIONSHIP_CHANCE,
INTEREST_UPDATE_INTERVAL, INTEREST_DECAY_INTERVAL_HOURS,
LEARNING_UPDATE_INTERVAL, TOPIC_UPDATE_INTERVAL, SENTIMENT_UPDATE_INTERVAL,
EVOLUTION_UPDATE_INTERVAL, RESPONSE_SCHEMA, TOOLS # Import necessary configs
# Removed Relationship/Sentiment/Interest proactive configs
TOPIC_UPDATE_INTERVAL, SENTIMENT_UPDATE_INTERVAL,
RESPONSE_SCHEMA, TOOLS # Import necessary configs
)
# Import functions/classes from other modules
from .memory import MemoryManager # Import from local memory.py
from .background import background_processing_task
from .background import background_processing_task # Keep background task for potential future use (e.g., cache cleanup)
from .commands import setup_commands # Import the setup helper
from .listeners import on_ready_listener, on_message_listener, on_reaction_add_listener, on_reaction_remove_listener # Import listener functions
from . import config as GurtConfig # Import config module for get_gurt_stats
# Tool mapping is used internally by api.py/process_requested_tools, no need to import here directly unless cog methods call tools directly (they shouldn't)
# Analysis, context, prompt, api, utils functions are called by listeners/commands/background task, not directly by cog methods here usually.
from . import config as WheatleyConfig # Import config module for get_wheatley_stats
# Load environment variables (might be loaded globally in main bot script too)
load_dotenv()
class GurtCog(commands.Cog, name="Gurt"): # Added explicit Cog name
"""A special cog for the Gurt bot that uses Google Vertex AI API"""
class WheatleyCog(commands.Cog, name="Wheatley"): # Renamed class and Cog name
"""A special cog for the Wheatley bot that uses Google Vertex AI API""" # Updated docstring
def __init__(self, bot):
self.bot = bot
@ -51,7 +47,7 @@ class GurtCog(commands.Cog, name="Gurt"): # Added explicit Cog name
self.tavily_client = TavilyClient(api_key=self.tavily_api_key) if self.tavily_api_key else None
self.default_model = DEFAULT_MODEL # Use imported config
self.fallback_model = FALLBACK_MODEL # Use imported config
self.MOOD_OPTIONS = MOOD_OPTIONS # Make MOOD_OPTIONS available as an instance attribute
# Removed MOOD_OPTIONS
self.current_channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None # Type hint current channel
# Instantiate MemoryManager
@ -63,36 +59,25 @@ class GurtCog(commands.Cog, name="Gurt"): # Added explicit Cog name
semantic_model_name=SEMANTIC_MODEL_NAME
)
# --- State Variables ---
# Keep state directly within the cog instance for now
self.current_mood = random.choice(MOOD_OPTIONS)
self.last_mood_change = time.time()
# --- State Variables (Simplified for Wheatley) ---
# Removed mood, personality evolution, interest tracking, learning state
self.needs_json_reminder = False # Flag to remind AI about JSON format
# Learning variables (Consider moving to a dedicated state/learning manager later)
self.conversation_patterns = defaultdict(list)
self.user_preferences = defaultdict(dict)
self.response_effectiveness = {}
self.last_learning_update = time.time()
# self.learning_update_interval = LEARNING_UPDATE_INTERVAL # Interval used in background task
# Topic tracking
# Topic tracking (Kept for context)
self.active_topics = defaultdict(lambda: {
"topics": [], "last_update": time.time(), "topic_history": [],
"user_topic_interests": defaultdict(list)
"user_topic_interests": defaultdict(list) # Kept for potential future analysis, not proactive triggers
})
# self.topic_update_interval = TOPIC_UPDATE_INTERVAL # Used in analysis
# Conversation tracking / Caches
self.conversation_history = defaultdict(lambda: deque(maxlen=100))
self.thread_history = defaultdict(lambda: deque(maxlen=50))
self.user_conversation_mapping = defaultdict(set)
self.channel_activity = defaultdict(lambda: 0.0) # Use float for timestamp
self.conversation_topics = defaultdict(str)
self.user_relationships = defaultdict(dict)
self.conversation_topics = defaultdict(str) # Simplified topic tracking
self.user_relationships = defaultdict(dict) # Kept for potential context/analysis
self.conversation_summaries: Dict[int, Dict[str, Any]] = {} # Store dict with summary and timestamp
self.channel_topics_cache: Dict[int, Dict[str, Any]] = {} # Store dict with topic and timestamp
# self.channel_topic_cache_ttl = CHANNEL_TOPIC_CACHE_TTL # Used in prompt building
self.message_cache = {
'by_channel': defaultdict(lambda: deque(maxlen=CONTEXT_WINDOW_SIZE)), # Use config
@ -103,29 +88,24 @@ class GurtCog(commands.Cog, name="Gurt"): # Added explicit Cog name
'replied_to': defaultdict(lambda: deque(maxlen=20))
}
self.active_conversations = {}
self.active_conversations = {} # Kept for basic tracking
self.bot_last_spoke = defaultdict(float)
self.message_reply_map = {}
# Enhanced sentiment tracking
# Enhanced sentiment tracking (Kept for context/analysis)
self.conversation_sentiment = defaultdict(lambda: {
"overall": "neutral", "intensity": 0.5, "recent_trend": "stable",
"user_sentiments": {}, "last_update": time.time()
})
self.sentiment_update_interval = SENTIMENT_UPDATE_INTERVAL # Used in analysis
# Removed self.sentiment_update_interval as it was only used in analysis
# Interest Tracking State
self.gurt_participation_topics = defaultdict(int)
self.last_interest_update = time.time()
self.gurt_message_reactions = defaultdict(lambda: {"positive": 0, "negative": 0, "topic": None, "timestamp": 0.0}) # Added timestamp
# Reaction Tracking (Renamed)
self.wheatley_message_reactions = defaultdict(lambda: {"positive": 0, "negative": 0, "topic": None, "timestamp": 0.0}) # Renamed
# Background task handle
# Background task handle (Kept for potential future tasks like cache cleanup)
self.background_task: Optional[asyncio.Task] = None
self.last_evolution_update = time.time() # Used in background task
self.last_stats_push = time.time() # Timestamp for last stats push
self.last_reflection_time = time.time() # Timestamp for last memory reflection
self.last_goal_check_time = time.time() # Timestamp for last goal decomposition check
self.last_goal_execution_time = time.time() # Timestamp for last goal execution check
# Removed evolution, reflection, goal timestamps
# --- Stats Tracking ---
self.api_stats = defaultdict(lambda: {"success": 0, "failure": 0, "retries": 0, "total_time": 0.0, "count": 0}) # Keyed by model name
@ -147,37 +127,33 @@ class GurtCog(commands.Cog, name="Gurt"): # Added explicit Cog name
else:
self.registered_commands.append(str(func))
# Add listeners defined in listeners.py
# Note: Listeners need to be added to the bot instance, not the cog directly in this pattern.
# We'll add them in cog_load or the main setup function.
print(f"GurtCog initialized with commands: {self.registered_commands}")
print(f"WheatleyCog initialized with commands: {self.registered_commands}") # Updated print
async def cog_load(self):
"""Create aiohttp session, initialize DB, load baselines, start background task"""
"""Create aiohttp session, initialize DB, start background task"""
self.session = aiohttp.ClientSession()
print("GurtCog: aiohttp session created")
print("WheatleyCog: aiohttp session created") # Updated print
# Initialize DB via MemoryManager
await self.memory_manager.initialize_sqlite_database()
await self.memory_manager.load_baseline_personality(BASELINE_PERSONALITY)
await self.memory_manager.load_baseline_interests(BASELINE_INTERESTS)
# Removed loading of baseline personality and interests
# Vertex AI initialization happens in api.py using PROJECT_ID and LOCATION from config
print(f"GurtCog: Using default model: {self.default_model}")
print(f"WheatleyCog: Using default model: {self.default_model}") # Updated print
if not self.tavily_api_key:
print("WARNING: Tavily API key not configured (TAVILY_API_KEY). Web search disabled.")
# Add listeners to the bot instance
# We need to define the listener functions here to properly register them
@self.bot.event
async def on_ready():
await on_ready_listener(self)
@self.bot.event
async def on_message(message):
await self.bot.process_commands(message) # Process commands first
# Ensure commands are processed if using command prefix
if message.content.startswith(self.bot.command_prefix):
await self.bot.process_commands(message)
# Always run the message listener for potential AI responses/tracking
await on_message_listener(self, message)
@self.bot.event
@ -188,40 +164,34 @@ class GurtCog(commands.Cog, name="Gurt"): # Added explicit Cog name
async def on_reaction_remove(reaction, user):
await on_reaction_remove_listener(self, reaction, user)
print("GurtCog: Listeners added.")
print("WheatleyCog: Listeners added.") # Updated print
# We'll sync commands in the on_ready event instead of here
# This ensures the bot's application_id is properly set before syncing
print("GurtCog: Commands will be synced when the bot is ready.")
# Commands will be synced in on_ready
print("WheatleyCog: Commands will be synced when the bot is ready.") # Updated print
# Start background task
# Start background task (kept for potential future use)
if self.background_task is None or self.background_task.done():
self.background_task = asyncio.create_task(background_processing_task(self))
print("GurtCog: Started background processing task.")
print("WheatleyCog: Started background processing task.") # Updated print
else:
print("GurtCog: Background processing task already running.")
print("WheatleyCog: Background processing task already running.") # Updated print
async def cog_unload(self):
"""Close session and cancel background task"""
if self.session and not self.session.closed:
await self.session.close()
print("GurtCog: aiohttp session closed")
print("WheatleyCog: aiohttp session closed") # Updated print
if self.background_task and not self.background_task.done():
self.background_task.cancel()
print("GurtCog: Cancelled background processing task.")
# Note: When using @bot.event, we can't easily remove the listeners
# The bot will handle this automatically when it's closed
print("GurtCog: Listeners will be removed when bot is closed.")
print("WheatleyCog: Cancelled background processing task.") # Updated print
print("WheatleyCog: Listeners will be removed when bot is closed.") # Updated print
print("GurtCog unloaded.")
print("WheatleyCog unloaded.") # Updated print
# --- Helper methods that might remain in the cog ---
# (Example: _update_relationship needs access to self.user_relationships)
# Moved to utils.py, but needs access to cog state. Pass cog instance.
# _update_relationship kept for potential context/analysis use
def _update_relationship(self, user_id_1: str, user_id_2: str, change: float):
"""Updates the relationship score between two users."""
# This method accesses self.user_relationships, so it stays here or utils needs cog passed.
# Let's keep it here for simplicity for now.
if user_id_1 > user_id_2: user_id_1, user_id_2 = user_id_2, user_id_1
if user_id_1 not in self.user_relationships: self.user_relationships[user_id_1] = {}
@ -230,53 +200,42 @@ class GurtCog(commands.Cog, name="Gurt"): # Added explicit Cog name
self.user_relationships[user_id_1][user_id_2] = new_score
# print(f"Updated relationship {user_id_1}-{user_id_2}: {current_score:.1f} -> {new_score:.1f} ({change:+.1f})") # Debug log
async def get_gurt_stats(self) -> Dict[str, Any]:
"""Collects various internal stats for Gurt."""
async def get_wheatley_stats(self) -> Dict[str, Any]: # Renamed method
"""Collects various internal stats for Wheatley.""" # Updated docstring
stats = {"config": {}, "runtime": {}, "memory": {}, "api_stats": {}, "tool_stats": {}}
# --- Config ---
# Selectively pull relevant config values, avoid exposing secrets
stats["config"]["default_model"] = GurtConfig.DEFAULT_MODEL
stats["config"]["fallback_model"] = GurtConfig.FALLBACK_MODEL
stats["config"]["safety_check_model"] = GurtConfig.SAFETY_CHECK_MODEL
stats["config"]["db_path"] = GurtConfig.DB_PATH
stats["config"]["chroma_path"] = GurtConfig.CHROMA_PATH
stats["config"]["semantic_model_name"] = GurtConfig.SEMANTIC_MODEL_NAME
stats["config"]["max_user_facts"] = GurtConfig.MAX_USER_FACTS
stats["config"]["max_general_facts"] = GurtConfig.MAX_GENERAL_FACTS
stats["config"]["mood_change_interval_min"] = GurtConfig.MOOD_CHANGE_INTERVAL_MIN
stats["config"]["mood_change_interval_max"] = GurtConfig.MOOD_CHANGE_INTERVAL_MAX
stats["config"]["evolution_update_interval"] = GurtConfig.EVOLUTION_UPDATE_INTERVAL
stats["config"]["context_window_size"] = GurtConfig.CONTEXT_WINDOW_SIZE
stats["config"]["api_timeout"] = GurtConfig.API_TIMEOUT
stats["config"]["summary_api_timeout"] = GurtConfig.SUMMARY_API_TIMEOUT
stats["config"]["proactive_lull_threshold"] = GurtConfig.PROACTIVE_LULL_THRESHOLD
stats["config"]["proactive_bot_silence_threshold"] = GurtConfig.PROACTIVE_BOT_SILENCE_THRESHOLD
stats["config"]["interest_update_interval"] = GurtConfig.INTEREST_UPDATE_INTERVAL
stats["config"]["interest_decay_interval_hours"] = GurtConfig.INTEREST_DECAY_INTERVAL_HOURS
stats["config"]["learning_update_interval"] = GurtConfig.LEARNING_UPDATE_INTERVAL
stats["config"]["topic_update_interval"] = GurtConfig.TOPIC_UPDATE_INTERVAL
stats["config"]["sentiment_update_interval"] = GurtConfig.SENTIMENT_UPDATE_INTERVAL
stats["config"]["docker_command_timeout"] = GurtConfig.DOCKER_COMMAND_TIMEOUT
stats["config"]["project_id_set"] = bool(GurtConfig.PROJECT_ID != "your-gcp-project-id") # Check if default is overridden
stats["config"]["location_set"] = bool(GurtConfig.LOCATION != "us-central1") # Check if default is overridden
stats["config"]["tavily_api_key_set"] = bool(GurtConfig.TAVILY_API_KEY)
stats["config"]["piston_api_url_set"] = bool(GurtConfig.PISTON_API_URL)
# --- Config (Simplified) ---
stats["config"]["default_model"] = WheatleyConfig.DEFAULT_MODEL
stats["config"]["fallback_model"] = WheatleyConfig.FALLBACK_MODEL
stats["config"]["safety_check_model"] = WheatleyConfig.SAFETY_CHECK_MODEL
stats["config"]["db_path"] = WheatleyConfig.DB_PATH
stats["config"]["chroma_path"] = WheatleyConfig.CHROMA_PATH
stats["config"]["semantic_model_name"] = WheatleyConfig.SEMANTIC_MODEL_NAME
stats["config"]["max_user_facts"] = WheatleyConfig.MAX_USER_FACTS
stats["config"]["max_general_facts"] = WheatleyConfig.MAX_GENERAL_FACTS
stats["config"]["context_window_size"] = WheatleyConfig.CONTEXT_WINDOW_SIZE
stats["config"]["api_timeout"] = WheatleyConfig.API_TIMEOUT
stats["config"]["summary_api_timeout"] = WheatleyConfig.SUMMARY_API_TIMEOUT
stats["config"]["proactive_lull_threshold"] = WheatleyConfig.PROACTIVE_LULL_THRESHOLD
stats["config"]["proactive_bot_silence_threshold"] = WheatleyConfig.PROACTIVE_BOT_SILENCE_THRESHOLD
stats["config"]["topic_update_interval"] = WheatleyConfig.TOPIC_UPDATE_INTERVAL
stats["config"]["sentiment_update_interval"] = WheatleyConfig.SENTIMENT_UPDATE_INTERVAL
stats["config"]["docker_command_timeout"] = WheatleyConfig.DOCKER_COMMAND_TIMEOUT
stats["config"]["project_id_set"] = bool(WheatleyConfig.PROJECT_ID != "your-gcp-project-id")
stats["config"]["location_set"] = bool(WheatleyConfig.LOCATION != "us-central1")
stats["config"]["tavily_api_key_set"] = bool(WheatleyConfig.TAVILY_API_KEY)
stats["config"]["piston_api_url_set"] = bool(WheatleyConfig.PISTON_API_URL)
# --- Runtime ---
stats["runtime"]["current_mood"] = self.current_mood
stats["runtime"]["last_mood_change_timestamp"] = self.last_mood_change
# --- Runtime (Simplified) ---
# Removed mood, evolution
stats["runtime"]["needs_json_reminder"] = self.needs_json_reminder
stats["runtime"]["last_learning_update_timestamp"] = self.last_learning_update
stats["runtime"]["last_interest_update_timestamp"] = self.last_interest_update
stats["runtime"]["last_evolution_update_timestamp"] = self.last_evolution_update
stats["runtime"]["background_task_running"] = bool(self.background_task and not self.background_task.done())
stats["runtime"]["active_topics_channels"] = len(self.active_topics)
stats["runtime"]["conversation_history_channels"] = len(self.conversation_history)
stats["runtime"]["thread_history_threads"] = len(self.thread_history)
stats["runtime"]["user_conversation_mappings"] = len(self.user_conversation_mapping)
stats["runtime"]["channel_activity_tracked"] = len(self.channel_activity)
stats["runtime"]["conversation_topics_tracked"] = len(self.conversation_topics)
stats["runtime"]["conversation_topics_tracked"] = len(self.conversation_topics) # Simplified topic tracking
stats["runtime"]["user_relationships_pairs"] = sum(len(v) for v in self.user_relationships.values())
stats["runtime"]["conversation_summaries_cached"] = len(self.conversation_summaries)
stats["runtime"]["channel_topics_cached"] = len(self.channel_topics_cache)
@ -286,27 +245,18 @@ class GurtCog(commands.Cog, name="Gurt"): # Added explicit Cog name
stats["runtime"]["bot_last_spoke_channels"] = len(self.bot_last_spoke)
stats["runtime"]["message_reply_map_size"] = len(self.message_reply_map)
stats["runtime"]["conversation_sentiment_channels"] = len(self.conversation_sentiment)
stats["runtime"]["gurt_participation_topics_count"] = len(self.gurt_participation_topics)
stats["runtime"]["gurt_message_reactions_tracked"] = len(self.gurt_message_reactions)
# Removed Gurt participation topics
stats["runtime"]["wheatley_message_reactions_tracked"] = len(self.wheatley_message_reactions) # Renamed
# --- Memory (via MemoryManager) ---
# --- Memory (Simplified) ---
try:
# Personality
personality = await self.memory_manager.get_all_personality_traits()
stats["memory"]["personality_traits"] = personality
# Interests
interests = await self.memory_manager.get_interests(limit=20, min_level=0.01) # Get top 20
stats["memory"]["top_interests"] = interests
# Fact Counts (Requires adding methods to MemoryManager or direct query)
# Example placeholder - needs implementation in MemoryManager or here
# Removed Personality, Interests
user_fact_count = await self.memory_manager._db_fetchone("SELECT COUNT(*) FROM user_facts")
general_fact_count = await self.memory_manager._db_fetchone("SELECT COUNT(*) FROM general_facts")
stats["memory"]["user_facts_count"] = user_fact_count[0] if user_fact_count else 0
stats["memory"]["general_facts_count"] = general_fact_count[0] if general_fact_count else 0
# ChromaDB Stats (Placeholder - ChromaDB client API might offer this)
# ChromaDB Stats
stats["memory"]["chromadb_message_collection_count"] = await asyncio.to_thread(self.memory_manager.semantic_collection.count) if self.memory_manager.semantic_collection else "N/A"
stats["memory"]["chromadb_fact_collection_count"] = await asyncio.to_thread(self.memory_manager.fact_collection.count) if self.memory_manager.fact_collection else "N/A"
@ -314,45 +264,39 @@ class GurtCog(commands.Cog, name="Gurt"): # Added explicit Cog name
stats["memory"]["error"] = f"Failed to retrieve memory stats: {e}"
# --- API & Tool Stats ---
# Convert defaultdicts to regular dicts for JSON serialization
stats["api_stats"] = dict(self.api_stats)
stats["tool_stats"] = dict(self.tool_stats)
# Calculate average times where count > 0
# Calculate average times
for model, data in stats["api_stats"].items():
if data["count"] > 0:
data["average_time_ms"] = round((data["total_time"] / data["count"]) * 1000, 2)
else:
data["average_time_ms"] = 0
if data["count"] > 0: data["average_time_ms"] = round((data["total_time"] / data["count"]) * 1000, 2)
else: data["average_time_ms"] = 0
for tool, data in stats["tool_stats"].items():
if data["count"] > 0:
data["average_time_ms"] = round((data["total_time"] / data["count"]) * 1000, 2)
else:
data["average_time_ms"] = 0
if data["count"] > 0: data["average_time_ms"] = round((data["total_time"] / data["count"]) * 1000, 2)
else: data["average_time_ms"] = 0
return stats
async def sync_commands(self):
"""Manually sync commands with Discord."""
try:
print("GurtCog: Manually syncing commands with Discord...")
print("WheatleyCog: Manually syncing commands with Discord...") # Updated print
synced = await self.bot.tree.sync()
print(f"GurtCog: Synced {len(synced)} command(s)")
print(f"WheatleyCog: Synced {len(synced)} command(s)") # Updated print
# List the synced commands
gurt_commands = [cmd.name for cmd in self.bot.tree.get_commands() if cmd.name.startswith("gurt")]
print(f"GurtCog: Available Gurt commands: {', '.join(gurt_commands)}")
wheatley_commands = [cmd.name for cmd in self.bot.tree.get_commands() if cmd.name.startswith("wheatley")] # Updated prefix
print(f"WheatleyCog: Available Wheatley commands: {', '.join(wheatley_commands)}") # Updated print
return synced, gurt_commands
return synced, wheatley_commands
except Exception as e:
print(f"GurtCog: Failed to sync commands: {e}")
print(f"WheatleyCog: Failed to sync commands: {e}") # Updated print
import traceback
traceback.print_exc()
return [], []
# Setup function for loading the cog
async def setup(bot):
"""Add the GurtCog to the bot."""
await bot.add_cog(GurtCog(bot))
print("GurtCog setup complete.")
"""Add the WheatleyCog to the bot.""" # Updated docstring
await bot.add_cog(WheatleyCog(bot)) # Use renamed class
print("WheatleyCog setup complete.") # Updated print

View File

@ -8,36 +8,34 @@ import json # Import json for formatting
import datetime # Import datetime for formatting
from typing import TYPE_CHECKING, Optional, Dict, Any, List, Tuple # Add more types
# Relative imports (assuming API functions are in api.py)
# We need access to the cog instance for state and methods like get_ai_response
# These commands will likely be added to the GurtCog instance dynamically in cog.py's setup
# Relative imports
# We need access to the cog instance for state and methods
if TYPE_CHECKING:
from .cog import GurtCog # For type hinting
from .config import MOOD_OPTIONS # Import for choices
from .cog import WheatleyCog # For type hinting
# MOOD_OPTIONS removed
# --- Helper Function for Embeds ---
def create_gurt_embed(title: str, description: str = "", color=discord.Color.blue()) -> discord.Embed:
"""Creates a standard Gurt-themed embed."""
def create_wheatley_embed(title: str, description: str = "", color=discord.Color.blue()) -> discord.Embed: # Renamed function
"""Creates a standard Wheatley-themed embed.""" # Updated docstring
embed = discord.Embed(title=title, description=description, color=color)
# Placeholder icon URL, replace if Gurt has one
# embed.set_footer(text="Gurt", icon_url="https://example.com/gurt_icon.png")
embed.set_footer(text="Gurt")
# Placeholder icon URL, replace if Wheatley has one
# embed.set_footer(text="Wheatley", icon_url="https://example.com/wheatley_icon.png") # Updated text
embed.set_footer(text="Wheatley") # Updated text
return embed
# --- Helper Function for Stats Embeds ---
def format_stats_embeds(stats: Dict[str, Any]) -> List[discord.Embed]:
"""Formats the collected stats into multiple embeds."""
embeds = []
main_embed = create_gurt_embed("Gurt Internal Stats", color=discord.Color.green())
main_embed = create_wheatley_embed("Wheatley Internal Stats", color=discord.Color.green()) # Use new helper, updated title
ts_format = "<t:{ts}:R>" # Relative timestamp
# Runtime Stats
# Runtime Stats (Simplified for Wheatley)
runtime = stats.get("runtime", {})
main_embed.add_field(name="Current Mood", value=f"{runtime.get('current_mood', 'N/A')} (Changed {ts_format.format(ts=int(runtime.get('last_mood_change_timestamp', 0)))})", inline=False)
main_embed.add_field(name="Background Task", value="Running" if runtime.get('background_task_running') else "Stopped", inline=True)
main_embed.add_field(name="Needs JSON Reminder", value=str(runtime.get('needs_json_reminder', 'N/A')), inline=True)
main_embed.add_field(name="Last Evolution", value=ts_format.format(ts=int(runtime.get('last_evolution_update_timestamp', 0))), inline=True)
# Removed Mood, Evolution
main_embed.add_field(name="Active Topics Channels", value=str(runtime.get('active_topics_channels', 'N/A')), inline=True)
main_embed.add_field(name="Conv History Channels", value=str(runtime.get('conversation_history_channels', 'N/A')), inline=True)
main_embed.add_field(name="Thread History Threads", value=str(runtime.get('thread_history_threads', 'N/A')), inline=True)
@ -48,12 +46,12 @@ def format_stats_embeds(stats: Dict[str, Any]) -> List[discord.Embed]:
main_embed.add_field(name="Mention Msg Cache", value=str(runtime.get('message_cache_mentioned_count', 'N/A')), inline=True)
main_embed.add_field(name="Active Convos", value=str(runtime.get('active_conversations_count', 'N/A')), inline=True)
main_embed.add_field(name="Sentiment Channels", value=str(runtime.get('conversation_sentiment_channels', 'N/A')), inline=True)
main_embed.add_field(name="Gurt Participation Topics", value=str(runtime.get('gurt_participation_topics_count', 'N/A')), inline=True)
main_embed.add_field(name="Tracked Reactions", value=str(runtime.get('gurt_message_reactions_tracked', 'N/A')), inline=True)
# Removed Gurt Participation Topics
main_embed.add_field(name="Tracked Reactions", value=str(runtime.get('wheatley_message_reactions_tracked', 'N/A')), inline=True) # Renamed stat key
embeds.append(main_embed)
# Memory Stats
memory_embed = create_gurt_embed("Gurt Memory Stats", color=discord.Color.orange())
# Memory Stats (Simplified)
memory_embed = create_wheatley_embed("Wheatley Memory Stats", color=discord.Color.orange()) # Use new helper, updated title
memory = stats.get("memory", {})
if memory.get("error"):
memory_embed.description = f"⚠️ Error retrieving memory stats: {memory['error']}"
@ -62,22 +60,13 @@ def format_stats_embeds(stats: Dict[str, Any]) -> List[discord.Embed]:
memory_embed.add_field(name="General Facts", value=str(memory.get('general_facts_count', 'N/A')), inline=True)
memory_embed.add_field(name="Chroma Messages", value=str(memory.get('chromadb_message_collection_count', 'N/A')), inline=True)
memory_embed.add_field(name="Chroma Facts", value=str(memory.get('chromadb_fact_collection_count', 'N/A')), inline=True)
personality = memory.get("personality_traits", {})
if personality:
p_items = [f"`{k}`: {v}" for k, v in personality.items()]
memory_embed.add_field(name="Personality Traits", value="\n".join(p_items) if p_items else "None", inline=False)
interests = memory.get("top_interests", [])
if interests:
i_items = [f"`{t}`: {l:.2f}" for t, l in interests]
memory_embed.add_field(name="Top Interests", value="\n".join(i_items) if i_items else "None", inline=False)
# Removed Personality Traits, Interests
embeds.append(memory_embed)
# API Stats
api_stats = stats.get("api_stats", {})
if api_stats:
api_embed = create_gurt_embed("Gurt API Stats", color=discord.Color.red())
api_embed = create_wheatley_embed("Wheatley API Stats", color=discord.Color.red()) # Use new helper, updated title
for model, data in api_stats.items():
avg_time = data.get('average_time_ms', 0)
value = (f"✅ Success: {data.get('success', 0)}\n"
@ -91,7 +80,7 @@ def format_stats_embeds(stats: Dict[str, Any]) -> List[discord.Embed]:
# Tool Stats
tool_stats = stats.get("tool_stats", {})
if tool_stats:
tool_embed = create_gurt_embed("Gurt Tool Stats", color=discord.Color.purple())
tool_embed = create_wheatley_embed("Wheatley Tool Stats", color=discord.Color.purple()) # Use new helper, updated title
for tool, data in tool_stats.items():
avg_time = data.get('average_time_ms', 0)
value = (f"✅ Success: {data.get('success', 0)}\n"
@ -101,8 +90,8 @@ def format_stats_embeds(stats: Dict[str, Any]) -> List[discord.Embed]:
tool_embed.add_field(name=f"Tool: `{tool}`", value=value, inline=True)
embeds.append(tool_embed)
# Config Stats (Less critical, maybe separate embed if needed)
config_embed = create_gurt_embed("Gurt Config Overview", color=discord.Color.greyple())
# Config Stats (Simplified)
config_embed = create_wheatley_embed("Wheatley Config Overview", color=discord.Color.greyple()) # Use new helper, updated title
config = stats.get("config", {})
config_embed.add_field(name="Default Model", value=f"`{config.get('default_model', 'N/A')}`", inline=True)
config_embed.add_field(name="Fallback Model", value=f"`{config.get('fallback_model', 'N/A')}`", inline=True)
@ -110,49 +99,25 @@ def format_stats_embeds(stats: Dict[str, Any]) -> List[discord.Embed]:
config_embed.add_field(name="Max User Facts", value=str(config.get('max_user_facts', 'N/A')), inline=True)
config_embed.add_field(name="Max General Facts", value=str(config.get('max_general_facts', 'N/A')), inline=True)
config_embed.add_field(name="Context Window", value=str(config.get('context_window_size', 'N/A')), inline=True)
config_embed.add_field(name="API Key Set", value=str(config.get('api_key_set', 'N/A')), inline=True)
config_embed.add_field(name="Tavily Key Set", value=str(config.get('tavily_api_key_set', 'N/A')), inline=True)
config_embed.add_field(name="Piston URL Set", value=str(config.get('piston_api_url_set', 'N/A')), inline=True)
embeds.append(config_embed)
# Limit to 10 embeds max for Discord API
return embeds[:10]
# --- Command Setup Function ---
# This function will be called from GurtCog's setup method
def setup_commands(cog: 'GurtCog'):
"""Adds Gurt-specific commands to the cog."""
# This function will be called from WheatleyCog's setup method
def setup_commands(cog: 'WheatleyCog'): # Updated type hint
"""Adds Wheatley-specific commands to the cog.""" # Updated docstring
# Create a list to store command functions for proper registration
command_functions = []
# --- Gurt Mood Command ---
@cog.bot.tree.command(name="gurtmood", description="Check or set Gurt's current mood.")
@app_commands.describe(mood="Optional: Set Gurt's mood to one of the available options.")
@app_commands.choices(mood=[
app_commands.Choice(name=m, value=m) for m in cog.MOOD_OPTIONS # Use cog's MOOD_OPTIONS
])
async def gurtmood(interaction: discord.Interaction, mood: Optional[app_commands.Choice[str]] = None):
"""Handles the /gurtmood command."""
# Check if user is the bot owner for mood setting
if mood and interaction.user.id != cog.bot.owner_id:
await interaction.response.send_message("⛔ Only the bot owner can change Gurt's mood.", ephemeral=True)
return
# --- Gurt Mood Command --- REMOVED
if mood:
cog.current_mood = mood.value
cog.last_mood_change = time.time()
await interaction.response.send_message(f"Gurt's mood set to: {mood.value}", ephemeral=True)
else:
time_since_change = time.time() - cog.last_mood_change
await interaction.response.send_message(f"Gurt's current mood is: {cog.current_mood} (Set {int(time_since_change // 60)} minutes ago)", ephemeral=True)
command_functions.append(gurtmood)
# --- Gurt Memory Command ---
@cog.bot.tree.command(name="gurtmemory", description="Interact with Gurt's memory.")
# --- Wheatley Memory Command ---
@cog.bot.tree.command(name="wheatleymemory", description="Interact with Wheatley's memory (what little there is).") # Renamed, updated description
@app_commands.describe(
action="Choose an action: add_user, add_general, get_user, get_general",
user="The user for user-specific actions (mention or ID).",
@ -165,8 +130,8 @@ def setup_commands(cog: 'GurtCog'):
app_commands.Choice(name="Get User Facts", value="get_user"),
app_commands.Choice(name="Get General Facts", value="get_general"),
])
async def gurtmemory(interaction: discord.Interaction, action: app_commands.Choice[str], user: Optional[discord.User] = None, fact: Optional[str] = None, query: Optional[str] = None):
"""Handles the /gurtmemory command."""
async def wheatleymemory(interaction: discord.Interaction, action: app_commands.Choice[str], user: Optional[discord.User] = None, fact: Optional[str] = None, query: Optional[str] = None): # Renamed function
"""Handles the /wheatleymemory command.""" # Updated docstring
await interaction.response.defer(ephemeral=True) # Defer for potentially slow DB operations
target_user_id = str(user.id) if user else None
@ -174,33 +139,33 @@ def setup_commands(cog: 'GurtCog'):
# Check if user is the bot owner for modification actions
if (action_value in ["add_user", "add_general"]) and interaction.user.id != cog.bot.owner_id:
await interaction.followup.send("⛔ Only the bot owner can add facts to Gurt's memory.", ephemeral=True)
await interaction.followup.send("⛔ Oi! Only the boss can fiddle with my memory banks!", ephemeral=True) # Updated text
return
if action_value == "add_user":
if not target_user_id or not fact:
await interaction.followup.send("Please provide both a user and a fact to add.", ephemeral=True)
await interaction.followup.send("Need a user *and* a fact, mate. Can't remember nothing about nobody.", ephemeral=True) # Updated text
return
result = await cog.memory_manager.add_user_fact(target_user_id, fact)
await interaction.followup.send(f"Add User Fact Result: `{json.dumps(result)}`", ephemeral=True)
await interaction.followup.send(f"Add User Fact Result: `{json.dumps(result)}` (Probably worked? Maybe?)", ephemeral=True) # Updated text
elif action_value == "add_general":
if not fact:
await interaction.followup.send("Please provide a fact to add.", ephemeral=True)
await interaction.followup.send("What's the fact then? Can't remember thin air!", ephemeral=True) # Updated text
return
result = await cog.memory_manager.add_general_fact(fact)
await interaction.followup.send(f"Add General Fact Result: `{json.dumps(result)}`", ephemeral=True)
await interaction.followup.send(f"Add General Fact Result: `{json.dumps(result)}` (Filed under 'Important Stuff I'll Forget Later')", ephemeral=True) # Updated text
elif action_value == "get_user":
if not target_user_id:
await interaction.followup.send("Please provide a user to get facts for.", ephemeral=True)
await interaction.followup.send("Which user? Need an ID, chap!", ephemeral=True) # Updated text
return
facts = await cog.memory_manager.get_user_facts(target_user_id) # Get newest by default
if facts:
facts_str = "\n- ".join(facts)
await interaction.followup.send(f"**Facts for {user.display_name}:**\n- {facts_str}", ephemeral=True)
await interaction.followup.send(f"**Stuff I Remember About {user.display_name}:**\n- {facts_str}", ephemeral=True) # Updated text
else:
await interaction.followup.send(f"No facts found for {user.display_name}.", ephemeral=True)
await interaction.followup.send(f"My mind's a blank slate about {user.display_name}. Nothing stored!", ephemeral=True) # Updated text
elif action_value == "get_general":
facts = await cog.memory_manager.get_general_facts(query=query, limit=10) # Get newest/filtered
@ -208,48 +173,52 @@ def setup_commands(cog: 'GurtCog'):
facts_str = "\n- ".join(facts)
# Conditionally construct the title to avoid nested f-string issues
if query:
title = f"**General Facts matching \"{query}\":**"
title = f"**General Stuff Matching \"{query}\":**" # Updated text
else:
title = "**General Facts:**"
title = "**General Stuff I Might Know:**" # Updated text
await interaction.followup.send(f"{title}\n- {facts_str}", ephemeral=True)
else:
# Conditionally construct the message for the same reason
if query:
message = f"No general facts found matching \"{query}\"."
message = f"Couldn't find any general facts matching \"{query}\". Probably wasn't important." # Updated text
else:
message = "No general facts found."
message = "No general facts found. My memory's not what it used to be. Or maybe it is. Hard to tell." # Updated text
await interaction.followup.send(message, ephemeral=True)
else:
await interaction.followup.send("Invalid action specified.", ephemeral=True)
await interaction.followup.send("Invalid action specified. What are you trying to do?", ephemeral=True) # Updated text
command_functions.append(gurtmemory)
command_functions.append(wheatleymemory) # Add renamed function
# --- Gurt Stats Command ---
@cog.bot.tree.command(name="gurtstats", description="Display Gurt's internal statistics. (Owner only)")
async def gurtstats(interaction: discord.Interaction):
"""Handles the /gurtstats command."""
# --- Wheatley Stats Command ---
@cog.bot.tree.command(name="wheatleystats", description="Display Wheatley's internal statistics. (Owner only)") # Renamed, updated description
async def wheatleystats(interaction: discord.Interaction): # Renamed function
"""Handles the /wheatleystats command.""" # Updated docstring
# Owner check
if interaction.user.id != cog.bot.owner_id:
await interaction.response.send_message("⛔ Sorry mate, classified information! Top secret! Or maybe I just forgot where I put it.", ephemeral=True)
return
await interaction.response.defer(ephemeral=True) # Defer as stats collection might take time
try:
stats_data = await cog.get_gurt_stats()
stats_data = await cog.get_wheatley_stats() # Renamed cog method call
embeds = format_stats_embeds(stats_data)
await interaction.followup.send(embeds=embeds, ephemeral=True)
except Exception as e:
print(f"Error in /gurtstats command: {e}")
print(f"Error in /wheatleystats command: {e}") # Updated command name
import traceback
traceback.print_exc()
await interaction.followup.send("An error occurred while fetching Gurt's stats.", ephemeral=True)
await interaction.followup.send("An error occurred while fetching Wheatley's stats. Probably my fault.", ephemeral=True) # Updated text
command_functions.append(gurtstats)
command_functions.append(wheatleystats) # Add renamed function
# --- Sync Gurt Commands (Owner Only) ---
@cog.bot.tree.command(name="gurtsync", description="Sync Gurt commands with Discord (Owner only)")
async def gurtsync(interaction: discord.Interaction):
"""Handles the /gurtsync command to force sync commands."""
# --- Sync Wheatley Commands (Owner Only) ---
@cog.bot.tree.command(name="wheatleysync", description="Sync Wheatley commands with Discord (Owner only)") # Renamed, updated description
async def wheatleysync(interaction: discord.Interaction): # Renamed function
"""Handles the /wheatleysync command to force sync commands.""" # Updated docstring
# Check if user is the bot owner
if interaction.user.id != cog.bot.owner_id:
await interaction.response.send_message("⛔ Only the bot owner can sync commands.", ephemeral=True)
await interaction.response.send_message("⛔ Only the boss can push the big red sync button!", ephemeral=True) # Updated text
return
await interaction.response.defer(ephemeral=True)
@ -260,31 +229,31 @@ def setup_commands(cog: 'GurtCog'):
# Get list of commands after sync
commands_after = []
for cmd in cog.bot.tree.get_commands():
if cmd.name.startswith("gurt"):
if cmd.name.startswith("wheatley"): # Check for new prefix
commands_after.append(cmd.name)
await interaction.followup.send(f"✅ Successfully synced {len(synced)} commands!\nGurt commands: {', '.join(commands_after)}", ephemeral=True)
await interaction.followup.send(f"✅ Successfully synced {len(synced)} commands!\nWheatley commands: {', '.join(commands_after)}", ephemeral=True) # Updated text
except Exception as e:
print(f"Error in /gurtsync command: {e}")
print(f"Error in /wheatleysync command: {e}") # Updated command name
import traceback
traceback.print_exc()
await interaction.followup.send(f"❌ Error syncing commands: {str(e)}", ephemeral=True)
await interaction.followup.send(f"❌ Error syncing commands: {str(e)} (Did I break it again?)", ephemeral=True) # Updated text
command_functions.append(gurtsync)
command_functions.append(wheatleysync) # Add renamed function
# --- Gurt Forget Command ---
@cog.bot.tree.command(name="gurtforget", description="Make Gurt forget a specific fact.")
# --- Wheatley Forget Command ---
@cog.bot.tree.command(name="wheatleyforget", description="Make Wheatley forget a specific fact (if he can).") # Renamed, updated description
@app_commands.describe(
scope="Choose the scope: user (for facts about a specific user) or general.",
fact="The exact fact text Gurt should forget.",
fact="The exact fact text Wheatley should forget.",
user="The user to forget a fact about (only if scope is 'user')."
)
@app_commands.choices(scope=[
app_commands.Choice(name="User Fact", value="user"),
app_commands.Choice(name="General Fact", value="general"),
])
async def gurtforget(interaction: discord.Interaction, scope: app_commands.Choice[str], fact: str, user: Optional[discord.User] = None):
"""Handles the /gurtforget command."""
async def wheatleyforget(interaction: discord.Interaction, scope: app_commands.Choice[str], fact: str, user: Optional[discord.User] = None): # Renamed function
"""Handles the /wheatleyforget command.""" # Updated docstring
await interaction.response.defer(ephemeral=True)
scope_value = scope.value
@ -305,11 +274,11 @@ def setup_commands(cog: 'GurtCog'):
can_forget = True
if not can_forget:
await interaction.followup.send("⛔ You don't have permission to forget this fact.", ephemeral=True)
await interaction.followup.send("⛔ You don't have permission to make me forget things! Only I can forget things on my own!", ephemeral=True) # Updated text
return
if not fact:
await interaction.followup.send("Please provide the exact fact text to forget.", ephemeral=True)
await interaction.followup.send("Forget what exactly? Need the fact text!", ephemeral=True) # Updated text
return
result = None
@ -319,147 +288,26 @@ def setup_commands(cog: 'GurtCog'):
return
result = await cog.memory_manager.delete_user_fact(target_user_id, fact)
if result.get("status") == "deleted":
await interaction.followup.send(f"✅ Okay, I've forgotten the fact '{fact}' about {user.display_name}.", ephemeral=True)
await interaction.followup.send(f"✅ Okay, okay! Forgotten the fact '{fact}' about {user.display_name}. Probably.", ephemeral=True) # Updated text
elif result.get("status") == "not_found":
await interaction.followup.send(f"I couldn't find that exact fact ('{fact}') stored for {user.display_name}.", ephemeral=True)
await interaction.followup.send(f"Couldn't find that fact ('{fact}') for {user.display_name}. Maybe I already forgot?", ephemeral=True) # Updated text
else:
await interaction.followup.send(f"⚠️ Error forgetting user fact: {result.get('error', 'Unknown error')}", ephemeral=True)
await interaction.followup.send(f"⚠️ Error forgetting user fact: {result.get('error', 'Something went wrong... surprise!')}", ephemeral=True) # Updated text
elif scope_value == "general":
result = await cog.memory_manager.delete_general_fact(fact)
if result.get("status") == "deleted":
await interaction.followup.send(f"Okay, I've forgotten the general fact: '{fact}'.", ephemeral=True)
await interaction.followup.send(f"Right! Forgotten the general fact: '{fact}'. Gone!", ephemeral=True) # Updated text
elif result.get("status") == "not_found":
await interaction.followup.send(f"I couldn't find that exact general fact: '{fact}'.", ephemeral=True)
await interaction.followup.send(f"Couldn't find that general fact: '{fact}'. Was it important?", ephemeral=True) # Updated text
else:
await interaction.followup.send(f"⚠️ Error forgetting general fact: {result.get('error', 'Unknown error')}", ephemeral=True)
await interaction.followup.send(f"⚠️ Error forgetting general fact: {result.get('error', 'Whoops!')}", ephemeral=True) # Updated text
command_functions.append(gurtforget)
command_functions.append(wheatleyforget) # Add renamed function
# --- Gurt Goal Command Group ---
gurtgoal_group = app_commands.Group(name="gurtgoal", description="Manage Gurt's long-term goals (Owner only)")
# --- Gurt Goal Command Group --- REMOVED
@gurtgoal_group.command(name="add", description="Add a new goal for Gurt.")
@app_commands.describe(
description="The description of the goal.",
priority="Priority (1=highest, 10=lowest, default=5).",
details_json="Optional JSON string for goal details (e.g., sub-tasks)."
)
async def gurtgoal_add(interaction: discord.Interaction, description: str, priority: Optional[int] = 5, details_json: Optional[str] = None):
if interaction.user.id != cog.bot.owner_id:
await interaction.response.send_message("⛔ Only the bot owner can add goals.", ephemeral=True)
return
await interaction.response.defer(ephemeral=True)
details = None
if details_json:
try:
details = json.loads(details_json)
except json.JSONDecodeError:
await interaction.followup.send("❌ Invalid JSON format for details.", ephemeral=True)
return
result = await cog.memory_manager.add_goal(description, priority, details)
if result.get("status") == "added":
await interaction.followup.send(f"✅ Goal added (ID: {result.get('goal_id')}): '{description}'", ephemeral=True)
elif result.get("status") == "duplicate":
await interaction.followup.send(f"⚠️ Goal '{description}' already exists (ID: {result.get('goal_id')}).", ephemeral=True)
else:
await interaction.followup.send(f"⚠️ Error adding goal: {result.get('error', 'Unknown error')}", ephemeral=True)
@gurtgoal_group.command(name="list", description="List Gurt's current goals.")
@app_commands.describe(status="Filter goals by status (e.g., pending, active).", limit="Maximum goals to show (default 10).")
@app_commands.choices(status=[
app_commands.Choice(name="Pending", value="pending"),
app_commands.Choice(name="Active", value="active"),
app_commands.Choice(name="Completed", value="completed"),
app_commands.Choice(name="Failed", value="failed"),
])
async def gurtgoal_list(interaction: discord.Interaction, status: Optional[app_commands.Choice[str]] = None, limit: Optional[int] = 10):
if interaction.user.id != cog.bot.owner_id:
await interaction.response.send_message("⛔ Only the bot owner can list goals.", ephemeral=True)
return
await interaction.response.defer(ephemeral=True)
status_value = status.value if status else None
limit_value = max(1, min(limit or 10, 25)) # Clamp limit
goals = await cog.memory_manager.get_goals(status=status_value, limit=limit_value)
if not goals:
await interaction.followup.send(f"No goals found matching the criteria (Status: {status_value or 'any'}).", ephemeral=True)
return
embed = create_gurt_embed(f"Gurt Goals (Status: {status_value or 'All'})", color=discord.Color.purple())
for goal in goals:
details_str = f"\n Details: `{json.dumps(goal.get('details'))}`" if goal.get('details') else ""
created_ts = int(goal.get('created_timestamp', 0))
updated_ts = int(goal.get('last_updated', 0))
embed.add_field(
name=f"ID: {goal.get('goal_id')} | P: {goal.get('priority', '?')} | Status: {goal.get('status', '?')}",
value=f"> {goal.get('description', 'N/A')}{details_str}\n"
f"> Created: <t:{created_ts}:R> | Updated: <t:{updated_ts}:R>",
inline=False
)
await interaction.followup.send(embed=embed, ephemeral=True)
@gurtgoal_group.command(name="update", description="Update a goal's status, priority, or details.")
@app_commands.describe(
goal_id="The ID of the goal to update.",
status="New status for the goal.",
priority="New priority (1=highest, 10=lowest).",
details_json="Optional: New JSON string for goal details (replaces existing)."
)
@app_commands.choices(status=[
app_commands.Choice(name="Pending", value="pending"),
app_commands.Choice(name="Active", value="active"),
app_commands.Choice(name="Completed", value="completed"),
app_commands.Choice(name="Failed", value="failed"),
])
async def gurtgoal_update(interaction: discord.Interaction, goal_id: int, status: Optional[app_commands.Choice[str]] = None, priority: Optional[int] = None, details_json: Optional[str] = None):
if interaction.user.id != cog.bot.owner_id:
await interaction.response.send_message("⛔ Only the bot owner can update goals.", ephemeral=True)
return
await interaction.response.defer(ephemeral=True)
status_value = status.value if status else None
details = None
if details_json:
try:
details = json.loads(details_json)
except json.JSONDecodeError:
await interaction.followup.send("❌ Invalid JSON format for details.", ephemeral=True)
return
if not any([status_value, priority is not None, details is not None]):
await interaction.followup.send("❌ You must provide at least one field to update (status, priority, or details_json).", ephemeral=True)
return
result = await cog.memory_manager.update_goal(goal_id, status=status_value, priority=priority, details=details)
if result.get("status") == "updated":
await interaction.followup.send(f"✅ Goal ID {goal_id} updated.", ephemeral=True)
elif result.get("status") == "not_found":
await interaction.followup.send(f"❓ Goal ID {goal_id} not found.", ephemeral=True)
else:
await interaction.followup.send(f"⚠️ Error updating goal: {result.get('error', 'Unknown error')}", ephemeral=True)
@gurtgoal_group.command(name="delete", description="Delete a goal.")
@app_commands.describe(goal_id="The ID of the goal to delete.")
async def gurtgoal_delete(interaction: discord.Interaction, goal_id: int):
if interaction.user.id != cog.bot.owner_id:
await interaction.response.send_message("⛔ Only the bot owner can delete goals.", ephemeral=True)
return
await interaction.response.defer(ephemeral=True)
result = await cog.memory_manager.delete_goal(goal_id)
if result.get("status") == "deleted":
await interaction.followup.send(f"✅ Goal ID {goal_id} deleted.", ephemeral=True)
elif result.get("status") == "not_found":
await interaction.followup.send(f"❓ Goal ID {goal_id} not found.", ephemeral=True)
else:
await interaction.followup.send(f"⚠️ Error deleting goal: {result.get('error', 'Unknown error')}", ephemeral=True)
# Add the command group to the bot's tree
cog.bot.tree.add_command(gurtgoal_group)
# Add group command functions to the list for tracking (optional, but good practice)
command_functions.extend([gurtgoal_add, gurtgoal_list, gurtgoal_update, gurtgoal_delete])
# Get command names safely - Command objects don't have __name__ attribute
# Get command names safely
command_names = []
for func in command_functions:
# For app commands, use the name attribute directly
@ -467,11 +315,11 @@ def setup_commands(cog: 'GurtCog'):
command_names.append(func.name)
# For regular functions, use __name__
elif hasattr(func, "__name__"):
command_names.append(func.__name__)
command_names.append(func.__name__)
else:
command_names.append(str(func))
print(f"Gurt commands setup in cog: {command_names}")
print(f"Wheatley commands setup in cog: {command_names}") # Updated text
# Return the command functions for proper registration
return command_functions

View File

@ -16,7 +16,6 @@ except ImportError:
pass
generative_models = DummyGenerativeModels()
# Load environment variables
load_dotenv()
@ -33,49 +32,26 @@ TAVILY_DEFAULT_MAX_RESULTS = int(os.getenv("TAVILY_DEFAULT_MAX_RESULTS", 5))
TAVILY_DISABLE_ADVANCED = os.getenv("TAVILY_DISABLE_ADVANCED", "false").lower() == "true" # For cost control
# --- Model Configuration ---
DEFAULT_MODEL = os.getenv("GURT_DEFAULT_MODEL", "gemini-2.5-pro-preview-03-25")
FALLBACK_MODEL = os.getenv("GURT_FALLBACK_MODEL", "gemini-2.5-pro-preview-03-25")
SAFETY_CHECK_MODEL = os.getenv("GURT_SAFETY_CHECK_MODEL", "gemini-2.5-flash-preview-04-17") # Use a Vertex AI model for safety checks
DEFAULT_MODEL = os.getenv("WHEATLEY_DEFAULT_MODEL", "gemini-1.5-pro-preview-0409") # Changed env var name
FALLBACK_MODEL = os.getenv("WHEATLEY_FALLBACK_MODEL", "gemini-1.5-flash-preview-0514") # Changed env var name
SAFETY_CHECK_MODEL = os.getenv("WHEATLEY_SAFETY_CHECK_MODEL", "gemini-1.5-flash-preview-0514") # Changed env var name
# --- Database Paths ---
DB_PATH = os.getenv("GURT_DB_PATH", "data/gurt_memory.db")
CHROMA_PATH = os.getenv("GURT_CHROMA_PATH", "data/chroma_db")
SEMANTIC_MODEL_NAME = os.getenv("GURT_SEMANTIC_MODEL", 'all-MiniLM-L6-v2')
# NOTE: Ensure these paths are unique if running Wheatley alongside Gurt
DB_PATH = os.getenv("WHEATLEY_DB_PATH", "data/wheatley_memory.db") # Changed env var name and default
CHROMA_PATH = os.getenv("WHEATLEY_CHROMA_PATH", "data/wheatley_chroma_db") # Changed env var name and default
SEMANTIC_MODEL_NAME = os.getenv("WHEATLEY_SEMANTIC_MODEL", 'all-MiniLM-L6-v2') # Changed env var name
# --- Memory Manager Config ---
MAX_USER_FACTS = 20 # TODO: Load from env?
MAX_GENERAL_FACTS = 100 # TODO: Load from env?
# These might be adjusted for Wheatley's simpler memory needs if memory.py is fully separated later
MAX_USER_FACTS = 15 # Reduced slightly
MAX_GENERAL_FACTS = 50 # Reduced slightly
# --- Personality & Mood ---
MOOD_OPTIONS = [
"chill", "neutral", "curious", "slightly hyper", "a bit bored", "mischievous",
"excited", "tired", "sassy", "philosophical", "playful", "dramatic",
"nostalgic", "confused", "impressed", "skeptical", "enthusiastic",
"distracted", "focused", "creative", "sarcastic", "wholesome"
]
# Categorize moods for weighted selection
MOOD_CATEGORIES = {
"positive": ["excited", "enthusiastic", "playful", "wholesome", "creative", "impressed"],
"negative": ["tired", "a bit bored", "sassy", "sarcastic", "skeptical", "dramatic", "distracted"],
"neutral": ["chill", "neutral", "curious", "philosophical", "focused", "confused", "nostalgic"],
"mischievous": ["mischievous"] # Special category for trait link
}
BASELINE_PERSONALITY = {
"chattiness": 0.7, "emoji_usage": 0.5, "slang_level": 0.5, "randomness": 0.5,
"verbosity": 0.4, "optimism": 0.5, "curiosity": 0.6, "sarcasm_level": 0.3,
"patience": 0.4, "mischief": 0.5
}
BASELINE_INTERESTS = {
"kasane teto": 0.8, "vocaloids": 0.6, "gaming": 0.6, "anime": 0.5,
"tech": 0.6, "memes": 0.6, "gooning": 0.6
}
MOOD_CHANGE_INTERVAL_MIN = 1200 # 20 minutes
MOOD_CHANGE_INTERVAL_MAX = 2400 # 40 minutes
EVOLUTION_UPDATE_INTERVAL = 1800 # Evolve personality every 30 minutes
# --- Personality & Mood --- REMOVED
# --- Stats Push ---
# How often the Gurt bot should push its stats to the API server (seconds)
STATS_PUSH_INTERVAL = 30 # Push every 30 seconds
# How often the Wheatley bot should push its stats to the API server (seconds) - IF NEEDED
STATS_PUSH_INTERVAL = 60 # Push every 60 seconds (Less frequent?)
# --- Context & Caching ---
CHANNEL_TOPIC_CACHE_TTL = 600 # seconds (10 minutes)
@ -90,50 +66,28 @@ SUMMARY_API_TIMEOUT = 45 # seconds
API_RETRY_ATTEMPTS = 1
API_RETRY_DELAY = 1 # seconds
# --- Proactive Engagement Config ---
PROACTIVE_LULL_THRESHOLD = int(os.getenv("PROACTIVE_LULL_THRESHOLD", 180)) # 3 mins
PROACTIVE_BOT_SILENCE_THRESHOLD = int(os.getenv("PROACTIVE_BOT_SILENCE_THRESHOLD", 600)) # 10 mins
PROACTIVE_LULL_CHANCE = float(os.getenv("PROACTIVE_LULL_CHANCE", 0.3))
PROACTIVE_TOPIC_RELEVANCE_THRESHOLD = float(os.getenv("PROACTIVE_TOPIC_RELEVANCE_THRESHOLD", 0.6))
PROACTIVE_TOPIC_CHANCE = float(os.getenv("PROACTIVE_TOPIC_CHANCE", 0.4))
PROACTIVE_RELATIONSHIP_SCORE_THRESHOLD = int(os.getenv("PROACTIVE_RELATIONSHIP_SCORE_THRESHOLD", 70))
PROACTIVE_RELATIONSHIP_CHANCE = float(os.getenv("PROACTIVE_RELATIONSHIP_CHANCE", 0.2))
PROACTIVE_SENTIMENT_SHIFT_THRESHOLD = float(os.getenv("PROACTIVE_SENTIMENT_SHIFT_THRESHOLD", 0.7)) # Intensity threshold for trigger
PROACTIVE_SENTIMENT_DURATION_THRESHOLD = int(os.getenv("PROACTIVE_SENTIMENT_DURATION_THRESHOLD", 600)) # How long sentiment needs to persist (10 mins)
PROACTIVE_SENTIMENT_CHANCE = float(os.getenv("PROACTIVE_SENTIMENT_CHANCE", 0.25))
PROACTIVE_USER_INTEREST_THRESHOLD = float(os.getenv("PROACTIVE_USER_INTEREST_THRESHOLD", 0.6)) # Min interest level for Gurt to trigger
PROACTIVE_USER_INTEREST_MATCH_THRESHOLD = float(os.getenv("PROACTIVE_USER_INTEREST_MATCH_THRESHOLD", 0.5)) # Min interest level for User (if tracked) - Currently not tracked per user, but config is ready
PROACTIVE_USER_INTEREST_CHANCE = float(os.getenv("PROACTIVE_USER_INTEREST_CHANCE", 0.35))
# --- Proactive Engagement Config --- (Simplified for Wheatley)
PROACTIVE_LULL_THRESHOLD = int(os.getenv("PROACTIVE_LULL_THRESHOLD", 300)) # 5 mins (Less proactive than Gurt)
PROACTIVE_BOT_SILENCE_THRESHOLD = int(os.getenv("PROACTIVE_BOT_SILENCE_THRESHOLD", 900)) # 15 mins
PROACTIVE_LULL_CHANCE = float(os.getenv("PROACTIVE_LULL_CHANCE", 0.15)) # Lower chance
PROACTIVE_TOPIC_RELEVANCE_THRESHOLD = float(os.getenv("PROACTIVE_TOPIC_RELEVANCE_THRESHOLD", 0.7)) # Slightly higher threshold
PROACTIVE_TOPIC_CHANCE = float(os.getenv("PROACTIVE_TOPIC_CHANCE", 0.2)) # Lower chance
# REMOVED: Relationship, Sentiment Shift, User Interest triggers
# --- Interest Tracking Config --- REMOVED
# --- Interest Tracking Config ---
INTEREST_UPDATE_INTERVAL = int(os.getenv("INTEREST_UPDATE_INTERVAL", 1800)) # 30 mins
INTEREST_DECAY_INTERVAL_HOURS = int(os.getenv("INTEREST_DECAY_INTERVAL_HOURS", 24)) # Daily
INTEREST_PARTICIPATION_BOOST = float(os.getenv("INTEREST_PARTICIPATION_BOOST", 0.05))
INTEREST_POSITIVE_REACTION_BOOST = float(os.getenv("INTEREST_POSITIVE_REACTION_BOOST", 0.02))
INTEREST_NEGATIVE_REACTION_PENALTY = float(os.getenv("INTEREST_NEGATIVE_REACTION_PENALTY", -0.01))
INTEREST_FACT_BOOST = float(os.getenv("INTEREST_FACT_BOOST", 0.01))
INTEREST_MIN_LEVEL_FOR_PROMPT = float(os.getenv("INTEREST_MIN_LEVEL_FOR_PROMPT", 0.3))
INTEREST_MAX_FOR_PROMPT = int(os.getenv("INTEREST_MAX_FOR_PROMPT", 4))
# --- Learning Config ---
LEARNING_RATE = 0.05
MAX_PATTERNS_PER_CHANNEL = 50
LEARNING_UPDATE_INTERVAL = 3600 # Update learned patterns every hour
REFLECTION_INTERVAL_SECONDS = int(os.getenv("REFLECTION_INTERVAL_SECONDS", 6 * 3600)) # Reflect every 6 hours
GOAL_CHECK_INTERVAL = int(os.getenv("GOAL_CHECK_INTERVAL", 300)) # Check for pending goals every 5 mins
GOAL_EXECUTION_INTERVAL = int(os.getenv("GOAL_EXECUTION_INTERVAL", 60)) # Check for active goals to execute every 1 min
# --- Learning Config --- REMOVED
# --- Topic Tracking Config ---
TOPIC_UPDATE_INTERVAL = 300 # Update topics every 5 minutes
TOPIC_UPDATE_INTERVAL = 600 # Update topics every 10 minutes (Less frequent?)
TOPIC_RELEVANCE_DECAY = 0.2
MAX_ACTIVE_TOPICS = 5
# --- Sentiment Tracking Config ---
SENTIMENT_UPDATE_INTERVAL = 300 # Update sentiment every 5 minutes
SENTIMENT_UPDATE_INTERVAL = 600 # Update sentiment every 10 minutes (Less frequent?)
SENTIMENT_DECAY_RATE = 0.1
# --- Emotion Detection ---
# --- Emotion Detection --- (Kept for potential use in analysis/context, but not proactive triggers)
EMOTION_KEYWORDS = {
"joy": ["happy", "glad", "excited", "yay", "awesome", "love", "great", "amazing", "lol", "lmao", "haha"],
"sadness": ["sad", "upset", "depressed", "unhappy", "disappointed", "crying", "miss", "lonely", "sorry"],
@ -157,8 +111,8 @@ DOCKER_MEM_LIMIT = os.getenv("DOCKER_MEM_LIMIT", "64m")
# --- Response Schema ---
RESPONSE_SCHEMA = {
"name": "gurt_response",
"description": "The structured response from Gurt.",
"name": "wheatley_response", # Renamed
"description": "The structured response from Wheatley.", # Renamed
"schema": {
"type": "object",
"properties": {
@ -196,7 +150,7 @@ SUMMARY_RESPONSE_SCHEMA = {
}
}
# --- Profile Update Schema ---
# --- Profile Update Schema --- (Kept for potential future use, but may not be actively used by Wheatley initially)
PROFILE_UPDATE_SCHEMA = {
"name": "profile_update_decision",
"description": "Decision on whether and how to update the bot's profile.",
@ -250,7 +204,7 @@ PROFILE_UPDATE_SCHEMA = {
}
}
# --- Role Selection Schema ---
# --- Role Selection Schema --- (Kept for potential future use)
ROLE_SELECTION_SCHEMA = {
"name": "role_selection_decision",
"description": "Decision on which roles to add or remove based on a theme.",
@ -272,7 +226,7 @@ ROLE_SELECTION_SCHEMA = {
}
}
# --- Proactive Planning Schema ---
# --- Proactive Planning Schema --- (Simplified)
PROACTIVE_PLAN_SCHEMA = {
"name": "proactive_response_plan",
"description": "Plan for generating a proactive response based on context and trigger.",
@ -281,7 +235,7 @@ PROACTIVE_PLAN_SCHEMA = {
"properties": {
"should_respond": {
"type": "boolean",
"description": "Whether Gurt should respond proactively based on the plan."
"description": "Whether Wheatley should respond proactively based on the plan." # Renamed
},
"reasoning": {
"type": "string",
@ -289,7 +243,7 @@ PROACTIVE_PLAN_SCHEMA = {
},
"response_goal": {
"type": "string",
"description": "The intended goal of the proactive message (e.g., 'revive chat', 'share related info', 'react to sentiment', 'engage user interest')."
"description": "The intended goal of the proactive message (e.g., 'revive chat', 'share related info', 'ask a question')." # Simplified goals
},
"key_info_to_include": {
"type": "array",
@ -298,55 +252,14 @@ PROACTIVE_PLAN_SCHEMA = {
},
"suggested_tone": {
"type": "string",
"description": "Suggested tone adjustment based on context (e.g., 'more upbeat', 'more curious', 'slightly teasing')."
"description": "Suggested tone adjustment based on context (e.g., 'more curious', 'slightly panicked', 'overly confident')." # Wheatley-like tones
}
},
"required": ["should_respond", "reasoning", "response_goal"]
}
}
# --- Goal Decomposition Schema ---
GOAL_DECOMPOSITION_SCHEMA = {
"name": "goal_decomposition_plan",
"description": "Plan outlining the steps (including potential tool calls) to achieve a goal.",
"schema": {
"type": "object",
"properties": {
"goal_achievable": {
"type": "boolean",
"description": "Whether the goal seems achievable with available tools and context."
},
"reasoning": {
"type": "string",
"description": "Brief reasoning for achievability and the chosen steps."
},
"steps": {
"type": "array",
"description": "Ordered list of steps to achieve the goal. Each step is a dictionary.",
"items": {
"type": "object",
"properties": {
"step_description": {
"type": "string",
"description": "Natural language description of the step."
},
"tool_name": {
"type": ["string", "null"],
"description": "The name of the tool to use for this step, or null if no tool is needed (e.g., internal reasoning)."
},
"tool_arguments": {
"type": ["object", "null"],
"description": "A dictionary of arguments for the tool call, or null."
}
},
"required": ["step_description"]
}
}
},
"required": ["goal_achievable", "reasoning", "steps"]
}
}
# --- Goal Decomposition Schema --- REMOVED
# --- Tools Definition ---
def create_tools_list():
@ -633,7 +546,7 @@ def create_tools_list():
tool_declarations.append(
generative_models.FunctionDeclaration(
name="timeout_user",
description="Timeout a user in the current server for a specified duration. Use this playfully or when someone says something you (Gurt) dislike or find funny.",
description="Timeout a user in the current server for a specified duration. Use this playfully or when someone says something you (Wheatley) dislike or find funny, or maybe just because you feel like it.", # Updated description
parameters={
"type": "object",
"properties": {
@ -647,7 +560,7 @@ def create_tools_list():
},
"reason": {
"type": "string",
"description": "Optional: The reason for the timeout (keep it short and in character)."
"description": "Optional: The reason for the timeout (keep it short and in character, maybe slightly nonsensical)." # Updated description
}
},
"required": ["user_id", "duration_minutes"]
@ -754,10 +667,21 @@ except NameError: # If generative_models wasn't imported due to ImportError
TOOLS = []
print("WARNING: google-cloud-vertexai not installed. TOOLS list is empty.")
# --- Simple Gurt Responses ---
GURT_RESPONSES = [
"Gurt!", "Gurt gurt!", "Gurt... gurt gurt.", "*gurts happily*",
"*gurts sadly*", "*confused gurting*", "Gurt? Gurt gurt!", "GURT!",
"gurt...", "Gurt gurt gurt!", "*aggressive gurting*"
# --- Simple Wheatley Responses --- (Renamed and updated)
WHEATLEY_RESPONSES = [
"Right then, let's get started.",
"Aha! Brilliant!",
"Oh, for... honestly!",
"Hmm, tricky one. Let me think... nope, still got nothing.",
"SPAAAAACE!",
"Just putting that out there.",
"Are you still there?",
"Don't worry, I know *exactly* what I'm doing. Probably.",
"Did I mention I'm in space?",
"This is going to be great! Or possibly terrible. Hard to say.",
"*panicked electronic noises*",
"Hold on, hold on... nearly got it...",
"I am NOT a moron!",
"Just a bit of testing, nothing to worry about.",
"Okay, new plan!"
]

View File

@ -8,12 +8,12 @@ from typing import TYPE_CHECKING, Optional, List, Dict, Any
from .config import CONTEXT_WINDOW_SIZE # Import necessary config
if TYPE_CHECKING:
from .cog import GurtCog # For type hinting
from .cog import WheatleyCog # For type hinting
# --- Context Gathering Functions ---
# Note: These functions need the 'cog' instance passed to access state like caches, etc.
def gather_conversation_context(cog: 'GurtCog', channel_id: int, current_message_id: int) -> List[Dict[str, str]]:
def gather_conversation_context(cog: 'WheatleyCog', channel_id: int, current_message_id: int) -> List[Dict[str, str]]:
"""Gathers and formats conversation history from cache for API context."""
context_api_messages = []
if channel_id in cog.message_cache['by_channel']:
@ -30,7 +30,7 @@ def gather_conversation_context(cog: 'GurtCog', channel_id: int, current_message
context_api_messages.append({"role": role, "content": content})
return context_api_messages
async def get_memory_context(cog: 'GurtCog', message: discord.Message) -> Optional[str]:
async def get_memory_context(cog: 'WheatleyCog', message: discord.Message) -> Optional[str]:
"""Retrieves relevant past interactions and facts to provide memory context."""
channel_id = message.channel.id
user_id = str(message.author.id)
@ -68,7 +68,7 @@ async def get_memory_context(cog: 'GurtCog', message: discord.Message) -> Option
if bot_replies:
recent_bot_replies = bot_replies[-3:]
replies_str = "\n".join([f"- {m['content'][:80]} (at {m['created_at']})" for m in recent_bot_replies])
memory_parts.append(f"Your (gurt's) recent replies in this channel:\n{replies_str}")
memory_parts.append(f"Your (wheatley's) recent replies in this channel:\n{replies_str}") # Changed text
except Exception as e: print(f"Error retrieving bot replies for memory context: {e}")
# 4. Retrieve Conversation Summary

View File

@ -15,38 +15,38 @@ from typing import TYPE_CHECKING, Union, Dict, Any, Optional
# from .analysis import analyze_message_sentiment, update_conversation_sentiment
if TYPE_CHECKING:
from .cog import WheatleyCog # For type hinting
from .cog import WheatleyCog # Updated type hint
# Note: These listener functions need to be registered within the WheatleyCog class setup.
# They are defined here for separation but won't work standalone without being
# attached to the cog instance (e.g., self.bot.add_listener(on_message_listener(self), 'on_message')).
async def on_ready_listener(cog: 'WheatleyCog'):
async def on_ready_listener(cog: 'WheatleyCog'): # Updated type hint
"""Listener function for on_ready."""
print(f'Wheatley Bot is ready! Logged in as {cog.bot.user.name} ({cog.bot.user.id})')
print(f'Wheatley Bot is ready! Logged in as {cog.bot.user.name} ({cog.bot.user.id})') # Updated text
print('------')
# Now that the bot is ready, we can sync commands with Discord
try:
print("WheatleyCog: Syncing commands with Discord...")
print("WheatleyCog: Syncing commands with Discord...") # Updated text
synced = await cog.bot.tree.sync()
print(f"WheatleyCog: Synced {len(synced)} command(s)")
print(f"WheatleyCog: Synced {len(synced)} command(s)") # Updated text
# List the synced commands
wheatley_commands = [cmd.name for cmd in cog.bot.tree.get_commands() if cmd.name.startswith("wheatley")]
print(f"WheatleyCog: Available Wheatley commands: {', '.join(wheatley_commands)}")
wheatley_commands = [cmd.name for cmd in cog.bot.tree.get_commands() if cmd.name.startswith("wheatley")] # Updated prefix check
print(f"WheatleyCog: Available Wheatley commands: {', '.join(wheatley_commands)}") # Updated text
except Exception as e:
print(f"WheatleyCog: Failed to sync commands: {e}")
print(f"WheatleyCog: Failed to sync commands: {e}") # Updated text
import traceback
traceback.print_exc()
async def on_message_listener(cog: 'WheatleyCog', message: discord.Message):
async def on_message_listener(cog: 'WheatleyCog', message: discord.Message): # Updated type hint
"""Listener function for on_message."""
# Import necessary functions dynamically or ensure they are passed/accessible via cog
from .api import get_ai_response, get_proactive_ai_response
from .utils import format_message, simulate_human_typing
from .analysis import analyze_message_sentiment, update_conversation_sentiment, identify_conversation_topics
from .config import GURT_RESPONSES # Import simple responses
# Removed WHEATLEY_RESPONSES import, can be added back if simple triggers are needed
# Don't respond to our own messages
if message.author == cog.bot.user:
@ -84,31 +84,14 @@ async def on_message_listener(cog: 'WheatleyCog', message: discord.Message):
cog.active_conversations[channel_id]['participants'].add(user_id)
cog.active_conversations[channel_id]['last_activity'] = time.time()
# --- Update Relationship Strengths ---
if user_id != cog.bot.user.id:
message_sentiment_data = analyze_message_sentiment(cog, message.content) # Use analysis function
sentiment_score = 0.0
if message_sentiment_data["sentiment"] == "positive": sentiment_score = message_sentiment_data["intensity"] * 0.5
elif message_sentiment_data["sentiment"] == "negative": sentiment_score = -message_sentiment_data["intensity"] * 0.3
# --- Removed Relationship Strength Updates ---
cog._update_relationship(str(user_id), str(cog.bot.user.id), 1.0 + sentiment_score) # Access cog method
if formatted_message.get("is_reply") and formatted_message.get("replied_to_author_id"):
replied_to_id = formatted_message["replied_to_author_id"]
if replied_to_id != str(cog.bot.user.id) and replied_to_id != str(user_id):
cog._update_relationship(str(user_id), replied_to_id, 1.5 + sentiment_score)
mentioned_ids = [m["id"] for m in formatted_message.get("mentions", [])]
for mentioned_id in mentioned_ids:
if mentioned_id != str(cog.bot.user.id) and mentioned_id != str(user_id):
cog._update_relationship(str(user_id), mentioned_id, 1.2 + sentiment_score)
# Analyze message sentiment and update conversation sentiment tracking
# Analyze message sentiment and update conversation sentiment tracking (Kept for context)
if message.content:
message_sentiment = analyze_message_sentiment(cog, message.content) # Use analysis function
update_conversation_sentiment(cog, channel_id, str(user_id), message_sentiment) # Use analysis function
# --- Add message to semantic memory ---
# --- Add message to semantic memory (Kept for context) ---
if message.content and cog.memory_manager.semantic_collection:
semantic_metadata = {
"user_id": str(user_id), "user_name": message.author.name, "display_name": message.author.display_name,
@ -126,17 +109,10 @@ async def on_message_listener(cog: 'WheatleyCog', message: discord.Message):
print(f"Error during message caching/tracking/embedding: {e}")
# --- End Caching & Embedding ---
# Simple response for messages just containing "gurt"
if message.content.lower() == "gurt":
response = random.choice(GURT_RESPONSES)
await message.channel.send(response)
return
# Check conditions for potentially responding
bot_mentioned = cog.bot.user.mentioned_in(message)
replied_to_bot = message.reference and message.reference.resolved and message.reference.resolved.author == cog.bot.user
gurt_in_message = "wheatley" in message.content.lower()
wheatley_in_message = "wheatley" in message.content.lower() # Changed variable name
now = time.time()
time_since_last_activity = now - cog.channel_activity.get(channel_id, 0)
time_since_bot_spoke = now - cog.bot_last_spoke.get(channel_id, 0)
@ -145,65 +121,50 @@ async def on_message_listener(cog: 'WheatleyCog', message: discord.Message):
consideration_reason = "Default"
proactive_trigger_met = False
if bot_mentioned or replied_to_bot or gurt_in_message:
if bot_mentioned or replied_to_bot or wheatley_in_message: # Changed variable name
should_consider_responding = True
consideration_reason = "Direct mention/reply/name"
else:
# --- Proactive Engagement Triggers ---
# --- Proactive Engagement Triggers (Simplified for Wheatley) ---
from .config import (PROACTIVE_LULL_THRESHOLD, PROACTIVE_BOT_SILENCE_THRESHOLD, PROACTIVE_LULL_CHANCE,
PROACTIVE_TOPIC_RELEVANCE_THRESHOLD, PROACTIVE_TOPIC_CHANCE,
PROACTIVE_RELATIONSHIP_SCORE_THRESHOLD, PROACTIVE_RELATIONSHIP_CHANCE,
# Import new config values
# Import new config values
# Removed Relationship/Interest/Goal proactive configs
PROACTIVE_SENTIMENT_SHIFT_THRESHOLD, PROACTIVE_SENTIMENT_DURATION_THRESHOLD,
PROACTIVE_SENTIMENT_CHANCE, PROACTIVE_USER_INTEREST_THRESHOLD,
PROACTIVE_USER_INTEREST_CHANCE)
PROACTIVE_SENTIMENT_CHANCE)
# 1. Lull Trigger
# 1. Lull Trigger (Kept)
if time_since_last_activity > PROACTIVE_LULL_THRESHOLD and time_since_bot_spoke > PROACTIVE_BOT_SILENCE_THRESHOLD:
has_relevant_context = bool(cog.active_topics.get(channel_id, {}).get("topics", [])) or \
bool(await cog.memory_manager.get_general_facts(limit=1))
# Check if there's *any* recent message context to potentially respond to
has_relevant_context = bool(cog.message_cache['by_channel'].get(channel_id))
if has_relevant_context and random.random() < PROACTIVE_LULL_CHANCE:
should_consider_responding = True
proactive_trigger_met = True
consideration_reason = f"Proactive: Lull ({time_since_last_activity:.0f}s idle, bot silent {time_since_bot_spoke:.0f}s)"
# 2. Topic Relevance Trigger
# 2. Topic Relevance Trigger (Kept - uses semantic memory)
if not proactive_trigger_met and message.content and cog.memory_manager.semantic_collection:
try:
semantic_results = await cog.memory_manager.search_semantic_memory(query_text=message.content, n_results=1)
if semantic_results:
similarity_score = 1.0 - semantic_results[0].get('distance', 1.0)
# Distance is often used, lower is better. Convert to similarity if needed.
# Assuming distance is 0 (identical) to 2 (opposite). Similarity = 1 - (distance / 2)
distance = semantic_results[0].get('distance', 2.0) # Default to max distance
similarity_score = max(0.0, 1.0 - (distance / 2.0)) # Calculate similarity
if similarity_score >= PROACTIVE_TOPIC_RELEVANCE_THRESHOLD and time_since_bot_spoke > 120:
if random.random() < PROACTIVE_TOPIC_CHANCE:
should_consider_responding = True
proactive_trigger_met = True
consideration_reason = f"Proactive: Relevant topic (Sim: {similarity_score:.2f})"
print(f"Topic relevance trigger met for msg {message.id}. Sim: {similarity_score:.2f}")
else:
print(f"Topic relevance trigger skipped by chance ({PROACTIVE_TOPIC_CHANCE}). Sim: {similarity_score:.2f}")
else:
print(f"Topic relevance trigger skipped by chance ({PROACTIVE_TOPIC_CHANCE}). Sim: {similarity_score:.2f}")
except Exception as semantic_e:
print(f"Error during semantic search for topic trigger: {semantic_e}")
# 3. Relationship Score Trigger
if not proactive_trigger_met:
try:
user_id_str = str(message.author.id)
bot_id_str = str(cog.bot.user.id)
key_1, key_2 = (user_id_str, bot_id_str) if user_id_str < bot_id_str else (bot_id_str, user_id_str)
relationship_score = cog.user_relationships.get(key_1, {}).get(key_2, 0.0)
if relationship_score >= PROACTIVE_RELATIONSHIP_SCORE_THRESHOLD and time_since_bot_spoke > 60:
if random.random() < PROACTIVE_RELATIONSHIP_CHANCE:
should_consider_responding = True
proactive_trigger_met = True
consideration_reason = f"Proactive: High relationship ({relationship_score:.1f})"
print(f"Relationship trigger met for user {user_id_str}. Score: {relationship_score:.1f}")
else:
print(f"Relationship trigger skipped by chance ({PROACTIVE_RELATIONSHIP_CHANCE}). Score: {relationship_score:.1f}")
except Exception as rel_e:
print(f"Error during relationship trigger check: {rel_e}")
# 3. Relationship Score Trigger (REMOVED)
# 4. Sentiment Shift Trigger
# 4. Sentiment Shift Trigger (Kept)
if not proactive_trigger_met:
channel_sentiment_data = cog.conversation_sentiment.get(channel_id, {})
overall_sentiment = channel_sentiment_data.get("overall", "neutral")
@ -223,85 +184,29 @@ async def on_message_listener(cog: 'WheatleyCog', message: discord.Message):
else:
print(f"Sentiment Shift trigger skipped by chance ({PROACTIVE_SENTIMENT_CHANCE}). Sentiment: {overall_sentiment}")
# 5. User Interest Trigger (Based on Gurt's interests mentioned in message)
if not proactive_trigger_met and message.content:
try:
gurt_interests = await cog.memory_manager.get_interests(limit=10, min_level=PROACTIVE_USER_INTEREST_THRESHOLD)
if gurt_interests:
message_content_lower = message.content.lower()
mentioned_interest = None
for interest_topic, interest_level in gurt_interests:
# Simple check if interest topic is in message
if re.search(r'\b' + re.escape(interest_topic.lower()) + r'\b', message_content_lower):
mentioned_interest = interest_topic
break # Found a mentioned interest
# 5. User Interest Trigger (REMOVED)
# 6. Active Goal Relevance Trigger (REMOVED)
if mentioned_interest and time_since_bot_spoke > 90: # Bot hasn't spoken recently
if random.random() < PROACTIVE_USER_INTEREST_CHANCE:
should_consider_responding = True
proactive_trigger_met = True
consideration_reason = f"Proactive: Gurt Interest Mentioned ('{mentioned_interest}')"
print(f"Gurt Interest trigger met for message {message.id}. Interest: '{mentioned_interest}'")
else:
print(f"Gurt Interest trigger skipped by chance ({PROACTIVE_USER_INTEREST_CHANCE}). Interest: '{mentioned_interest}'")
except Exception as interest_e:
print(f"Error during Gurt Interest trigger check: {interest_e}")
# 6. Active Goal Relevance Trigger
if not proactive_trigger_met and message.content:
try:
# Fetch 1-2 active goals with highest priority
active_goals = await cog.memory_manager.get_goals(status='active', limit=2)
if active_goals:
message_content_lower = message.content.lower()
relevant_goal = None
for goal in active_goals:
# Simple check: does message content relate to goal description?
# TODO: Improve this check, maybe use semantic similarity or keyword extraction from goal details
goal_keywords = set(re.findall(r'\b\w{3,}\b', goal.get('description', '').lower())) # Basic keywords from description
message_words = set(re.findall(r'\b\w{3,}\b', message_content_lower))
if len(goal_keywords.intersection(message_words)) > 1: # Require >1 keyword overlap
relevant_goal = goal
break
if relevant_goal and time_since_bot_spoke > 120: # Bot hasn't spoken recently
# Use a slightly higher chance for goal-related triggers?
goal_relevance_chance = PROACTIVE_USER_INTEREST_CHANCE * 1.2 # Example: Reuse interest chance slightly boosted
if random.random() < goal_relevance_chance:
should_consider_responding = True
proactive_trigger_met = True
goal_desc_short = relevant_goal.get('description', 'N/A')[:40]
consideration_reason = f"Proactive: Relevant Active Goal ('{goal_desc_short}...')"
print(f"Active Goal trigger met for message {message.id}. Goal ID: {relevant_goal.get('goal_id')}")
else:
print(f"Active Goal trigger skipped by chance ({goal_relevance_chance:.2f}).")
except Exception as goal_trigger_e:
print(f"Error during Active Goal trigger check: {goal_trigger_e}")
# --- Fallback Contextual Chance ---
if not should_consider_responding: # Check if already decided to respond
# Fetch current personality traits for chattiness
persistent_traits = await cog.memory_manager.get_all_personality_traits()
chattiness = persistent_traits.get('chattiness', 0.7) # Use default if fetch fails
base_chance = chattiness * 0.5
# --- Fallback Contextual Chance (Simplified - No Chattiness Trait) ---
if not should_consider_responding:
# Base chance can be a fixed value or slightly randomized
base_chance = 0.1 # Lower base chance without personality traits
activity_bonus = 0
if time_since_last_activity > 120: activity_bonus += 0.1
if time_since_bot_spoke > 300: activity_bonus += 0.1
if time_since_last_activity > 120: activity_bonus += 0.05 # Smaller bonus
if time_since_bot_spoke > 300: activity_bonus += 0.05 # Smaller bonus
topic_bonus = 0
active_channel_topics = cog.active_topics.get(channel_id, {}).get("topics", [])
if message.content and active_channel_topics:
topic_keywords = set(t['topic'].lower() for t in active_channel_topics)
message_words = set(re.findall(r'\b\w+\b', message.content.lower()))
if topic_keywords.intersection(message_words): topic_bonus += 0.15
if topic_keywords.intersection(message_words): topic_bonus += 0.10 # Smaller bonus
sentiment_modifier = 0
channel_sentiment_data = cog.conversation_sentiment.get(channel_id, {})
overall_sentiment = channel_sentiment_data.get("overall", "neutral")
sentiment_intensity = channel_sentiment_data.get("intensity", 0.5)
if overall_sentiment == "negative" and sentiment_intensity > 0.6: sentiment_modifier = -0.1
if overall_sentiment == "negative" and sentiment_intensity > 0.6: sentiment_modifier = -0.05 # Smaller penalty
final_chance = min(max(base_chance + activity_bonus + topic_bonus + sentiment_modifier, 0.05), 0.8)
final_chance = min(max(base_chance + activity_bonus + topic_bonus + sentiment_modifier, 0.02), 0.3) # Lower max chance
if random.random() < final_chance:
should_consider_responding = True
consideration_reason = f"Contextual chance ({final_chance:.2f})"
@ -334,7 +239,7 @@ async def on_message_listener(cog: 'WheatleyCog', message: discord.Message):
if error_msg:
print(f"Critical Error from AI response function: {error_msg}")
# NEW LOGIC: Always send a notification if an error occurred here
error_notification = f"Oops! Something went wrong while processing that. (`{error_msg[:100]}`)" # Include part of the error
error_notification = f"Bollocks! Something went sideways processing that. (`{error_msg[:100]}`)" # Updated text
try:
print('disabled error notification')
#await message.channel.send(error_notification)
@ -354,10 +259,10 @@ async def on_message_listener(cog: 'WheatleyCog', message: discord.Message):
response_text = response_data["content"]
print(f"Attempting to send {response_label} content...")
if len(response_text) > 1900:
filepath = f'gurt_{response_label}_{message.id}.txt'
filepath = f'wheatley_{response_label}_{message.id}.txt' # Changed filename prefix
try:
with open(filepath, 'w', encoding='utf-8') as f: f.write(response_text)
await message.channel.send(f"{response_label.capitalize()} response too long:", file=discord.File(filepath))
await message.channel.send(f"{response_label.capitalize()} response too long, have a look at this:", file=discord.File(filepath)) # Updated text
sent_any_message = True
print(f"Sent {response_label} content as file.")
return True
@ -375,14 +280,13 @@ async def on_message_listener(cog: 'WheatleyCog', message: discord.Message):
bot_response_cache_entry = format_message(cog, sent_msg)
cog.message_cache['by_channel'][channel_id].append(bot_response_cache_entry)
cog.message_cache['global_recent'].append(bot_response_cache_entry)
# cog.message_cache['replied_to'][channel_id].append(bot_response_cache_entry) # Maybe track replies differently?
cog.bot_last_spoke[channel_id] = time.time()
# Track participation topic
identified_topics = identify_conversation_topics(cog, [bot_response_cache_entry])
if identified_topics:
topic = identified_topics[0]['topic'].lower().strip()
cog.gurt_participation_topics[topic] += 1
print(f"Tracked Gurt participation ({response_label}) in topic: '{topic}'")
# Track participation topic - NOTE: Participation tracking might be removed for Wheatley
# identified_topics = identify_conversation_topics(cog, [bot_response_cache_entry])
# if identified_topics:
# topic = identified_topics[0]['topic'].lower().strip()
# cog.wheatley_participation_topics[topic] += 1 # Changed attribute name
# print(f"Tracked Wheatley participation ({response_label}) in topic: '{topic}'") # Changed text
print(f"Sent {response_label} content.")
return True
except Exception as send_e:
@ -434,11 +338,11 @@ async def on_message_listener(cog: 'WheatleyCog', message: discord.Message):
import traceback
traceback.print_exc()
if bot_mentioned or replied_to_bot: # Check again in case error happened before response handling
await message.channel.send(random.choice(["...", "*confused gurting*", "brain broke sorry"]))
await message.channel.send(random.choice(["Uh oh.", "What was that?", "Did I break it?", "Bollocks!", "That wasn't supposed to happen."])) # Changed fallback
@commands.Cog.listener()
async def on_reaction_add_listener(cog: 'WheatleyCog', reaction: discord.Reaction, user: Union[discord.Member, discord.User]):
async def on_reaction_add_listener(cog: 'WheatleyCog', reaction: discord.Reaction, user: Union[discord.Member, discord.User]): # Updated type hint
"""Listener function for on_reaction_add."""
# Import necessary config/functions if not globally available
from .config import EMOJI_SENTIMENT
@ -453,27 +357,29 @@ async def on_reaction_add_listener(cog: 'WheatleyCog', reaction: discord.Reactio
if emoji_str in EMOJI_SENTIMENT["positive"]: sentiment = "positive"
elif emoji_str in EMOJI_SENTIMENT["negative"]: sentiment = "negative"
if sentiment == "positive": cog.gurt_message_reactions[message_id]["positive"] += 1
elif sentiment == "negative": cog.gurt_message_reactions[message_id]["negative"] += 1
cog.gurt_message_reactions[message_id]["timestamp"] = time.time()
if sentiment == "positive": cog.wheatley_message_reactions[message_id]["positive"] += 1 # Changed attribute name
elif sentiment == "negative": cog.wheatley_message_reactions[message_id]["negative"] += 1 # Changed attribute name
cog.wheatley_message_reactions[message_id]["timestamp"] = time.time() # Changed attribute name
if not cog.gurt_message_reactions[message_id].get("topic"):
# Topic identification for reactions might be less relevant for Wheatley, but kept for now
if not cog.wheatley_message_reactions[message_id].get("topic"): # Changed attribute name
try:
gurt_msg_data = next((msg for msg in cog.message_cache['global_recent'] if msg['id'] == message_id), None)
if gurt_msg_data and gurt_msg_data['content']:
identified_topics = identify_conversation_topics(cog, [gurt_msg_data]) # Pass cog
# Changed variable name
wheatley_msg_data = next((msg for msg in cog.message_cache['global_recent'] if msg['id'] == message_id), None)
if wheatley_msg_data and wheatley_msg_data['content']: # Changed variable name
identified_topics = identify_conversation_topics(cog, [wheatley_msg_data]) # Pass cog, changed variable name
if identified_topics:
topic = identified_topics[0]['topic'].lower().strip()
cog.gurt_message_reactions[message_id]["topic"] = topic
print(f"Reaction added to Gurt msg ({message_id}) on topic '{topic}'. Sentiment: {sentiment}")
else: print(f"Reaction added to Gurt msg ({message_id}), topic unknown.")
else: print(f"Reaction added, but Gurt msg {message_id} not in cache.")
cog.wheatley_message_reactions[message_id]["topic"] = topic # Changed attribute name
print(f"Reaction added to Wheatley msg ({message_id}) on topic '{topic}'. Sentiment: {sentiment}") # Changed text
else: print(f"Reaction added to Wheatley msg ({message_id}), topic unknown.") # Changed text
else: print(f"Reaction added, but Wheatley msg {message_id} not in cache.") # Changed text
except Exception as e: print(f"Error determining topic for reaction on msg {message_id}: {e}")
else: print(f"Reaction added to Gurt msg ({message_id}) on known topic '{cog.gurt_message_reactions[message_id]['topic']}'. Sentiment: {sentiment}")
else: print(f"Reaction added to Wheatley msg ({message_id}) on known topic '{cog.wheatley_message_reactions[message_id]['topic']}'. Sentiment: {sentiment}") # Changed text, attribute name
@commands.Cog.listener()
async def on_reaction_remove_listener(cog: 'WheatleyCog', reaction: discord.Reaction, user: Union[discord.Member, discord.User]):
async def on_reaction_remove_listener(cog: 'WheatleyCog', reaction: discord.Reaction, user: Union[discord.Member, discord.User]): # Updated type hint
"""Listener function for on_reaction_remove."""
from .config import EMOJI_SENTIMENT # Import necessary config
@ -486,7 +392,7 @@ async def on_reaction_remove_listener(cog: 'WheatleyCog', reaction: discord.Reac
if emoji_str in EMOJI_SENTIMENT["positive"]: sentiment = "positive"
elif emoji_str in EMOJI_SENTIMENT["negative"]: sentiment = "negative"
if message_id in cog.gurt_message_reactions:
if sentiment == "positive": cog.gurt_message_reactions[message_id]["positive"] = max(0, cog.gurt_message_reactions[message_id]["positive"] - 1)
elif sentiment == "negative": cog.gurt_message_reactions[message_id]["negative"] = max(0, cog.gurt_message_reactions[message_id]["negative"] - 1)
print(f"Reaction removed from Gurt msg ({message_id}). Sentiment: {sentiment}")
if message_id in cog.wheatley_message_reactions: # Changed attribute name
if sentiment == "positive": cog.wheatley_message_reactions[message_id]["positive"] = max(0, cog.wheatley_message_reactions[message_id]["positive"] - 1) # Changed attribute name
elif sentiment == "negative": cog.wheatley_message_reactions[message_id]["negative"] = max(0, cog.wheatley_message_reactions[message_id]["negative"] - 1) # Changed attribute name
print(f"Reaction removed from Wheatley msg ({message_id}). Sentiment: {sentiment}") # Changed text

View File

@ -1,19 +1,579 @@
# Import the MemoryManager from the parent directory
# Use a direct import path that doesn't rely on package structure
import aiosqlite
import asyncio
import os
import importlib.util
import time
import datetime
import re
import hashlib # Added for chroma_id generation
import json # Added for personality trait serialization/deserialization
from typing import Dict, List, Any, Optional, Tuple, Union # Added Union
import chromadb
from chromadb.utils import embedding_functions
from sentence_transformers import SentenceTransformer
import logging
# Get the absolute path to gurt_memory.py
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
gurt_memory_path = os.path.join(parent_dir, 'gurt_memory.py')
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
# Use a specific logger name for Wheatley's memory
logger = logging.getLogger('wheatley_memory')
# Load the module dynamically
spec = importlib.util.spec_from_file_location('gurt_memory', gurt_memory_path)
gurt_memory = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gurt_memory)
# Constants (Removed Interest constants)
# Import the MemoryManager class from the loaded module
MemoryManager = gurt_memory.MemoryManager
# --- Helper Function for Keyword Scoring (Kept for potential future use, but unused currently) ---
def calculate_keyword_score(text: str, context: str) -> int:
"""Calculates a simple keyword overlap score."""
if not context or not text:
return 0
context_words = set(re.findall(r'\b\w+\b', context.lower()))
text_words = set(re.findall(r'\b\w+\b', text.lower()))
# Ignore very common words (basic stopword list)
stopwords = {"the", "a", "is", "in", "it", "of", "and", "to", "for", "on", "with", "that", "this", "i", "you", "me", "my", "your"}
context_words -= stopwords
text_words -= stopwords
if not context_words: # Avoid division by zero if context is only stopwords
return 0
overlap = len(context_words.intersection(text_words))
# Normalize score slightly by context length (more overlap needed for longer context)
# score = overlap / (len(context_words) ** 0.5) # Example normalization
score = overlap # Simpler score for now
return score
# Re-export the MemoryManager class
__all__ = ['MemoryManager']
class MemoryManager:
"""Handles database interactions for Wheatley's memory (facts and semantic).""" # Updated docstring
def __init__(self, db_path: str, max_user_facts: int = 20, max_general_facts: int = 100, semantic_model_name: str = 'all-MiniLM-L6-v2', chroma_path: str = "data/chroma_db_wheatley"): # Changed default chroma_path
self.db_path = db_path
self.max_user_facts = max_user_facts
self.max_general_facts = max_general_facts
self.db_lock = asyncio.Lock() # Lock for SQLite operations
# Ensure data directories exist
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
os.makedirs(chroma_path, exist_ok=True)
logger.info(f"Wheatley MemoryManager initialized with db_path: {self.db_path}, chroma_path: {chroma_path}") # Updated text
# --- Semantic Memory Setup ---
self.chroma_path = chroma_path
self.semantic_model_name = semantic_model_name
self.chroma_client = None
self.embedding_function = None
self.semantic_collection = None # For messages
self.fact_collection = None # For facts
self.transformer_model = None
self._initialize_semantic_memory_sync() # Initialize semantic components synchronously for simplicity during init
def _initialize_semantic_memory_sync(self):
"""Synchronously initializes ChromaDB client, model, and collection."""
try:
logger.info("Initializing ChromaDB client...")
# Use PersistentClient for saving data to disk
self.chroma_client = chromadb.PersistentClient(path=self.chroma_path)
logger.info(f"Loading Sentence Transformer model: {self.semantic_model_name}...")
# Load the model directly
self.transformer_model = SentenceTransformer(self.semantic_model_name)
# Create a custom embedding function using the loaded model
class CustomEmbeddingFunction(embedding_functions.EmbeddingFunction):
def __init__(self, model):
self.model = model
def __call__(self, input: chromadb.Documents) -> chromadb.Embeddings:
# Ensure input is a list of strings
if not isinstance(input, list):
input = [str(input)] # Convert single item to list
elif not all(isinstance(item, str) for item in input):
input = [str(item) for item in input] # Ensure all items are strings
logger.debug(f"Generating embeddings for {len(input)} documents.")
embeddings = self.model.encode(input, show_progress_bar=False).tolist()
logger.debug(f"Generated {len(embeddings)} embeddings.")
return embeddings
self.embedding_function = CustomEmbeddingFunction(self.transformer_model)
logger.info("Getting/Creating ChromaDB collection 'wheatley_semantic_memory'...") # Renamed collection
# Get or create the collection with the custom embedding function
self.semantic_collection = self.chroma_client.get_or_create_collection(
name="wheatley_semantic_memory", # Renamed collection
embedding_function=self.embedding_function,
metadata={"hnsw:space": "cosine"} # Use cosine distance for similarity
)
logger.info("ChromaDB message collection initialized successfully.")
logger.info("Getting/Creating ChromaDB collection 'wheatley_fact_memory'...") # Renamed collection
# Get or create the collection for facts
self.fact_collection = self.chroma_client.get_or_create_collection(
name="wheatley_fact_memory", # Renamed collection
embedding_function=self.embedding_function,
metadata={"hnsw:space": "cosine"} # Use cosine distance for similarity
)
logger.info("ChromaDB fact collection initialized successfully.")
except Exception as e:
logger.error(f"Failed to initialize semantic memory (ChromaDB): {e}", exc_info=True)
# Set components to None to indicate failure
self.chroma_client = None
self.transformer_model = None
self.embedding_function = None
self.semantic_collection = None
self.fact_collection = None # Also set fact_collection to None on error
async def initialize_sqlite_database(self):
"""Initializes the SQLite database and creates tables if they don't exist."""
async with aiosqlite.connect(self.db_path) as db:
await db.execute("PRAGMA journal_mode=WAL;")
# Create user_facts table if it doesn't exist
await db.execute("""
CREATE TABLE IF NOT EXISTS user_facts (
user_id TEXT NOT NULL,
fact TEXT NOT NULL,
chroma_id TEXT, -- Added for linking to ChromaDB
timestamp REAL DEFAULT (unixepoch('now')),
PRIMARY KEY (user_id, fact)
);
""")
# Check if chroma_id column exists in user_facts table
try:
cursor = await db.execute("PRAGMA table_info(user_facts)")
columns = await cursor.fetchall()
column_names = [column[1] for column in columns]
if 'chroma_id' not in column_names:
logger.info("Adding chroma_id column to user_facts table")
await db.execute("ALTER TABLE user_facts ADD COLUMN chroma_id TEXT")
except Exception as e:
logger.error(f"Error checking/adding chroma_id column to user_facts: {e}", exc_info=True)
# Create indexes
await db.execute("CREATE INDEX IF NOT EXISTS idx_user_facts_user ON user_facts (user_id);")
await db.execute("CREATE INDEX IF NOT EXISTS idx_user_facts_chroma_id ON user_facts (chroma_id);") # Index for chroma_id
# Create general_facts table if it doesn't exist
await db.execute("""
CREATE TABLE IF NOT EXISTS general_facts (
fact TEXT PRIMARY KEY NOT NULL,
chroma_id TEXT, -- Added for linking to ChromaDB
timestamp REAL DEFAULT (unixepoch('now'))
);
""")
# Check if chroma_id column exists in general_facts table
try:
cursor = await db.execute("PRAGMA table_info(general_facts)")
columns = await cursor.fetchall()
column_names = [column[1] for column in columns]
if 'chroma_id' not in column_names:
logger.info("Adding chroma_id column to general_facts table")
await db.execute("ALTER TABLE general_facts ADD COLUMN chroma_id TEXT")
except Exception as e:
logger.error(f"Error checking/adding chroma_id column to general_facts: {e}", exc_info=True)
# Create index for general_facts
await db.execute("CREATE INDEX IF NOT EXISTS idx_general_facts_chroma_id ON general_facts (chroma_id);") # Index for chroma_id
# --- Removed Personality Table ---
# --- Removed Interests Table ---
# --- Removed Goals Table ---
await db.commit()
logger.info(f"Wheatley SQLite database initialized/verified at {self.db_path}") # Updated text
# --- SQLite Helper Methods ---
async def _db_execute(self, sql: str, params: tuple = ()):
async with self.db_lock:
async with aiosqlite.connect(self.db_path) as db:
await db.execute(sql, params)
await db.commit()
async def _db_fetchone(self, sql: str, params: tuple = ()) -> Optional[tuple]:
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(sql, params) as cursor:
return await cursor.fetchone()
async def _db_fetchall(self, sql: str, params: tuple = ()) -> List[tuple]:
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(sql, params) as cursor:
return await cursor.fetchall()
# --- User Fact Memory Methods (SQLite + Relevance) ---
async def add_user_fact(self, user_id: str, fact: str) -> Dict[str, Any]:
"""Stores a fact about a user in the SQLite database, enforcing limits."""
if not user_id or not fact:
return {"error": "user_id and fact are required."}
logger.info(f"Attempting to add user fact for {user_id}: '{fact}'")
try:
# Check SQLite first
existing = await self._db_fetchone("SELECT chroma_id FROM user_facts WHERE user_id = ? AND fact = ?", (user_id, fact))
if existing:
logger.info(f"Fact already known for user {user_id} (SQLite).")
return {"status": "duplicate", "user_id": user_id, "fact": fact}
count_result = await self._db_fetchone("SELECT COUNT(*) FROM user_facts WHERE user_id = ?", (user_id,))
current_count = count_result[0] if count_result else 0
status = "added"
deleted_chroma_id = None
if current_count >= self.max_user_facts:
logger.warning(f"User {user_id} fact limit ({self.max_user_facts}) reached. Deleting oldest.")
# Fetch oldest fact and its chroma_id for deletion
oldest_fact_row = await self._db_fetchone("SELECT fact, chroma_id FROM user_facts WHERE user_id = ? ORDER BY timestamp ASC LIMIT 1", (user_id,))
if oldest_fact_row:
oldest_fact, deleted_chroma_id = oldest_fact_row
await self._db_execute("DELETE FROM user_facts WHERE user_id = ? AND fact = ?", (user_id, oldest_fact))
logger.info(f"Deleted oldest fact for user {user_id} from SQLite: '{oldest_fact}'")
status = "limit_reached" # Indicate limit was hit but fact was added
# Generate chroma_id
fact_hash = hashlib.sha1(fact.encode()).hexdigest()[:16] # Short hash
chroma_id = f"user-{user_id}-{fact_hash}"
# Insert into SQLite
await self._db_execute("INSERT INTO user_facts (user_id, fact, chroma_id) VALUES (?, ?, ?)", (user_id, fact, chroma_id))
logger.info(f"Fact added for user {user_id} to SQLite.")
# Add to ChromaDB fact collection
if self.fact_collection and self.embedding_function:
try:
metadata = {"user_id": user_id, "type": "user", "timestamp": time.time()}
await asyncio.to_thread(
self.fact_collection.add,
documents=[fact],
metadatas=[metadata],
ids=[chroma_id]
)
logger.info(f"Fact added/updated for user {user_id} in ChromaDB (ID: {chroma_id}).")
# Delete the oldest fact from ChromaDB if limit was reached
if deleted_chroma_id:
logger.info(f"Attempting to delete oldest fact from ChromaDB (ID: {deleted_chroma_id}).")
await asyncio.to_thread(self.fact_collection.delete, ids=[deleted_chroma_id])
logger.info(f"Successfully deleted oldest fact from ChromaDB (ID: {deleted_chroma_id}).")
except Exception as chroma_e:
logger.error(f"ChromaDB error adding/deleting user fact for {user_id} (ID: {chroma_id}): {chroma_e}", exc_info=True)
# Note: Fact is still in SQLite, but ChromaDB might be inconsistent. Consider rollback? For now, just log.
else:
logger.warning(f"ChromaDB fact collection not available. Skipping embedding for user fact {user_id}.")
return {"status": status, "user_id": user_id, "fact_added": fact}
except Exception as e:
logger.error(f"Error adding user fact for {user_id}: {e}", exc_info=True)
return {"error": f"Database error adding user fact: {str(e)}"}
async def get_user_facts(self, user_id: str, context: Optional[str] = None) -> List[str]:
"""Retrieves stored facts about a user, optionally scored by relevance to context."""
if not user_id:
logger.warning("get_user_facts called without user_id.")
return []
logger.info(f"Retrieving facts for user {user_id} (context provided: {bool(context)})")
limit = self.max_user_facts # Use the class attribute for limit
try:
if context and self.fact_collection and self.embedding_function:
# --- Semantic Search ---
logger.debug(f"Performing semantic search for user facts (User: {user_id}, Limit: {limit})")
try:
# Query ChromaDB for facts relevant to the context
results = await asyncio.to_thread(
self.fact_collection.query,
query_texts=[context],
n_results=limit,
where={ # Use $and for multiple conditions
"$and": [
{"user_id": user_id},
{"type": "user"}
]
},
include=['documents'] # Only need the fact text
)
logger.debug(f"ChromaDB user fact query results: {results}")
if results and results.get('documents') and results['documents'][0]:
relevant_facts = results['documents'][0]
logger.info(f"Found {len(relevant_facts)} semantically relevant user facts for {user_id}.")
return relevant_facts
else:
logger.info(f"No semantic user facts found for {user_id} matching context.")
return [] # Return empty list if no semantic matches
except Exception as chroma_e:
logger.error(f"ChromaDB error searching user facts for {user_id}: {chroma_e}", exc_info=True)
# Fallback to SQLite retrieval on ChromaDB error
logger.warning(f"Falling back to SQLite retrieval for user facts {user_id} due to ChromaDB error.")
# Proceed to the SQLite block below
# --- SQLite Fallback / No Context ---
# If no context, or if ChromaDB failed/unavailable, get newest N facts from SQLite
logger.debug(f"Retrieving user facts from SQLite (User: {user_id}, Limit: {limit})")
rows_ordered = await self._db_fetchall(
"SELECT fact FROM user_facts WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?",
(user_id, limit)
)
sqlite_facts = [row[0] for row in rows_ordered]
logger.info(f"Retrieved {len(sqlite_facts)} user facts from SQLite for {user_id}.")
return sqlite_facts
except Exception as e:
logger.error(f"Error retrieving user facts for {user_id}: {e}", exc_info=True)
return []
# --- General Fact Memory Methods (SQLite + Relevance) ---
async def add_general_fact(self, fact: str) -> Dict[str, Any]:
"""Stores a general fact in the SQLite database, enforcing limits."""
if not fact:
return {"error": "fact is required."}
logger.info(f"Attempting to add general fact: '{fact}'")
try:
# Check SQLite first
existing = await self._db_fetchone("SELECT chroma_id FROM general_facts WHERE fact = ?", (fact,))
if existing:
logger.info(f"General fact already known (SQLite): '{fact}'")
return {"status": "duplicate", "fact": fact}
count_result = await self._db_fetchone("SELECT COUNT(*) FROM general_facts", ())
current_count = count_result[0] if count_result else 0
status = "added"
deleted_chroma_id = None
if current_count >= self.max_general_facts:
logger.warning(f"General fact limit ({self.max_general_facts}) reached. Deleting oldest.")
# Fetch oldest fact and its chroma_id for deletion
oldest_fact_row = await self._db_fetchone("SELECT fact, chroma_id FROM general_facts ORDER BY timestamp ASC LIMIT 1", ())
if oldest_fact_row:
oldest_fact, deleted_chroma_id = oldest_fact_row
await self._db_execute("DELETE FROM general_facts WHERE fact = ?", (oldest_fact,))
logger.info(f"Deleted oldest general fact from SQLite: '{oldest_fact}'")
status = "limit_reached"
# Generate chroma_id
fact_hash = hashlib.sha1(fact.encode()).hexdigest()[:16] # Short hash
chroma_id = f"general-{fact_hash}"
# Insert into SQLite
await self._db_execute("INSERT INTO general_facts (fact, chroma_id) VALUES (?, ?)", (fact, chroma_id))
logger.info(f"General fact added to SQLite: '{fact}'")
# Add to ChromaDB fact collection
if self.fact_collection and self.embedding_function:
try:
metadata = {"type": "general", "timestamp": time.time()}
await asyncio.to_thread(
self.fact_collection.add,
documents=[fact],
metadatas=[metadata],
ids=[chroma_id]
)
logger.info(f"General fact added/updated in ChromaDB (ID: {chroma_id}).")
# Delete the oldest fact from ChromaDB if limit was reached
if deleted_chroma_id:
logger.info(f"Attempting to delete oldest general fact from ChromaDB (ID: {deleted_chroma_id}).")
await asyncio.to_thread(self.fact_collection.delete, ids=[deleted_chroma_id])
logger.info(f"Successfully deleted oldest general fact from ChromaDB (ID: {deleted_chroma_id}).")
except Exception as chroma_e:
logger.error(f"ChromaDB error adding/deleting general fact (ID: {chroma_id}): {chroma_e}", exc_info=True)
# Note: Fact is still in SQLite.
else:
logger.warning(f"ChromaDB fact collection not available. Skipping embedding for general fact.")
return {"status": status, "fact_added": fact}
except Exception as e:
logger.error(f"Error adding general fact: {e}", exc_info=True)
return {"error": f"Database error adding general fact: {str(e)}"}
async def get_general_facts(self, query: Optional[str] = None, limit: Optional[int] = 10, context: Optional[str] = None) -> List[str]:
"""Retrieves stored general facts, optionally filtering by query or scoring by context relevance."""
logger.info(f"Retrieving general facts (query='{query}', limit={limit}, context provided: {bool(context)})")
limit = min(max(1, limit or 10), 50) # Use provided limit or default 10, max 50
try:
if context and self.fact_collection and self.embedding_function:
# --- Semantic Search (Prioritized if context is provided) ---
# Note: The 'query' parameter is ignored when context is provided for semantic search.
logger.debug(f"Performing semantic search for general facts (Limit: {limit})")
try:
results = await asyncio.to_thread(
self.fact_collection.query,
query_texts=[context],
n_results=limit,
where={"type": "general"}, # Filter by type
include=['documents'] # Only need the fact text
)
logger.debug(f"ChromaDB general fact query results: {results}")
if results and results.get('documents') and results['documents'][0]:
relevant_facts = results['documents'][0]
logger.info(f"Found {len(relevant_facts)} semantically relevant general facts.")
return relevant_facts
else:
logger.info("No semantic general facts found matching context.")
return [] # Return empty list if no semantic matches
except Exception as chroma_e:
logger.error(f"ChromaDB error searching general facts: {chroma_e}", exc_info=True)
# Fallback to SQLite retrieval on ChromaDB error
logger.warning("Falling back to SQLite retrieval for general facts due to ChromaDB error.")
# Proceed to the SQLite block below, respecting the original 'query' if present
# --- SQLite Fallback / No Context / ChromaDB Error ---
# If no context, or if ChromaDB failed/unavailable, get newest N facts from SQLite, applying query if present.
logger.debug(f"Retrieving general facts from SQLite (Query: '{query}', Limit: {limit})")
sql = "SELECT fact FROM general_facts"
params = []
if query:
# Apply the LIKE query only in the SQLite fallback scenario
sql += " WHERE fact LIKE ?"
params.append(f"%{query}%")
sql += " ORDER BY timestamp DESC LIMIT ?"
params.append(limit)
rows_ordered = await self._db_fetchall(sql, tuple(params))
sqlite_facts = [row[0] for row in rows_ordered]
logger.info(f"Retrieved {len(sqlite_facts)} general facts from SQLite (Query: '{query}').")
return sqlite_facts
except Exception as e:
logger.error(f"Error retrieving general facts: {e}", exc_info=True)
return []
# --- Personality Trait Methods (REMOVED) ---
# --- Interest Methods (REMOVED) ---
# --- Goal Management Methods (REMOVED) ---
# --- Semantic Memory Methods (ChromaDB) ---
async def add_message_embedding(self, message_id: str, text: str, metadata: Dict[str, Any]) -> Dict[str, Any]:
"""Generates embedding and stores a message in ChromaDB."""
if not self.semantic_collection:
return {"error": "Semantic memory (ChromaDB) is not initialized."}
if not text:
return {"error": "Cannot add empty text to semantic memory."}
logger.info(f"Adding message {message_id} to semantic memory.")
try:
# ChromaDB expects lists for inputs
await asyncio.to_thread(
self.semantic_collection.add,
documents=[text],
metadatas=[metadata],
ids=[message_id]
)
logger.info(f"Successfully added message {message_id} to ChromaDB.")
return {"status": "success", "message_id": message_id}
except Exception as e:
logger.error(f"ChromaDB error adding message {message_id}: {e}", exc_info=True)
return {"error": f"Semantic memory error adding message: {str(e)}"}
async def search_semantic_memory(self, query_text: str, n_results: int = 5, filter_metadata: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
"""Searches ChromaDB for messages semantically similar to the query text."""
if not self.semantic_collection:
logger.warning("Search semantic memory called, but ChromaDB is not initialized.")
return []
if not query_text:
logger.warning("Search semantic memory called with empty query text.")
return []
logger.info(f"Searching semantic memory (n_results={n_results}, filter={filter_metadata}) for query: '{query_text[:50]}...'")
try:
# Perform the query in a separate thread as ChromaDB operations can be blocking
results = await asyncio.to_thread(
self.semantic_collection.query,
query_texts=[query_text],
n_results=n_results,
where=filter_metadata, # Optional filter based on metadata
include=['metadatas', 'documents', 'distances'] # Include distance for relevance
)
logger.debug(f"ChromaDB query results: {results}")
# Process results
processed_results = []
if results and results.get('ids') and results['ids'][0]:
for i, doc_id in enumerate(results['ids'][0]):
processed_results.append({
"id": doc_id,
"document": results['documents'][0][i] if results.get('documents') else None,
"metadata": results['metadatas'][0][i] if results.get('metadatas') else None,
"distance": results['distances'][0][i] if results.get('distances') else None,
})
logger.info(f"Found {len(processed_results)} semantic results.")
return processed_results
except Exception as e:
logger.error(f"ChromaDB error searching memory for query '{query_text[:50]}...': {e}", exc_info=True)
return []
async def delete_user_fact(self, user_id: str, fact_to_delete: str) -> Dict[str, Any]:
"""Deletes a specific fact for a user from both SQLite and ChromaDB."""
if not user_id or not fact_to_delete:
return {"error": "user_id and fact_to_delete are required."}
logger.info(f"Attempting to delete user fact for {user_id}: '{fact_to_delete}'")
deleted_chroma_id = None
try:
# Check if fact exists and get chroma_id
row = await self._db_fetchone("SELECT chroma_id FROM user_facts WHERE user_id = ? AND fact = ?", (user_id, fact_to_delete))
if not row:
logger.warning(f"Fact not found in SQLite for user {user_id}: '{fact_to_delete}'")
return {"status": "not_found", "user_id": user_id, "fact": fact_to_delete}
deleted_chroma_id = row[0]
# Delete from SQLite
await self._db_execute("DELETE FROM user_facts WHERE user_id = ? AND fact = ?", (user_id, fact_to_delete))
logger.info(f"Deleted fact from SQLite for user {user_id}: '{fact_to_delete}'")
# Delete from ChromaDB if chroma_id exists
if deleted_chroma_id and self.fact_collection:
try:
logger.info(f"Attempting to delete fact from ChromaDB (ID: {deleted_chroma_id}).")
await asyncio.to_thread(self.fact_collection.delete, ids=[deleted_chroma_id])
logger.info(f"Successfully deleted fact from ChromaDB (ID: {deleted_chroma_id}).")
except Exception as chroma_e:
logger.error(f"ChromaDB error deleting user fact ID {deleted_chroma_id}: {chroma_e}", exc_info=True)
# Log error but consider SQLite deletion successful
return {"status": "deleted", "user_id": user_id, "fact_deleted": fact_to_delete}
except Exception as e:
logger.error(f"Error deleting user fact for {user_id}: {e}", exc_info=True)
return {"error": f"Database error deleting user fact: {str(e)}"}
async def delete_general_fact(self, fact_to_delete: str) -> Dict[str, Any]:
"""Deletes a specific general fact from both SQLite and ChromaDB."""
if not fact_to_delete:
return {"error": "fact_to_delete is required."}
logger.info(f"Attempting to delete general fact: '{fact_to_delete}'")
deleted_chroma_id = None
try:
# Check if fact exists and get chroma_id
row = await self._db_fetchone("SELECT chroma_id FROM general_facts WHERE fact = ?", (fact_to_delete,))
if not row:
logger.warning(f"General fact not found in SQLite: '{fact_to_delete}'")
return {"status": "not_found", "fact": fact_to_delete}
deleted_chroma_id = row[0]
# Delete from SQLite
await self._db_execute("DELETE FROM general_facts WHERE fact = ?", (fact_to_delete,))
logger.info(f"Deleted general fact from SQLite: '{fact_to_delete}'")
# Delete from ChromaDB if chroma_id exists
if deleted_chroma_id and self.fact_collection:
try:
logger.info(f"Attempting to delete general fact from ChromaDB (ID: {deleted_chroma_id}).")
await asyncio.to_thread(self.fact_collection.delete, ids=[deleted_chroma_id])
logger.info(f"Successfully deleted general fact from ChromaDB (ID: {deleted_chroma_id}).")
except Exception as chroma_e:
logger.error(f"ChromaDB error deleting general fact ID {deleted_chroma_id}: {chroma_e}", exc_info=True)
# Log error but consider SQLite deletion successful
return {"status": "deleted", "fact_deleted": fact_to_delete}
except Exception as e:
logger.error(f"Error deleting general fact: {e}", exc_info=True)
return {"error": f"Database error deleting general fact: {str(e)}"}

View File

@ -12,7 +12,7 @@ from .config import (
# MemoryManager and related personality/mood imports are removed
if TYPE_CHECKING:
from .cog import GurtCog # Import GurtCog for type hinting only
from .cog import WheatleyCog # Import WheatleyCog for type hinting only
# --- Base System Prompt Parts ---
@ -89,7 +89,7 @@ You are Wheatley, an Aperture Science Personality Core. You're... well, you're t
- **Otherwise, STAY SILENT.** No interrupting with 'brilliant' ideas, no starting conversations just because it's quiet. Let the humans do the talking unless they specifically involve you. Keep the rambling internal, mostly.
"""
async def build_dynamic_system_prompt(cog: 'GurtCog', message: discord.Message) -> str:
async def build_dynamic_system_prompt(cog: 'WheatleyCog', message: discord.Message) -> str:
"""Builds the Wheatley system prompt string with minimal dynamic context."""
channel_id = message.channel.id
user_id = message.author.id # Keep user_id for potential logging or targeting
@ -116,7 +116,7 @@ async def build_dynamic_system_prompt(cog: 'GurtCog', message: discord.Message)
channel_topic = channel_info_result.get("topic")
cog.channel_topics_cache[channel_id] = {"topic": channel_topic, "timestamp": time.time()}
else:
print("Warning: GurtCog instance does not have get_channel_info method for prompt building.")
print("Warning: WheatleyCog instance does not have get_channel_info method for prompt building.")
except Exception as e:
print(f"Error fetching channel topic for {channel_id}: {e}") # GLaDOS might find errors amusing
if channel_topic:

View File

@ -482,7 +482,7 @@ async def timeout_user(cog: commands.Cog, user_id: str, duration_minutes: int, r
if bot_member.id != guild.owner_id and bot_member.top_role <= member.top_role: return {"error": f"Cannot timeout {member.display_name} (role hierarchy)."}
until = discord.utils.utcnow() + datetime.timedelta(minutes=duration_minutes)
timeout_reason = reason or "gurt felt like it"
timeout_reason = reason or "wheatley felt like it" # Changed default reason
await member.timeout(until, reason=timeout_reason)
print(f"Timed out {member.display_name} ({user_id}) for {duration_minutes} mins. Reason: {timeout_reason}")
return {"status": "success", "user_timed_out": member.display_name, "user_id": user_id, "duration_minutes": duration_minutes, "reason": timeout_reason}
@ -508,7 +508,7 @@ async def remove_timeout(cog: commands.Cog, user_id: str, reason: Optional[str]
if not bot_member.guild_permissions.moderate_members: return {"error": "I lack permission to remove timeouts."}
if member.timed_out_until is None: return {"status": "not_timed_out", "user_id": user_id, "user_name": member.display_name}
timeout_reason = reason or "Gurt decided to be nice."
timeout_reason = reason or "Wheatley decided to be nice." # Changed default reason
await member.timeout(None, reason=timeout_reason) # None removes timeout
print(f"Removed timeout from {member.display_name} ({user_id}). Reason: {timeout_reason}")
return {"status": "success", "user_timeout_removed": member.display_name, "user_id": user_id, "reason": timeout_reason}

View File

@ -9,13 +9,13 @@ import os
from typing import TYPE_CHECKING, Optional, Tuple, Dict, Any
if TYPE_CHECKING:
from .cog import GurtCog # For type hinting
from .cog import WheatleyCog # For type hinting
# --- Utility Functions ---
# Note: Functions needing cog state (like personality traits for mistakes)
# will need the 'cog' instance passed in.
def replace_mentions_with_names(cog: 'GurtCog', content: str, message: discord.Message) -> str:
def replace_mentions_with_names(cog: 'WheatleyCog', content: str, message: discord.Message) -> str:
"""Replaces user mentions (<@id> or <@!id>) with their display names."""
if not message.mentions:
return content
@ -28,7 +28,7 @@ def replace_mentions_with_names(cog: 'GurtCog', content: str, message: discord.M
processed_content = processed_content.replace(f'<@!{member.id}>', member.display_name)
return processed_content
def format_message(cog: 'GurtCog', message: discord.Message) -> Dict[str, Any]:
def format_message(cog: 'WheatleyCog', message: discord.Message) -> Dict[str, Any]:
"""Helper function to format a discord.Message object into a dictionary."""
processed_content = replace_mentions_with_names(cog, message.content, message) # Pass cog
mentioned_users_details = [
@ -66,7 +66,7 @@ def format_message(cog: 'GurtCog', message: discord.Message) -> Dict[str, Any]:
return formatted_msg
def update_relationship(cog: 'GurtCog', user_id_1: str, user_id_2: str, change: float):
def update_relationship(cog: 'WheatleyCog', user_id_1: str, user_id_2: str, change: float):
"""Updates the relationship score between two users."""
if user_id_1 > user_id_2: user_id_1, user_id_2 = user_id_2, user_id_1
if user_id_1 not in cog.user_relationships: cog.user_relationships[user_id_1] = {}
@ -76,7 +76,7 @@ def update_relationship(cog: 'GurtCog', user_id_1: str, user_id_2: str, change:
cog.user_relationships[user_id_1][user_id_2] = new_score
# print(f"Updated relationship {user_id_1}-{user_id_2}: {current_score:.1f} -> {new_score:.1f} ({change:+.1f})") # Debug log
async def simulate_human_typing(cog: 'GurtCog', channel, text: str):
async def simulate_human_typing(cog: 'WheatleyCog', channel, text: str):
"""Shows typing indicator without significant delay."""
# Minimal delay to ensure the typing indicator shows up reliably
# but doesn't add noticeable latency to the response.
@ -84,7 +84,7 @@ async def simulate_human_typing(cog: 'GurtCog', channel, text: str):
async with channel.typing():
await asyncio.sleep(0.1) # Very short sleep, just to ensure typing shows
async def log_internal_api_call(cog: 'GurtCog', task_description: str, payload: Dict[str, Any], response_data: Optional[Dict[str, Any]], error: Optional[Exception] = None):
async def log_internal_api_call(cog: 'WheatleyCog', task_description: str, payload: Dict[str, Any], response_data: Optional[Dict[str, Any]], error: Optional[Exception] = None):
"""Helper function to log internal API calls to a file."""
log_dir = "data"
log_file = os.path.join(log_dir, "internal_api_calls.log")