diff --git a/docs/plans/2026-02-26-conversational-memory-plan.md b/docs/plans/2026-02-26-conversational-memory-plan.md new file mode 100644 index 0000000..960d642 --- /dev/null +++ b/docs/plans/2026-02-26-conversational-memory-plan.md @@ -0,0 +1,900 @@ +# Conversational Memory Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add persistent conversational memory so the bot knows people, remembers past interactions, and gives context-aware answers. + +**Architecture:** Two-layer memory system — permanent profile in existing `UserState.UserNotes` column, expiring memories in new `UserMemory` table. LLM extracts memories after conversations (active) and from sentiment analysis (passive). At chat time, relevant memories are retrieved via recency + topic matching and injected into the prompt. + +**Tech Stack:** Python 3, discord.py, pyodbc/MSSQL, OpenAI-compatible API (tool calling) + +**Note:** This project has no test framework configured. Skip TDD steps — implement directly and test via running the bot. + +--- + +### Task 1: Database — UserMemory table and CRUD methods + +**Files:** +- Modify: `utils/database.py` + +**Step 1: Add UserMemory table to schema** + +In `_create_schema()`, after the existing `LlmLog` table creation block (around line 165), add: + +```python +cursor.execute(""" + IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'UserMemory') + CREATE TABLE UserMemory ( + Id BIGINT IDENTITY(1,1) PRIMARY KEY, + UserId BIGINT NOT NULL, + Memory NVARCHAR(500) NOT NULL, + Topics NVARCHAR(200) NOT NULL, + Importance NVARCHAR(10) NOT NULL, + ExpiresAt DATETIME2 NOT NULL, + Source NVARCHAR(20) NOT NULL, + CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + INDEX IX_UserMemory_UserId (UserId), + INDEX IX_UserMemory_ExpiresAt (ExpiresAt) + ) +""") +``` + +**Step 2: Add `save_memory()` method** + +Add after the `save_llm_log` methods (~line 441): + +```python +# ------------------------------------------------------------------ +# User Memory (conversational memory system) +# ------------------------------------------------------------------ +async def save_memory( + self, + user_id: int, + memory: str, + topics: str, + importance: str, + expires_at: datetime, + source: str, +) -> None: + """Save an expiring memory for a user.""" + if not self._available: + return + try: + await asyncio.to_thread( + self._save_memory_sync, + user_id, memory, topics, importance, expires_at, source, + ) + except Exception: + logger.exception("Failed to save user memory") + +def _save_memory_sync(self, user_id, memory, topics, importance, expires_at, source): + conn = self._connect() + try: + cursor = conn.cursor() + cursor.execute( + """INSERT INTO UserMemory (UserId, Memory, Topics, Importance, ExpiresAt, Source) + VALUES (?, ?, ?, ?, ?, ?)""", + user_id, memory[:500], topics[:200], importance[:10], expires_at, source[:20], + ) + cursor.close() + finally: + conn.close() +``` + +**Step 3: Add `get_recent_memories()` method** + +```python +async def get_recent_memories(self, user_id: int, limit: int = 5) -> list[dict]: + """Get the most recent non-expired memories for a user.""" + if not self._available: + return [] + try: + return await asyncio.to_thread(self._get_recent_memories_sync, user_id, limit) + except Exception: + logger.exception("Failed to get recent memories") + return [] + +def _get_recent_memories_sync(self, user_id, limit) -> list[dict]: + conn = self._connect() + try: + cursor = conn.cursor() + cursor.execute( + """SELECT TOP (?) Memory, Topics, Importance, CreatedAt + FROM UserMemory + WHERE UserId = ? AND ExpiresAt > SYSUTCDATETIME() + ORDER BY CreatedAt DESC""", + limit, user_id, + ) + rows = cursor.fetchall() + cursor.close() + return [ + {"memory": row[0], "topics": row[1], "importance": row[2], "created_at": row[3]} + for row in rows + ] + finally: + conn.close() +``` + +**Step 4: Add `get_memories_by_topics()` method** + +```python +async def get_memories_by_topics( + self, user_id: int, topic_keywords: list[str], limit: int = 5, +) -> list[dict]: + """Get non-expired memories matching any of the given topic keywords.""" + if not self._available or not topic_keywords: + return [] + try: + return await asyncio.to_thread( + self._get_memories_by_topics_sync, user_id, topic_keywords, limit, + ) + except Exception: + logger.exception("Failed to get memories by topics") + return [] + +def _get_memories_by_topics_sync(self, user_id, topic_keywords, limit) -> list[dict]: + conn = self._connect() + try: + cursor = conn.cursor() + # Build OR conditions for each keyword + conditions = " OR ".join(["Topics LIKE ?" for _ in topic_keywords]) + params = [f"%{kw.lower()}%" for kw in topic_keywords] + query = f"""SELECT TOP (?) Memory, Topics, Importance, CreatedAt + FROM UserMemory + WHERE UserId = ? AND ExpiresAt > SYSUTCDATETIME() + AND ({conditions}) + ORDER BY + CASE Importance WHEN 'high' THEN 3 WHEN 'medium' THEN 2 ELSE 1 END DESC, + CreatedAt DESC""" + cursor.execute(query, limit, user_id, *params) + rows = cursor.fetchall() + cursor.close() + return [ + {"memory": row[0], "topics": row[1], "importance": row[2], "created_at": row[3]} + for row in rows + ] + finally: + conn.close() +``` + +**Step 5: Add pruning methods** + +```python +async def prune_expired_memories(self) -> int: + """Delete all expired memories. Returns count deleted.""" + if not self._available: + return 0 + try: + return await asyncio.to_thread(self._prune_expired_memories_sync) + except Exception: + logger.exception("Failed to prune expired memories") + return 0 + +def _prune_expired_memories_sync(self) -> int: + conn = self._connect() + try: + cursor = conn.cursor() + cursor.execute("DELETE FROM UserMemory WHERE ExpiresAt < SYSUTCDATETIME()") + count = cursor.rowcount + cursor.close() + return count + finally: + conn.close() + +async def prune_excess_memories(self, user_id: int, max_memories: int = 50) -> int: + """Delete lowest-priority memories if a user exceeds the cap. Returns count deleted.""" + if not self._available: + return 0 + try: + return await asyncio.to_thread( + self._prune_excess_memories_sync, user_id, max_memories, + ) + except Exception: + logger.exception("Failed to prune excess memories") + return 0 + +def _prune_excess_memories_sync(self, user_id, max_memories) -> int: + conn = self._connect() + try: + cursor = conn.cursor() + cursor.execute( + """DELETE FROM UserMemory + WHERE Id IN ( + SELECT Id FROM UserMemory + WHERE UserId = ? + ORDER BY + CASE Importance WHEN 'high' THEN 3 WHEN 'medium' THEN 2 ELSE 1 END DESC, + CreatedAt DESC + OFFSET ? ROWS + )""", + user_id, max_memories, + ) + count = cursor.rowcount + cursor.close() + return count + finally: + conn.close() +``` + +**Step 6: Commit** + +```bash +git add utils/database.py +git commit -m "feat: add UserMemory table and CRUD methods for conversational memory" +``` + +--- + +### Task 2: LLM Client — Memory extraction tool and method + +**Files:** +- Modify: `utils/llm_client.py` +- Create: `prompts/memory_extraction.txt` + +**Step 1: Create memory extraction prompt** + +Create `prompts/memory_extraction.txt`: + +``` +You are a memory extraction system for a Discord bot. Given a conversation between a user and the bot, extract any noteworthy information worth remembering for future interactions. + +RULES: +- Only extract genuinely NEW information not already in the user's profile. +- Be concise — each memory should be one sentence max. +- Assign appropriate expiration based on how long the information stays relevant: + - "permanent": Stable facts — name, job, hobbies, games they play, personality traits, pets, relationships + - "30d": Semi-stable preferences, ongoing situations — "trying to quit Warzone", "grinding for rank 500" + - "7d": Temporary situations — "excited about upcoming DLC", "on vacation this week" + - "3d": Short-term context — "had a bad day", "playing with friends tonight" + - "1d": Momentary state — "drunk right now", "tilted from losses", "in a good mood" +- Assign topic tags that would help retrieve this memory later (game names, "personal", "work", "mood", etc.) +- Assign importance: "high" for things they'd expect you to remember, "medium" for useful context, "low" for minor color +- If you learn a permanent fact about the user, provide a profile_update that incorporates the new fact into their existing profile. Rewrite the ENTIRE profile summary — don't just append. Keep it under 500 characters. +- If nothing worth remembering was said, return an empty memories array and null profile_update. +- Do NOT store things the bot said — only facts about or from the user. + +Use the extract_memories tool to report your findings. +``` + +**Step 2: Add MEMORY_EXTRACTION_TOOL definition to `llm_client.py`** + +Add after the `CONVERSATION_TOOL` definition (around line 204): + +```python +MEMORY_EXTRACTION_TOOL = { + "type": "function", + "function": { + "name": "extract_memories", + "description": "Extract noteworthy memories from a conversation for future reference.", + "parameters": { + "type": "object", + "properties": { + "memories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "memory": { + "type": "string", + "description": "A concise fact or observation worth remembering.", + }, + "topics": { + "type": "array", + "items": {"type": "string"}, + "description": "Topic tags for retrieval (e.g., 'gta', 'personal', 'warzone').", + }, + "expiration": { + "type": "string", + "enum": ["1d", "3d", "7d", "30d", "permanent"], + "description": "How long this memory stays relevant.", + }, + "importance": { + "type": "string", + "enum": ["low", "medium", "high"], + "description": "How important this memory is for future interactions.", + }, + }, + "required": ["memory", "topics", "expiration", "importance"], + }, + "description": "Memories to store. Only include genuinely new or noteworthy information.", + }, + "profile_update": { + "type": ["string", "null"], + "description": "Full updated profile summary incorporating new permanent facts, or null if no profile changes.", + }, + }, + "required": ["memories"], + }, + }, +} + +MEMORY_EXTRACTION_PROMPT = (_PROMPTS_DIR / "memory_extraction.txt").read_text(encoding="utf-8") +``` + +**Step 3: Add `extract_memories()` method to `LLMClient`** + +Add after the `chat()` method (around line 627): + +```python +async def extract_memories( + self, + conversation: list[dict[str, str]], + username: str, + current_profile: str = "", +) -> dict | None: + """Extract memories from a conversation. Returns dict with 'memories' list and optional 'profile_update'.""" + convo_text = "\n".join( + f"{'Bot' if m['role'] == 'assistant' else username}: {m['content']}" + for m in conversation + if m.get("content") + ) + + user_content = f"=== USER PROFILE ===\n{current_profile or '(no profile yet)'}\n\n" + user_content += f"=== CONVERSATION ===\n{convo_text}\n\n" + user_content += "Extract any noteworthy memories from this conversation." + user_content = self._append_no_think(user_content) + + req_json = json.dumps([ + {"role": "system", "content": MEMORY_EXTRACTION_PROMPT[:500]}, + {"role": "user", "content": user_content[:500]}, + ], default=str) + 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": MEMORY_EXTRACTION_PROMPT}, + {"role": "user", "content": user_content}, + ], + tools=[MEMORY_EXTRACTION_TOOL], + tool_choice={"type": "function", "function": {"name": "extract_memories"}}, + **temp_kwargs, + max_completion_tokens=1024, + ) + + elapsed = int((time.monotonic() - t0) * 1000) + choice = response.choices[0] + usage = response.usage + + if choice.message.tool_calls: + tool_call = choice.message.tool_calls[0] + resp_text = tool_call.function.arguments + args = json.loads(resp_text) + self._log_llm("memory_extraction", elapsed, True, req_json, resp_text, + input_tokens=usage.prompt_tokens if usage else None, + output_tokens=usage.completion_tokens if usage else None) + return self._validate_memory_result(args) + + logger.warning("No tool call in memory extraction response.") + self._log_llm("memory_extraction", elapsed, False, req_json, error="No tool call") + return None + + except Exception as e: + elapsed = int((time.monotonic() - t0) * 1000) + logger.error("Memory extraction error: %s", e) + self._log_llm("memory_extraction", elapsed, False, req_json, error=str(e)) + return None + +@staticmethod +def _validate_memory_result(result: dict) -> dict: + """Validate and normalize memory extraction result.""" + if not isinstance(result, dict): + return {"memories": [], "profile_update": None} + + memories = [] + for m in result.get("memories", []): + if not isinstance(m, dict) or not m.get("memory"): + continue + memories.append({ + "memory": str(m["memory"])[:500], + "topics": [str(t).lower() for t in m.get("topics", []) if t], + "expiration": m.get("expiration", "7d") if m.get("expiration") in ("1d", "3d", "7d", "30d", "permanent") else "7d", + "importance": m.get("importance", "medium") if m.get("importance") in ("low", "medium", "high") else "medium", + }) + + profile_update = result.get("profile_update") + if profile_update and isinstance(profile_update, str): + profile_update = profile_update[:500] + else: + profile_update = None + + return {"memories": memories, "profile_update": profile_update} +``` + +**Step 4: Commit** + +```bash +git add utils/llm_client.py prompts/memory_extraction.txt +git commit -m "feat: add memory extraction LLM tool and prompt" +``` + +--- + +### Task 3: DramaTracker — Update user notes handling + +**Files:** +- Modify: `utils/drama_tracker.py` + +**Step 1: Add `set_user_profile()` method** + +Add after `update_user_notes()` (around line 210): + +```python +def set_user_profile(self, user_id: int, profile: str) -> None: + """Replace the user's profile summary (permanent memory).""" + user = self.get_user(user_id) + user.notes = profile[:500] +``` + +This replaces the entire notes field with the LLM-generated profile summary. The existing `update_user_notes()` method continues to work for backward compatibility with the sentiment pipeline during the transition — passive `note_update` values will still append until Task 5 routes them through the new memory system. + +**Step 2: Commit** + +```bash +git add utils/drama_tracker.py +git commit -m "feat: add set_user_profile method to DramaTracker" +``` + +--- + +### Task 4: ChatCog — Memory retrieval and injection + +**Files:** +- Modify: `cogs/chat.py` + +**Step 1: Add memory retrieval helper** + +Add a helper method to `ChatCog` and a module-level utility for formatting relative timestamps: + +```python +# At module level, after the imports +from datetime import datetime, timezone + +_TOPIC_KEYWORDS = { + "gta", "warzone", "cod", "battlefield", "fortnite", "apex", "valorant", + "minecraft", "roblox", "league", "dota", "overwatch", "destiny", "halo", + "work", "job", "school", "college", "girlfriend", "boyfriend", "wife", + "husband", "dog", "cat", "pet", "car", "music", "movie", "food", +} + +def _extract_topic_keywords(text: str, channel_name: str = "") -> list[str]: + """Extract potential topic keywords from message text and channel name.""" + words = set(text.lower().split()) + keywords = list(words & _TOPIC_KEYWORDS) + # Add channel name as topic if it's a game channel + if channel_name and channel_name not in ("general", "off-topic", "memes"): + keywords.append(channel_name.lower()) + return keywords[:5] # cap at 5 keywords + +def _format_relative_time(dt: datetime) -> str: + """Format a datetime as a relative time string.""" + now = datetime.now(timezone.utc) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + delta = now - dt + days = delta.days + if days == 0: + hours = delta.seconds // 3600 + if hours == 0: + return "just now" + return f"{hours}h ago" + if days == 1: + return "yesterday" + if days < 7: + return f"{days} days ago" + if days < 30: + weeks = days // 7 + return f"{weeks}w ago" + months = days // 30 + return f"{months}mo ago" +``` + +Add method to `ChatCog`: + +```python +async def _build_memory_context(self, user_id: int, message_text: str, channel_name: str) -> str: + """Build the memory context block to inject into the chat prompt.""" + parts = [] + + # Layer 1: Profile (from DramaTracker / UserNotes) + profile = self.bot.drama_tracker.get_user_notes(user_id) + if profile: + parts.append(f"Profile: {profile}") + + # Layer 2: Recent expiring memories + recent = await self.bot.db.get_recent_memories(user_id, limit=5) + if recent: + recent_strs = [ + f"{m['memory']} ({_format_relative_time(m['created_at'])})" + for m in recent + ] + parts.append("Recent: " + " | ".join(recent_strs)) + + # Layer 3: Topic-matched memories + keywords = _extract_topic_keywords(message_text, channel_name) + if keywords: + topic_memories = await self.bot.db.get_memories_by_topics(user_id, keywords, limit=5) + # Deduplicate against recent memories + recent_texts = {m["memory"] for m in recent} if recent else set() + topic_memories = [m for m in topic_memories if m["memory"] not in recent_texts] + if topic_memories: + topic_strs = [ + f"{m['memory']} ({_format_relative_time(m['created_at'])})" + for m in topic_memories + ] + parts.append("Relevant: " + " | ".join(topic_strs)) + + if not parts: + return "" + + return "[What you know about this person:]\n" + "\n".join(parts) +``` + +**Step 2: Inject memory context into chat path** + +In `on_message()`, in the text-only chat path, after building `extra_context` with user notes and recent messages (around line 200), replace the existing user notes injection: + +Find this block (around lines 179-183): +```python +extra_context = "" +user_notes = self.bot.drama_tracker.get_user_notes(message.author.id) +if user_notes: + extra_context += f"[Notes about {message.author.display_name}: {user_notes}]\n" +``` + +Replace with: +```python +extra_context = "" +memory_context = await self._build_memory_context( + message.author.id, content, message.channel.name, +) +if memory_context: + extra_context += memory_context + "\n" +``` + +This replaces the old flat notes injection with the layered memory context block. + +**Step 3: Commit** + +```bash +git add cogs/chat.py +git commit -m "feat: inject persistent memory context into chat responses" +``` + +--- + +### Task 5: ChatCog — Memory extraction after conversations + +**Files:** +- Modify: `cogs/chat.py` + +**Step 1: Add memory saving helper** + +Add to `ChatCog`: + +```python +async def _extract_and_save_memories( + self, user_id: int, username: str, conversation: list[dict[str, str]], +) -> None: + """Background task: extract memories from conversation and save them.""" + try: + current_profile = self.bot.drama_tracker.get_user_notes(user_id) + result = await self.bot.llm.extract_memories( + conversation, username, current_profile, + ) + if not result: + return + + # Save expiring memories + for mem in result.get("memories", []): + if mem["expiration"] == "permanent": + continue # permanent facts go into profile_update + exp_days = {"1d": 1, "3d": 3, "7d": 7, "30d": 30} + days = exp_days.get(mem["expiration"], 7) + expires_at = datetime.now(timezone.utc) + timedelta(days=days) + await self.bot.db.save_memory( + user_id=user_id, + memory=mem["memory"], + topics=",".join(mem["topics"]), + importance=mem["importance"], + expires_at=expires_at, + source="chat", + ) + # Prune if over cap + await self.bot.db.prune_excess_memories(user_id) + + # Update profile if warranted + profile_update = result.get("profile_update") + if profile_update: + self.bot.drama_tracker.set_user_profile(user_id, profile_update) + self._dirty_users.add(user_id) + + logger.info( + "Extracted %d memories for %s (profile_update=%s)", + len(result.get("memories", [])), + username, + bool(profile_update), + ) + except Exception: + logger.exception("Failed to extract memories for %s", username) +``` + +**Step 2: Add `_dirty_users` set and flush task** + +Add to `__init__`: + +```python +self._dirty_users: set[int] = set() +``` + +Memory extraction marks users as dirty when their profile changes. The existing flush mechanism in `SentimentCog` handles DB writes — but since `ChatCog` now also modifies user state, add a simple flush in the memory extraction itself. The `set_user_profile` call dirties the in-memory DramaTracker, and SentimentCog's periodic flush (every 5 minutes) will persist it. + +**Step 3: Add `timedelta` import and fire memory extraction after reply** + +Add `from datetime import datetime, timedelta, timezone` to the imports at the top of the file. + +In `on_message()`, after the bot sends its reply (after `await message.reply(...)`, around line 266), add: + +```python +# Fire-and-forget memory extraction +if not image_attachment: + asyncio.create_task(self._extract_and_save_memories( + message.author.id, + message.author.display_name, + list(self._chat_history[ch_id]), + )) +``` + +**Step 4: Commit** + +```bash +git add cogs/chat.py +git commit -m "feat: extract and save memories after chat conversations" +``` + +--- + +### Task 6: Sentiment pipeline — Route note_update into memory system + +**Files:** +- Modify: `cogs/sentiment/__init__.py` + +**Step 1: Update note_update handling in `_process_finding()`** + +Find the note_update block (around lines 378-381): +```python +# Note update +if note_update: + self.bot.drama_tracker.update_user_notes(user_id, note_update) + self._dirty_users.add(user_id) +``` + +Replace with: +```python +# Note update — route to memory system +if note_update: + # Still update the legacy notes for backward compat with analysis prompt + self.bot.drama_tracker.update_user_notes(user_id, note_update) + self._dirty_users.add(user_id) + # Also save as an expiring memory (7d default for passive observations) + asyncio.create_task(self.bot.db.save_memory( + user_id=user_id, + memory=note_update[:500], + topics=db_topic_category or "general", + importance="medium", + expires_at=datetime.now(timezone.utc) + timedelta(days=7), + source="passive", + )) +``` + +**Step 2: Add necessary imports at top of file** + +Ensure `timedelta` is imported. Check existing imports — `datetime` and `timezone` are likely already imported. Add `timedelta` if missing: + +```python +from datetime import datetime, timedelta, timezone +``` + +**Step 3: Commit** + +```bash +git add cogs/sentiment/__init__.py +git commit -m "feat: route sentiment note_updates into memory system" +``` + +--- + +### Task 7: Bot — Memory pruning background task + +**Files:** +- Modify: `bot.py` + +**Step 1: Add pruning task to `on_ready()`** + +In `BCSBot.on_ready()` (around line 165), after the permissions check loop, add: + +```python +# Start memory pruning background task +if not hasattr(self, "_memory_prune_task") or self._memory_prune_task.done(): + self._memory_prune_task = asyncio.create_task(self._prune_memories_loop()) +``` + +**Step 2: Add the pruning loop method to `BCSBot`** + +Add to the `BCSBot` class, after `on_ready()`: + +```python +async def _prune_memories_loop(self): + """Background task that prunes expired memories every 6 hours.""" + await self.wait_until_ready() + while not self.is_closed(): + try: + count = await self.db.prune_expired_memories() + if count > 0: + logger.info("Pruned %d expired memories.", count) + except Exception: + logger.exception("Memory pruning error") + await asyncio.sleep(6 * 3600) # Every 6 hours +``` + +**Step 3: Commit** + +```bash +git add bot.py +git commit -m "feat: add background memory pruning task" +``` + +--- + +### Task 8: Migrate existing user notes to profile format + +**Files:** +- Create: `scripts/migrate_notes_to_profiles.py` + +This is a one-time migration script to convert existing timestamped note lines into profile summaries using the LLM. + +**Step 1: Create migration script** + +```python +"""One-time migration: convert existing timestamped UserNotes into profile summaries. + +Run with: python scripts/migrate_notes_to_profiles.py + +Requires .env with DB_CONNECTION_STRING and LLM env vars. +""" +import asyncio +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from dotenv import load_dotenv +load_dotenv() + +from utils.database import Database +from utils.llm_client import LLMClient + + +async def main(): + db = Database() + if not await db.init(): + print("Database not available.") + return + + llm = LLMClient( + base_url=os.getenv("LLM_BASE_URL", ""), + model=os.getenv("LLM_MODEL", "gpt-4o-mini"), + api_key=os.getenv("LLM_API_KEY", "not-needed"), + ) + + states = await db.load_all_user_states() + migrated = 0 + + for state in states: + notes = state.get("user_notes", "") + if not notes or not notes.strip(): + continue + + # Check if already looks like a profile (no timestamps) + if not any(line.strip().startswith("[") for line in notes.split("\n")): + print(f" User {state['user_id']}: already looks like a profile, skipping.") + continue + + print(f" User {state['user_id']}: migrating notes...") + print(f" Old: {notes[:200]}") + + # Ask LLM to summarize notes into a profile + result = await llm.extract_memories( + conversation=[{"role": "user", "content": f"Here are observation notes about a user:\n{notes}"}], + username="unknown", + current_profile="", + ) + + if result and result.get("profile_update"): + profile = result["profile_update"] + print(f" New: {profile[:200]}") + await db.save_user_state( + user_id=state["user_id"], + offense_count=state["offense_count"], + immune=state["immune"], + off_topic_count=state["off_topic_count"], + baseline_coherence=state.get("baseline_coherence", 0.85), + user_notes=profile, + warned=state.get("warned", False), + last_offense_at=state.get("last_offense_at"), + ) + migrated += 1 + else: + print(f" No profile generated, keeping existing notes.") + + await llm.close() + await db.close() + print(f"\nMigrated {migrated}/{len(states)} user profiles.") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +**Step 2: Commit** + +```bash +git add scripts/migrate_notes_to_profiles.py +git commit -m "feat: add one-time migration script for user notes to profiles" +``` + +--- + +### Task 9: Integration test — End-to-end verification + +**Step 1: Start the bot locally and verify** + +```bash +docker compose up --build +``` + +**Step 2: Verify schema migration** + +Check Docker logs for successful DB initialization — the new `UserMemory` table should be created automatically. + +**Step 3: Test memory extraction** + +1. @mention the bot in a Discord channel with a message like "Hey, I've been grinding GTA all week trying to hit rank 500" +2. Check logs for `Extracted N memories for {username}` — confirms memory extraction ran +3. Check DB: `SELECT * FROM UserMemory` should have rows + +**Step 4: Test memory retrieval** + +1. @mention the bot again with "what do you know about me?" +2. The response should reference the GTA grinding from the previous interaction +3. Check logs for the memory context block being built + +**Step 5: Test memory expiration** + +Manually insert a test memory with an expired timestamp and verify the pruning task removes it (or wait for the 6-hour cycle, or temporarily shorten the interval for testing). + +**Step 6: Commit any fixes** + +```bash +git add -A +git commit -m "fix: integration test fixes for conversational memory" +``` + +--- + +### Summary + +| Task | What | Files | +|------|------|-------| +| 1 | DB schema + CRUD | `utils/database.py` | +| 2 | LLM extraction tool | `utils/llm_client.py`, `prompts/memory_extraction.txt` | +| 3 | DramaTracker profile setter | `utils/drama_tracker.py` | +| 4 | Memory retrieval + injection in chat | `cogs/chat.py` | +| 5 | Memory extraction after chat | `cogs/chat.py` | +| 6 | Sentiment pipeline routing | `cogs/sentiment/__init__.py` | +| 7 | Background pruning task | `bot.py` | +| 8 | Migration script | `scripts/migrate_notes_to_profiles.py` | +| 9 | Integration test | (manual) |