w
This commit is contained in:
parent
4c67aa7303
commit
2a14e17102
12
run_wheatley_bot.py
Normal file
12
run_wheatley_bot.py
Normal 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
8
wheatley/__init__.py
Normal 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
686
wheatley/analysis.py
Normal 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
1123
wheatley/api.py
Normal file
File diff suppressed because it is too large
Load Diff
438
wheatley/background.py
Normal file
438
wheatley/background.py
Normal 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
358
wheatley/cog.py
Normal 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
477
wheatley/commands.py
Normal 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
763
wheatley/config.py
Normal 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
167
wheatley/context.py
Normal 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
492
wheatley/listeners.py
Normal 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
19
wheatley/memory.py
Normal 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
134
wheatley/prompt.py
Normal 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
1
wheatley/state.py
Normal file
@ -0,0 +1 @@
|
||||
# Management of dynamic state variables might go here.
|
817
wheatley/tools.py
Normal file
817
wheatley/tools.py
Normal 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
124
wheatley/utils.py
Normal 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
82
wheatley_bot.py
Normal 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}")
|
Loading…
x
Reference in New Issue
Block a user