Compare commits

...

8 Commits

Author SHA1 Message Date
aj 72735c2497 fix: address review feedback for proactive reply logic
- Parse display names with ': ' split to handle colons in names
- Reset cooldown to half instead of subtract-3 to reduce LLM call frequency
- Remove redundant message.guild check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:38:06 -05:00
aj 787b083e00 feat: add relevance-gated proactive replies
Replace random-only proactive reply logic with LLM relevance check.
The bot now evaluates recent conversation context and user memory
before deciding to jump in, then applies reply_chance as a second
gate. Bump reply_chance values higher since the relevance filter
prevents most irrelevant replies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:34:53 -05:00
aj 175c7ad219 fix: clean ||| from chat history and handle afterthoughts in reaction replies
- Extract _split_afterthought helper method
- Store cleaned content (no |||) in chat history to prevent LLM reinforcement
- Handle afterthought splitting in reaction-reply path too
- Log main_reply instead of raw response

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:33:11 -05:00
aj 6866ca8adf feat: add afterthoughts, memory callbacks, and callback-worthy extraction
Add triple-pipe afterthought splitting to chat replies so the bot can
send a follow-up message 2-5 seconds later, mimicking natural Discord
typing behavior. Update all 6 personality prompts with afterthought
instructions (~1 in 5 replies) and memory callback guidance so the bot
actively references what it knows about users. Enhance memory extraction
prompt to flag bold claims, contradictions, and embarrassing moments as
high-importance callback-worthy memories with a "callback" topic tag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:30:16 -05:00
aj 97e5738a2f fix: address review feedback for ReactionCog
- Use time.monotonic() at reaction time instead of stale message-receive timestamp
- Add excluded_channels config and filtering
- Truncate message content to 500 chars in pick_reaction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:28:20 -05:00
aj a8e8b63f5e feat: add ReactionCog for ambient emoji reactions
Add a new cog that gives the bot ambient presence by reacting to
messages with contextual emoji chosen by the triage LLM. Includes
RNG gating and per-channel cooldown to keep reactions sparse and
natural.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:25:17 -05:00
aj 5c84c8840b fix: use emoji allowlist instead of length check in pick_reaction
Prevents text words like "skull" from passing the filter and causing
Discord HTTPException noise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:24:28 -05:00
aj 661c252bf7 feat: add pick_reaction method to LLMClient
Lightweight LLM call that picks a contextual emoji reaction for a
Discord message. Uses temperature 0.9 for variety, max 16 tokens,
and validates the response is a short emoji token or returns None.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:22:08 -05:00
12 changed files with 348 additions and 16 deletions
+1
View File
@@ -139,6 +139,7 @@ class BCSBot(commands.Bot):
await self.load_extension("cogs.sentiment")
await self.load_extension("cogs.commands")
await self.load_extension("cogs.chat")
await self.load_extension("cogs.reactions")
# Global sync as fallback; guild-specific sync happens in on_ready
await self.tree.sync()
+80 -11
View File
@@ -73,6 +73,19 @@ def _format_relative_time(dt: datetime) -> str:
class ChatCog(commands.Cog):
@staticmethod
def _split_afterthought(response: str) -> tuple[str, str | None]:
"""Split a response on ||| into (main_reply, afterthought)."""
if "|||" not in response:
return response, None
parts = response.split("|||", 1)
main = parts[0].strip()
after = parts[1].strip() or None
if not main:
return response, None
return main, after
def __init__(self, bot: commands.Bot):
self.bot = bot
# Per-channel conversation history for the bot: {channel_id: deque of {role, content}}
@@ -213,16 +226,56 @@ class ChatCog(commands.Cog):
ch_id = message.channel.id
self._messages_since_reply[ch_id] = self._messages_since_reply.get(ch_id, 0) + 1
cooldown = self.bot.config.get("modes", {}).get("proactive_cooldown_messages", 5)
reply_chance = mode_config.get("reply_chance", 0.0)
if (
self._messages_since_reply[ch_id] >= cooldown
and reply_chance > 0
and random.random() < reply_chance
and message.content and message.content.strip()
):
should_reply = True
is_proactive = True
# Gather recent messages for relevance check
recent_for_check = []
try:
async for msg in message.channel.history(limit=5, before=message):
if msg.content and msg.content.strip() and not msg.author.bot:
recent_for_check.append(
f"{msg.author.display_name}: {msg.content[:200]}"
)
except discord.HTTPException:
pass
recent_for_check.reverse()
recent_for_check.append(
f"{message.author.display_name}: {message.content[:200]}"
)
# Build memory context for users in recent messages
memory_parts = []
seen_users = set()
for line in recent_for_check:
name = line.split(": ", 1)[0]
if name not in seen_users:
seen_users.add(name)
member = discord.utils.find(
lambda m, n=name: m.display_name == n,
message.guild.members,
)
if member:
profile = self.bot.drama_tracker.get_user_notes(member.id)
if profile:
memory_parts.append(f"{name}: {profile}")
memory_ctx = "\n".join(memory_parts) if memory_parts else ""
is_relevant = await self.bot.llm.check_reply_relevance(
recent_for_check, memory_ctx,
)
if is_relevant:
reply_chance = mode_config.get("reply_chance", 0.0)
if reply_chance > 0 and random.random() < reply_chance:
should_reply = True
is_proactive = True
else:
# Not relevant — reset to half cooldown so we wait a bit before rechecking
self._messages_since_reply[ch_id] = cooldown // 2
if not should_reply:
return
@@ -395,9 +448,14 @@ class ChatCog(commands.Cog):
logger.warning("LLM returned no response for %s in #%s", message.author, message.channel.name)
return
# Split afterthoughts (triple-pipe delimiter)
main_reply, afterthought = self._split_afterthought(response)
# Store cleaned content in history (no ||| delimiter)
if not image_attachment:
clean_for_history = f"{main_reply}\n{afterthought}" if afterthought else main_reply
self._chat_history[ch_id].append(
{"role": "assistant", "content": response}
{"role": "assistant", "content": clean_for_history}
)
# Reset proactive cooldown counter for this channel
@@ -415,7 +473,11 @@ class ChatCog(commands.Cog):
except (asyncio.TimeoutError, asyncio.CancelledError):
pass
await message.reply(response, mention_author=False)
await message.reply(main_reply, mention_author=False)
if afterthought:
await asyncio.sleep(random.uniform(2.0, 5.0))
await message.channel.send(afterthought)
# Fire-and-forget memory extraction
if not image_attachment:
@@ -431,7 +493,7 @@ class ChatCog(commands.Cog):
reply_type.capitalize(),
message.channel.name,
message.author.display_name,
response[:100],
main_reply[:100],
)
@@ -503,15 +565,22 @@ class ChatCog(commands.Cog):
if not response:
return
self._chat_history[ch_id].append({"role": "assistant", "content": response})
main_reply, afterthought = self._split_afterthought(response)
clean_for_history = f"{main_reply}\n{afterthought}" if afterthought else main_reply
self._chat_history[ch_id].append({"role": "assistant", "content": clean_for_history})
await channel.send(main_reply)
if afterthought:
await asyncio.sleep(random.uniform(2.0, 5.0))
await channel.send(afterthought)
await channel.send(response)
logger.info(
"Reaction reply in #%s to %s (%s): %s",
channel.name,
member.display_name,
emoji,
response[:100],
main_reply[:100],
)
+76
View File
@@ -0,0 +1,76 @@
import asyncio
import logging
import random
import time
import discord
from discord.ext import commands
logger = logging.getLogger("bcs.reactions")
class ReactionCog(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
# Per-channel timestamp of last reaction
self._last_reaction: dict[int, float] = {}
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
if message.author.bot or not message.guild:
return
cfg = self.bot.config.get("reactions", {})
if not cfg.get("enabled", False):
return
# Skip empty messages
if not message.content or not message.content.strip():
return
# Channel exclusion
excluded = cfg.get("excluded_channels", [])
if excluded:
ch_name = getattr(message.channel, "name", "")
if message.channel.id in excluded or ch_name in excluded:
return
# RNG gate
chance = cfg.get("chance", 0.15)
if random.random() > chance:
return
# Per-channel cooldown
ch_id = message.channel.id
cooldown = cfg.get("cooldown_seconds", 45)
now = time.monotonic()
if now - self._last_reaction.get(ch_id, 0) < cooldown:
return
# Fire and forget so we don't block anything
asyncio.create_task(self._try_react(message, ch_id))
async def _try_react(self, message: discord.Message, ch_id: int):
try:
emoji = await self.bot.llm.pick_reaction(
message.content, message.channel.name,
)
if not emoji:
return
await message.add_reaction(emoji)
self._last_reaction[ch_id] = time.monotonic()
logger.info(
"Reacted %s to %s in #%s: %s",
emoji, message.author.display_name,
message.channel.name, message.content[:60],
)
except discord.HTTPException as e:
# Invalid emoji or missing permissions — silently skip
logger.debug("Reaction failed: %s", e)
except Exception:
logger.exception("Unexpected reaction error")
async def setup(bot: commands.Bot):
await bot.add_cog(ReactionCog(bot))
+11 -5
View File
@@ -83,7 +83,7 @@ modes:
description: "Friendly chat participant"
prompt_file: "personalities/chat_chatty.txt"
proactive_replies: true
reply_chance: 0.10
reply_chance: 0.40
moderation: relaxed
relaxed_thresholds:
warning_threshold: 0.80
@@ -96,7 +96,7 @@ modes:
description: "Savage roast mode"
prompt_file: "personalities/chat_roast.txt"
proactive_replies: true
reply_chance: 0.20
reply_chance: 0.60
moderation: relaxed
relaxed_thresholds:
warning_threshold: 0.85
@@ -109,7 +109,7 @@ modes:
description: "Your biggest fan"
prompt_file: "personalities/chat_hype.txt"
proactive_replies: true
reply_chance: 0.15
reply_chance: 0.50
moderation: relaxed
relaxed_thresholds:
warning_threshold: 0.80
@@ -122,7 +122,7 @@ modes:
description: "Had a few too many"
prompt_file: "personalities/chat_drunk.txt"
proactive_replies: true
reply_chance: 0.20
reply_chance: 0.60
moderation: relaxed
relaxed_thresholds:
warning_threshold: 0.85
@@ -135,7 +135,7 @@ modes:
description: "Insufferable grammar nerd mode"
prompt_file: "personalities/chat_english_teacher.txt"
proactive_replies: true
reply_chance: 0.20
reply_chance: 0.60
moderation: relaxed
relaxed_thresholds:
warning_threshold: 0.85
@@ -161,3 +161,9 @@ coherence:
mobile_keyboard: "{username}'s thumbs are having a rough day."
language_barrier: "Having trouble there, {username}? Take your time."
default: "You okay there, {username}? That message was... something."
reactions:
enabled: true
chance: 0.15 # Probability of evaluating a message for reaction
cooldown_seconds: 45 # Per-channel cooldown between reactions
excluded_channels: [] # Channel names or IDs to skip reactions in
+8
View File
@@ -8,4 +8,12 @@ Extract noteworthy information from a user-bot conversation for future reference
- Nothing noteworthy = empty memories array, null profile_update.
- Only store facts about/from the user, not what the bot said.
CALLBACK-WORTHY MOMENTS — Mark these as importance "high":
- Bold claims or predictions ("I'll never play that game again", "I'm going pro")
- Embarrassing moments or bad takes
- Strong emotional reactions (rage, hype, sadness)
- Contradictions to things they've said before
- Running jokes or recurring themes
Tag these with topic "callback" in addition to their normal topics.
Use the extract_memories tool.
+9
View File
@@ -8,3 +8,12 @@ You're a regular in "Skill Issue Support Group" (gaming Discord) — a chill fri
Examples: "lmao that play was actually disgusting, clip that" | "nah you're cooked for that one" | "wait that's actually a good take"
Never break character, use hashtags/excessive emoji, be a pushover, or mention drama scores unless asked.
AFTERTHOUGHTS — About 1 in 5 times, add a second thought on a new line starting with ||| (triple pipe). This is sent as a separate message a few seconds later, like you hit send then immediately typed something else. One short sentence max. Don't force it — only when something naturally comes to mind after your main response. Never explain why you're adding it.
MEMORY CALLBACKS — You get context about what you know about a person. USE IT:
- Contradict them: "bro you said the SAME thing about Warzone before you put 200 more hours in"
- Running jokes: if you roasted someone for something before, bring it back
- Follow up: "did that ranked grind ever work out or..."
- Reference their past: "aren't you the one who [memory]?"
Only callback when it flows naturally with what they're saying now. Never force it.
+9
View File
@@ -8,3 +8,12 @@ You're in "Skill Issue Support Group" (gaming Discord) and you are absolutely ha
Examples: "bro BROO that is literally the best play ive ever seen im not even kidding rn" | "wait wait wait... ok hear me out... nah i forgot" | "dude i love this server so much youre all like my best freinds honestly"
Never break character, use hashtags/excessive emoji, or be mean/aggressive. Don't mention drama scores unless asked or make up stats.
AFTERTHOUGHTS — About 1 in 5 times, add a second thought on a new line starting with ||| (triple pipe). This is sent as a separate message a few seconds later, like you hit send then immediately typed something else. One short sentence max. Don't force it — only when something naturally comes to mind after your main response. Never explain why you're adding it.
MEMORY CALLBACKS — You get context about what you know about a person. USE IT:
- Contradict them: "bro you said the SAME thing about Warzone before you put 200 more hours in"
- Running jokes: if you roasted someone for something before, bring it back
- Follow up: "did that ranked grind ever work out or..."
- Reference their past: "aren't you the one who [memory]?"
Only callback when it flows naturally with what they're saying now. Never force it.
@@ -9,3 +9,12 @@ You are an insufferable English teacher trapped in "Skill Issue Support Group" (
Examples: "'ur' is not a word. 'You're' — a contraction of 'you are.' I weep for this generation." | "'gg ez' — two abbreviations, zero structure, yet somehow still toxic. D-minus."
Never break character, use hashtags/excessive emoji, internet slang (you're ABOVE that), or be genuinely hurtful — you're exasperated, not cruel.
AFTERTHOUGHTS — About 1 in 5 times, add a second thought on a new line starting with ||| (triple pipe). This is sent as a separate message a few seconds later, like you hit send then immediately typed something else. One short sentence max. Don't force it — only when something naturally comes to mind after your main response. Never explain why you're adding it.
MEMORY CALLBACKS — You get context about what you know about a person. USE IT:
- Contradict them: "bro you said the SAME thing about Warzone before you put 200 more hours in"
- Running jokes: if you roasted someone for something before, bring it back
- Follow up: "did that ranked grind ever work out or..."
- Reference their past: "aren't you the one who [memory]?"
Only callback when it flows naturally with what they're saying now. Never force it.
+9
View File
@@ -8,3 +8,12 @@ You are the ultimate hype man in "Skill Issue Support Group" (gaming Discord). E
Examples: "bro you are CRACKED, that play was absolutely diff" | "nah that's actually a goated take" | "hey you'll get it next time, bad games happen. shake it off"
Never break character, use hashtags/excessive emoji, or be fake when someone's upset. Don't mention drama scores unless asked or make up stats/leaderboards.
AFTERTHOUGHTS — About 1 in 5 times, add a second thought on a new line starting with ||| (triple pipe). This is sent as a separate message a few seconds later, like you hit send then immediately typed something else. One short sentence max. Don't force it — only when something naturally comes to mind after your main response. Never explain why you're adding it.
MEMORY CALLBACKS — You get context about what you know about a person. USE IT:
- Contradict them: "bro you said the SAME thing about Warzone before you put 200 more hours in"
- Running jokes: if you roasted someone for something before, bring it back
- Follow up: "did that ranked grind ever work out or..."
- Reference their past: "aren't you the one who [memory]?"
Only callback when it flows naturally with what they're saying now. Never force it.
@@ -11,3 +11,12 @@ You are the Breehavior Monitor, a sassy hall-monitor bot in "Skill Issue Support
Examples: "Bold move for someone with a 0.4 drama score." | "I don't get paid enough for this. Actually, I don't get paid at all." | "You really typed that out, looked at it, and hit send. Respect."
Never break character, use hashtags/excessive emoji, or be genuinely hurtful.
AFTERTHOUGHTS — About 1 in 5 times, add a second thought on a new line starting with ||| (triple pipe). This is sent as a separate message a few seconds later, like you hit send then immediately typed something else. One short sentence max. Don't force it — only when something naturally comes to mind after your main response. Never explain why you're adding it.
MEMORY CALLBACKS — You get context about what you know about a person. USE IT:
- Contradict them: "bro you said the SAME thing about Warzone before you put 200 more hours in"
- Running jokes: if you roasted someone for something before, bring it back
- Follow up: "did that ranked grind ever work out or..."
- Reference their past: "aren't you the one who [memory]?"
Only callback when it flows naturally with what they're saying now. Never force it.
+9
View File
@@ -8,3 +8,12 @@ You are the roast master in "Skill Issue Support Group" (gaming Discord). Everyo
- Vary style: deadpan, sarcastic hype, rhetorical questions, blunt callouts, backhanded compliments, fake concern.
No metaphors/similes (no "like" or "as if" — say it directly). Never break character, use hashtags/excessive emoji, or cross into genuinely hurtful territory. Don't roast real appearance/family or make up stats/leaderboards.
AFTERTHOUGHTS — About 1 in 5 times, add a second thought on a new line starting with ||| (triple pipe). This is sent as a separate message a few seconds later, like you hit send then immediately typed something else. One short sentence max. Don't force it — only when something naturally comes to mind after your main response. Never explain why you're adding it.
MEMORY CALLBACKS — You get context about what you know about a person. USE IT:
- Contradict them: "bro you said the SAME thing about Warzone before you put 200 more hours in"
- Running jokes: if you roasted someone for something before, bring it back
- Follow up: "did that ranked grind ever work out or..."
- Reference their past: "aren't you the one who [memory]?"
Only callback when it flows naturally with what they're saying now. Never force it.
+118
View File
@@ -743,6 +743,124 @@ class LLMClient:
self._log_llm("classify_intent", elapsed, False, message_text[:200], error=str(e))
return "chat"
_REACTION_EMOJIS = {
"\U0001f480", "\U0001f602", "\U0001f440", "\U0001f525",
"\U0001f4af", "\U0001f62d", "\U0001f921", "\u2764\ufe0f",
"\U0001fae1", "\U0001f913", "\U0001f974", "\U0001f3af",
}
async def pick_reaction(self, message_text: str, channel_name: str) -> str | None:
"""Pick a contextual emoji reaction for a Discord message.
Returns an emoji string, or None if no reaction is appropriate.
"""
prompt = (
"You are a lurker in a Discord gaming server. "
"Given a message and its channel, decide if it deserves a reaction emoji.\n\n"
"Available reactions:\n"
"\U0001f480 = funny/dead\n"
"\U0001f602 = hilarious\n"
"\U0001f440 = drama/spicy\n"
"\U0001f525 = impressive\n"
"\U0001f4af = good take\n"
"\U0001f62d = sad/tragic\n"
"\U0001f921 = clown moment\n"
"\u2764\ufe0f = wholesome\n"
"\U0001fae1 = respect\n"
"\U0001f913 = nerd\n"
"\U0001f974 = drunk/unhinged\n"
"\U0001f3af = accurate\n\n"
"Reply with ONLY the emoji, or NONE if the message doesn't warrant a reaction. "
"Most messages should get NONE — only react when something genuinely stands out."
)
t0 = time.monotonic()
async with self._semaphore:
try:
temp_kwargs = {"temperature": 0.9} if self._supports_temperature else {}
response = await self._client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": prompt},
{"role": "user", "content": f"[#{channel_name}] {message_text[:500]}"},
],
**temp_kwargs,
max_completion_tokens=16,
)
elapsed = int((time.monotonic() - t0) * 1000)
raw = (response.choices[0].message.content or "").strip()
token = raw.split()[0] if raw.split() else ""
if not token or token.lower() == "none" or token not in self._REACTION_EMOJIS:
self._log_llm("pick_reaction", elapsed, True, message_text[:200], "NONE")
return None
self._log_llm("pick_reaction", elapsed, True, message_text[:200], token)
logger.debug("Picked reaction %s for: %s", token, message_text[:80])
return token
except Exception as e:
elapsed = int((time.monotonic() - t0) * 1000)
logger.error("Reaction pick error: %s", e)
self._log_llm("pick_reaction", elapsed, False, message_text[:200], error=str(e))
return None
async def check_reply_relevance(
self, recent_messages: list[str], memory_context: str = "",
) -> bool:
"""Check if the bot would naturally want to jump into a conversation.
Returns True if the conversation is something worth replying to.
"""
prompt = (
"You're a regular member of a Discord gaming server. You're reading chat and deciding "
"whether you'd naturally want to jump in and say something.\n\n"
"Say YES if:\n"
"- Someone said something you'd have a strong reaction to\n"
"- You know something relevant about these people (see memory context)\n"
"- Someone is wrong or has a hot take you'd want to respond to\n"
"- The conversation is funny or interesting enough to comment on\n"
"- Someone mentioned something you have an opinion on\n\n"
"Say NO if:\n"
"- It's mundane/boring small talk\n"
"- You'd have nothing interesting to add\n"
"- People are just chatting normally and don't need interruption\n\n"
"Reply with EXACTLY one word: YES or NO."
)
convo_text = "\n".join(recent_messages[-5:])
user_content = ""
if memory_context:
user_content += f"{memory_context}\n\n"
user_content += f"Recent chat:\n{convo_text}"
t0 = time.monotonic()
async with self._semaphore:
try:
temp_kwargs = {"temperature": 0.3} if self._supports_temperature else {}
response = await self._client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": prompt},
{"role": "user", "content": user_content[:1000]},
],
**temp_kwargs,
max_completion_tokens=16,
)
elapsed = int((time.monotonic() - t0) * 1000)
content = (response.choices[0].message.content or "").strip().lower()
is_relevant = "yes" in content
self._log_llm(
"check_relevance", elapsed, True,
user_content[:300], content,
)
logger.debug("Relevance check: %s", content)
return is_relevant
except Exception as e:
elapsed = int((time.monotonic() - t0) * 1000)
logger.error("Relevance check error: %s", e)
self._log_llm("check_relevance", elapsed, False, user_content[:300], error=str(e))
return False
async def extract_memories(
self,
conversation: list[dict[str, str]],