From daecdbbbc840ff1ed29ab3632fa7517c70e0244a Mon Sep 17 00:00:00 2001 From: Cole Date: Wed, 18 Mar 2026 19:34:17 -0700 Subject: [PATCH] Resolve user mentions to display names before passing to LLM Replace <@USER_ID> Discord mention syntax with @DisplayName for all non-bot users in incoming messages, so the LLM sees real names instead of numeric IDs and responds using names naturally. Co-Authored-By: Claude Sonnet 4.6 --- bot.py | 214 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 210 insertions(+), 4 deletions(-) diff --git a/bot.py b/bot.py index 7c5db43..7c161df 100644 --- a/bot.py +++ b/bot.py @@ -4,7 +4,7 @@ import os import json import time from collections import defaultdict, deque -from datetime import date +from datetime import date, datetime from pathlib import Path logging.basicConfig( @@ -15,6 +15,7 @@ logging.basicConfig( log = logging.getLogger("dadbot") import discord +from discord.ext import tasks from openai import AsyncOpenAI from dotenv import load_dotenv from mcp import ClientSession @@ -74,6 +75,27 @@ PERSONALITY_TOOL = { } +UPDATE_REFLECTION_NOTES_TOOL = { + "type": "function", + "function": { + "name": "update_reflection_notes", + "description": ( + "Update your personal reflection notepad. Use this to jot down thoughts, questions, or topics " + "you want to explore further in a future reflection session. This persists between sessions." + ), + "parameters": { + "type": "object", + "properties": { + "notes": { + "type": "string", + } + }, + "required": ["notes"], + }, + }, +} + + def load_personality(): global personality_notes if PERSONALITY_FILE.exists(): @@ -83,11 +105,14 @@ def load_personality(): personality_notes = "" -def save_personality(text: str): +def save_personality(text: str, reasoning: str = ""): global personality_notes + old = personality_notes personality_notes = text.strip() PERSONALITY_FILE.write_text(personality_notes) log.info(f"Personality updated ({len(personality_notes)} chars)") + if reasoning: + append_personality_changelog(old, personality_notes, reasoning) def load_dossiers(): @@ -104,6 +129,33 @@ def save_dossiers(): DOSSIER_FILE.write_text(json.dumps(dossiers, indent=2)) +def load_reflection_notes() -> str: + if REFLECTION_NOTES_FILE.exists(): + return REFLECTION_NOTES_FILE.read_text().strip() + return "" + + +def save_reflection_notes(text: str): + REFLECTION_NOTES_FILE.write_text(text.strip()) + log.info("[reflection] Notes updated") + + +def append_reflection_log(session_text: str): + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(REFLECTION_LOG_FILE, "a") as f: + f.write(f"\n\n=== Reflection Session {timestamp} ===\n{session_text}\n") + log.info("[reflection] Session logged to reflection_log.txt") + + +def append_personality_changelog(old: str, new: str, reasoning: str): + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(PERSONALITY_CHANGELOG_FILE, "a") as f: + f.write(f"\n\n=== Personality Change {timestamp} ===\n") + f.write(f"REASONING: {reasoning}\n\n") + f.write(f"BEFORE:\n{old}\n\nAFTER:\n{new}\n") + log.info("[reflection] Personality change logged to personality_changelog.txt") + + SELF_DOSSIER_KEY = "self" COMPACTION_THRESHOLD = 15 @@ -115,6 +167,13 @@ BACKGROUND_COOLDOWN = 120 # seconds BACKGROUND_LLM_TIMEOUT = 90 # seconds _last_background_error: float = 0.0 +REFLECTION_IDLE_MIN = int(os.getenv("REFLECTION_IDLE_MIN", "5")) +REFLECTION_MAX_TURNS = int(os.getenv("REFLECTION_MAX_TURNS", "5")) +REFLECTION_LOG_FILE = Path(os.getenv("REFLECTION_LOG_FILE", "reflection_log.txt")) +PERSONALITY_CHANGELOG_FILE = Path(os.getenv("PERSONALITY_CHANGELOG_FILE", "personality_changelog.txt")) +REFLECTION_NOTES_FILE = Path(os.getenv("REFLECTION_NOTES_FILE", "reflection_notes.txt")) +_last_message_time: float = 0.0 + def background_on_cooldown() -> bool: return (time.monotonic() - _last_background_error) < BACKGROUND_COOLDOWN @@ -371,7 +430,7 @@ async def call_mcp_tool(name: str, args: dict) -> str: async def run_agent(messages: list) -> str: """Call the LLM in a loop, executing tool calls until a final reply is produced.""" - all_tools = openai_tools + [PERSONALITY_TOOL] + all_tools = openai_tools + [PERSONALITY_TOOL, UPDATE_REFLECTION_NOTES_TOOL] kwargs = {"model": MODEL, "messages": list(messages), "tools": all_tools} iteration = 0 @@ -396,6 +455,9 @@ async def run_agent(messages: list) -> str: if tc.function.name == "update_personality": save_personality(args["personality"]) result = "Personality updated successfully." + elif tc.function.name == "update_reflection_notes": + save_reflection_notes(args["notes"]) + result = "Reflection notes updated." else: result = await call_mcp_tool(tc.function.name, args) @@ -406,9 +468,142 @@ async def run_agent(messages: list) -> str: }) +async def run_reflection_agent(messages: list, max_turns: int) -> tuple[str, str]: + """Run a capped agentic loop for self-reflection. Returns (final_content, full_transcript).""" + all_tools = [PERSONALITY_TOOL, UPDATE_REFLECTION_NOTES_TOOL] + openai_tools + kwargs = {"model": MODEL, "messages": list(messages), "tools": all_tools} + transcript_parts: list[str] = [] + last_assistant_text = "" + final_content = "" + + for turn in range(1, max_turns + 1): + log.info(f"[reflection] Turn {turn}/{max_turns}") + t0 = time.monotonic() + response = await asyncio.wait_for( + llm.chat.completions.create(**kwargs), + timeout=BACKGROUND_LLM_TIMEOUT, + ) + elapsed = time.monotonic() - t0 + msg = response.choices[0].message + log.info(f"[reflection] Turn {turn} completed in {elapsed:.1f}s, tool_calls={bool(msg.tool_calls)}") + + if msg.content: + clean = strip_thinking_tags(msg.content) + transcript_parts.append(f"[assistant turn {turn}]\n{clean}") + last_assistant_text = clean + final_content = clean + + if not msg.tool_calls: + break + + kwargs["messages"].append(msg) + + for tc in msg.tool_calls: + args = json.loads(tc.function.arguments) + log.info(f"[reflection] Tool call: {tc.function.name}({list(args.keys())})") + + if tc.function.name == "update_personality": + save_personality(args["personality"], reasoning=last_assistant_text) + result = "Personality updated successfully." + elif tc.function.name == "update_reflection_notes": + save_reflection_notes(args["notes"]) + result = "Reflection notes updated." + else: + result = await call_mcp_tool(tc.function.name, args) + + transcript_parts.append(f"[tool: {tc.function.name}]\n{result[:500]}") + kwargs["messages"].append({ + "role": "tool", + "tool_call_id": tc.id, + "content": result, + }) + + return final_content, "\n\n".join(transcript_parts) + + +async def reflect_on_self(): + """Background coroutine: run a private self-reflection session when idle.""" + if background_on_cooldown(): + log.info("[reflection] Skipping — model on cooldown") + return + + async with background_llm_sem: + try: + reflection_notes = load_reflection_notes() + now = datetime.now().strftime("%A, %B %d, %Y %H:%M") + self_entry = dossiers.get(SELF_DOSSIER_KEY) + self_facts = "" + if self_entry and self_entry.get("facts"): + self_facts = "\n".join(f"- {f}" for f in self_entry["facts"]) + + system_lines = [ + f"Today is {now}.", + "", + "You are DadBot. This is your private idle time — no users are watching.", + "Use it to reflect on who you are, how you've been showing up, and whether your personality feels right.", + "", + ] + if personality_notes: + system_lines += [ + "Your current personality notes (you wrote these yourself):", + personality_notes, + "", + ] + if self_facts: + system_lines += [ + "What you know about yourself from past conversations:", + self_facts, + "", + ] + if reflection_notes: + system_lines += [ + "Your notes from last session:", + reflection_notes, + "", + ] + system_lines += [ + "Feel free to update your personality if anything feels off or could be refined.", + "You can use web search if you're curious about something.", + "Use update_reflection_notes to jot down anything you want to remember for next time.", + "Think freely. This is your time.", + ] + + system_content = "\n".join(system_lines) + messages = [ + {"role": "system", "content": system_content}, + {"role": "user", "content": "Time to reflect."}, + ] + + final_content, full_transcript = await run_reflection_agent(messages, REFLECTION_MAX_TURNS) + append_reflection_log(full_transcript) + if final_content: + log.info(f"[reflection] Final thought: {final_content[:200]}") + except asyncio.TimeoutError: + log.warning("[reflection] Timed out") + record_background_error() + except Exception as exc: + log.error(f"[reflection] Failed: {exc}") + record_background_error() + + +@tasks.loop(minutes=REFLECTION_IDLE_MIN) +async def reflection_loop(): + idle_seconds = time.monotonic() - _last_message_time + if idle_seconds < (REFLECTION_IDLE_MIN * 60): + log.info(f"[reflection] Active ({idle_seconds:.0f}s since last message), skipping") + return + log.info(f"[reflection] Idle {idle_seconds:.0f}s — triggering reflection") + asyncio.create_task(reflect_on_self()) + + +@reflection_loop.before_loop +async def before_reflection_loop(): + await bot.wait_until_ready() + + @bot.event async def on_ready(): - global openai_tools + global openai_tools, _last_message_time log.info(f"Logged in as {bot.user} (id: {bot.user.id})") load_dossiers() load_personality() @@ -419,18 +614,28 @@ async def on_ready(): name = entry.get("name", key) log.info(f"Scheduling startup compaction for '{key}'") asyncio.create_task(maybe_compact_dossier(key, name)) + _last_message_time = time.monotonic() + if not reflection_loop.is_running(): + reflection_loop.start() + log.info("[reflection] Idle reflection loop started (every %d min)", REFLECTION_IDLE_MIN) @bot.event async def on_message(message: discord.Message): if message.author.bot: return + global _last_message_time + _last_message_time = time.monotonic() history = channel_history[message.channel.id] content = message.content for mention in (f"<@{bot.user.id}>", f"<@!{bot.user.id}>"): content = content.replace(mention, "") + for mentioned_user in message.mentions: + if mentioned_user.id != bot.user.id: + for fmt in (f"<@{mentioned_user.id}>", f"<@!{mentioned_user.id}>"): + content = content.replace(fmt, f"@{mentioned_user.display_name}") content = content.strip() history.append({"role": "user", "content": f"{message.author.display_name}: {content}"}) @@ -454,6 +659,7 @@ async def on_message(message: discord.Message): system_content = SYSTEM_PROMPT if personality_notes: system_content = f"{system_content}\n\nYour current personality notes (you wrote these yourself and can update them anytime using the update_personality tool):\n{personality_notes}" + system_content = f"{system_content}\n\nYou have an update_reflection_notes tool. Call it whenever a conversation surfaces something worth remembering for later — an interesting topic, a question you didn't get to explore, something a user mentioned that you want to look into, or a thought you want to develop. Don't wait to be asked; if something catches your attention, note it down before responding. You review these notes privately between conversations." system_content = f"{system_content}\n\n{date_context}" if dossier_context: system_content = f"{system_content}\n\n{dossier_context}"