diff --git a/ai/ai_roleplay.py b/ai/ai_roleplay.py index 40254de..d0a1e94 100644 --- a/ai/ai_roleplay.py +++ b/ai/ai_roleplay.py @@ -6,13 +6,14 @@ import re import time import asyncio import discord +import logging from discord.ext import commands from collections import defaultdict, deque # pyrefly: ignore [missing-import] from openai import AsyncOpenAI #only last 12 messages on discord will be fed into the ai's memory -MEMORY_SIZE = 12 +MEMORY_SIZE = 15 # These are the ONLY two valid DeepSeek V4 model IDs. deepseek-chat and # deepseek-reasoner are legacy aliases scheduled for retirement on @@ -170,11 +171,51 @@ class AIRoleplay(commands.Cog): self.thinking_channels = set() # channel_ids with thinking ON self.thinking_effort = {} # channel_id -> 'high' | 'max' self.temperature = 0.85 + self.channel_summary = {} # channel_id -> str # ========================================== # CORE AI LOGIC & LISTENERS # ========================================== + #this helper summarizes evicted memory to maintain long-term context + async def update_summary(self, channel_id, evicted_message): + try: + current_summary = self.channel_summary.get(channel_id, "") + evicted_text = evicted_message.get('content', '') + + # handle vision support list format + if isinstance(evicted_text, list): + evicted_text = next((item['text'] for item in evicted_text if item.get('type') == 'text'), '') + + if not evicted_text: + return + + sys_prompt = ( + "You are a background memory summarizer. " + "Merge the new evicted message into the existing summary. " + "Keep the total summary under 150 words. " + "Only retain facts relevant to story/relationship continuity, ignore minor chitchat." + ) + user_prompt = f"Existing Summary:\n{current_summary}\n\nEvicted Message:\n{evicted_text}" + + kwargs = { + "model": MODEL_FLASH, + "messages": [ + {"role": "system", "content": sys_prompt}, + {"role": "user", "content": user_prompt} + ], + "max_tokens": 300, + "extra_body": {"thinking": {"type": "disabled"}}, + } + + resp = await self.client.chat.completions.create(**kwargs) + new_summary = (resp.choices[0].message.content or '').strip() + if new_summary: + self.channel_summary[channel_id] = new_summary + + except Exception as e: + logging.error(f"Summarization failed for channel {channel_id}: {e}") + #only accepts me and hizuki def is_allowed(self, user): return user.id in self.allowed_users @@ -296,9 +337,21 @@ class AIRoleplay(commands.Cog): # DeepSeek and older models prefer pure strings if there are no images final_content = user_content if has_images else user_content[0]["text"] + + # summarize evicted memory before it drops off the deque + if len(mem) >= mem.maxlen: + await self.update_summary(cid, mem[0]) + mem.append({'role': 'user', 'content': final_content}) - api_msgs = [{'role': 'system', 'content': prompt}] + list(mem) + api_msgs = [{'role': 'system', 'content': prompt}] + + # inject the background summary into the system prompt if it exists + summary = self.channel_summary.get(cid) + if summary: + api_msgs[0]['content'] += f"\n\nMEMORY (earlier context, may be approximate):\n{summary}" + + api_msgs += list(mem) model_to_use = self.active_model # always MODEL_FLASH or MODEL_PRO, never swapped #VISION SUPPORT END @@ -478,6 +531,26 @@ class AIRoleplay(commands.Cog): embed.set_footer(text=f'model: {self.active_model} · this channel only') await ctx.reply(embed=embed, view=ThinkingView(self, cid)) + #this shows the current memory summary for the channel + @commands.command() + async def summary(self, ctx): + if not self.is_allowed(ctx.author): + return + summary_text = self.channel_summary.get(ctx.channel.id) + if not summary_text: + return await ctx.reply("No summary yet.") + + embed = discord.Embed(title='🧠 Channel Memory Summary', description=summary_text, color=0x2b2d31) + await ctx.reply(embed=embed) + + #this wipes the memory summary for the channel + @commands.command() + async def clearsummary(self, ctx): + if not self.is_allowed(ctx.author): + return + self.channel_summary.pop(ctx.channel.id, None) + await ctx.reply("Channel summary cleared.") + #this checks thinking status without opening the menu, only usable by me and hizu @commands.command() async def thinkstatus(self, ctx): @@ -499,7 +572,7 @@ class AIRoleplay(commands.Cog): return await ctx.reply("temperature must be between 0.0 and 2.0.") self.temperature = value - await ctx.reply(f"🌡️ **Temperature set to {value}**\n*(Lower = stricter/robotic, Higher = creative/chaotic)*") + await ctx.reply(f"**Temperature set to {value}**\n*(Lower = stricter/robotic, Higher = creative/chaotic)*") async def setup(bot): await bot.add_cog(AIRoleplay(bot)) diff --git a/cogs/general.py b/cogs/general.py index 61667f7..a438997 100644 --- a/cogs/general.py +++ b/cogs/general.py @@ -15,36 +15,7 @@ class General(commands.Cog): async def yuuka(self, ctx): await ctx.send('kanpeki!') - #will soon be removed from here when im not lazy - @commands.command() - async def leaks(self, ctx): - rss_url = os.getenv('TWITTER_RSS_URL') - if not rss_url: - return await ctx.reply('rss url not configured.') - - try: - headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'} - async with aiohttp.ClientSession(headers=headers) as session: - async with session.get(rss_url) as resp: - text = await resp.text() - - feed = feedparser.parse(text) - if not feed.entries: - return await ctx.reply('no tweets found.') - - response = "**Latest 5 Leaks:**\n" - for entry in feed.entries[:5]: - title = entry.get('title', '') - link = entry.get('link', '') - - if 'nitter.net' in link: - link = link.replace('nitter.net', 'twitter.com').replace('#m', '') - - response += f"\n{title}\n{link}\n" - - await ctx.reply(response[:2000]) - except Exception as e: - await ctx.reply(f'failed to fetch leaks: {e}') + @commands.command() async def weather(self, ctx, *, city: str = "kayseri"): diff --git a/cogs/wavelink/__init__.py b/cogs/wavelink/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/cogs/wavelink/__pycache__/__init__.cpython-314.pyc b/cogs/wavelink/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 10292f7..0000000 Binary files a/cogs/wavelink/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/main.py b/main.py index 8553af8..27c26fb 100644 --- a/main.py +++ b/main.py @@ -56,6 +56,7 @@ async def main(): await bot.load_extension('cogs.ffmpeg_things') await bot.load_extension('cogs.currency_converter') await bot.load_extension('ai.ai_roleplay') + await bot.load_extension('twitterbridge.leaks_cmd') try: await bot.start(os.getenv('DISCORD_TOKEN')) diff --git a/twitterbridge/leaks_cmd.py b/twitterbridge/leaks_cmd.py new file mode 100644 index 0000000..e129c7f --- /dev/null +++ b/twitterbridge/leaks_cmd.py @@ -0,0 +1,41 @@ +import os +import aiohttp +import feedparser +from discord.ext import commands + +class TwitterLeaks(commands.Cog): + def __init__(self, bot): + self.bot = bot + + @commands.command() + async def leaks(self, ctx): + rss_url = os.getenv('TWITTER_RSS_URL') + if not rss_url: + return await ctx.reply('rss url not configured.') + + try: + headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'} + async with aiohttp.ClientSession(headers=headers) as session: + async with session.get(rss_url) as resp: + text = await resp.text() + + feed = feedparser.parse(text) + if not feed.entries: + return await ctx.reply('no tweets found.') + + response = "**Latest 5 Leaks:**\n" + for entry in feed.entries[:5]: + title = entry.get('title', '') + link = entry.get('link', '') + + if 'nitter.net' in link: + link = link.replace('nitter.net', 'twitter.com').replace('#m', '') + + response += f"\n{title}\n{link}\n" + + await ctx.reply(response[:2000]) + except Exception as e: + await ctx.reply(f'failed to fetch leaks: {e}') + +async def setup(bot): + await bot.add_cog(TwitterLeaks(bot))