From 67011535cd2a4470074f4759b23eae06b97b59e0 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 26 Feb 2026 12:53:18 -0500 Subject: [PATCH] feat: add memory extraction LLM tool and prompt Co-Authored-By: Claude Opus 4.6 --- prompts/memory_extraction.txt | 18 ++++ utils/llm_client.py | 165 ++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 prompts/memory_extraction.txt diff --git a/prompts/memory_extraction.txt b/prompts/memory_extraction.txt new file mode 100644 index 0000000..31755fd --- /dev/null +++ b/prompts/memory_extraction.txt @@ -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. \ No newline at end of file diff --git a/utils/llm_client.py b/utils/llm_client.py index 2bac7a9..51cced6 100644 --- a/utils/llm_client.py +++ b/utils/llm_client.py @@ -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,