Compare commits

...

15 Commits

Author SHA1 Message Date
aj efe7f901c2 Merge branch 'worktree-agent-a27a0179' 2026-02-26 13:04:25 -05:00
aj ca17b6ac61 Merge branch 'worktree-agent-a0b1ccc2' 2026-02-26 13:04:24 -05:00
aj 8a092c720f Merge branch 'worktree-agent-a78eaee3' 2026-02-26 13:04:18 -05:00
aj 365907a7a0 feat: extract and save memories after chat conversations
Merge worktree: adds _extract_and_save_memories() method and fire-and-forget
extraction call after each chat reply. Combined with Task 4's memory
retrieval and injection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:04:12 -05:00
aj e488b2b227 feat: extract and save memories after chat conversations
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:02:42 -05:00
aj 7ca369b641 feat: add one-time migration script for user notes to profiles
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:59:03 -05:00
aj 305c9bf113 feat: route sentiment note_updates into memory system
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:58:14 -05:00
aj 2054ca7b24 feat: add background memory pruning task
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:58:12 -05:00
aj d61e85d928 feat: inject persistent memory context into chat responses
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:56:02 -05:00
aj 89fabd85da feat: add set_user_profile method to DramaTracker
Replaces the entire notes field with an LLM-generated profile summary,
used by the memory extraction system for permanent facts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:54:05 -05:00
aj 67011535cd feat: add memory extraction LLM tool and prompt
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:53:18 -05:00
aj 8686f4fdd6 fix: align default limits and parameter names to spec
- get_recent_memories: limit default 10 → 5
- get_memories_by_topics: limit default 10 → 5
- prune_excess_memories: rename 'cap' → 'max_memories'

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:50:47 -05:00
aj 75adafefd6 feat: add UserMemory table and CRUD methods for conversational memory
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:48:54 -05:00
aj 333fbb3932 docs: add conversational memory implementation plan
9-task step-by-step plan covering DB schema, LLM extraction tool, memory
retrieval/injection in chat, sentiment pipeline routing, background pruning,
and migration script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:44:18 -05:00
aj d652c32063 docs: add conversational memory design document
Outlines persistent memory system for making the bot a real conversational
participant that knows people and remembers past interactions. Uses existing
UserNotes column for permanent profiles and a new UserMemory table for
expiring context with LLM-assigned lifetimes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:41:28 -05:00
10 changed files with 1761 additions and 6 deletions
+16
View File
@@ -209,6 +209,22 @@ class BCSBot(commands.Bot):
", ".join(missing),
)
# 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())
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
async def close(self):
await self.db.close()
await self.llm.close()
+145 -4
View File
@@ -3,6 +3,7 @@ import logging
import random
import re
from collections import deque
from datetime import datetime, timedelta, timezone
from pathlib import Path
import discord
@@ -25,6 +26,52 @@ def _load_prompt(filename: str) -> str:
return _prompt_cache[filename]
_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",
}
_GENERIC_CHANNELS = {"general", "off-topic", "memes"}
def _extract_topic_keywords(text: str, channel_name: str) -> list[str]:
"""Extract topic keywords from message text and channel name."""
words = set(text.lower().split()) & _TOPIC_KEYWORDS
if channel_name.lower() not in _GENERIC_CHANNELS:
words.add(channel_name.lower())
return list(words)[:5]
def _format_relative_time(dt: datetime) -> str:
"""Return a human-readable relative time string."""
now = datetime.now(timezone.utc)
# Ensure dt is timezone-aware
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
delta = now - dt
seconds = int(delta.total_seconds())
if seconds < 60:
return "just now"
minutes = seconds // 60
if minutes < 60:
return f"{minutes}m ago"
hours = minutes // 60
if hours < 24:
return f"{hours}h ago"
days = hours // 24
if days == 1:
return "yesterday"
if days < 7:
return f"{days} days ago"
weeks = days // 7
if weeks < 5:
return f"{weeks}w ago"
months = days // 30
return f"{months}mo ago"
class ChatCog(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
@@ -32,6 +79,8 @@ class ChatCog(commands.Cog):
self._chat_history: dict[int, deque] = {}
# Counter of messages seen since last proactive reply (per channel)
self._messages_since_reply: dict[int, int] = {}
# Users whose profile has been updated and needs DB flush
self._dirty_users: set[int] = set()
def _get_active_prompt(self) -> str:
"""Load the chat prompt for the current mode."""
@@ -39,6 +88,88 @@ class ChatCog(commands.Cog):
prompt_file = mode_config.get("prompt_file", "chat_personality.txt")
return _load_prompt(prompt_file)
async def _build_memory_context(self, user_id: int, message_text: str, channel_name: str) -> str:
"""Build a layered memory context block for the chat prompt."""
lines = []
# Layer 1: Profile (always)
profile = self.bot.drama_tracker.get_user_notes(user_id)
if profile:
lines.append(f"Profile: {profile}")
# Layer 2: Recent memories (last 5)
recent_memories = await self.bot.db.get_recent_memories(user_id, limit=5)
if recent_memories:
parts = []
for mem in recent_memories:
time_str = _format_relative_time(mem["created_at"])
parts.append(f"{mem['memory']} ({time_str})")
lines.append("Recent: " + " | ".join(parts))
# Layer 3: Topic-matched memories (deduplicated against recent)
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 = {mem["memory"] for mem in recent_memories} if recent_memories else set()
unique_topic = [mem for mem in topic_memories if mem["memory"] not in recent_texts]
if unique_topic:
parts = []
for mem in unique_topic:
time_str = _format_relative_time(mem["created_at"])
parts.append(f"{mem['memory']} ({time_str})")
lines.append("Relevant: " + " | ".join(parts))
if not lines:
return ""
return "[What you know about this person:]\n" + "\n".join(lines)
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)
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
if message.author.bot:
@@ -176,11 +307,13 @@ class ChatCog(commands.Cog):
context_parts.append(f"{user_data.offense_count} offense(s)")
score_context = f"[Server context: {message.author.display_name}{', '.join(context_parts)}]"
# Gather user notes and recent messages for richer context
# Gather memory context and recent messages for richer context
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"
memory_context = await self._build_memory_context(
message.author.id, content, message.channel.name,
)
if memory_context:
extra_context += memory_context + "\n"
# Include mention scan findings if available
if scan_summary:
@@ -265,6 +398,14 @@ class ChatCog(commands.Cog):
await message.reply(response, mention_author=False)
# 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]),
))
reply_type = "proactive" if is_proactive else "chat"
logger.info(
"%s reply in #%s to %s: %s",
+12 -2
View File
@@ -1,6 +1,6 @@
import asyncio
import logging
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
import discord
@@ -375,10 +375,20 @@ class SentimentCog(commands.Cog):
db_message_id, self._dirty_users,
)
# Note update
# 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",
))
self._dirty_users.add(user_id)
@@ -0,0 +1,216 @@
# Conversational Memory Design
## Goal
Make the bot a real conversational participant that knows people, remembers past interactions, can answer general questions, and gives input based on accumulated context. People should be able to ask it questions and get thoughtful answers informed by who they are and what's happened before.
## Design Decisions
- **Memory approach**: Structured memory tables in existing MSSQL database
- **Learning mode**: Both passive (observing chat via sentiment analysis) and active (direct conversations)
- **Knowledge scope**: General knowledge + server/people awareness (no web search)
- **Permanent memory**: Stored in existing `UserState.UserNotes` column (repurposed as LLM-maintained profile)
- **Expiring memory**: New `UserMemory` table for transient context with LLM-assigned expiration
## Database Changes
### Repurposed: `UserState.UserNotes`
No schema change needed. The column already exists as `NVARCHAR(MAX)`. Currently stores timestamped observation lines (max 10). Will be repurposed as an LLM-maintained **permanent profile summary** — a compact paragraph of durable facts about a user.
Example content:
```
GTA Online grinder (rank 400+, wants to hit 500), sarcastic humor, works night shifts, hates battle royales. Has a dog named Rex. Banters with the bot, usually tries to get roasted. Been in the server since early 2024.
```
The LLM rewrites this field as a whole when new permanent facts emerge, rather than appending timestamped lines.
### New Table: `UserMemory`
Stores expiring memories — transient context that's relevant for days or weeks but not forever.
```sql
CREATE TABLE UserMemory (
Id BIGINT IDENTITY(1,1) PRIMARY KEY,
UserId BIGINT NOT NULL,
Memory NVARCHAR(500) NOT NULL,
Topics NVARCHAR(200) NOT NULL, -- comma-separated tags
Importance NVARCHAR(10) NOT NULL, -- low, medium, high
ExpiresAt DATETIME2 NOT NULL,
Source NVARCHAR(20) NOT NULL, -- 'chat' or 'passive'
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
INDEX IX_UserMemory_UserId (UserId),
INDEX IX_UserMemory_ExpiresAt (ExpiresAt)
)
```
Example rows:
| Memory | Topics | Importance | ExpiresAt | Source |
|--------|--------|------------|-----------|--------|
| Frustrated about losing ranked matches in Warzone | warzone,fps,frustration | medium | +7d | passive |
| Said they're quitting Warzone for good | warzone,fps | high | +30d | chat |
| Drunk tonight, celebrating Friday | personal,celebration | low | +1d | chat |
| Excited about GTA DLC dropping next week | gta,dlc | medium | +7d | passive |
## Memory Extraction
### From Direct Conversations (ChatCog)
After the bot sends a chat reply, a **fire-and-forget background task** calls the triage LLM to extract memories from the conversation. This does not block the reply.
New LLM tool definition:
```python
MEMORY_EXTRACTION_TOOL = {
"type": "function",
"function": {
"name": "extract_memories",
"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. Use 'permanent' for stable facts about the person."
},
"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": "If a permanent fact was learned, provide the full updated profile summary incorporating the new info. Null if no profile changes needed."
}
},
"required": ["memories"]
}
}
}
```
The extraction prompt receives:
- The conversation that just happened (from `_chat_history`)
- The user's current profile (`UserNotes`)
- Instructions to only extract genuinely new information
### From Passive Observation (SentimentCog)
The existing `note_update` field from analysis results currently feeds `DramaTracker.update_user_notes()`. This will be enhanced:
- If `note_update` contains a durable fact (the LLM can flag this), update `UserNotes` profile
- If it's transient observation, insert into `UserMemory` with a 7d default expiration
- The analysis tool's `note_update` field description gets updated to indicate whether the note is permanent or transient
## Memory Retrieval at Chat Time
When building context for a chat reply, memories are pulled in layers and injected as a structured block:
### Layer 1: Profile (always included)
```python
profile = user_state.user_notes # permanent profile summary
```
### Layer 2: Recent Expiring Memories (last 5 by CreatedAt)
```sql
SELECT TOP 5 Memory, Topics, CreatedAt
FROM UserMemory
WHERE UserId = ? AND ExpiresAt > SYSUTCDATETIME()
ORDER BY CreatedAt DESC
```
### Layer 3: Topic-Matched Memories
Extract keywords from the current message, match against `Topics` column:
```sql
SELECT TOP 5 Memory, Topics, CreatedAt
FROM UserMemory
WHERE UserId = ? AND ExpiresAt > SYSUTCDATETIME()
AND (Topics LIKE '%gta%' OR Topics LIKE '%warzone%') -- dynamic from message keywords
ORDER BY Importance DESC, CreatedAt DESC
```
### Layer 4: Channel Bias
If in a game channel (e.g., `#gta-online`), add the game name as a topic filter to boost relevant memories.
### Injected Context Format
```
[What you know about {username}:]
Profile: GTA grinder (rank 400+), sarcastic, works night shifts, hates BRs. Banters with the bot.
Recent: Said they're quitting Warzone (2 days ago) | Excited about GTA DLC (yesterday)
Relevant: Mentioned trying to hit rank 500 in GTA (3 weeks ago)
```
Target: ~200-400 tokens of memory context per chat interaction.
## Memory Maintenance
### Pruning (daily background task)
```sql
DELETE FROM UserMemory WHERE ExpiresAt < SYSUTCDATETIME()
```
Also enforce a per-user cap (50 memories). When exceeded, delete oldest low-importance memories first:
```sql
-- Delete excess memories beyond cap, keeping high importance longest
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,
CreatedAt DESC
OFFSET 50 ROWS
)
```
### Profile Consolidation
When a `permanent` memory is extracted, the LLM provides an updated `profile_update` string that incorporates the new fact into the existing profile. This replaces `UserNotes` directly — no separate consolidation task needed.
## Integration Changes
| File | Changes |
|------|---------|
| `utils/database.py` | Add `UserMemory` table creation in schema. Add CRUD: `save_memory()`, `get_recent_memories()`, `get_memories_by_topics()`, `prune_expired_memories()`, `prune_excess_memories()`. Update `save_user_state()` (no schema change needed). |
| `utils/llm_client.py` | Add `extract_memories()` method with `MEMORY_EXTRACTION_TOOL`. Add `MEMORY_EXTRACTION_PROMPT` for the extraction system prompt. |
| `utils/drama_tracker.py` | `update_user_notes()` changes from appending timestamped lines to replacing the full profile string when a profile update is provided. Keep backward compat for non-profile note_updates during transition. |
| `cogs/chat.py` | At chat time: query DB for memories, build memory context block, inject into prompt. After reply: fire-and-forget memory extraction task. |
| `cogs/sentiment/` | Route `note_update` from analysis into `UserMemory` table (expiring) or `UserNotes` profile update (permanent). |
| `bot.py` | Start daily memory pruning background task on bot ready. |
## What Stays the Same
- In-memory `_chat_history` deque (10 turns per channel) for immediate conversation coherence
- All existing moderation/analysis logic
- Mode system and personality prompts (memory context is additive)
- `UserState` table schema (no changes)
- Existing DramaTracker hydration flow
## Token Budget
Per chat interaction:
- Profile summary: ~50-100 tokens
- Recent memories (5): ~75-125 tokens
- Topic-matched memories (5): ~75-125 tokens
- **Total memory context: ~200-350 tokens**
Memory extraction call (background, triage model): ~500 input tokens, ~200 output tokens per conversation.
@@ -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) |
+18
View File
@@ -0,0 +1,18 @@
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.
+78
View File
@@ -0,0 +1,78 @@
"""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())
+206
View File
@@ -164,6 +164,22 @@ class Database:
)
""")
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)
)
""")
cursor.close()
def _parse_database_name(self) -> str:
@@ -491,6 +507,196 @@ class Database:
finally:
conn.close()
# ------------------------------------------------------------------
# UserMemory (conversational memory per user)
# ------------------------------------------------------------------
async def save_memory(
self,
user_id: int,
memory: str,
topics: str,
importance: str,
expires_at: datetime,
source: str,
) -> None:
"""Insert a single memory row 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 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()
async def get_recent_memories(self, user_id: int, limit: int = 5) -> list[dict]:
"""Get the N 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()
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 via LIKE."""
if not self._available:
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()
if not topic_keywords:
cursor.close()
return []
# Build OR conditions for each keyword
conditions = " OR ".join(["Topics LIKE ?" for _ in topic_keywords])
params = [limit, user_id] + [f"%{kw}%" for kw in topic_keywords]
cursor.execute(
f"""SELECT TOP (?) Memory, Topics, Importance, CreatedAt
FROM UserMemory
WHERE UserId = ? AND ExpiresAt > SYSUTCDATETIME()
AND ({conditions})
ORDER BY
CASE Importance
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
ELSE 4
END,
CreatedAt DESC""",
*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()
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 excess memories for a user beyond the cap, keeping high importance and newest first.
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 (
SELECT Id, ROW_NUMBER() OVER (
ORDER BY
CASE Importance
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
ELSE 4
END,
CreatedAt DESC
) AS rn
FROM UserMemory
WHERE UserId = ?
) ranked
WHERE rn > ?
)""",
user_id, max_memories,
)
count = cursor.rowcount
cursor.close()
return count
finally:
conn.close()
async def close(self):
"""No persistent connection to close (connections are per-operation)."""
pass
+5
View File
@@ -209,6 +209,11 @@ class DramaTracker:
if len(lines) > 10:
user.notes = "\n".join(lines[-10:])
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]
def clear_user_notes(self, user_id: int) -> None:
self.get_user(user_id).notes = ""
+165
View File
@@ -203,6 +203,55 @@ CONVERSATION_TOOL = {
},
}
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")
_NO_TEMPERATURE_MODELS = {"gpt-5-nano", "o1", "o1-mini", "o1-preview", "o3", "o3-mini", "o4-mini"}
@@ -626,6 +675,122 @@ class LLMClient:
self._log_llm("chat", elapsed, False, req_json, error=str(e))
return None
async def extract_memories(
self,
conversation: list[dict[str, str]],
username: str,
current_profile: str = "",
) -> dict | None:
"""Extract memories from a conversation for a specific user.
Returns dict with "memories" list and optional "profile_update", or None on failure.
"""
# Format conversation as readable lines
convo_lines = []
for msg in conversation:
role = msg.get("role", "")
content = msg.get("content", "")
if role == "assistant":
convo_lines.append(f"Bot: {content}")
else:
convo_lines.append(f"{username}: {content}")
convo_text = "\n".join(convo_lines)
user_content = ""
if current_profile:
user_content += f"=== CURRENT PROFILE FOR {username} ===\n{current_profile}\n\n"
else:
user_content += f"=== CURRENT PROFILE FOR {username} ===\n(no profile yet)\n\n"
user_content += f"=== CONVERSATION ===\n{convo_text}\n\n"
user_content += f"Extract any noteworthy memories from this conversation with {username}."
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("LLM 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."""
valid_expirations = {"1d", "3d", "7d", "30d", "permanent"}
valid_importances = {"low", "medium", "high"}
memories = []
for mem in result.get("memories", []):
if not isinstance(mem, dict):
continue
memory_text = str(mem.get("memory", ""))[:500]
if not memory_text:
continue
topics = mem.get("topics", [])
if not isinstance(topics, list):
topics = []
topics = [str(t).lower() for t in topics]
expiration = str(mem.get("expiration", "7d"))
if expiration not in valid_expirations:
expiration = "7d"
importance = str(mem.get("importance", "medium"))
if importance not in valid_importances:
importance = "medium"
memories.append({
"memory": memory_text,
"topics": topics,
"expiration": expiration,
"importance": importance,
})
profile_update = result.get("profile_update")
if profile_update is not None:
profile_update = str(profile_update)[:500]
return {
"memories": memories,
"profile_update": profile_update,
}
async def analyze_image(
self,
image_bytes: bytes,