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>
This commit is contained in:
900
docs/plans/2026-02-26-conversational-memory-plan.md
Normal file
900
docs/plans/2026-02-26-conversational-memory-plan.md
Normal file
@@ -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) |
|
||||||
Reference in New Issue
Block a user