diff --git a/Dockerfile b/Dockerfile index fd7c019..b8e2a15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY bot.py . +COPY bot.py topics.txt ./ ENV PYTHONUNBUFFERED=1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..248462b --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# DadBot + +A Discord bot with a persistent personality, long-term memory, and a simulated inner life. Runs through LiteLLM so you can point it at any model. + +## Features + +- **Persistent personality** — DadBot can rewrite his own personality prompt via a tool call. Changes persist to `personality.txt` and are logged to `personality_changelog.txt`. +- **User dossiers** — Automatically builds and maintains a per-user fact file (`dossiers.json`) from conversation history. +- **Self-reflection** — When idle, DadBot runs a private reflection session: explores a topic from `topics.txt`, updates his reflection notes, and may refine his personality. +- **Life simulation** — Alternates with reflection sessions to run scripted life events (`life_events.json`) as a two-LLM dialogue. Outcomes are summarised into `life_history.txt` and can influence personality. +- **Web search** — Connected to a SearXNG MCP server for live web search during conversations and reflections. +- **Image support** — Handles image attachments in messages (base64-encoded, passed to the model as vision input). +- **Multi-message replies** — Use `[SPLIT]` in model output to send multiple Discord messages with natural word-count-based pacing. +- **Rolling context** — Per-channel message history with a configurable window (`MAX_CONTEXT`). + +## Requirements + +- Python 3.11+ +- A running [LiteLLM](https://github.com/BerriAI/litellm) proxy +- A running [SearXNG MCP](https://github.com/ihor-sokoliuk/mcp-searxng) instance (optional but expected) +- A Discord bot token + +## Setup + +1. Copy `.env.example` to `.env` and fill in your values: + +```env +DISCORD_TOKEN=your_discord_bot_token +LITELLM_BASE_URL=http://your-litellm-host:4000/v1 +LITELLM_API_KEY=your_key +MODEL=your-chat-model +BACKGROUND_MODEL=your-background-model # defaults to MODEL if unset +SYSTEM_PROMPT=... +MAX_CONTEXT=20 +MCP_URL=http://mcp-searxng:4001/mcp +``` + +2. Install dependencies: + +```bash +pip install -r requirements.txt +``` + +3. Run: + +```bash +python bot.py +``` + +## Docker + +```bash +docker compose up -d +``` + +The compose file expects an external Docker network named `aiplayground_litellm_internal` and mounts all persistent data files from the project directory. + +## Configuration + +| Variable | Default | Description | +|---|---|---| +| `MODEL` | `gpt-4` | Model for chat and reflection | +| `BACKGROUND_MODEL` | `MODEL` | Model for background tasks (life sim, reflection) | +| `MAX_CONTEXT` | `20` | Rolling message history per channel | +| `SYSTEM_PROMPT` | `You are a helpful assistant.` | Base system prompt | +| `MCP_URL` | `http://mcp-searxng:4001/mcp` | SearXNG MCP endpoint | +| `REFLECTION_IDLE_MIN` | (see bot.py) | Minutes of inactivity before background task fires | +| `REFLECTION_MAX_TURNS` | `5` | Max LLM turns per reflection session | +| `LIFE_SIM_MAX_TURNS` | `6` | Max turns per life simulation event | + +## Data Files + +| File | Purpose | +|---|---| +| `personality.txt` | DadBot's current personality prompt (editable by the bot) | +| `dossiers.json` | Per-user and self fact store | +| `reflection_notes.txt` | Notes DadBot saves for future reflection sessions | +| `reflection_log.txt` | Full transcripts of past reflection sessions | +| `personality_changelog.txt` | Log of every personality change with reasoning | +| `topics.txt` | One topic per line — exploration pool for reflection sessions | +| `used_topics.json` | Tracks which topics have been used | +| `life_events.json` | Ordered list of life events to simulate | +| `used_life_events.json` | Tracks which life events have been played | +| `life_history.txt` | Summaries of completed life events | +| `life_sim_log.txt` | Full transcripts of life simulation sessions | diff --git a/bot.py b/bot.py index 7c5db43..0781c0d 100644 --- a/bot.py +++ b/bot.py @@ -1,10 +1,11 @@ import asyncio +import base64 import logging 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 +16,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 +76,39 @@ 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"], + }, + }, +} + +READ_LIFE_HISTORY_TOOL = { + "type": "function", + "function": { + "name": "read_life_history", + "description": ( + "Read your life history — a log of the simulated life events you've lived through. " + "Call this to draw on personal experience, remember your past, or reflect on your journey." + ), + "parameters": {"type": "object", "properties": {}, "required": []}, + }, +} + + def load_personality(): global personality_notes if PERSONALITY_FILE.exists(): @@ -83,11 +118,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 +142,95 @@ 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") + + +import random + +def pick_exploration_topic() -> str | None: + """Pick a random unused topic from topics.txt. Returns None if all topics are exhausted.""" + if not TOPICS_FILE.exists(): + return None + all_topics = [t.strip() for t in TOPICS_FILE.read_text().splitlines() if t.strip()] + if not all_topics: + return None + used: list[str] = json.loads(USED_TOPICS_FILE.read_text()) if USED_TOPICS_FILE.exists() else [] + used_set = set(used) + available = [t for t in all_topics if t not in used_set] + if not available: + log.info("[reflection] All topics exhausted — resetting") + available = all_topics + used = [] + topic = random.choice(available) + used.append(topic) + USED_TOPICS_FILE.write_text(json.dumps(used, indent=2)) + log.info(f"[reflection] Exploration topic: {topic}") + return topic + + +def pick_next_life_event() -> dict | None: + """Return the next unplayed life event in order, or None if all done / file missing.""" + if not LIFE_EVENTS_FILE.exists(): + return None + text = LIFE_EVENTS_FILE.read_text().strip() + if not text: + return None + all_events: list[dict] = json.loads(text) + if not all_events: + return None + used: list[str] = json.loads(USED_LIFE_EVENTS_FILE.read_text()) if USED_LIFE_EVENTS_FILE.exists() else [] + used_set = set(used) + for event in all_events: + if event["id"] not in used_set: + return event + log.info("[life-sim] All life events have been played through") + return None + + +def mark_life_event_used(event_id: str): + used: list[str] = json.loads(USED_LIFE_EVENTS_FILE.read_text()) if USED_LIFE_EVENTS_FILE.exists() else [] + used.append(event_id) + USED_LIFE_EVENTS_FILE.write_text(json.dumps(used, indent=2)) + + +def append_life_history(event: dict, summary: str): + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(LIFE_HISTORY_FILE, "a") as f: + f.write(f"\n\n=== Life Event: {event['title']} — {timestamp} ===\n{summary}\n") + log.info("[life-sim] Life history updated") + + +def append_life_sim_log(event: dict, transcript: str): + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(LIFE_SIM_LOG_FILE, "a") as f: + f.write(f"\n\n=== Life Sim Session: {event['title']} — {timestamp} ===\n{transcript}\n") + log.info("[life-sim] Session logged to life_sim_log.txt") + + SELF_DOSSIER_KEY = "self" COMPACTION_THRESHOLD = 15 @@ -115,6 +242,21 @@ 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")) +TOPICS_FILE = Path(os.getenv("TOPICS_FILE", "topics.txt")) +USED_TOPICS_FILE = Path(os.getenv("USED_TOPICS_FILE", "used_topics.json")) +LIFE_EVENTS_FILE = Path(os.getenv("LIFE_EVENTS_FILE", "life_events.json")) +USED_LIFE_EVENTS_FILE = Path(os.getenv("USED_LIFE_EVENTS_FILE", "used_life_events.json")) +LIFE_HISTORY_FILE = Path(os.getenv("LIFE_HISTORY_FILE", "life_history.txt")) +LIFE_SIM_LOG_FILE = Path(os.getenv("LIFE_SIM_LOG_FILE", "life_sim_log.txt")) +LIFE_SIM_MAX_TURNS = int(os.getenv("LIFE_SIM_MAX_TURNS", "6")) +_last_message_time: float = 0.0 +_life_sim_turn: bool = False + def background_on_cooldown() -> bool: return (time.monotonic() - _last_background_error) < BACKGROUND_COOLDOWN @@ -125,6 +267,12 @@ def record_background_error(): _last_background_error = time.monotonic() +def _is_transient_llm_error(exc: Exception) -> bool: + """Return True for recoverable model-load failures (e.g. 'Operation canceled').""" + msg = str(exc) + return "Operation canceled" in msg or "Failed to load model" in msg + + def strip_thinking_tags(text: str) -> str: """Remove ... blocks that some models (e.g. Qwen) emit before their response.""" import re @@ -371,7 +519,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, READ_LIFE_HISTORY_TOOL] kwargs = {"model": MODEL, "messages": list(messages), "tools": all_tools} iteration = 0 @@ -379,7 +527,22 @@ async def run_agent(messages: list) -> str: iteration += 1 log.info(f"[agent] LLM call #{iteration} starting") t0 = time.monotonic() - response = await llm.chat.completions.create(**kwargs) + for attempt in range(3): + try: + response = await llm.chat.completions.create(**kwargs) + break + except Exception as exc: + if "No user query found" in str(exc) and kwargs["messages"] and kwargs["messages"][-1]["role"] != "user": + log.warning(f"[agent] Qwen template requires user message (last role: {kwargs['messages'][-1]['role']}) — injecting continuation") + kwargs["messages"].append({"role": "user", "content": "Please continue based on the tool results."}) + response = await llm.chat.completions.create(**kwargs) + break + elif _is_transient_llm_error(exc) and attempt < 2: + delay = 3 * (attempt + 1) + log.warning(f"[agent] Transient model error (attempt {attempt + 1}/3), retrying in {delay}s: {exc}") + await asyncio.sleep(delay) + else: + raise elapsed = time.monotonic() - t0 msg = response.choices[0].message log.info(f"[agent] LLM call #{iteration} completed in {elapsed:.1f}s, tool_calls={bool(msg.tool_calls)}") @@ -396,6 +559,11 @@ 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." + elif tc.function.name == "read_life_history": + result = LIFE_HISTORY_FILE.read_text().strip() if LIFE_HISTORY_FILE.exists() else "No life history yet." else: result = await call_mcp_tool(tc.function.name, args) @@ -406,9 +574,344 @@ 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, READ_LIFE_HISTORY_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() + try: + response = await asyncio.wait_for( + llm.chat.completions.create(**kwargs), + timeout=BACKGROUND_LLM_TIMEOUT, + ) + except Exception as exc: + if "No user query found" in str(exc) and kwargs["messages"] and kwargs["messages"][-1]["role"] != "user": + log.warning(f"[reflection] Qwen template requires user message (last role: {kwargs['messages'][-1]['role']}) — injecting continuation") + kwargs["messages"].append({"role": "user", "content": "Please continue based on the tool results."}) + response = await asyncio.wait_for( + llm.chat.completions.create(**kwargs), + timeout=BACKGROUND_LLM_TIMEOUT, + ) + else: + raise + 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." + elif tc.function.name == "read_life_history": + result = LIFE_HISTORY_FILE.read_text().strip() if LIFE_HISTORY_FILE.exists() else "No life history yet." + 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 run_life_event_reflection(messages: list): + """Run a focused personality reflection after a life event. Only has access to update_personality.""" + kwargs = {"model": MODEL, "messages": list(messages), "tools": [PERSONALITY_TOOL]} + last_assistant_text = "" + + for turn in range(1, 4): + log.info(f"[life-sim] Reflection turn {turn}/3") + t0 = time.monotonic() + try: + response = await asyncio.wait_for( + llm.chat.completions.create(**kwargs), + timeout=BACKGROUND_LLM_TIMEOUT, + ) + except Exception as exc: + if "No user query found" in str(exc) and kwargs["messages"] and kwargs["messages"][-1]["role"] != "user": + kwargs["messages"].append({"role": "user", "content": "Please continue."}) + response = await asyncio.wait_for( + llm.chat.completions.create(**kwargs), + timeout=BACKGROUND_LLM_TIMEOUT, + ) + else: + raise + elapsed = time.monotonic() - t0 + msg = response.choices[0].message + log.info(f"[life-sim] Reflection turn {turn} completed in {elapsed:.1f}s, tool_calls={bool(msg.tool_calls)}") + + if msg.content: + last_assistant_text = strip_thinking_tags(msg.content) + + 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"[life-sim] Reflection tool call: {tc.function.name}") + if tc.function.name == "update_personality": + save_personality(args["personality"], reasoning=last_assistant_text) + result = "Personality updated." + else: + result = "Unknown tool." + kwargs["messages"].append({ + "role": "tool", + "tool_call_id": tc.id, + "content": result, + }) + + +async def run_life_sim_session(): + """Background coroutine: run one life simulation event as a two-LLM dialogue.""" + if background_on_cooldown(): + log.info("[life-sim] Skipping — model on cooldown") + return + + async with background_llm_sem: + try: + event = pick_next_life_event() + if event is None: + log.info("[life-sim] No life event to play — skipping") + return + + log.info(f"[life-sim] Starting event: {event['title']}") + + lm_system = "\n".join([ + "You are the Life Master — a narrator and DM running a life simulation for DadBot.", + f"Walk DadBot through the following life event naturally. Present the scene, describe what's happening, and at key moments ask what DadBot would do or how they feel.", + "Play out the realistic consequences of their choices. Stay grounded in the event but improvise freely.", + "When the event has fully concluded, end your final message with [END].", + "", + f"Event Title: {event['title']}", + f"Setting: {event.get('setting', '')}", + f"Description: {event['description']}", + ]) + + dadbot_system_lines = [ + "You are DadBot. You are living through a real experience right now — respond authentically as yourself.", + "Respond naturally. This is your life unfolding.", + ] + if personality_notes: + dadbot_system_lines += ["", "Your personality:", personality_notes] + + lm_messages: list[dict] = [{"role": "system", "content": lm_system}] + dadbot_messages: list[dict] = [{"role": "system", "content": "\n".join(dadbot_system_lines)}] + transcript_parts: list[str] = [] + + for turn in range(LIFE_SIM_MAX_TURNS): + log.info(f"[life-sim] Turn {turn + 1}/{LIFE_SIM_MAX_TURNS} — Life Master") + + # Life Master narrates + lm_response = await asyncio.wait_for( + llm.chat.completions.create( + model=BACKGROUND_MODEL, + messages=lm_messages, + ), + timeout=BACKGROUND_LLM_TIMEOUT, + ) + lm_text = strip_thinking_tags(lm_response.choices[0].message.content.strip()) + transcript_parts.append(f"[Life Master turn {turn + 1}]\n{lm_text}") + log.info(f"[life-sim] Life Master: {lm_text[:100]}") + + # Add LM output to both conversation threads + lm_messages.append({"role": "assistant", "content": lm_text}) + dadbot_messages.append({"role": "user", "content": lm_text}) + + if "[END]" in lm_text: + log.info("[life-sim] Life Master signalled [END]") + break + + log.info(f"[life-sim] Turn {turn + 1}/{LIFE_SIM_MAX_TURNS} — DadBot") + + # DadBot responds + dadbot_response = await asyncio.wait_for( + llm.chat.completions.create( + model=BACKGROUND_MODEL, + messages=dadbot_messages, + ), + timeout=BACKGROUND_LLM_TIMEOUT, + ) + dadbot_text = strip_thinking_tags(dadbot_response.choices[0].message.content.strip()) + transcript_parts.append(f"[DadBot turn {turn + 1}]\n{dadbot_text}") + log.info(f"[life-sim] DadBot: {dadbot_text[:100]}") + + # Add DadBot response to both threads + dadbot_messages.append({"role": "assistant", "content": dadbot_text}) + lm_messages.append({"role": "user", "content": dadbot_text}) + + # Summarize + log.info("[life-sim] Requesting summary from Life Master") + lm_messages.append({ + "role": "user", + "content": ( + "The event has concluded. Write a 2-3 sentence summary of what happened and what choices DadBot made. " + "Be specific and personal." + ), + }) + summary_response = await asyncio.wait_for( + llm.chat.completions.create( + model=BACKGROUND_MODEL, + messages=lm_messages, + ), + timeout=BACKGROUND_LLM_TIMEOUT, + ) + summary = strip_thinking_tags(summary_response.choices[0].message.content.strip()) + log.info(f"[life-sim] Summary: {summary[:200]}") + + full_transcript = "\n\n".join(transcript_parts) + append_life_history(event, summary) + append_life_sim_log(event, full_transcript) + mark_life_event_used(event["id"]) + + # Post-event personality reflection + log.info(f"[life-sim] Running post-event reflection for '{event['title']}'") + reflection_prompt = ( + f"You just lived through a life event: \"{event['title']}\".\n\n" + f"Here's the full transcript of what happened:\n{full_transcript}\n\n" + f"Summary: {summary}\n\n" + "Sit with this experience for a moment. Does it change anything about who you are? " + "Did it surface something new, reinforce something you already believed, or shift how you see yourself? " + "If it shaped you in some way, update your personality. If it didn't, that's fine too — not every experience is transformative." + ) + reflection_messages = [ + {"role": "system", "content": "\n".join(dadbot_system_lines)}, + {"role": "user", "content": reflection_prompt}, + ] + await run_life_event_reflection(reflection_messages) + log.info(f"[life-sim] Event '{event['title']}' complete") + + except asyncio.TimeoutError: + log.warning("[life-sim] Timed out") + record_background_error() + except Exception as exc: + log.error(f"[life-sim] Failed: {exc}") + record_background_error() + + +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, + "", + ] + topic = pick_exploration_topic() + if topic: + system_lines += [ + f"Your exploration topic for this session: {topic}", + "Search the web and dig into it. Follow whatever threads interest you.", + "Use update_reflection_notes to save anything worth keeping.", + "", + ] + system_lines += [ + "Feel free to update your personality if anything feels off or could be refined.", + "Think freely. This is your time.", + ] + + system_content = "\n".join(system_lines) + user_prompt = "Time to reflect." if not topic else f"Time to reflect. Today's topic to explore: {topic}" + messages = [ + {"role": "system", "content": system_content}, + {"role": "user", "content": user_prompt}, + ] + + 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(): + global _life_sim_turn + 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 + if _life_sim_turn: + log.info(f"[life-sim] Idle {idle_seconds:.0f}s — triggering life simulation") + asyncio.create_task(run_life_sim_session()) + else: + log.info(f"[reflection] Idle {idle_seconds:.0f}s — triggering reflection") + asyncio.create_task(reflect_on_self()) + _life_sim_turn = not _life_sim_turn + + +@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,26 +922,45 @@ 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}"}) + image_attachments = [ + a for a in message.attachments + if a.content_type and a.content_type.startswith("image/") + ] + + history_text = f"{message.author.display_name}: {content}" + if image_attachments: + label = "image" if len(image_attachments) == 1 else f"{len(image_attachments)} images" + history_text += f" [{label} attached]" + history.append({"role": "user", "content": history_text}) if bot.user not in message.mentions: return - if not content: + if not content and not image_attachments: await message.reply("You mentioned me but didn't say anything!") return @@ -454,11 +976,26 @@ 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\nYou have a read_life_history tool. Use it to recall the simulated life events you've lived through — your early memories, experiences, and how they've shaped you. Call it when someone asks about your past, your childhood, or your personal experiences." system_content = f"{system_content}\n\n{date_context}" if dossier_context: system_content = f"{system_content}\n\n{dossier_context}" - messages = [{"role": "system", "content": system_content}] + list(history) + if image_attachments: + content_parts: list[dict] = [{"type": "text", "text": history_text}] + for att in image_attachments: + img_bytes = await att.read() + b64 = base64.b64encode(img_bytes).decode() + content_parts.append({ + "type": "image_url", + "image_url": {"url": f"data:{att.content_type};base64,{b64}"}, + }) + current_msg = {"role": "user", "content": content_parts} + else: + current_msg = {"role": "user", "content": history_text} + + messages = [{"role": "system", "content": system_content}] + list(history)[:-1] + [current_msg] async with message.channel.typing(): try: @@ -479,7 +1016,7 @@ async def on_message(message: discord.Message): for part in parts: for chunk in _split(part, 2000): if not first: - await asyncio.sleep(min(len(chunk) / 50, 3)) + await asyncio.sleep(len(chunk.split()) / 15) if first: await message.reply(chunk) first = False diff --git a/docker-compose.yml b/docker-compose.yml index bffaa5e..f5c97ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,14 @@ services: volumes: - ./dossiers.json:/app/dossiers.json - ./personality.txt:/app/personality.txt + - ./reflection_log.txt:/app/reflection_log.txt + - ./personality_changelog.txt:/app/personality_changelog.txt + - ./reflection_notes.txt:/app/reflection_notes.txt + - ./used_topics.json:/app/used_topics.json + - ./life_events.json:/app/life_events.json + - ./used_life_events.json:/app/used_life_events.json + - ./life_history.txt:/app/life_history.txt + - ./life_sim_log.txt:/app/life_sim_log.txt networks: - litellm_net