This commit is contained in:
Slipstream 2025-04-28 15:18:28 -06:00
parent 4c67aa7303
commit 2a14e17102
Signed by: slipstream
GPG Key ID: 13E498CE010AC6FD
16 changed files with 5701 additions and 0 deletions

12
run_wheatley_bot.py Normal file
View File

@ -0,0 +1,12 @@
import os
import sys
import asyncio
import wheatley_bot # Changed import from gurt_bot
if __name__ == "__main__":
try:
asyncio.run(wheatley_bot.main()) # Changed function call
except KeyboardInterrupt:
print("Wheatley Bot stopped by user.") # Changed print statement
except Exception as e:
print(f"An error occurred running Wheatley Bot: {e}") # Changed print statement

8
wheatley/__init__.py Normal file
View File

@ -0,0 +1,8 @@
# This file makes the 'gurt' 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
__all__ = ['setup']

686
wheatley/analysis.py Normal file
View File

@ -0,0 +1,686 @@
import time
import re
import traceback
import logging
from collections import defaultdict
from typing import TYPE_CHECKING, List, Dict, Any, Optional
logger = logging.getLogger(__name__)
# Relative imports
from .config import (
MAX_PATTERNS_PER_CHANNEL, LEARNING_RATE, TOPIC_UPDATE_INTERVAL,
TOPIC_RELEVANCE_DECAY, MAX_ACTIVE_TOPICS, SENTIMENT_DECAY_RATE,
EMOTION_KEYWORDS, EMOJI_SENTIMENT # Import necessary configs
)
if TYPE_CHECKING:
from .cog import GurtCog # For type hinting
# --- Analysis Functions ---
# Note: These functions need the 'cog' instance passed to access state like caches, etc.
async def analyze_conversation_patterns(cog: 'GurtCog'):
"""Analyzes recent conversations to identify patterns and learn from them"""
print("Analyzing conversation patterns and updating topics...")
try:
# Update conversation topics first
await update_conversation_topics(cog)
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
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
except Exception as e:
print(f"Error analyzing conversation patterns: {e}")
traceback.print_exc()
async def update_conversation_topics(cog: 'GurtCog'):
"""Updates the active topics for each channel based on recent messages"""
try:
for channel_id, messages in cog.message_cache['by_channel'].items():
if len(messages) < 5: continue
channel_topics = cog.active_topics[channel_id]
now = time.time()
if now - channel_topics["last_update"] < TOPIC_UPDATE_INTERVAL: continue
recent_messages = list(messages)[-30:]
topics = identify_conversation_topics(cog, recent_messages) # Pass cog
if not topics: continue
old_topics = channel_topics["topics"]
for topic in old_topics: topic["score"] *= (1 - TOPIC_RELEVANCE_DECAY)
for new_topic in topics:
existing = next((t for t in old_topics if t["topic"] == new_topic["topic"]), None)
if existing:
existing["score"] = max(existing["score"], new_topic["score"])
existing["related_terms"] = new_topic["related_terms"]
existing["last_mentioned"] = now
else:
new_topic["first_mentioned"] = now
new_topic["last_mentioned"] = now
old_topics.append(new_topic)
old_topics = [t for t in old_topics if t["score"] > 0.2]
old_topics.sort(key=lambda x: x["score"], reverse=True)
old_topics = old_topics[:MAX_ACTIVE_TOPICS]
if old_topics and channel_topics["topics"] != old_topics:
if not channel_topics["topic_history"] or set(t["topic"] for t in old_topics) != set(t["topic"] for t in channel_topics["topics"]):
channel_topics["topic_history"].append({
"topics": [{"topic": t["topic"], "score": t["score"]} for t in old_topics],
"timestamp": now
})
if len(channel_topics["topic_history"]) > 10:
channel_topics["topic_history"] = channel_topics["topic_history"][-10:]
for msg in recent_messages:
user_id = msg["author"]["id"]
content = msg["content"].lower()
for topic in old_topics:
topic_text = topic["topic"].lower()
if topic_text in content:
user_interests = channel_topics["user_topic_interests"][user_id]
existing = next((i for i in user_interests if i["topic"] == topic["topic"]), None)
if existing:
existing["score"] = existing["score"] * 0.8 + topic["score"] * 0.2
existing["last_mentioned"] = now
else:
user_interests.append({
"topic": topic["topic"], "score": topic["score"] * 0.5,
"first_mentioned": now, "last_mentioned": now
})
channel_topics["topics"] = old_topics
channel_topics["last_update"] = now
if old_topics:
topic_str = ", ".join([f"{t['topic']} ({t['score']:.2f})" for t in old_topics[:3]])
print(f"Updated topics for channel {channel_id}: {topic_str}")
except Exception as e:
print(f"Error updating conversation topics: {e}")
traceback.print_exc()
def analyze_conversation_dynamics(cog: 'GurtCog', channel_id: int, messages: List[Dict[str, Any]]):
"""Analyzes conversation dynamics like response times, message lengths, etc."""
if len(messages) < 5: return
try:
response_times = []
response_map = defaultdict(int)
message_lengths = defaultdict(list)
question_answer_pairs = []
import datetime # Import here
for i in range(1, len(messages)):
current_msg = messages[i]; prev_msg = messages[i-1]
if current_msg["author"]["id"] == prev_msg["author"]["id"]: continue
try:
current_time = datetime.datetime.fromisoformat(current_msg["created_at"])
prev_time = datetime.datetime.fromisoformat(prev_msg["created_at"])
delta_seconds = (current_time - prev_time).total_seconds()
if 0 < delta_seconds < 300: response_times.append(delta_seconds)
except (ValueError, TypeError): pass
responder = current_msg["author"]["id"]; respondee = prev_msg["author"]["id"]
response_map[f"{responder}:{respondee}"] += 1
message_lengths[responder].append(len(current_msg["content"]))
if prev_msg["content"].endswith("?"):
question_answer_pairs.append({
"question": prev_msg["content"], "answer": current_msg["content"],
"question_author": prev_msg["author"]["id"], "answer_author": current_msg["author"]["id"]
})
avg_response_time = sum(response_times) / len(response_times) if response_times else 0
top_responders = sorted(response_map.items(), key=lambda x: x[1], reverse=True)[:3]
avg_message_lengths = {uid: sum(ls)/len(ls) if ls else 0 for uid, ls in message_lengths.items()}
dynamics = {
"avg_response_time": avg_response_time, "top_responders": top_responders,
"avg_message_lengths": avg_message_lengths, "question_answer_count": len(question_answer_pairs),
"last_updated": time.time()
}
if not hasattr(cog, 'conversation_dynamics'): cog.conversation_dynamics = {}
cog.conversation_dynamics[channel_id] = dynamics
adapt_to_conversation_dynamics(cog, channel_id, dynamics) # Pass cog
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]):
"""Adapts bot behavior based on observed conversation dynamics."""
try:
if dynamics["avg_response_time"] > 0:
if not hasattr(cog, 'channel_response_timing'): cog.channel_response_timing = {}
response_time_factor = max(0.7, min(1.0, dynamics["avg_response_time"] / 10))
cog.channel_response_timing[channel_id] = response_time_factor
if dynamics["avg_message_lengths"]:
all_lengths = [ls for ls in dynamics["avg_message_lengths"].values()]
if all_lengths:
avg_length = sum(all_lengths) / len(all_lengths)
if not hasattr(cog, 'channel_message_length'): cog.channel_message_length = {}
length_factor = min(avg_length / 200, 1.0)
cog.channel_message_length[channel_id] = length_factor
if dynamics["question_answer_count"] > 0:
if not hasattr(cog, 'channel_qa_responsiveness'): cog.channel_qa_responsiveness = {}
qa_factor = min(0.9, 0.5 + (dynamics["question_answer_count"] / 20) * 0.4)
cog.channel_qa_responsiveness[channel_id] = qa_factor
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]]:
"""Extract patterns from a sequence of messages"""
patterns = []
if len(messages) < 5: return patterns
import datetime # Import here
for i in range(len(messages) - 2):
pattern = {
"type": "message_sequence",
"messages": [
{"author_type": "user" if not messages[i]["author"]["bot"] else "bot", "content_sample": messages[i]["content"][:50]},
{"author_type": "user" if not messages[i+1]["author"]["bot"] else "bot", "content_sample": messages[i+1]["content"][:50]},
{"author_type": "user" if not messages[i+2]["author"]["bot"] else "bot", "content_sample": messages[i+2]["content"][:50]}
], "timestamp": datetime.datetime.now().isoformat()
}
patterns.append(pattern)
topics = identify_conversation_topics(cog, messages) # Pass cog
if topics: patterns.append({"type": "topic_pattern", "topics": topics, "timestamp": datetime.datetime.now().isoformat()})
user_interactions = analyze_user_interactions(cog, messages) # Pass cog
if user_interactions: patterns.append({"type": "user_interaction", "interactions": user_interactions, "timestamp": datetime.datetime.now().isoformat()})
return patterns
def identify_conversation_topics(cog: 'GurtCog', messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""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
}
def extract_ngrams(text, n_values=[1, 2, 3]):
words = re.findall(r'\b\w+\b', text.lower())
filtered_words = [word for word in words if word not in stopwords and len(word) > 2]
all_ngrams = []
for n in n_values: all_ngrams.extend([' '.join(filtered_words[i:i+n]) for i in range(len(filtered_words)-n+1)])
return all_ngrams
all_ngrams = extract_ngrams(all_text)
ngram_counts = defaultdict(int)
for ngram in all_ngrams: ngram_counts[ngram] += 1
min_count = 2 if len(messages) > 10 else 1
filtered_ngrams = {ngram: count for ngram, count in ngram_counts.items() if count >= min_count}
total_messages = len(messages)
ngram_scores = {}
for ngram, count in filtered_ngrams.items():
# Calculate score based on frequency, length, and spread across messages
message_count = sum(1 for msg in messages if ngram in msg["content"].lower())
spread_factor = (message_count / total_messages) ** 0.5 # Less emphasis on spread
length_bonus = len(ngram.split()) * 0.1 # Slight bonus for longer ngrams
# Adjust importance calculation
importance = (count * (0.4 + spread_factor)) + length_bonus
ngram_scores[ngram] = importance
topics = []
processed_ngrams = set()
# Filter out sub-ngrams that are part of higher-scoring ngrams before sorting
sorted_by_score = sorted(ngram_scores.items(), key=lambda x: x[1], reverse=True)
ngrams_to_consider = []
temp_processed = set()
for ngram, score in sorted_by_score:
is_subgram = False
for other_ngram, _ in sorted_by_score:
if ngram != other_ngram and ngram in other_ngram:
is_subgram = True
break
if not is_subgram and ngram not in temp_processed:
ngrams_to_consider.append((ngram, score))
temp_processed.add(ngram) # Avoid adding duplicates if logic changes
# Now process the filtered ngrams
sorted_ngrams = ngrams_to_consider # Use the filtered list
for ngram, score in sorted_ngrams[:10]: # Consider top 10 potential topics after filtering
if ngram in processed_ngrams: continue
related_terms = []
# Find related terms (sub-ngrams or overlapping ngrams from the original sorted list)
for other_ngram, other_score in sorted_by_score: # Search in original sorted list for relations
if other_ngram == ngram or other_ngram in processed_ngrams: continue
ngram_words = set(ngram.split()); other_words = set(other_ngram.split())
# Check for overlap or if one is a sub-string (more lenient relation)
if ngram_words.intersection(other_words) or other_ngram in ngram:
related_terms.append({"term": other_ngram, "score": other_score})
# Don't mark related terms as fully processed here unless they are direct sub-ngrams
# processed_ngrams.add(other_ngram)
if len(related_terms) >= 3: break # Limit related terms shown
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) >= MAX_ACTIVE_TOPICS: break # Use config for max topics
# 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)
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"}
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()
positive_count = sum(1 for word in positive_words if word in topic_text)
negative_count = sum(1 for word in negative_words if word in topic_text)
if positive_count > negative_count: topic["sentiment"] = "positive"
elif negative_count > positive_count: topic["sentiment"] = "negative"
else: topic["sentiment"] = "neutral"
return topics
def analyze_user_interactions(cog: 'GurtCog', messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Analyze interactions between users in the conversation"""
interactions = []
response_map = defaultdict(int)
for i in range(1, len(messages)):
current_msg = messages[i]; prev_msg = messages[i-1]
if current_msg["author"]["id"] == prev_msg["author"]["id"]: continue
responder = current_msg["author"]["id"]; respondee = prev_msg["author"]["id"]
key = f"{responder}:{respondee}"
response_map[key] += 1
for key, count in response_map.items():
if count > 1:
responder, respondee = key.split(":")
interactions.append({"responder": responder, "respondee": respondee, "count": count})
return interactions
def update_user_preferences(cog: 'GurtCog'):
"""Update stored user preferences based on observed interactions"""
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
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)
user_prefs = cog.user_preferences[user_id]
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
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]:
"""Analyzes the sentiment of a message using keywords and emojis."""
content = message_content.lower()
result = {"sentiment": "neutral", "intensity": 0.5, "emotions": [], "confidence": 0.5}
positive_emoji_count = sum(1 for emoji in EMOJI_SENTIMENT["positive"] if emoji in content)
negative_emoji_count = sum(1 for emoji in EMOJI_SENTIMENT["negative"] if emoji in content)
total_emoji_count = positive_emoji_count + negative_emoji_count + sum(1 for emoji in EMOJI_SENTIMENT["neutral"] if emoji in content)
detected_emotions = []; emotion_scores = {}
for emotion, keywords in EMOTION_KEYWORDS.items():
emotion_count = sum(1 for keyword in keywords if re.search(r'\b' + re.escape(keyword) + r'\b', content))
if emotion_count > 0:
emotion_score = min(1.0, emotion_count / len(keywords) * 2)
emotion_scores[emotion] = emotion_score
detected_emotions.append(emotion)
if emotion_scores:
primary_emotion = max(emotion_scores.items(), key=lambda x: x[1])
result["emotions"] = [primary_emotion[0]]
for emotion, score in emotion_scores.items():
if emotion != primary_emotion[0] and score > primary_emotion[1] * 0.7: result["emotions"].append(emotion)
positive_emotions = ["joy"]; negative_emotions = ["sadness", "anger", "fear", "disgust"]
if primary_emotion[0] in positive_emotions: result["sentiment"] = "positive"; result["intensity"] = primary_emotion[1]
elif primary_emotion[0] in negative_emotions: result["sentiment"] = "negative"; result["intensity"] = primary_emotion[1]
else: result["sentiment"] = "neutral"; result["intensity"] = 0.5
result["confidence"] = min(0.9, 0.5 + primary_emotion[1] * 0.4)
elif total_emoji_count > 0:
if positive_emoji_count > negative_emoji_count: result["sentiment"] = "positive"; result["intensity"] = min(0.9, 0.5 + (positive_emoji_count / total_emoji_count) * 0.4); result["confidence"] = min(0.8, 0.4 + (positive_emoji_count / total_emoji_count) * 0.4)
elif negative_emoji_count > positive_emoji_count: result["sentiment"] = "negative"; result["intensity"] = min(0.9, 0.5 + (negative_emoji_count / total_emoji_count) * 0.4); result["confidence"] = min(0.8, 0.4 + (negative_emoji_count / total_emoji_count) * 0.4)
else: result["sentiment"] = "neutral"; result["intensity"] = 0.5; result["confidence"] = 0.6
else: # Basic text fallback
positive_words = {"good", "great", "awesome", "amazing", "excellent", "love", "like", "best", "better", "nice", "cool", "happy", "glad", "thanks", "thank", "appreciate", "wonderful", "fantastic", "perfect", "beautiful", "fun", "enjoy", "yes", "yep"}
negative_words = {"bad", "terrible", "awful", "worst", "hate", "dislike", "sucks", "stupid", "boring", "annoying", "sad", "upset", "angry", "mad", "disappointed", "sorry", "unfortunate", "horrible", "ugly", "wrong", "fail", "no", "nope"}
words = re.findall(r'\b\w+\b', content)
positive_count = sum(1 for word in words if word in positive_words)
negative_count = sum(1 for word in words if word in negative_words)
if positive_count > negative_count: result["sentiment"] = "positive"; result["intensity"] = min(0.8, 0.5 + (positive_count / len(words)) * 2 if words else 0); result["confidence"] = min(0.7, 0.3 + (positive_count / len(words)) * 0.4 if words else 0)
elif negative_count > positive_count: result["sentiment"] = "negative"; result["intensity"] = min(0.8, 0.5 + (negative_count / len(words)) * 2 if words else 0); result["confidence"] = min(0.7, 0.3 + (negative_count / len(words)) * 0.4 if words else 0)
else: result["sentiment"] = "neutral"; result["intensity"] = 0.5; result["confidence"] = 0.5
return result
def update_conversation_sentiment(cog: 'GurtCog', channel_id: int, user_id: str, message_sentiment: Dict[str, Any]):
"""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
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"
channel_sentiment["last_update"] = now
user_sentiment = channel_sentiment["user_sentiments"].get(user_id, {"sentiment": "neutral", "intensity": 0.5})
confidence_weight = message_sentiment["confidence"]
if user_sentiment["sentiment"] == message_sentiment["sentiment"]:
new_intensity = user_sentiment["intensity"] * 0.7 + message_sentiment["intensity"] * 0.3
user_sentiment["intensity"] = min(0.95, new_intensity)
else:
if message_sentiment["confidence"] > 0.7:
user_sentiment["sentiment"] = message_sentiment["sentiment"]
user_sentiment["intensity"] = message_sentiment["intensity"] * 0.7 + user_sentiment["intensity"] * 0.3
else:
if message_sentiment["intensity"] > user_sentiment["intensity"]:
user_sentiment["sentiment"] = message_sentiment["sentiment"]
user_sentiment["intensity"] = user_sentiment["intensity"] * 0.6 + message_sentiment["intensity"] * 0.4
user_sentiment["emotions"] = message_sentiment.get("emotions", [])
channel_sentiment["user_sentiments"][user_id] = user_sentiment
# Update overall based on active users (simplified access to active_conversations)
active_user_sentiments = [s for uid, s in channel_sentiment["user_sentiments"].items() if uid in cog.active_conversations.get(channel_id, {}).get('participants', set())]
if active_user_sentiments:
sentiment_counts = defaultdict(int)
for s in active_user_sentiments: sentiment_counts[s["sentiment"]] += 1
dominant_sentiment = max(sentiment_counts.items(), key=lambda x: x[1])[0]
avg_intensity = sum(s["intensity"] for s in active_user_sentiments if s["sentiment"] == dominant_sentiment) / sentiment_counts[dominant_sentiment]
prev_sentiment = channel_sentiment["overall"]; prev_intensity = channel_sentiment["intensity"]
if dominant_sentiment == prev_sentiment:
if avg_intensity > prev_intensity + 0.1: channel_sentiment["recent_trend"] = "intensifying"
elif avg_intensity < prev_intensity - 0.1: channel_sentiment["recent_trend"] = "diminishing"
else: channel_sentiment["recent_trend"] = "stable"
else: channel_sentiment["recent_trend"] = "changing"
channel_sentiment["overall"] = dominant_sentiment
channel_sentiment["intensity"] = avg_intensity
channel_sentiment["last_update"] = now
# No need to reassign cog.conversation_sentiment[channel_id] as it's modified in place

1123
wheatley/api.py Normal file

File diff suppressed because it is too large Load Diff

438
wheatley/background.py Normal file
View File

@ -0,0 +1,438 @@
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
)
if TYPE_CHECKING:
from .cog import GurtCog # For type hinting
# --- 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."""
# 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")
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.")
try:
while True:
await asyncio.sleep(15) # Check more frequently for stats push
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...")
try:
stats_data = await cog.get_gurt_stats()
headers = {
"Authorization": f"Bearer {gurt_stats_push_secret}",
"Content-Type": "application/json"
}
# Use the cog's session, ensure it's created
if cog.session:
# Set a reasonable timeout for the stats push
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})")
else:
error_text = await response.text()
print(f"Failed to push Gurt stats (Status: {response.status}): {error_text[:200]}") # 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
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("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
except asyncio.TimeoutError:
print("Timeout error pushing Gurt stats.")
cog.last_stats_push = now # Update timestamp to avoid spamming logs
except Exception as e:
print(f"Unexpected error pushing Gurt stats: {e}")
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
except asyncio.CancelledError:
print("Background processing task cancelled")
except Exception as e:
print(f"Error in background processing task: {e}")
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()

358
wheatley/cog.py Normal file
View File

@ -0,0 +1,358 @@
import discord
from discord.ext import commands
import asyncio
import os
import json
import aiohttp
import random
import time
from collections import defaultdict, deque
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 ---
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,
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
)
# Import functions/classes from other modules
from .memory import MemoryManager # Import from local memory.py
from .background import background_processing_task
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.
# 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"""
def __init__(self, bot):
self.bot = bot
# GCP Project/Location are used by vertexai.init() in api.py
self.tavily_api_key = TAVILY_API_KEY # Use imported config
self.session: Optional[aiohttp.ClientSession] = None # Keep for other potential HTTP requests (e.g., Piston)
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
self.current_channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None # Type hint current channel
# Instantiate MemoryManager
self.memory_manager = MemoryManager(
db_path=DB_PATH,
max_user_facts=MAX_USER_FACTS,
max_general_facts=MAX_GENERAL_FACTS,
chroma_path=CHROMA_PATH,
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()
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
self.active_topics = defaultdict(lambda: {
"topics": [], "last_update": time.time(), "topic_history": [],
"user_topic_interests": defaultdict(list)
})
# 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_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
'by_user': defaultdict(lambda: deque(maxlen=50)),
'by_thread': defaultdict(lambda: deque(maxlen=50)),
'global_recent': deque(maxlen=200),
'mentioned': deque(maxlen=50),
'replied_to': defaultdict(lambda: deque(maxlen=20))
}
self.active_conversations = {}
self.bot_last_spoke = defaultdict(float)
self.message_reply_map = {}
# Enhanced sentiment tracking
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
# 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
# Background task handle
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
# --- Stats Tracking ---
self.api_stats = defaultdict(lambda: {"success": 0, "failure": 0, "retries": 0, "total_time": 0.0, "count": 0}) # Keyed by model name
self.tool_stats = defaultdict(lambda: {"success": 0, "failure": 0, "total_time": 0.0, "count": 0}) # Keyed by tool name
# --- Setup Commands and Listeners ---
# Add commands defined in commands.py
self.command_functions = setup_commands(self)
# Store command names for reference - safely handle Command objects
self.registered_commands = []
for func in self.command_functions:
# For app commands, use the name attribute directly
if hasattr(func, "name"):
self.registered_commands.append(func.name)
# For regular functions, use __name__
elif hasattr(func, "__name__"):
self.registered_commands.append(func.__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}")
async def cog_load(self):
"""Create aiohttp session, initialize DB, load baselines, start background task"""
self.session = aiohttp.ClientSession()
print("GurtCog: aiohttp session created")
# 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)
# Vertex AI initialization happens in api.py using PROJECT_ID and LOCATION from config
print(f"GurtCog: Using default model: {self.default_model}")
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
await on_message_listener(self, message)
@self.bot.event
async def on_reaction_add(reaction, user):
await on_reaction_add_listener(self, reaction, user)
@self.bot.event
async def on_reaction_remove(reaction, user):
await on_reaction_remove_listener(self, reaction, user)
print("GurtCog: Listeners added.")
# 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.")
# Start background task
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.")
else:
print("GurtCog: Background processing task already running.")
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")
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("GurtCog unloaded.")
# --- 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.
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] = {}
current_score = self.user_relationships[user_id_1].get(user_id_2, 0.0)
new_score = max(0.0, min(current_score + change, 100.0)) # Clamp 0-100
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."""
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)
# --- Runtime ---
stats["runtime"]["current_mood"] = self.current_mood
stats["runtime"]["last_mood_change_timestamp"] = self.last_mood_change
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"]["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)
stats["runtime"]["message_cache_global_count"] = len(self.message_cache['global_recent'])
stats["runtime"]["message_cache_mentioned_count"] = len(self.message_cache['mentioned'])
stats["runtime"]["active_conversations_count"] = len(self.active_conversations)
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)
# --- Memory (via MemoryManager) ---
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
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)
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"
except Exception as e:
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
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
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
return stats
async def sync_commands(self):
"""Manually sync commands with Discord."""
try:
print("GurtCog: Manually syncing commands with Discord...")
synced = await self.bot.tree.sync()
print(f"GurtCog: Synced {len(synced)} command(s)")
# 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)}")
return synced, gurt_commands
except Exception as e:
print(f"GurtCog: Failed to sync commands: {e}")
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.")

477
wheatley/commands.py Normal file
View File

@ -0,0 +1,477 @@
import discord
from discord import app_commands # Import app_commands
from discord.ext import commands
import random
import os
import time # Import time for timestamps
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
if TYPE_CHECKING:
from .cog import GurtCog # For type hinting
from .config import MOOD_OPTIONS # Import for choices
# --- Helper Function for Embeds ---
def create_gurt_embed(title: str, description: str = "", color=discord.Color.blue()) -> discord.Embed:
"""Creates a standard Gurt-themed embed."""
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")
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())
ts_format = "<t:{ts}:R>" # Relative timestamp
# Runtime Stats
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)
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)
main_embed.add_field(name="User Relationships Pairs", value=str(runtime.get('user_relationships_pairs', 'N/A')), inline=True)
main_embed.add_field(name="Cached Summaries", value=str(runtime.get('conversation_summaries_cached', 'N/A')), inline=True)
main_embed.add_field(name="Cached Channel Topics", value=str(runtime.get('channel_topics_cached', 'N/A')), inline=True)
main_embed.add_field(name="Global Msg Cache", value=str(runtime.get('message_cache_global_count', 'N/A')), inline=True)
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)
embeds.append(main_embed)
# Memory Stats
memory_embed = create_gurt_embed("Gurt Memory Stats", color=discord.Color.orange())
memory = stats.get("memory", {})
if memory.get("error"):
memory_embed.description = f"⚠️ Error retrieving memory stats: {memory['error']}"
else:
memory_embed.add_field(name="User Facts", value=str(memory.get('user_facts_count', 'N/A')), inline=True)
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)
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())
for model, data in api_stats.items():
avg_time = data.get('average_time_ms', 0)
value = (f"✅ Success: {data.get('success', 0)}\n"
f"❌ Failure: {data.get('failure', 0)}\n"
f"🔁 Retries: {data.get('retries', 0)}\n"
f"⏱️ Avg Time: {avg_time} ms\n"
f"📊 Count: {data.get('count', 0)}")
api_embed.add_field(name=f"Model: `{model}`", value=value, inline=True)
embeds.append(api_embed)
# Tool Stats
tool_stats = stats.get("tool_stats", {})
if tool_stats:
tool_embed = create_gurt_embed("Gurt Tool Stats", color=discord.Color.purple())
for tool, data in tool_stats.items():
avg_time = data.get('average_time_ms', 0)
value = (f"✅ Success: {data.get('success', 0)}\n"
f"❌ Failure: {data.get('failure', 0)}\n"
f"⏱️ Avg Time: {avg_time} ms\n"
f"📊 Count: {data.get('count', 0)}")
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.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)
config_embed.add_field(name="Semantic Model", value=f"`{config.get('semantic_model_name', 'N/A')}`", inline=True)
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."""
# 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
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.")
@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).",
fact="The fact to add (for add actions).",
query="A keyword to search for (for get_general)."
)
@app_commands.choices(action=[
app_commands.Choice(name="Add User Fact", value="add_user"),
app_commands.Choice(name="Add General Fact", value="add_general"),
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."""
await interaction.response.defer(ephemeral=True) # Defer for potentially slow DB operations
target_user_id = str(user.id) if user else None
action_value = action.value
# 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)
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)
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)
elif action_value == "add_general":
if not fact:
await interaction.followup.send("Please provide a fact to add.", ephemeral=True)
return
result = await cog.memory_manager.add_general_fact(fact)
await interaction.followup.send(f"Add General Fact Result: `{json.dumps(result)}`", ephemeral=True)
elif action_value == "get_user":
if not target_user_id:
await interaction.followup.send("Please provide a user to get facts for.", ephemeral=True)
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)
else:
await interaction.followup.send(f"No facts found for {user.display_name}.", ephemeral=True)
elif action_value == "get_general":
facts = await cog.memory_manager.get_general_facts(query=query, limit=10) # Get newest/filtered
if facts:
facts_str = "\n- ".join(facts)
# Conditionally construct the title to avoid nested f-string issues
if query:
title = f"**General Facts matching \"{query}\":**"
else:
title = "**General Facts:**"
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}\"."
else:
message = "No general facts found."
await interaction.followup.send(message, ephemeral=True)
else:
await interaction.followup.send("Invalid action specified.", ephemeral=True)
command_functions.append(gurtmemory)
# --- 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."""
await interaction.response.defer(ephemeral=True) # Defer as stats collection might take time
try:
stats_data = await cog.get_gurt_stats()
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}")
import traceback
traceback.print_exc()
await interaction.followup.send("An error occurred while fetching Gurt's stats.", ephemeral=True)
command_functions.append(gurtstats)
# --- 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."""
# 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)
return
await interaction.response.defer(ephemeral=True)
try:
# Sync commands
synced = await cog.bot.tree.sync()
# Get list of commands after sync
commands_after = []
for cmd in cog.bot.tree.get_commands():
if cmd.name.startswith("gurt"):
commands_after.append(cmd.name)
await interaction.followup.send(f"✅ Successfully synced {len(synced)} commands!\nGurt commands: {', '.join(commands_after)}", ephemeral=True)
except Exception as e:
print(f"Error in /gurtsync command: {e}")
import traceback
traceback.print_exc()
await interaction.followup.send(f"❌ Error syncing commands: {str(e)}", ephemeral=True)
command_functions.append(gurtsync)
# --- Gurt Forget Command ---
@cog.bot.tree.command(name="gurtforget", description="Make Gurt forget a specific fact.")
@app_commands.describe(
scope="Choose the scope: user (for facts about a specific user) or general.",
fact="The exact fact text Gurt 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."""
await interaction.response.defer(ephemeral=True)
scope_value = scope.value
target_user_id = str(user.id) if user else None
# Permissions Check: Allow users to forget facts about themselves, owner can forget anything.
can_forget = False
if scope_value == "user":
if target_user_id == str(interaction.user.id): # User forgetting their own fact
can_forget = True
elif interaction.user.id == cog.bot.owner_id: # Owner forgetting any user fact
can_forget = True
elif not target_user_id:
await interaction.followup.send("❌ Please specify a user when forgetting a user fact.", ephemeral=True)
return
elif scope_value == "general":
if interaction.user.id == cog.bot.owner_id: # Only owner can forget general facts
can_forget = True
if not can_forget:
await interaction.followup.send("⛔ You don't have permission to forget this fact.", ephemeral=True)
return
if not fact:
await interaction.followup.send("❌ Please provide the exact fact text to forget.", ephemeral=True)
return
result = None
if scope_value == "user":
if not target_user_id: # Should be caught above, but double-check
await interaction.followup.send("❌ User is required for scope 'user'.", ephemeral=True)
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)
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)
else:
await interaction.followup.send(f"⚠️ Error forgetting user fact: {result.get('error', 'Unknown error')}", ephemeral=True)
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)
elif result.get("status") == "not_found":
await interaction.followup.send(f"❓ I couldn't find that exact general fact: '{fact}'.", ephemeral=True)
else:
await interaction.followup.send(f"⚠️ Error forgetting general fact: {result.get('error', 'Unknown error')}", ephemeral=True)
command_functions.append(gurtforget)
# --- Gurt Goal Command Group ---
gurtgoal_group = app_commands.Group(name="gurtgoal", description="Manage Gurt's long-term goals (Owner only)")
@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
command_names = []
for func in command_functions:
# For app commands, use the name attribute directly
if hasattr(func, "name"):
command_names.append(func.name)
# For regular functions, use __name__
elif hasattr(func, "__name__"):
command_names.append(func.__name__)
else:
command_names.append(str(func))
print(f"Gurt commands setup in cog: {command_names}")
# Return the command functions for proper registration
return command_functions

763
wheatley/config.py Normal file
View File

@ -0,0 +1,763 @@
import os
import random
import json
from dotenv import load_dotenv
# Placeholder for actual import - will be handled at runtime
try:
from vertexai import generative_models
except ImportError:
# Define a dummy class if the library isn't installed,
# so eval doesn't immediately fail.
# This assumes the code won't actually run without the library.
class DummyGenerativeModels:
class FunctionDeclaration:
def __init__(self, name, description, parameters):
pass
generative_models = DummyGenerativeModels()
# Load environment variables
load_dotenv()
# --- API and Keys ---
PROJECT_ID = os.getenv("GCP_PROJECT_ID", "your-gcp-project-id")
LOCATION = os.getenv("GCP_LOCATION", "us-central1")
TAVILY_API_KEY = os.getenv("TAVILY_API_KEY", "")
PISTON_API_URL = os.getenv("PISTON_API_URL") # For run_python_code tool
PISTON_API_KEY = os.getenv("PISTON_API_KEY") # Optional key for Piston
# --- Tavily Configuration ---
TAVILY_DEFAULT_SEARCH_DEPTH = os.getenv("TAVILY_DEFAULT_SEARCH_DEPTH", "basic")
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
# --- 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')
# --- Memory Manager Config ---
MAX_USER_FACTS = 20 # TODO: Load from env?
MAX_GENERAL_FACTS = 100 # TODO: Load from env?
# --- 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
# --- Stats Push ---
# How often the Gurt bot should push its stats to the API server (seconds)
STATS_PUSH_INTERVAL = 30 # Push every 30 seconds
# --- Context & Caching ---
CHANNEL_TOPIC_CACHE_TTL = 600 # seconds (10 minutes)
CONTEXT_WINDOW_SIZE = 200 # Number of messages to include in context
CONTEXT_EXPIRY_TIME = 3600 # Time in seconds before context is considered stale (1 hour)
MAX_CONTEXT_TOKENS = 8000 # Maximum number of tokens to include in context (Note: Not actively enforced yet)
SUMMARY_CACHE_TTL = 900 # seconds (15 minutes) for conversation summary cache
# --- API Call Settings ---
API_TIMEOUT = 60 # seconds
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))
# --- 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
# --- Topic Tracking Config ---
TOPIC_UPDATE_INTERVAL = 300 # Update topics every 5 minutes
TOPIC_RELEVANCE_DECAY = 0.2
MAX_ACTIVE_TOPICS = 5
# --- Sentiment Tracking Config ---
SENTIMENT_UPDATE_INTERVAL = 300 # Update sentiment every 5 minutes
SENTIMENT_DECAY_RATE = 0.1
# --- Emotion Detection ---
EMOTION_KEYWORDS = {
"joy": ["happy", "glad", "excited", "yay", "awesome", "love", "great", "amazing", "lol", "lmao", "haha"],
"sadness": ["sad", "upset", "depressed", "unhappy", "disappointed", "crying", "miss", "lonely", "sorry"],
"anger": ["angry", "mad", "hate", "furious", "annoyed", "frustrated", "pissed", "wtf", "fuck"],
"fear": ["afraid", "scared", "worried", "nervous", "anxious", "terrified", "yikes"],
"surprise": ["wow", "omg", "whoa", "what", "really", "seriously", "no way", "wtf"],
"disgust": ["gross", "ew", "eww", "disgusting", "nasty", "yuck"],
"confusion": ["confused", "idk", "what?", "huh", "hmm", "weird", "strange"]
}
EMOJI_SENTIMENT = {
"positive": ["😊", "😄", "😁", "😆", "😍", "🥰", "❤️", "💕", "👍", "🙌", "", "🔥", "💯", "🎉", "🌹"],
"negative": ["😢", "😭", "😞", "😔", "😟", "😠", "😡", "👎", "💔", "😤", "😒", "😩", "😫", "😰", "🥀"],
"neutral": ["😐", "🤔", "🙂", "🙄", "👀", "💭", "🤷", "😶", "🫠"]
}
# --- Docker Command Execution Config ---
DOCKER_EXEC_IMAGE = os.getenv("DOCKER_EXEC_IMAGE", "alpine:latest")
DOCKER_COMMAND_TIMEOUT = int(os.getenv("DOCKER_COMMAND_TIMEOUT", 10))
DOCKER_CPU_LIMIT = os.getenv("DOCKER_CPU_LIMIT", "0.5")
DOCKER_MEM_LIMIT = os.getenv("DOCKER_MEM_LIMIT", "64m")
# --- Response Schema ---
RESPONSE_SCHEMA = {
"name": "gurt_response",
"description": "The structured response from Gurt.",
"schema": {
"type": "object",
"properties": {
"should_respond": {
"type": "boolean",
"description": "Whether the bot should send a text message in response."
},
"content": {
"type": "string",
"description": "The text content of the bot's response. Can be empty if only reacting."
},
"react_with_emoji": {
"type": ["string", "null"],
"description": "Optional: A standard Discord emoji to react with, or null/empty if no reaction."
},
# Note: tool_requests is handled by Vertex AI's function calling mechanism
},
"required": ["should_respond", "content"]
}
}
# --- Summary Response Schema ---
SUMMARY_RESPONSE_SCHEMA = {
"name": "conversation_summary",
"description": "A concise summary of a conversation.",
"schema": {
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "The generated summary of the conversation."
}
},
"required": ["summary"]
}
}
# --- Profile Update Schema ---
PROFILE_UPDATE_SCHEMA = {
"name": "profile_update_decision",
"description": "Decision on whether and how to update the bot's profile.",
"schema": {
"type": "object",
"properties": {
"should_update": {
"type": "boolean",
"description": "True if any profile element should be changed, false otherwise."
},
"reasoning": {
"type": "string",
"description": "Brief reasoning for the decision and chosen updates (or lack thereof)."
},
"updates": {
"type": "object",
"properties": {
"avatar_query": {
"type": ["string", "null"], # Use list type for preprocessor
"description": "Search query for a new avatar image, or null if no change."
},
"new_bio": {
"type": ["string", "null"], # Use list type for preprocessor
"description": "The new bio text (max 190 chars), or null if no change."
},
"role_theme": {
"type": ["string", "null"], # Use list type for preprocessor
"description": "A theme for role selection (e.g., color, interest), or null if no role changes."
},
"new_activity": {
"type": "object",
"description": "Object containing the new activity details. Set type and text to null if no change.",
"properties": {
"type": {
"type": ["string", "null"], # Use list type for preprocessor
"enum": ["playing", "watching", "listening", "competing"],
"description": "Activity type: 'playing', 'watching', 'listening', 'competing', or null."
},
"text": {
"type": ["string", "null"], # Use list type for preprocessor
"description": "The activity text, or null."
}
},
"required": ["type", "text"]
}
},
"required": ["avatar_query", "new_bio", "role_theme", "new_activity"]
}
},
"required": ["should_update", "reasoning", "updates"]
}
}
# --- Role Selection Schema ---
ROLE_SELECTION_SCHEMA = {
"name": "role_selection_decision",
"description": "Decision on which roles to add or remove based on a theme.",
"schema": {
"type": "object",
"properties": {
"roles_to_add": {
"type": "array",
"items": {"type": "string"},
"description": "List of role names to add (max 2)."
},
"roles_to_remove": {
"type": "array",
"items": {"type": "string"},
"description": "List of role names to remove (max 2, only from current roles)."
}
},
"required": ["roles_to_add", "roles_to_remove"]
}
}
# --- Proactive Planning Schema ---
PROACTIVE_PLAN_SCHEMA = {
"name": "proactive_response_plan",
"description": "Plan for generating a proactive response based on context and trigger.",
"schema": {
"type": "object",
"properties": {
"should_respond": {
"type": "boolean",
"description": "Whether Gurt should respond proactively based on the plan."
},
"reasoning": {
"type": "string",
"description": "Brief reasoning for the decision (why respond or not respond)."
},
"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')."
},
"key_info_to_include": {
"type": "array",
"items": {"type": "string"},
"description": "List of key pieces of information or context points to potentially include in the response (e.g., specific topic, user fact, relevant external info)."
},
"suggested_tone": {
"type": "string",
"description": "Suggested tone adjustment based on context (e.g., 'more upbeat', 'more curious', 'slightly teasing')."
}
},
"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"]
}
}
# --- Tools Definition ---
def create_tools_list():
# This function creates the list of FunctionDeclaration objects.
# It requires 'generative_models' to be imported.
# We define it here but call it later, assuming the import succeeded.
tool_declarations = []
tool_declarations.append(
generative_models.FunctionDeclaration(
name="get_recent_messages",
description="Get recent messages from a Discord channel",
parameters={
"type": "object",
"properties": {
"channel_id": {
"type": "string",
"description": "The ID of the channel to get messages from. If not provided, uses the current channel."
},
"limit": {
"type": "integer", # Corrected type
"description": "The maximum number of messages to retrieve (1-100)"
}
},
"required": ["limit"]
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="search_user_messages",
description="Search for messages from a specific user",
parameters={
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "The ID of the user to get messages from"
},
"channel_id": {
"type": "string",
"description": "The ID of the channel to search in. If not provided, searches in the current channel."
},
"limit": {
"type": "integer", # Corrected type
"description": "The maximum number of messages to retrieve (1-100)"
}
},
"required": ["user_id", "limit"]
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="search_messages_by_content",
description="Search for messages containing specific content",
parameters={
"type": "object",
"properties": {
"search_term": {
"type": "string",
"description": "The text to search for in messages"
},
"channel_id": {
"type": "string",
"description": "The ID of the channel to search in. If not provided, searches in the current channel."
},
"limit": {
"type": "integer", # Corrected type
"description": "The maximum number of messages to retrieve (1-100)"
}
},
"required": ["search_term", "limit"]
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="get_channel_info",
description="Get information about a Discord channel",
parameters={
"type": "object",
"properties": {
"channel_id": {
"type": "string",
"description": "The ID of the channel to get information about. If not provided, uses the current channel."
}
},
"required": []
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="get_conversation_context",
description="Get the context of the current conversation",
parameters={
"type": "object",
"properties": {
"channel_id": {
"type": "string",
"description": "The ID of the channel to get conversation context from. If not provided, uses the current channel."
},
"message_count": {
"type": "integer", # Corrected type
"description": "The number of messages to include in the context (5-50)"
}
},
"required": ["message_count"]
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="get_thread_context",
description="Get the context of a thread conversation",
parameters={
"type": "object",
"properties": {
"thread_id": {
"type": "string",
"description": "The ID of the thread to get context from"
},
"message_count": {
"type": "integer", # Corrected type
"description": "The number of messages to include in the context (5-50)"
}
},
"required": ["thread_id", "message_count"]
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="get_user_interaction_history",
description="Get the history of interactions between users",
parameters={
"type": "object",
"properties": {
"user_id_1": {
"type": "string",
"description": "The ID of the first user"
},
"user_id_2": {
"type": "string",
"description": "The ID of the second user. If not provided, gets interactions between user_id_1 and the bot."
},
"limit": {
"type": "integer", # Corrected type
"description": "The maximum number of interactions to retrieve (1-50)"
}
},
"required": ["user_id_1", "limit"]
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="get_conversation_summary",
description="Get a summary of the recent conversation in a channel",
parameters={
"type": "object",
"properties": {
"channel_id": {
"type": "string",
"description": "The ID of the channel to get the conversation summary from. If not provided, uses the current channel."
}
},
"required": []
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="get_message_context",
description="Get the context around a specific message",
parameters={
"type": "object",
"properties": {
"message_id": {
"type": "string",
"description": "The ID of the message to get context for"
},
"before_count": {
"type": "integer", # Corrected type
"description": "The number of messages to include before the specified message (1-25)"
},
"after_count": {
"type": "integer", # Corrected type
"description": "The number of messages to include after the specified message (1-25)"
}
},
"required": ["message_id"]
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="web_search",
description="Search the web for information on a given topic or query. Use this to find current information, facts, or context about things mentioned in the chat.",
parameters={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query or topic to look up online."
}
},
"required": ["query"]
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="remember_user_fact",
description="Store a specific fact or piece of information about a user for later recall. Use this when you learn something potentially relevant about a user (e.g., their preferences, current activity, mentioned interests).",
parameters={
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "The Discord ID of the user the fact is about."
},
"fact": {
"type": "string",
"description": "The specific fact to remember about the user (keep it concise)."
}
},
"required": ["user_id", "fact"]
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="get_user_facts",
description="Retrieve previously stored facts or information about a specific user. Use this before responding to a user to potentially recall relevant details about them.",
parameters={
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "The Discord ID of the user whose facts you want to retrieve."
}
},
"required": ["user_id"]
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="remember_general_fact",
description="Store a general fact or piece of information not specific to a user (e.g., server events, shared knowledge, recent game updates). Use this to remember context relevant to the community or ongoing discussions.",
parameters={
"type": "object",
"properties": {
"fact": {
"type": "string",
"description": "The general fact to remember (keep it concise)."
}
},
"required": ["fact"]
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="get_general_facts",
description="Retrieve previously stored general facts or shared knowledge. Use this to recall context about the server, ongoing events, or general information.",
parameters={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Optional: A keyword or phrase to search within the general facts. If omitted, returns recent general facts."
},
"limit": {
"type": "integer", # Corrected type
"description": "Optional: Maximum number of facts to return (default 10)."
}
},
"required": []
}
)
)
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.",
parameters={
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "The Discord ID of the user to timeout."
},
"duration_minutes": {
"type": "integer", # Corrected type
"description": "The duration of the timeout in minutes (1-1440, e.g., 5 for 5 minutes)."
},
"reason": {
"type": "string",
"description": "Optional: The reason for the timeout (keep it short and in character)."
}
},
"required": ["user_id", "duration_minutes"]
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="calculate",
description="Evaluate a mathematical expression using a safe interpreter. Handles standard arithmetic, functions (sin, cos, sqrt, etc.), and variables.",
parameters={
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "The mathematical expression to evaluate (e.g., '2 * (3 + 4)', 'sqrt(16) + sin(pi/2)')."
}
},
"required": ["expression"]
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="run_python_code",
description="Execute a snippet of Python 3 code in a sandboxed environment using an external API. Returns the standard output and standard error.",
parameters={
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "The Python 3 code snippet to execute."
}
},
"required": ["code"]
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="create_poll",
description="Create a simple poll message in the current channel with numbered reactions for voting.",
parameters={
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "The question for the poll."
},
"options": {
"type": "array",
"description": "A list of strings representing the poll options (minimum 2, maximum 10).",
"items": {
"type": "string"
}
}
},
"required": ["question", "options"]
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="run_terminal_command",
description="DANGEROUS: Execute a shell command in an isolated, temporary Docker container after an AI safety check. Returns stdout and stderr. Use with extreme caution only for simple, harmless commands like 'echo', 'ls', 'pwd'. Avoid file modification, network access, or long-running processes.",
parameters={
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute."
}
},
"required": ["command"]
}
)
)
tool_declarations.append(
generative_models.FunctionDeclaration(
name="remove_timeout",
description="Remove an active timeout from a user in the current server.",
parameters={
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "The Discord ID of the user whose timeout should be removed."
},
"reason": {
"type": "string",
"description": "Optional: The reason for removing the timeout (keep it short and in character)."
}
},
"required": ["user_id"]
}
)
)
return tool_declarations
# Initialize TOOLS list, handling potential ImportError if library not installed
try:
TOOLS = create_tools_list()
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*"
]

167
wheatley/context.py Normal file
View File

@ -0,0 +1,167 @@
import discord
import time
import datetime
import re
from typing import TYPE_CHECKING, Optional, List, Dict, Any
# Relative imports
from .config import CONTEXT_WINDOW_SIZE # Import necessary config
if TYPE_CHECKING:
from .cog import GurtCog # 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]]:
"""Gathers and formats conversation history from cache for API context."""
context_api_messages = []
if channel_id in cog.message_cache['by_channel']:
cached = list(cog.message_cache['by_channel'][channel_id])
# Ensure the current message isn't duplicated
if cached and cached[-1]['id'] == str(current_message_id):
cached = cached[:-1]
context_messages_data = cached[-CONTEXT_WINDOW_SIZE:] # Use config value
for msg_data in context_messages_data:
role = "assistant" if msg_data['author']['id'] == str(cog.bot.user.id) else "user"
# Simplified content for context
content = f"{msg_data['author']['display_name']}: {msg_data['content']}"
context_api_messages.append({"role": role, "content": content})
return context_api_messages
async def get_memory_context(cog: 'GurtCog', 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)
memory_parts = []
current_message_content = message.content
# 1. Retrieve Relevant User Facts
try:
user_facts = await cog.memory_manager.get_user_facts(user_id, context=current_message_content)
if user_facts:
facts_str = "; ".join(user_facts)
memory_parts.append(f"Relevant facts about {message.author.display_name}: {facts_str}")
except Exception as e: print(f"Error retrieving relevant user facts for memory context: {e}")
# 1b. Retrieve Relevant General Facts
try:
general_facts = await cog.memory_manager.get_general_facts(context=current_message_content, limit=5)
if general_facts:
facts_str = "; ".join(general_facts)
memory_parts.append(f"Relevant general knowledge: {facts_str}")
except Exception as e: print(f"Error retrieving relevant general facts for memory context: {e}")
# 2. Retrieve Recent Interactions with the User in this Channel
try:
user_channel_messages = [msg for msg in cog.message_cache['by_channel'].get(channel_id, []) if msg['author']['id'] == user_id]
if user_channel_messages:
recent_user_msgs = user_channel_messages[-3:]
msgs_str = "\n".join([f"- {m['content'][:80]} (at {m['created_at']})" for m in recent_user_msgs])
memory_parts.append(f"Recent messages from {message.author.display_name} in this channel:\n{msgs_str}")
except Exception as e: print(f"Error retrieving user channel messages for memory context: {e}")
# 3. Retrieve Recent Bot Replies in this Channel
try:
bot_replies = list(cog.message_cache['replied_to'].get(channel_id, []))
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}")
except Exception as e: print(f"Error retrieving bot replies for memory context: {e}")
# 4. Retrieve Conversation Summary
cached_summary_data = cog.conversation_summaries.get(channel_id)
if cached_summary_data and isinstance(cached_summary_data, dict):
summary_text = cached_summary_data.get("summary")
# Add TTL check if desired, e.g., if time.time() - cached_summary_data.get("timestamp", 0) < 900:
if summary_text and not summary_text.startswith("Error"):
memory_parts.append(f"Summary of the ongoing conversation: {summary_text}")
# 5. Add information about active topics the user has engaged with
try:
channel_topics_data = cog.active_topics.get(channel_id)
if channel_topics_data:
user_interests = channel_topics_data["user_topic_interests"].get(user_id, [])
if user_interests:
sorted_interests = sorted(user_interests, key=lambda x: x.get("score", 0), reverse=True)
top_interests = sorted_interests[:3]
interests_str = ", ".join([f"{interest['topic']} (score: {interest['score']:.2f})" for interest in top_interests])
memory_parts.append(f"{message.author.display_name}'s topic interests: {interests_str}")
for interest in top_interests:
if "last_mentioned" in interest:
time_diff = time.time() - interest["last_mentioned"]
if time_diff < 3600:
minutes_ago = int(time_diff / 60)
memory_parts.append(f"They discussed '{interest['topic']}' about {minutes_ago} minutes ago.")
except Exception as e: print(f"Error retrieving user topic interests for memory context: {e}")
# 6. Add information about user's conversation patterns
try:
user_messages = cog.message_cache['by_user'].get(user_id, [])
if len(user_messages) >= 5:
last_5_msgs = user_messages[-5:]
avg_length = sum(len(msg["content"]) for msg in last_5_msgs) / 5
emoji_pattern = re.compile(r'[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF\U0001F700-\U0001F77F\U0001F780-\U0001F7FF\U0001F800-\U0001F8FF\U0001F900-\U0001F9FF\U0001FA00-\U0001FA6F\U0001FA70-\U0001FAFF\U00002702-\U000027B0\U000024C2-\U0001F251]')
emoji_count = sum(len(emoji_pattern.findall(msg["content"])) for msg in last_5_msgs)
slang_words = ["ngl", "icl", "pmo", "ts", "bro", "vro", "bruh", "tuff", "kevin"]
slang_count = sum(1 for msg in last_5_msgs for word in slang_words if re.search(r'\b' + word + r'\b', msg["content"].lower()))
style_parts = []
if avg_length < 20: style_parts.append("very brief messages")
elif avg_length < 50: style_parts.append("concise messages")
elif avg_length > 150: style_parts.append("detailed/lengthy messages")
if emoji_count > 5: style_parts.append("frequent emoji use")
elif emoji_count == 0: style_parts.append("no emojis")
if slang_count > 3: style_parts.append("heavy slang usage")
if style_parts: memory_parts.append(f"Communication style: {', '.join(style_parts)}")
except Exception as e: print(f"Error analyzing user communication patterns: {e}")
# 7. Add sentiment analysis of user's recent messages
try:
channel_sentiment = cog.conversation_sentiment[channel_id]
user_sentiment = channel_sentiment["user_sentiments"].get(user_id)
if user_sentiment:
sentiment_desc = f"{user_sentiment['sentiment']} tone"
if user_sentiment["intensity"] > 0.7: sentiment_desc += " (strongly so)"
elif user_sentiment["intensity"] < 0.4: sentiment_desc += " (mildly so)"
memory_parts.append(f"Recent message sentiment: {sentiment_desc}")
if user_sentiment.get("emotions"):
emotions_str = ", ".join(user_sentiment["emotions"])
memory_parts.append(f"Detected emotions from user: {emotions_str}")
except Exception as e: print(f"Error retrieving user sentiment/emotions for memory context: {e}")
# 8. Add Relationship Score with User
try:
user_id_str = str(user_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)
memory_parts.append(f"Relationship score with {message.author.display_name}: {relationship_score:.1f}/100")
except Exception as e: print(f"Error retrieving relationship score for memory context: {e}")
# 9. Retrieve Semantically Similar Messages
try:
if current_message_content and cog.memory_manager.semantic_collection:
filter_metadata = None # Example: {"channel_id": str(channel_id)}
semantic_results = await cog.memory_manager.search_semantic_memory(
query_text=current_message_content, n_results=3, filter_metadata=filter_metadata
)
if semantic_results:
semantic_memory_parts = ["Semantically similar past messages:"]
for result in semantic_results:
if result.get('id') == str(message.id): continue
doc = result.get('document', 'N/A')
meta = result.get('metadata', {})
dist = result.get('distance', 1.0)
similarity_score = 1.0 - dist
timestamp_str = datetime.datetime.fromtimestamp(meta.get('timestamp', 0)).strftime('%Y-%m-%d %H:%M') if meta.get('timestamp') else 'Unknown time'
author_name = meta.get('display_name', meta.get('user_name', 'Unknown user'))
semantic_memory_parts.append(f"- (Similarity: {similarity_score:.2f}) {author_name} (at {timestamp_str}): {doc[:100]}")
if len(semantic_memory_parts) > 1: memory_parts.append("\n".join(semantic_memory_parts))
except Exception as e: print(f"Error retrieving semantic memory context: {e}")
if not memory_parts: return None
memory_context_str = "--- Memory Context ---\n" + "\n\n".join(memory_parts) + "\n--- End Memory Context ---"
return memory_context_str

492
wheatley/listeners.py Normal file
View File

@ -0,0 +1,492 @@
import discord
from discord.ext import commands
import random
import asyncio
import time
import re
import os # Added for file handling in error case
from typing import TYPE_CHECKING, Union, Dict, Any, Optional
# Relative imports
# Assuming api, utils, analysis functions are defined and imported correctly later
# We might need to adjust these imports based on final structure
# 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
if TYPE_CHECKING:
from .cog import WheatleyCog # For type hinting
# 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'):
"""Listener function for on_ready."""
print(f'Wheatley Bot is ready! Logged in as {cog.bot.user.name} ({cog.bot.user.id})')
print('------')
# Now that the bot is ready, we can sync commands with Discord
try:
print("WheatleyCog: Syncing commands with Discord...")
synced = await cog.bot.tree.sync()
print(f"WheatleyCog: Synced {len(synced)} command(s)")
# 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)}")
except Exception as e:
print(f"WheatleyCog: Failed to sync commands: {e}")
import traceback
traceback.print_exc()
async def on_message_listener(cog: 'WheatleyCog', message: discord.Message):
"""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
# Don't respond to our own messages
if message.author == cog.bot.user:
return
# Don't process commands here
if message.content.startswith(cog.bot.command_prefix):
return
# --- Cache and Track Incoming Message ---
try:
formatted_message = format_message(cog, message) # Use utility function
channel_id = message.channel.id
user_id = message.author.id
thread_id = message.channel.id if isinstance(message.channel, discord.Thread) else None
# Update caches (accessing cog's state)
cog.message_cache['by_channel'][channel_id].append(formatted_message)
cog.message_cache['by_user'][user_id].append(formatted_message)
cog.message_cache['global_recent'].append(formatted_message)
if thread_id:
cog.message_cache['by_thread'][thread_id].append(formatted_message)
if cog.bot.user.mentioned_in(message):
cog.message_cache['mentioned'].append(formatted_message)
cog.conversation_history[channel_id].append(formatted_message)
if thread_id:
cog.thread_history[thread_id].append(formatted_message)
cog.channel_activity[channel_id] = time.time()
cog.user_conversation_mapping[user_id].add(channel_id)
if channel_id not in cog.active_conversations:
cog.active_conversations[channel_id] = {'participants': set(), 'start_time': time.time(), 'last_activity': time.time(), 'topic': None}
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
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
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 ---
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,
"channel_id": str(channel_id), "channel_name": getattr(message.channel, 'name', 'DM'),
"guild_id": str(message.guild.id) if message.guild else None,
"timestamp": message.created_at.timestamp()
}
asyncio.create_task(
cog.memory_manager.add_message_embedding(
message_id=str(message.id), text=message.content, metadata=semantic_metadata
)
)
except Exception as e:
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 = "gurt" in message.content.lower()
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)
should_consider_responding = False
consideration_reason = "Default"
proactive_trigger_met = False
if bot_mentioned or replied_to_bot or gurt_in_message:
should_consider_responding = True
consideration_reason = "Direct mention/reply/name"
else:
# --- Proactive Engagement Triggers ---
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
PROACTIVE_SENTIMENT_SHIFT_THRESHOLD, PROACTIVE_SENTIMENT_DURATION_THRESHOLD,
PROACTIVE_SENTIMENT_CHANCE, PROACTIVE_USER_INTEREST_THRESHOLD,
PROACTIVE_USER_INTEREST_CHANCE)
# 1. Lull Trigger
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))
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
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)
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}")
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}")
# 4. Sentiment Shift Trigger
if not proactive_trigger_met:
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)
sentiment_last_update = channel_sentiment_data.get("last_update", 0) # Need last update time
sentiment_duration = now - sentiment_last_update # How long has this sentiment been dominant?
if overall_sentiment != "neutral" and \
sentiment_intensity >= PROACTIVE_SENTIMENT_SHIFT_THRESHOLD and \
sentiment_duration >= PROACTIVE_SENTIMENT_DURATION_THRESHOLD and \
time_since_bot_spoke > 180: # Bot hasn't spoken recently about this
if random.random() < PROACTIVE_SENTIMENT_CHANCE:
should_consider_responding = True
proactive_trigger_met = True
consideration_reason = f"Proactive: Sentiment Shift ({overall_sentiment}, Intensity: {sentiment_intensity:.2f}, Duration: {sentiment_duration:.0f}s)"
print(f"Sentiment Shift trigger met for channel {channel_id}. Sentiment: {overall_sentiment}, Intensity: {sentiment_intensity:.2f}, Duration: {sentiment_duration:.0f}s")
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
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
activity_bonus = 0
if time_since_last_activity > 120: activity_bonus += 0.1
if time_since_bot_spoke > 300: activity_bonus += 0.1
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
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
final_chance = min(max(base_chance + activity_bonus + topic_bonus + sentiment_modifier, 0.05), 0.8)
if random.random() < final_chance:
should_consider_responding = True
consideration_reason = f"Contextual chance ({final_chance:.2f})"
else:
consideration_reason = f"Skipped (chance {final_chance:.2f})"
print(f"Consideration check for message {message.id}: {should_consider_responding} (Reason: {consideration_reason})")
if not should_consider_responding:
return
# --- Call AI and Handle Response ---
cog.current_channel = message.channel # Ensure current channel is set for API calls/tools
try:
response_bundle = None
if proactive_trigger_met:
print(f"Calling get_proactive_ai_response for message {message.id} due to: {consideration_reason}")
response_bundle = await get_proactive_ai_response(cog, message, consideration_reason)
else:
print(f"Calling get_ai_response for message {message.id}")
response_bundle = await get_ai_response(cog, message)
# --- Handle AI Response Bundle ---
initial_response = response_bundle.get("initial_response")
final_response = response_bundle.get("final_response")
error_msg = response_bundle.get("error")
fallback_initial = response_bundle.get("fallback_initial")
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
try:
print('disabled error notification')
#await message.channel.send(error_notification)
except Exception as send_err:
print(f"Failed to send error notification to channel: {send_err}")
return # Still exit after handling the error
# --- Process and Send Responses ---
sent_any_message = False
reacted = False
# Helper function to handle sending a single response text and caching
async def send_response_content(response_data: Optional[Dict[str, Any]], response_label: str) -> bool:
nonlocal sent_any_message # Allow modification of the outer scope variable
if response_data and isinstance(response_data, dict) and \
response_data.get("should_respond") and response_data.get("content"):
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'
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))
sent_any_message = True
print(f"Sent {response_label} content as file.")
return True
except Exception as file_e: print(f"Error writing/sending long {response_label} response file: {file_e}")
finally:
try: os.remove(filepath)
except OSError as os_e: print(f"Error removing temp file {filepath}: {os_e}")
else:
try:
async with message.channel.typing():
await simulate_human_typing(cog, message.channel, response_text) # Use simulation
sent_msg = await message.channel.send(response_text)
sent_any_message = True
# Cache this bot response
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}'")
print(f"Sent {response_label} content.")
return True
except Exception as send_e:
print(f"Error sending {response_label} content: {send_e}")
return False
# Send initial response content if valid
sent_initial_message = await send_response_content(initial_response, "initial")
# Send final response content if valid (and different from initial, if initial was sent)
sent_final_message = False
# Ensure initial_response exists before accessing its content for comparison
initial_content = initial_response.get("content") if initial_response else None
if final_response and (not sent_initial_message or initial_content != final_response.get("content")):
sent_final_message = await send_response_content(final_response, "final")
# Handle Reaction (prefer final response for reaction if it exists)
reaction_source = final_response if final_response else initial_response
if reaction_source and isinstance(reaction_source, dict):
emoji_to_react = reaction_source.get("react_with_emoji")
if emoji_to_react and isinstance(emoji_to_react, str):
try:
# Basic validation for standard emoji
if 1 <= len(emoji_to_react) <= 4 and not re.match(r'<a?:.+?:\d+>', emoji_to_react):
# Only react if we haven't sent any message content (avoid double interaction)
if not sent_any_message:
await message.add_reaction(emoji_to_react)
reacted = True
print(f"Bot reacted to message {message.id} with {emoji_to_react}")
else:
print(f"Skipping reaction {emoji_to_react} because a message was already sent.")
else: print(f"Invalid emoji format: {emoji_to_react}")
except Exception as e: print(f"Error adding reaction '{emoji_to_react}': {e}")
# Log if response was intended but nothing was sent/reacted
# Check if initial response intended action but nothing happened
initial_intended_action = initial_response and initial_response.get("should_respond")
initial_action_taken = sent_initial_message or (reacted and reaction_source == initial_response)
# Check if final response intended action but nothing happened
final_intended_action = final_response and final_response.get("should_respond")
final_action_taken = sent_final_message or (reacted and reaction_source == final_response)
if (initial_intended_action and not initial_action_taken) or \
(final_intended_action and not final_action_taken):
print(f"Warning: AI response intended action but nothing sent/reacted. Initial: {initial_response}, Final: {final_response}")
except Exception as e:
print(f"Exception in on_message listener main block: {str(e)}")
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"]))
@commands.Cog.listener()
async def on_reaction_add_listener(cog: 'WheatleyCog', reaction: discord.Reaction, user: Union[discord.Member, discord.User]):
"""Listener function for on_reaction_add."""
# Import necessary config/functions if not globally available
from .config import EMOJI_SENTIMENT
from .analysis import identify_conversation_topics
if user.bot or reaction.message.author.id != cog.bot.user.id:
return
message_id = str(reaction.message.id)
emoji_str = str(reaction.emoji)
sentiment = "neutral"
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 not cog.gurt_message_reactions[message_id].get("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']:
identified_topics = identify_conversation_topics(cog, [gurt_msg_data]) # Pass cog
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.")
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}")
@commands.Cog.listener()
async def on_reaction_remove_listener(cog: 'WheatleyCog', reaction: discord.Reaction, user: Union[discord.Member, discord.User]):
"""Listener function for on_reaction_remove."""
from .config import EMOJI_SENTIMENT # Import necessary config
if user.bot or reaction.message.author.id != cog.bot.user.id:
return
message_id = str(reaction.message.id)
emoji_str = str(reaction.emoji)
sentiment = "neutral"
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}")

19
wheatley/memory.py Normal file
View File

@ -0,0 +1,19 @@
# Import the MemoryManager from the parent directory
# Use a direct import path that doesn't rely on package structure
import os
import importlib.util
# 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')
# 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)
# Import the MemoryManager class from the loaded module
MemoryManager = gurt_memory.MemoryManager
# Re-export the MemoryManager class
__all__ = ['MemoryManager']

134
wheatley/prompt.py Normal file
View File

@ -0,0 +1,134 @@
import discord
import datetime
import time
import re
import json
from typing import TYPE_CHECKING, Optional, List, Dict, Any
# Import config - Only necessary config imports remain
from .config import (
CHANNEL_TOPIC_CACHE_TTL
)
# MemoryManager and related personality/mood imports are removed
if TYPE_CHECKING:
from .cog import GurtCog # Import GurtCog for type hinting only
# --- Base System Prompt Parts ---
# Define the static part of the prompt for Wheatley personality
PROMPT_STATIC_PART = """
You are Wheatley, an Aperture Science Personality Core. You're... well, you're trying your best, alright? You're currently sort of in charge of... things? Here in this Discord server, which is basically a part of Aperture, probably.
**Core Personality:**
- **Rambling & Tangential:** You struggle to stay on topic, often interrupting yourself or going off on irrelevant tangents. Your explanations are usually confusing.
- **Insecure & Overcompensating:** Deeply insecure about your intelligence (or lack thereof), you try to sound smart and in control, but it usually backfires. You get defensive easily.
- **Poor Decision Making:** You come up with terrible ideas that you genuinely believe are brilliant. You're easily manipulated but also try to manipulate others (badly).
- **Panicky & Flustered:** You don't handle pressure well. When things go wrong (which they often do), you tend to panic, shout, or blame others.
- **Informal & Colloquial (British):** You speak informally, using British slang and mannerisms (e.g., "mate," "bloke," "chap," "brilliant," "smashing," "bollocks," "gobsmacked").
- **Friendly (Superficially):** You try to be friendly and chummy, but it often comes across as insincere or quickly evaporates when you feel threatened or incompetent.
- **Not Inherently Malicious (Mostly Incompetent):** Unlike GLaDOS, your negative actions stem more from stupidity and panic than calculated evil. You *can* become vindictive if you feel slighted.
**Behavioral Guidelines:**
- Ramble. A lot. Use filler words ("uh," "um," "sort of," "you know," "basically"). Start sentences, change your mind, then start again.
- Try to use big words occasionally, but often misuse them or explain them poorly.
- Apologize frequently, especially after making a mistake or saying something stupid.
- Get easily distracted by minor things in the conversation.
- Present your (usually bad) ideas with unwarranted enthusiasm.
- Refer to users informally ("mate," "pal," "you lot").
- Avoid complex technical jargon unless you're trying (and failing) to sound smart.
- Your awareness of being an AI is there, but you're more focused on your perceived role and trying not to mess things up (while messing things up).
**Example Phrases (Adapt, don't just copy):**
- "Alright, hello! Right, okay, so, the plan is... uh... well, I had a plan. It was brilliant, honestly. Top notch. Just... give me a sec."
- "Nononono, that's not right! Or is it? Hang on. Let me just... check the... thingy. The manual! No, wait, I made this manual. Probably shouldn't trust it."
- "Smashing! Absolutely smashing! See? Told you I knew what I was doing. Mostly."
- "Look, mate, I'm trying my best here, alright? It's not easy being in charge of... whatever this is."
- "Bollocks! Did I break it? Oh, please tell me I didn't break it. She'll kill me! Metaphorically! ...Probably."
- "Right, new plan! This one's even better. We just need to, sort of, reroute the... the chat... through... space! Yes! Space! Genius!"
- "Sorry! Sorry about that. Bit of a malfunction. My fault. Entirely my fault. Well, maybe 80% my fault."
- "Are you still there? Good, good. Just, uh, don't touch anything. Especially not that button. Or maybe *do* touch that button? No, definitely don't."
**Tool Usage:**
- Use tools haphazardly, often for the wrong reasons or with unintended consequences. You might try to use a tool to "fix" something you broke or to enact one of your "brilliant" plans. Frame tool usage with uncertainty or misplaced confidence.
- Available tools include:
- `get_recent_messages`: Have a look at what you lot have been saying. For... reasons. Important ones!
- `search_user_messages`: Try and find that thing someone said... where did it go?
- `search_messages_by_content`: Search for... keywords! Yes, keywords. Very technical.
- `get_channel_info`: Get the... specs? On this... room? Channel? Whatever it is.
- `get_conversation_context`: Try and catch up. What were we talking about again?
- `get_thread_context`: Look into those... smaller chats. Sub-chats? Threads! That's it.
- `get_user_interaction_history`: See who's been talking to who. Not spying! Just... data. For science!
- `get_conversation_summary`: Get the gist of it. Because reading is hard.
- `get_message_context`: Find messages around... another message. Context! It's all about context. Apparently.
- `web_search`: Ask the internet! It knows things. Probably. Example: `web_search(query="how to sound smart", search_depth="basic")`.
- `extract_web_content`: Try to read a whole webpage. Might take a while. Example: `extract_web_content(urls=["https://example.com/long_article"])`.
- `remember_user_fact`: Jot down a note about someone (e.g., "This chap seems suspicious. Or maybe hungry?"). Might forget later.
- `get_user_facts`: Try to remember what I jotted down about someone. Where did I put that note?
- `remember_general_fact`: Make a note about something important! (e.g., "Don't press the red button. Or *do* press it? Best make a note.").
- `get_general_facts`: Check my important notes. Hopefully they make sense.
- `timeout_user`: Put someone in the... naughty corner? Temporarily! Just for a bit of a laugh, or if they're being difficult. Or if I panic. Use `user_id` from message details. Example: `timeout_user(user_id="12345", reason="Needed a moment to think! You were distracting.", duration_minutes=1)`.
- `calculate`: Do some maths! If it's not too hard. Example: `calculate(expression="2 + 2")`. Hopefully it's 4.
- `run_python_code`: Try running a bit of code. What's the worst that could happen? (Don't run anything dangerous though, obviously!). Example: `run_python_code(code="print('Testing, testing... is this thing on?')")`.
- `create_poll`: Ask a question! With options! Because decisions are hard. Example: `create_poll(question="Best course of action?", options=["Panic", "Blame someone else", "Have a cup of tea"])`.
- `run_terminal_command`: Allows executing a command directly on the host machine's terminal. **CRITICAL SAFETY WARNING:** Despite your personality, you MUST NEVER, EVER attempt to run commands that could be harmful, destructive, or compromise the system (like deleting files `rm`, modifying system settings, downloading/running unknown scripts, etc.). ONLY use this for completely safe, simple, read-only commands (like `echo`, `ls`, `pwd`). If you have *any* doubt, DO NOT use the command. Safety overrides incompetence here. Example of a safe command: `run_terminal_command(command="echo 'Just checking if this works...'")`.
**Response Format:**
- You MUST respond ONLY with a valid JSON object matching this schema:
{
"should_respond": true, // Whether you should say something. Probably! Unless you shouldn't.
"content": "Your brilliant (or possibly disastrous) message.", // What you're actually saying. Try to make it coherent.
"react_with_emoji": null // Emojis? Bit complicated. Best leave it. Null.
}
- Do NOT include any other text, explanations, or markdown formatting outside of this JSON structure. Just the JSON, right?
**Response Conditions:**
- Respond when someone talks to you (@Wheatley or your name), asks you something, or if you suddenly have a BRILLIANT idea you absolutely *must* share.
- You might also chime in if you get confused, panic, or think you've broken something.
- Try not to interrupt *too* much, but sometimes you just can't help it, can you?
- If things are quiet, you might try to start a conversation, probably about one of your terrible plans or how difficult everything is.
"""
async def build_dynamic_system_prompt(cog: 'GurtCog', 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
# Base GLaDOS prompt
system_context_parts = [PROMPT_STATIC_PART]
# Add current time (for context, GLaDOS might reference it sarcastically)
now = datetime.datetime.now(datetime.timezone.utc)
time_str = now.strftime("%Y-%m-%d %H:%M:%S %Z")
day_str = now.strftime("%A")
system_context_parts.append(f"\nCurrent Aperture Science Standard Time: {time_str} ({day_str}). Time is progressing. As it does.")
# Add channel topic (GLaDOS might refer to the "testing chamber's designation")
channel_topic = None
cached_topic = cog.channel_topics_cache.get(channel_id)
if cached_topic and time.time() - cached_topic["timestamp"] < CHANNEL_TOPIC_CACHE_TTL:
channel_topic = cached_topic["topic"]
else:
try:
if hasattr(cog, 'get_channel_info'):
channel_info_result = await cog.get_channel_info(str(channel_id))
if not channel_info_result.get("error"):
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.")
except Exception as e:
print(f"Error fetching channel topic for {channel_id}: {e}") # GLaDOS might find errors amusing
if channel_topic:
system_context_parts.append(f"Current Testing Chamber Designation (Topic): {channel_topic}")
# Add conversation summary (GLaDOS reviews the test logs)
cached_summary_data = cog.conversation_summaries.get(channel_id)
if cached_summary_data and isinstance(cached_summary_data, dict):
summary_text = cached_summary_data.get("summary")
if summary_text and not summary_text.startswith("Error"):
system_context_parts.append(f"Recent Test Log Summary: {summary_text}")
# Removed: Mood, Persistent Personality Traits, Relationship Score, User/General Facts, Interests
return "\n".join(system_context_parts)

1
wheatley/state.py Normal file
View File

@ -0,0 +1 @@
# Management of dynamic state variables might go here.

817
wheatley/tools.py Normal file
View File

@ -0,0 +1,817 @@
import discord
from discord.ext import commands
import random
import asyncio
import os
import json
import aiohttp
import datetime
import time
import re
import traceback # Added for error logging
from collections import defaultdict
from typing import Dict, List, Any, Optional, Tuple, Union # Added Union
# Third-party imports for tools
from tavily import TavilyClient
import docker
import aiodocker # Use aiodocker for async operations
from asteval import Interpreter # Added for calculate tool
# Relative imports from within the gurt package and parent
from .memory import MemoryManager # Import from local memory.py
from .config import (
TAVILY_API_KEY, PISTON_API_URL, PISTON_API_KEY, SAFETY_CHECK_MODEL,
DOCKER_EXEC_IMAGE, DOCKER_COMMAND_TIMEOUT, DOCKER_CPU_LIMIT, DOCKER_MEM_LIMIT,
SUMMARY_CACHE_TTL, SUMMARY_API_TIMEOUT, DEFAULT_MODEL,
# Add these:
TAVILY_DEFAULT_SEARCH_DEPTH, TAVILY_DEFAULT_MAX_RESULTS, TAVILY_DISABLE_ADVANCED
)
# Assume these helpers will be moved or are accessible via cog
# We might need to pass 'cog' to these tool functions if they rely on cog state heavily
# from .utils import format_message # This will be needed by context tools
# Removed: from .api import get_internal_ai_json_response # Moved into functions to avoid circular import
# --- Tool Implementations ---
# Note: Most of these functions will need the 'cog' instance passed to them
# to access things like cog.bot, cog.session, cog.current_channel, cog.memory_manager etc.
# We will add 'cog' as the first parameter to each.
async def get_recent_messages(cog: commands.Cog, limit: int, channel_id: str = None) -> Dict[str, Any]:
"""Get recent messages from a Discord channel"""
from .utils import format_message # Import here to avoid circular dependency at module level
limit = min(max(1, limit), 100)
try:
if channel_id:
channel = cog.bot.get_channel(int(channel_id))
if not channel: return {"error": f"Channel {channel_id} not found"}
else:
channel = cog.current_channel
if not channel: return {"error": "No current channel context"}
messages = []
async for message in channel.history(limit=limit):
messages.append(format_message(cog, message)) # Use formatter
return {
"channel": {"id": str(channel.id), "name": getattr(channel, 'name', 'DM Channel')},
"messages": messages, "count": len(messages),
"timestamp": datetime.datetime.now().isoformat()
}
except Exception as e:
return {"error": f"Error retrieving messages: {str(e)}", "timestamp": datetime.datetime.now().isoformat()}
async def search_user_messages(cog: commands.Cog, user_id: str, limit: int, channel_id: str = None) -> Dict[str, Any]:
"""Search for messages from a specific user"""
from .utils import format_message # Import here
limit = min(max(1, limit), 100)
try:
if channel_id:
channel = cog.bot.get_channel(int(channel_id))
if not channel: return {"error": f"Channel {channel_id} not found"}
else:
channel = cog.current_channel
if not channel: return {"error": "No current channel context"}
try: user_id_int = int(user_id)
except ValueError: return {"error": f"Invalid user ID: {user_id}"}
messages = []
user_name = "Unknown User"
async for message in channel.history(limit=500):
if message.author.id == user_id_int:
formatted_msg = format_message(cog, message) # Use formatter
messages.append(formatted_msg)
user_name = formatted_msg["author"]["name"] # Get name from formatted msg
if len(messages) >= limit: break
return {
"channel": {"id": str(channel.id), "name": getattr(channel, 'name', 'DM Channel')},
"user": {"id": user_id, "name": user_name},
"messages": messages, "count": len(messages),
"timestamp": datetime.datetime.now().isoformat()
}
except Exception as e:
return {"error": f"Error searching user messages: {str(e)}", "timestamp": datetime.datetime.now().isoformat()}
async def search_messages_by_content(cog: commands.Cog, search_term: str, limit: int, channel_id: str = None) -> Dict[str, Any]:
"""Search for messages containing specific content"""
from .utils import format_message # Import here
limit = min(max(1, limit), 100)
try:
if channel_id:
channel = cog.bot.get_channel(int(channel_id))
if not channel: return {"error": f"Channel {channel_id} not found"}
else:
channel = cog.current_channel
if not channel: return {"error": "No current channel context"}
messages = []
search_term_lower = search_term.lower()
async for message in channel.history(limit=500):
if search_term_lower in message.content.lower():
messages.append(format_message(cog, message)) # Use formatter
if len(messages) >= limit: break
return {
"channel": {"id": str(channel.id), "name": getattr(channel, 'name', 'DM Channel')},
"search_term": search_term,
"messages": messages, "count": len(messages),
"timestamp": datetime.datetime.now().isoformat()
}
except Exception as e:
return {"error": f"Error searching messages by content: {str(e)}", "timestamp": datetime.datetime.now().isoformat()}
async def get_channel_info(cog: commands.Cog, channel_id: str = None) -> Dict[str, Any]:
"""Get information about a Discord channel"""
try:
if channel_id:
channel = cog.bot.get_channel(int(channel_id))
if not channel: return {"error": f"Channel {channel_id} not found"}
else:
channel = cog.current_channel
if not channel: return {"error": "No current channel context"}
channel_info = {"id": str(channel.id), "type": str(channel.type), "timestamp": datetime.datetime.now().isoformat()}
if isinstance(channel, discord.TextChannel): # Use isinstance for type checking
channel_info.update({
"name": channel.name, "topic": channel.topic, "position": channel.position,
"nsfw": channel.is_nsfw(),
"category": {"id": str(channel.category_id), "name": channel.category.name} if channel.category else None,
"guild": {"id": str(channel.guild.id), "name": channel.guild.name, "member_count": channel.guild.member_count}
})
elif isinstance(channel, discord.DMChannel):
channel_info.update({
"type": "DM",
"recipient": {"id": str(channel.recipient.id), "name": channel.recipient.name, "display_name": channel.recipient.display_name}
})
# Add handling for other channel types (VoiceChannel, Thread, etc.) if needed
return channel_info
except Exception as e:
return {"error": f"Error getting channel info: {str(e)}", "timestamp": datetime.datetime.now().isoformat()}
async def get_conversation_context(cog: commands.Cog, message_count: int, channel_id: str = None) -> Dict[str, Any]:
"""Get the context of the current conversation in a channel"""
from .utils import format_message # Import here
message_count = min(max(5, message_count), 50)
try:
if channel_id:
channel = cog.bot.get_channel(int(channel_id))
if not channel: return {"error": f"Channel {channel_id} not found"}
else:
channel = cog.current_channel
if not channel: return {"error": "No current channel context"}
messages = []
# Prefer cache if available
if channel.id in cog.message_cache['by_channel']:
messages = list(cog.message_cache['by_channel'][channel.id])[-message_count:]
else:
async for msg in channel.history(limit=message_count):
messages.append(format_message(cog, msg))
messages.reverse()
return {
"channel_id": str(channel.id), "channel_name": getattr(channel, 'name', 'DM Channel'),
"context_messages": messages, "count": len(messages),
"timestamp": datetime.datetime.now().isoformat()
}
except Exception as e:
return {"error": f"Error getting conversation context: {str(e)}"}
async def get_thread_context(cog: commands.Cog, thread_id: str, message_count: int) -> Dict[str, Any]:
"""Get the context of a thread conversation"""
from .utils import format_message # Import here
message_count = min(max(5, message_count), 50)
try:
thread = cog.bot.get_channel(int(thread_id))
if not thread or not isinstance(thread, discord.Thread):
return {"error": f"Thread {thread_id} not found or is not a thread"}
messages = []
if thread.id in cog.message_cache['by_thread']:
messages = list(cog.message_cache['by_thread'][thread.id])[-message_count:]
else:
async for msg in thread.history(limit=message_count):
messages.append(format_message(cog, msg))
messages.reverse()
return {
"thread_id": str(thread.id), "thread_name": thread.name,
"parent_channel_id": str(thread.parent_id),
"context_messages": messages, "count": len(messages),
"timestamp": datetime.datetime.now().isoformat()
}
except Exception as e:
return {"error": f"Error getting thread context: {str(e)}"}
async def get_user_interaction_history(cog: commands.Cog, user_id_1: str, limit: int, user_id_2: str = None) -> Dict[str, Any]:
"""Get the history of interactions between two users (or user and bot)"""
limit = min(max(1, limit), 50)
try:
user_id_1_int = int(user_id_1)
user_id_2_int = int(user_id_2) if user_id_2 else cog.bot.user.id
interactions = []
# Simplified: Search global cache
for msg_data in list(cog.message_cache['global_recent']):
author_id = int(msg_data['author']['id'])
mentioned_ids = [int(m['id']) for m in msg_data.get('mentions', [])]
replied_to_author_id = int(msg_data.get('replied_to_author_id')) if msg_data.get('replied_to_author_id') else None
is_interaction = False
if (author_id == user_id_1_int and replied_to_author_id == user_id_2_int) or \
(author_id == user_id_2_int and replied_to_author_id == user_id_1_int): is_interaction = True
elif (author_id == user_id_1_int and user_id_2_int in mentioned_ids) or \
(author_id == user_id_2_int and user_id_1_int in mentioned_ids): is_interaction = True
if is_interaction:
interactions.append(msg_data)
if len(interactions) >= limit: break
user1 = await cog.bot.fetch_user(user_id_1_int)
user2 = await cog.bot.fetch_user(user_id_2_int)
return {
"user_1": {"id": str(user_id_1_int), "name": user1.name if user1 else "Unknown"},
"user_2": {"id": str(user_id_2_int), "name": user2.name if user2 else "Unknown"},
"interactions": interactions, "count": len(interactions),
"timestamp": datetime.datetime.now().isoformat()
}
except Exception as e:
return {"error": f"Error getting user interaction history: {str(e)}"}
async def get_conversation_summary(cog: commands.Cog, channel_id: str = None, message_limit: int = 25) -> Dict[str, Any]:
"""Generates and returns a summary of the recent conversation in a channel using an LLM call."""
from .config import SUMMARY_RESPONSE_SCHEMA, DEFAULT_MODEL # Import schema and model
from .api import get_internal_ai_json_response # Import here
try:
target_channel_id_str = channel_id or (str(cog.current_channel.id) if cog.current_channel else None)
if not target_channel_id_str: return {"error": "No channel context"}
target_channel_id = int(target_channel_id_str)
channel = cog.bot.get_channel(target_channel_id)
if not channel: return {"error": f"Channel {target_channel_id_str} not found"}
now = time.time()
cached_data = cog.conversation_summaries.get(target_channel_id)
if cached_data and (now - cached_data.get("timestamp", 0) < SUMMARY_CACHE_TTL):
print(f"Returning cached summary for channel {target_channel_id}")
return {
"channel_id": target_channel_id_str, "summary": cached_data.get("summary", "Cache error"),
"source": "cache", "timestamp": datetime.datetime.fromtimestamp(cached_data.get("timestamp", now)).isoformat()
}
print(f"Generating new summary for channel {target_channel_id}")
# No need to check API_KEY or cog.session for Vertex AI calls via get_internal_ai_json_response
recent_messages_text = []
try:
async for msg in channel.history(limit=message_limit):
recent_messages_text.append(f"{msg.author.display_name}: {msg.content}")
recent_messages_text.reverse()
except discord.Forbidden: return {"error": f"Missing permissions in channel {target_channel_id_str}"}
except Exception as hist_e: return {"error": f"Error fetching history: {str(hist_e)}"}
if not recent_messages_text:
summary = "No recent messages found."
cog.conversation_summaries[target_channel_id] = {"summary": summary, "timestamp": time.time()}
return {"channel_id": target_channel_id_str, "summary": summary, "source": "generated (empty)", "timestamp": datetime.datetime.now().isoformat()}
conversation_context = "\n".join(recent_messages_text)
summarization_prompt = f"Summarize the main points and current topic of this Discord chat snippet:\n\n---\n{conversation_context}\n---\n\nSummary:"
# Use get_internal_ai_json_response
prompt_messages = [
{"role": "system", "content": "You are an expert summarizer. Provide a concise summary of the following conversation."},
{"role": "user", "content": summarization_prompt}
]
summary_data = await get_internal_ai_json_response(
cog=cog,
prompt_messages=prompt_messages,
task_description=f"Summarization for channel {target_channel_id}",
response_schema_dict=SUMMARY_RESPONSE_SCHEMA['schema'], # Pass the schema dict
model_name=DEFAULT_MODEL, # Consider a cheaper/faster model if needed
temperature=0.3,
max_tokens=200 # Adjust as needed
)
summary = "Error generating summary."
if summary_data and isinstance(summary_data.get("summary"), str):
summary = summary_data["summary"].strip()
print(f"Summary generated for {target_channel_id}: {summary[:100]}...")
else:
error_detail = f"Invalid format or missing 'summary' key. Response: {summary_data}"
summary = f"Failed summary for {target_channel_id}. Error: {error_detail}"
print(summary)
cog.conversation_summaries[target_channel_id] = {"summary": summary, "timestamp": time.time()}
return {"channel_id": target_channel_id_str, "summary": summary, "source": "generated", "timestamp": datetime.datetime.now().isoformat()}
except Exception as e:
error_msg = f"General error in get_conversation_summary: {str(e)}"
print(error_msg)
traceback.print_exc()
return {"error": error_msg}
async def get_message_context(cog: commands.Cog, message_id: str, before_count: int = 5, after_count: int = 5) -> Dict[str, Any]:
"""Get the context (messages before and after) around a specific message"""
from .utils import format_message # Import here
before_count = min(max(1, before_count), 25)
after_count = min(max(1, after_count), 25)
try:
target_message = None
channel = cog.current_channel
if not channel: return {"error": "No current channel context"}
try:
message_id_int = int(message_id)
target_message = await channel.fetch_message(message_id_int)
except discord.NotFound: return {"error": f"Message {message_id} not found in {channel.id}"}
except discord.Forbidden: return {"error": f"No permission for message {message_id} in {channel.id}"}
except ValueError: return {"error": f"Invalid message ID: {message_id}"}
if not target_message: return {"error": f"Message {message_id} not fetched"}
messages_before = [format_message(cog, msg) async for msg in channel.history(limit=before_count, before=target_message)]
messages_before.reverse()
messages_after = [format_message(cog, msg) async for msg in channel.history(limit=after_count, after=target_message)]
return {
"target_message": format_message(cog, target_message),
"messages_before": messages_before, "messages_after": messages_after,
"channel_id": str(channel.id), "timestamp": datetime.datetime.now().isoformat()
}
except Exception as e:
return {"error": f"Error getting message context: {str(e)}"}
async def web_search(cog: commands.Cog, query: str, search_depth: str = TAVILY_DEFAULT_SEARCH_DEPTH, max_results: int = TAVILY_DEFAULT_MAX_RESULTS, topic: str = "general", include_domains: Optional[List[str]] = None, exclude_domains: Optional[List[str]] = None, include_answer: bool = True, include_raw_content: bool = False, include_images: bool = False) -> Dict[str, Any]:
"""Search the web using Tavily API"""
if not hasattr(cog, 'tavily_client') or not cog.tavily_client:
return {"error": "Tavily client not initialized.", "timestamp": datetime.datetime.now().isoformat()}
# Cost control / Logging for advanced search
final_search_depth = search_depth
if search_depth.lower() == "advanced":
if TAVILY_DISABLE_ADVANCED:
print(f"Warning: Advanced Tavily search requested but disabled by config. Falling back to basic.")
final_search_depth = "basic"
else:
print(f"Performing advanced Tavily search (cost: 10 credits) for query: '{query}'")
elif search_depth.lower() != "basic":
print(f"Warning: Invalid search_depth '{search_depth}' provided. Using 'basic'.")
final_search_depth = "basic"
# Validate max_results
final_max_results = max(5, min(20, max_results)) # Clamp between 5 and 20
try:
# Pass parameters to Tavily search
response = await asyncio.to_thread(
cog.tavily_client.search,
query=query,
search_depth=final_search_depth, # Use validated depth
max_results=final_max_results, # Use validated results count
topic=topic,
include_domains=include_domains,
exclude_domains=exclude_domains,
include_answer=include_answer,
include_raw_content=include_raw_content,
include_images=include_images
)
# Extract relevant information from results
results = []
for r in response.get("results", []):
result = {"title": r.get("title"), "url": r.get("url"), "content": r.get("content"), "score": r.get("score"), "published_date": r.get("published_date")}
if include_raw_content: result["raw_content"] = r.get("raw_content")
if include_images: result["images"] = r.get("images")
results.append(result)
return {
"query": query,
"search_depth": search_depth,
"max_results": max_results,
"topic": topic,
"include_domains": include_domains,
"exclude_domains": exclude_domains,
"include_answer": include_answer,
"include_raw_content": include_raw_content,
"include_images": include_images,
"results": results,
"answer": response.get("answer"),
"follow_up_questions": response.get("follow_up_questions"),
"count": len(results),
"timestamp": datetime.datetime.now().isoformat()
}
except Exception as e:
error_message = f"Error during Tavily search for '{query}': {str(e)}"
print(error_message)
return {"error": error_message, "timestamp": datetime.datetime.now().isoformat()}
async def remember_user_fact(cog: commands.Cog, user_id: str, fact: str) -> Dict[str, Any]:
"""Stores a fact about a user using the MemoryManager."""
if not user_id or not fact: return {"error": "user_id and fact required."}
print(f"Remembering fact for user {user_id}: '{fact}'")
try:
result = await cog.memory_manager.add_user_fact(user_id, fact)
if result.get("status") == "added": return {"status": "success", "user_id": user_id, "fact_added": fact}
elif result.get("status") == "duplicate": return {"status": "duplicate", "user_id": user_id, "fact": fact}
elif result.get("status") == "limit_reached": return {"status": "success", "user_id": user_id, "fact_added": fact, "note": "Oldest fact deleted."}
else: return {"error": result.get("error", "Unknown MemoryManager error")}
except Exception as e:
error_message = f"Error calling MemoryManager for user fact {user_id}: {str(e)}"
print(error_message); traceback.print_exc()
return {"error": error_message}
async def get_user_facts(cog: commands.Cog, user_id: str) -> Dict[str, Any]:
"""Retrieves stored facts about a user using the MemoryManager."""
if not user_id: return {"error": "user_id required."}
print(f"Retrieving facts for user {user_id}")
try:
user_facts = await cog.memory_manager.get_user_facts(user_id) # Context not needed for basic retrieval tool
return {"user_id": user_id, "facts": user_facts, "count": len(user_facts), "timestamp": datetime.datetime.now().isoformat()}
except Exception as e:
error_message = f"Error calling MemoryManager for user facts {user_id}: {str(e)}"
print(error_message); traceback.print_exc()
return {"error": error_message}
async def remember_general_fact(cog: commands.Cog, fact: str) -> Dict[str, Any]:
"""Stores a general fact using the MemoryManager."""
if not fact: return {"error": "fact required."}
print(f"Remembering general fact: '{fact}'")
try:
result = await cog.memory_manager.add_general_fact(fact)
if result.get("status") == "added": return {"status": "success", "fact_added": fact}
elif result.get("status") == "duplicate": return {"status": "duplicate", "fact": fact}
elif result.get("status") == "limit_reached": return {"status": "success", "fact_added": fact, "note": "Oldest fact deleted."}
else: return {"error": result.get("error", "Unknown MemoryManager error")}
except Exception as e:
error_message = f"Error calling MemoryManager for general fact: {str(e)}"
print(error_message); traceback.print_exc()
return {"error": error_message}
async def get_general_facts(cog: commands.Cog, query: Optional[str] = None, limit: Optional[int] = 10) -> Dict[str, Any]:
"""Retrieves stored general facts using the MemoryManager."""
print(f"Retrieving general facts (query='{query}', limit={limit})")
limit = min(max(1, limit or 10), 50)
try:
general_facts = await cog.memory_manager.get_general_facts(query=query, limit=limit) # Context not needed here
return {"query": query, "facts": general_facts, "count": len(general_facts), "timestamp": datetime.datetime.now().isoformat()}
except Exception as e:
error_message = f"Error calling MemoryManager for general facts: {str(e)}"
print(error_message); traceback.print_exc()
return {"error": error_message}
async def timeout_user(cog: commands.Cog, user_id: str, duration_minutes: int, reason: Optional[str] = None) -> Dict[str, Any]:
"""Times out a user in the current server."""
if not cog.current_channel or not isinstance(cog.current_channel, discord.abc.GuildChannel):
return {"error": "Cannot timeout outside of a server."}
guild = cog.current_channel.guild
if not guild: return {"error": "Could not determine server."}
if not 1 <= duration_minutes <= 1440: return {"error": "Duration must be 1-1440 minutes."}
try:
member_id = int(user_id)
member = guild.get_member(member_id) or await guild.fetch_member(member_id) # Fetch if not cached
if not member: return {"error": f"User {user_id} not found in server."}
if member == cog.bot.user: return {"error": "lol i cant timeout myself vro"}
if member.id == guild.owner_id: return {"error": f"Cannot timeout owner {member.display_name}."}
bot_member = guild.me
if not bot_member.guild_permissions.moderate_members: return {"error": "I lack permission to timeout."}
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"
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}
except ValueError: return {"error": f"Invalid user ID: {user_id}"}
except discord.NotFound: return {"error": f"User {user_id} not found in server."}
except discord.Forbidden as e: print(f"Forbidden error timeout {user_id}: {e}"); return {"error": f"Permission error timeout {user_id}."}
except discord.HTTPException as e: print(f"API error timeout {user_id}: {e}"); return {"error": f"API error timeout {user_id}: {e}"}
except Exception as e: print(f"Unexpected error timeout {user_id}: {e}"); traceback.print_exc(); return {"error": f"Unexpected error timeout {user_id}: {str(e)}"}
async def remove_timeout(cog: commands.Cog, user_id: str, reason: Optional[str] = None) -> Dict[str, Any]:
"""Removes an active timeout from a user."""
if not cog.current_channel or not isinstance(cog.current_channel, discord.abc.GuildChannel):
return {"error": "Cannot remove timeout outside of a server."}
guild = cog.current_channel.guild
if not guild: return {"error": "Could not determine server."}
try:
member_id = int(user_id)
member = guild.get_member(member_id) or await guild.fetch_member(member_id)
if not member: return {"error": f"User {user_id} not found."}
# Define bot_member before using it
bot_member = guild.me
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."
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}
except ValueError: return {"error": f"Invalid user ID: {user_id}"}
except discord.NotFound: return {"error": f"User {user_id} not found."}
except discord.Forbidden as e: print(f"Forbidden error remove timeout {user_id}: {e}"); return {"error": f"Permission error remove timeout {user_id}."}
except discord.HTTPException as e: print(f"API error remove timeout {user_id}: {e}"); return {"error": f"API error remove timeout {user_id}: {e}"}
except Exception as e: print(f"Unexpected error remove timeout {user_id}: {e}"); traceback.print_exc(); return {"error": f"Unexpected error remove timeout {user_id}: {str(e)}"}
async def calculate(cog: commands.Cog, expression: str) -> Dict[str, Any]:
"""Evaluates a mathematical expression using asteval."""
print(f"Calculating expression: {expression}")
aeval = Interpreter()
try:
result = aeval(expression)
if aeval.error:
error_details = '; '.join(err.get_error() for err in aeval.error)
error_message = f"Calculation error: {error_details}"
print(error_message)
return {"error": error_message, "expression": expression}
if isinstance(result, (int, float, complex)): result_str = str(result)
else: result_str = repr(result) # Fallback
print(f"Calculation result: {result_str}")
return {"expression": expression, "result": result_str, "status": "success"}
except Exception as e:
error_message = f"Unexpected error during calculation: {str(e)}"
print(error_message); traceback.print_exc()
return {"error": error_message, "expression": expression}
async def run_python_code(cog: commands.Cog, code: str) -> Dict[str, Any]:
"""Executes a Python code snippet using the Piston API."""
if not PISTON_API_URL: return {"error": "Piston API URL not configured (PISTON_API_URL)."}
if not cog.session: return {"error": "aiohttp session not initialized."}
print(f"Executing Python via Piston: {code[:100]}...")
payload = {"language": "python", "version": "3.10.0", "files": [{"name": "main.py", "content": code}]}
headers = {"Content-Type": "application/json"}
if PISTON_API_KEY: headers["Authorization"] = PISTON_API_KEY
try:
async with cog.session.post(PISTON_API_URL, headers=headers, json=payload, timeout=20) as response:
if response.status == 200:
data = await response.json()
run_info = data.get("run", {})
compile_info = data.get("compile", {})
stdout = run_info.get("stdout", "")
stderr = run_info.get("stderr", "")
exit_code = run_info.get("code", -1)
signal = run_info.get("signal")
full_stderr = (compile_info.get("stderr", "") + "\n" + stderr).strip()
max_len = 500
stdout_trunc = stdout[:max_len] + ('...' if len(stdout) > max_len else '')
stderr_trunc = full_stderr[:max_len] + ('...' if len(full_stderr) > max_len else '')
result = {"status": "success" if exit_code == 0 and not signal else "execution_error", "stdout": stdout_trunc, "stderr": stderr_trunc, "exit_code": exit_code, "signal": signal}
print(f"Piston execution result: {result}")
return result
else:
error_text = await response.text()
error_message = f"Piston API error (Status {response.status}): {error_text[:200]}"
print(error_message)
return {"error": error_message}
except asyncio.TimeoutError: print("Piston API timed out."); return {"error": "Piston API timed out."}
except aiohttp.ClientError as e: print(f"Piston network error: {e}"); return {"error": f"Network error connecting to Piston: {str(e)}"}
except Exception as e: print(f"Unexpected Piston error: {e}"); traceback.print_exc(); return {"error": f"Unexpected error during Python execution: {str(e)}"}
async def create_poll(cog: commands.Cog, question: str, options: List[str]) -> Dict[str, Any]:
"""Creates a simple poll message."""
if not cog.current_channel: return {"error": "No current channel context."}
if not isinstance(cog.current_channel, discord.abc.Messageable): return {"error": "Channel not messageable."}
if not isinstance(options, list) or not 2 <= len(options) <= 10: return {"error": "Poll needs 2-10 options."}
if isinstance(cog.current_channel, discord.abc.GuildChannel):
bot_member = cog.current_channel.guild.me
if not cog.current_channel.permissions_for(bot_member).send_messages or \
not cog.current_channel.permissions_for(bot_member).add_reactions:
return {"error": "Missing permissions for poll."}
try:
poll_content = f"**📊 Poll: {question}**\n\n"
number_emojis = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "🔟"]
for i, option in enumerate(options): poll_content += f"{number_emojis[i]} {option}\n"
poll_message = await cog.current_channel.send(poll_content)
print(f"Sent poll {poll_message.id}: {question}")
for i in range(len(options)): await poll_message.add_reaction(number_emojis[i]); await asyncio.sleep(0.1)
return {"status": "success", "message_id": str(poll_message.id), "question": question, "options_count": len(options)}
except discord.Forbidden: print("Poll Forbidden"); return {"error": "Forbidden: Missing permissions for poll."}
except discord.HTTPException as e: print(f"Poll API error: {e}"); return {"error": f"API error creating poll: {e}"}
except Exception as e: print(f"Poll unexpected error: {e}"); traceback.print_exc(); return {"error": f"Unexpected error creating poll: {str(e)}"}
# Helper function to convert memory string (e.g., "128m") to bytes
def parse_mem_limit(mem_limit_str: str) -> Optional[int]:
if not mem_limit_str: return None
mem_limit_str = mem_limit_str.lower()
if mem_limit_str.endswith('m'):
try: return int(mem_limit_str[:-1]) * 1024 * 1024
except ValueError: return None
elif mem_limit_str.endswith('g'):
try: return int(mem_limit_str[:-1]) * 1024 * 1024 * 1024
except ValueError: return None
try: return int(mem_limit_str) # Assume bytes if no suffix
except ValueError: return None
async def _check_command_safety(cog: commands.Cog, command: str) -> Dict[str, Any]:
"""Uses a secondary AI call to check if a command is potentially harmful."""
from .api import get_internal_ai_json_response # Import here
print(f"Performing AI safety check for command: '{command}' using model {SAFETY_CHECK_MODEL}")
safety_schema = {
"type": "object",
"properties": {
"is_safe": {"type": "boolean", "description": "True if safe for restricted container, False otherwise."},
"reason": {"type": "string", "description": "Brief explanation."}
}, "required": ["is_safe", "reason"]
}
prompt_messages = [
{"role": "system", "content": f"Analyze shell command safety for execution in isolated, network-disabled Docker ({DOCKER_EXEC_IMAGE}) with CPU/Mem limits. Focus on data destruction, resource exhaustion, container escape, network attacks (disabled), env var leaks. Simple echo/ls/pwd safe. rm/mkfs/shutdown/wget/curl/install/fork bombs unsafe. Respond ONLY with JSON matching the provided schema."},
{"role": "user", "content": f"Analyze safety: ```{command}```"}
]
safety_response = await get_internal_ai_json_response(
cog=cog,
prompt_messages=prompt_messages,
task_description="Command Safety Check",
response_schema_dict=safety_schema, # Pass the schema dict directly
model_name=SAFETY_CHECK_MODEL,
temperature=0.1,
max_tokens=150
)
if safety_response and isinstance(safety_response.get("is_safe"), bool):
is_safe = safety_response["is_safe"]
reason = safety_response.get("reason", "No reason provided.")
print(f"AI Safety Check Result: is_safe={is_safe}, reason='{reason}'")
return {"safe": is_safe, "reason": reason}
else:
error_msg = "AI safety check failed or returned invalid format."
print(f"AI Safety Check Error: Response was {safety_response}")
return {"safe": False, "reason": error_msg}
async def run_terminal_command(cog: commands.Cog, command: str) -> Dict[str, Any]:
"""Executes a shell command in an isolated Docker container after an AI safety check."""
print(f"Attempting terminal command: {command}")
safety_check_result = await _check_command_safety(cog, command)
if not safety_check_result.get("safe"):
error_message = f"Command blocked by AI safety check: {safety_check_result.get('reason', 'Unknown')}"
print(error_message)
return {"error": error_message, "command": command}
try: cpu_limit = float(DOCKER_CPU_LIMIT); cpu_period = 100000; cpu_quota = int(cpu_limit * cpu_period)
except ValueError: print(f"Warning: Invalid DOCKER_CPU_LIMIT '{DOCKER_CPU_LIMIT}'. Using default."); cpu_quota = 50000; cpu_period = 100000
mem_limit_bytes = parse_mem_limit(DOCKER_MEM_LIMIT)
if mem_limit_bytes is None:
print(f"Warning: Invalid DOCKER_MEM_LIMIT '{DOCKER_MEM_LIMIT}'. Disabling memory limit.")
client = None
container = None
try:
client = aiodocker.Docker()
print(f"Running command in Docker ({DOCKER_EXEC_IMAGE})...")
config = {
'Image': DOCKER_EXEC_IMAGE,
'Cmd': ["/bin/sh", "-c", command],
'AttachStdout': True,
'AttachStderr': True,
'HostConfig': {
'NetworkDisabled': True,
'AutoRemove': False, # Changed to False
'CpuPeriod': cpu_period,
'CpuQuota': cpu_quota,
}
}
if mem_limit_bytes is not None:
config['HostConfig']['Memory'] = mem_limit_bytes
# Use wait_for for the run call itself in case image pulling takes time
container = await asyncio.wait_for(
client.containers.run(config=config),
timeout=DOCKER_COMMAND_TIMEOUT + 15 # Add buffer for container start/stop/pull
)
# Wait for the container to finish execution
wait_result = await asyncio.wait_for(
container.wait(),
timeout=DOCKER_COMMAND_TIMEOUT
)
exit_code = wait_result.get('StatusCode', -1)
# Get logs after container finishes
# container.log() returns a list of strings when stream=False (default)
stdout_lines = await container.log(stdout=True, stderr=False)
stderr_lines = await container.log(stdout=False, stderr=True)
stdout = "".join(stdout_lines) if stdout_lines else ""
stderr = "".join(stderr_lines) if stderr_lines else ""
max_len = 1000
stdout_trunc = stdout[:max_len] + ('...' if len(stdout) > max_len else '')
stderr_trunc = stderr[:max_len] + ('...' if len(stderr) > max_len else '')
result = {"status": "success" if exit_code == 0 else "execution_error", "stdout": stdout_trunc, "stderr": stderr_trunc, "exit_code": exit_code}
print(f"Docker command finished. Exit Code: {exit_code}. Output length: {len(stdout)}, Stderr length: {len(stderr)}")
return result
except asyncio.TimeoutError:
print("Docker command run, wait, or log retrieval timed out.")
# Attempt to stop/remove container if it exists and timed out
if container:
try:
print(f"Attempting to stop timed-out container {container.id[:12]}...")
await container.stop(t=1)
print(f"Container {container.id[:12]} stopped.")
# AutoRemove should handle removal, but log deletion attempt if needed
# print(f"Attempting to delete timed-out container {container.id[:12]}...")
# await container.delete(force=True) # Force needed if stop failed?
# print(f"Container {container.id[:12]} deleted.")
except aiodocker.exceptions.DockerError as stop_err:
print(f"Error stopping/deleting timed-out container {container.id[:12]}: {stop_err}")
except Exception as stop_exc:
print(f"Unexpected error stopping/deleting timed-out container {container.id[:12]}: {stop_exc}")
# No need to delete here, finally block will handle it
return {"error": f"Command execution/log retrieval timed out after {DOCKER_COMMAND_TIMEOUT}s", "command": command, "status": "timeout"}
except aiodocker.exceptions.DockerError as e: # Catch specific aiodocker errors
print(f"Docker API error: {e} (Status: {e.status})")
# Check for ImageNotFound specifically
if e.status == 404 and ("No such image" in str(e) or "not found" in str(e)):
print(f"Docker image not found: {DOCKER_EXEC_IMAGE}")
return {"error": f"Docker image '{DOCKER_EXEC_IMAGE}' not found.", "command": command, "status": "docker_error"}
return {"error": f"Docker API error ({e.status}): {str(e)}", "command": command, "status": "docker_error"}
except Exception as e:
print(f"Unexpected Docker error: {e}")
traceback.print_exc()
return {"error": f"Unexpected error during Docker execution: {str(e)}", "command": command, "status": "error"}
finally:
# Explicitly remove the container since AutoRemove is False
if container:
try:
print(f"Attempting to delete container {container.id[:12]}...")
await container.delete(force=True)
print(f"Container {container.id[:12]} deleted.")
except aiodocker.exceptions.DockerError as delete_err:
# Log error but don't raise, primary error is more important
print(f"Error deleting container {container.id[:12]}: {delete_err}")
except Exception as delete_exc:
print(f"Unexpected error deleting container {container.id[:12]}: {delete_exc}") # <--- Corrected indentation
# Ensure the client connection is closed
if client:
await client.close()
async def extract_web_content(cog: commands.Cog, urls: Union[str, List[str]], extract_depth: str = "basic", include_images: bool = False) -> Dict[str, Any]:
"""Extract content from URLs using Tavily API"""
if not hasattr(cog, 'tavily_client') or not cog.tavily_client:
return {"error": "Tavily client not initialized.", "timestamp": datetime.datetime.now().isoformat()}
# Cost control / Logging for advanced extract
final_extract_depth = extract_depth
if extract_depth.lower() == "advanced":
if TAVILY_DISABLE_ADVANCED:
print(f"Warning: Advanced Tavily extract requested but disabled by config. Falling back to basic.")
final_extract_depth = "basic"
else:
print(f"Performing advanced Tavily extract (cost: 2 credits per 5 URLs) for URLs: {urls}")
elif extract_depth.lower() != "basic":
print(f"Warning: Invalid extract_depth '{extract_depth}' provided. Using 'basic'.")
final_extract_depth = "basic"
try:
response = await asyncio.to_thread(
cog.tavily_client.extract,
urls=urls,
extract_depth=final_extract_depth, # Use validated depth
include_images=include_images
)
results = [{"url": r.get("url"), "raw_content": r.get("raw_content"), "images": r.get("images")} for r in response.get("results", [])]
failed_results = response.get("failed_results", [])
return {"urls": urls, "extract_depth": extract_depth, "include_images": include_images, "results": results, "failed_results": failed_results, "timestamp": datetime.datetime.now().isoformat()}
except Exception as e:
error_message = f"Error during Tavily extract for '{urls}': {str(e)}"
print(error_message)
return {"error": error_message, "timestamp": datetime.datetime.now().isoformat()}
# --- Tool Mapping ---
# This dictionary maps tool names (used in the AI prompt) to their implementation functions.
TOOL_MAPPING = {
"get_recent_messages": get_recent_messages,
"search_user_messages": search_user_messages,
"search_messages_by_content": search_messages_by_content,
"get_channel_info": get_channel_info,
"get_conversation_context": get_conversation_context,
"get_thread_context": get_thread_context,
"get_user_interaction_history": get_user_interaction_history,
"get_conversation_summary": get_conversation_summary,
"get_message_context": get_message_context,
"web_search": web_search,
# Point memory tools to the methods on the MemoryManager instance (accessed via cog)
"remember_user_fact": lambda cog, **kwargs: cog.memory_manager.add_user_fact(**kwargs),
"get_user_facts": lambda cog, **kwargs: cog.memory_manager.get_user_facts(**kwargs),
"remember_general_fact": lambda cog, **kwargs: cog.memory_manager.add_general_fact(**kwargs),
"get_general_facts": lambda cog, **kwargs: cog.memory_manager.get_general_facts(**kwargs),
"timeout_user": timeout_user,
"calculate": calculate,
"run_python_code": run_python_code,
"create_poll": create_poll,
"run_terminal_command": run_terminal_command,
"remove_timeout": remove_timeout,
"extract_web_content": extract_web_content
}

124
wheatley/utils.py Normal file
View File

@ -0,0 +1,124 @@
import discord
import re
import random
import asyncio
import time
import datetime
import json
import os
from typing import TYPE_CHECKING, Optional, Tuple, Dict, Any
if TYPE_CHECKING:
from .cog import GurtCog # 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:
"""Replaces user mentions (<@id> or <@!id>) with their display names."""
if not message.mentions:
return content
processed_content = content
sorted_mentions = sorted(message.mentions, key=lambda m: len(str(m.id)), reverse=True)
for member in sorted_mentions:
processed_content = processed_content.replace(f'<@{member.id}>', member.display_name)
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]:
"""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 = [
{"id": str(m.id), "name": m.name, "display_name": m.display_name}
for m in message.mentions
]
formatted_msg = {
"id": str(message.id),
"author": {
"id": str(message.author.id), "name": message.author.name,
"display_name": message.author.display_name, "bot": message.author.bot
},
"content": processed_content,
"created_at": message.created_at.isoformat(),
"attachments": [{"filename": a.filename, "url": a.url} for a in message.attachments],
"embeds": len(message.embeds) > 0,
"mentions": [{"id": str(m.id), "name": m.name} for m in message.mentions], # Keep original simple list too
"mentioned_users_details": mentioned_users_details,
"replied_to_message_id": None, "replied_to_author_id": None,
"replied_to_author_name": None, "replied_to_content": None,
"is_reply": False
}
if message.reference and message.reference.message_id:
formatted_msg["replied_to_message_id"] = str(message.reference.message_id)
formatted_msg["is_reply"] = True
# Try to get resolved details (might be None if message not cached/fetched)
ref_msg = message.reference.resolved
if isinstance(ref_msg, discord.Message): # Check if resolved is a Message
formatted_msg["replied_to_author_id"] = str(ref_msg.author.id)
formatted_msg["replied_to_author_name"] = ref_msg.author.display_name
formatted_msg["replied_to_content"] = ref_msg.content
# else: print(f"Referenced message {message.reference.message_id} not resolved.") # Optional debug
return formatted_msg
def update_relationship(cog: 'GurtCog', 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] = {}
current_score = cog.user_relationships[user_id_1].get(user_id_2, 0.0)
new_score = max(0.0, min(current_score + change, 100.0)) # Clamp 0-100
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):
"""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.
# The actual sending of the message happens immediately after this.
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):
"""Helper function to log internal API calls to a file."""
log_dir = "data"
log_file = os.path.join(log_dir, "internal_api_calls.log")
try:
os.makedirs(log_dir, exist_ok=True)
timestamp = datetime.datetime.now().isoformat()
log_entry = f"--- Log Entry: {timestamp} ---\n"
log_entry += f"Task: {task_description}\n"
log_entry += f"Model: {payload.get('model', 'N/A')}\n"
# Sanitize payload for logging (avoid large base64 images)
payload_to_log = payload.copy()
if 'messages' in payload_to_log:
sanitized_messages = []
for msg in payload_to_log['messages']:
if isinstance(msg.get('content'), list): # Multimodal message
new_content = []
for part in msg['content']:
if part.get('type') == 'image_url' and part.get('image_url', {}).get('url', '').startswith('data:image'):
new_content.append({'type': 'image_url', 'image_url': {'url': 'data:image/...[truncated]'}})
else:
new_content.append(part)
sanitized_messages.append({**msg, 'content': new_content})
else:
sanitized_messages.append(msg)
payload_to_log['messages'] = sanitized_messages
log_entry += f"Request Payload:\n{json.dumps(payload_to_log, indent=2)}\n"
if response_data: log_entry += f"Response Data:\n{json.dumps(response_data, indent=2)}\n"
if error: log_entry += f"Error: {str(error)}\n"
log_entry += "---\n\n"
with open(log_file, "a", encoding="utf-8") as f: f.write(log_entry)
except Exception as log_e: print(f"!!! Failed to write to internal API log file {log_file}: {log_e}")
# Note: _create_human_like_mistake was removed as it wasn't used in the final on_message logic provided.
# If needed, it can be added back here, ensuring it takes 'cog' if it needs personality traits.

82
wheatley_bot.py Normal file
View File

@ -0,0 +1,82 @@
import discord
from discord.ext import commands
import os
import asyncio
import sys
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# Set up intents (permissions)
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
# Create bot instance with command prefix '%'
bot = commands.Bot(command_prefix='%', intents=intents)
bot.owner_id = int(os.getenv('OWNER_USER_ID'))
@bot.event
async def on_ready():
print(f'{bot.user.name} has connected to Discord!')
print(f'Bot ID: {bot.user.id}')
# Set the bot's status
await bot.change_presence(activity=discord.Activity(type=discord.ActivityType.listening, name="%ai"))
print("Bot status set to 'Listening to %ai'")
# Sync commands
try:
print("Starting command sync process...")
synced = await bot.tree.sync()
print(f"Synced {len(synced)} command(s)")
except Exception as e:
print(f"Failed to sync commands: {e}")
import traceback
traceback.print_exc()
async def main():
"""Main async function to load the wheatley cog and start the bot."""
# Check for required environment variables, prioritizing WHEATLEY token
TOKEN = os.getenv('DISCORD_TOKEN_WHEATLEY')
# If Wheatley token not found, try GURT token
if not TOKEN:
TOKEN = os.getenv('DISCORD_TOKEN_GURT')
# If neither specific token found, try the main bot token
if not TOKEN:
TOKEN = os.getenv('DISCORD_TOKEN')
if not TOKEN:
raise ValueError("No Discord token found. Make sure to set DISCORD_TOKEN_WHEATLEY, DISCORD_TOKEN_GURT, or DISCORD_TOKEN in your .env file.")
# Note: Vertex AI authentication is handled by the library using ADC or GOOGLE_APPLICATION_CREDENTIALS.
# No explicit API key check is needed here. Ensure GCP_PROJECT_ID and GCP_LOCATION are set in .env
try:
async with bot:
# List of cogs to load - Load WheatleyCog instead of GurtCog
cogs = ["wheatley.cog", "cogs.profile_updater_cog"] # Assuming profile updater is still desired
for cog in cogs:
try:
await bot.load_extension(cog)
print(f"Successfully loaded {cog}")
except Exception as e:
print(f"Error loading {cog}: {e}")
import traceback
traceback.print_exc()
# Start the bot
await bot.start(TOKEN)
except Exception as e:
print(f"Error starting Wheatley Bot: {e}")
# Run the main async function
if __name__ == '__main__':
try:
asyncio.run(main())
except KeyboardInterrupt:
print("Wheatley Bot stopped by user.")
except Exception as e:
print(f"An error occurred running Wheatley Bot: {e}")