""" RUBRA — memory_engine.py Persistent, cross-session user memory — durable facts about a person that carry across every NEW conversation, not just the current one. The gap this closes: session history already makes RUBRA remember everything within ONE chat. The moment someone clicks "New Chat", that session_id is gone and so is everything RUBRA knew. This module is keyed on a stable user identity instead (see main.py's `mem_key` resolution), so starting a new chat doesn't mean starting from zero — the same way a person doesn't forget a friend just because they're now talking on a new phone call instead of the last one. Design mirrors how memory works for Claude itself: silent application (never "according to my memory of you..."), durable facts only (not a transcript of every message), bounded size (oldest facts age out), and never forced into a reply that isn't about that topic. """ import re import json import logging from typing import Optional, List from database import user_memory_load, user_memory_save log = logging.getLogger("rubra.memory") MAX_FACTS = 40 # bounded growth — oldest facts drop off past this _NAME_PATTERNS = [ r"\b(?:my name is|i am|i'm|call me)\s+([A-Z][a-zA-Z]{1,20})\b", r"\b(?:amar naam|আমার নাম)\s+([A-Za-z\u0980-\u09FF]{2,20})\b", ] # Common false positives for the "i'm X" pattern — moods/states, not names. _NAME_FALSE_POSITIVES = { "fine", "good", "ok", "okay", "not", "sorry", "happy", "sad", "tired", "done", "here", "back", "working", "trying", "going", "sure", "glad", "stuck", "confused", "busy", "free", "ready", "new", "still", "also", } def _extract_name_heuristic(message: str) -> Optional[str]: """Cheap regex pass — runs on every message, no LLM call needed.""" for pattern in _NAME_PATTERNS: m = re.search(pattern, message, re.IGNORECASE) if m: name = m.group(1).strip() if name.lower() not in _NAME_FALSE_POSITIVES: return name return None async def extract_and_update_memory(user_id: str, user_msg: str, assistant_msg: str, llm_func) -> None: """ Fire-and-forget after a turn completes — call via asyncio.create_task, never awaited inline, so memory extraction can't slow down the actual reply. Never raises: a memory-extraction failure must never surface as a broken chat response. Cheap heuristic name extraction always runs. A short LLM call extracts 0-3 durable facts, but only for exchanges substantial enough to likely contain one (skips "ok thanks", "hi", etc. — not worth a call). """ if not user_id: return try: mem = user_memory_load(user_id) name = mem.get("name") facts = list(mem.get("facts", [])) heuristic_name = _extract_name_heuristic(user_msg) if heuristic_name and not name: name = heuristic_name if len(user_msg.split()) >= 6: prompt = ( "Extract 0 to 3 SHORT durable facts about the user from this single " "exchange that would help in FUTURE, otherwise-unrelated conversations — " "things like their name, profession, ongoing projects, preferences, or " "communication style. Do NOT extract one-off task details (e.g. \"asked to " "fix a typo\" is not durable; \"is building an e-commerce site called Foo\" " "IS durable). If nothing durable came up, return an empty list.\n\n" f"User: {user_msg[:500]}\nAssistant: {assistant_msg[:500]}\n\n" "Respond with ONLY a JSON array of short strings, e.g. " '["works as a graphic designer", "prefers concise answers"]. ' "No explanation, no markdown fences, no extra text." ) raw = "" try: async for tok in llm_func([{"role": "user", "content": prompt}], mode="fast"): raw += tok except Exception as e: log.warning(f"[MEMORY] extraction call failed: {e}") raw = "" raw = raw.strip() if raw.startswith("```"): raw = re.sub(r'^```\w*\n?', '', raw) raw = re.sub(r'\n?```$', '', raw) try: new_facts = json.loads(raw) if raw else [] if isinstance(new_facts, list): for f in new_facts: f = str(f).strip() if f and f not in facts: facts.append(f) except Exception: pass # model didn't return clean JSON — skip silently, not fatal facts = facts[-MAX_FACTS:] if name or facts: user_memory_save(user_id, name, facts) except Exception as e: log.warning(f"[MEMORY] update failed for {user_id}: {e}") def format_memory_for_prompt(user_id: str) -> str: """ Builds the system_note injected into EVERY conversation (new or ongoing). Empty string if nothing is known yet — never inject a hollow "I don't know anything about you" block; absence of memory should be invisible, not announced. """ if not user_id: return "" mem = user_memory_load(user_id) name, facts = mem.get("name"), mem.get("facts", []) if not name and not facts: return "" lines = ["[WHAT YOU KNOW ABOUT THIS PERSON — from earlier conversations]"] if name: lines.append(f"Their name is {name}. Use it naturally when it fits — don't overuse it.") for f in facts[-MAX_FACTS:]: lines.append(f"- {f}") lines.append( "Use this the way a person remembers a friend across conversations — never say " "\"according to my memory\" or list these facts back at them, and don't force an " "irrelevant fact into a reply that isn't about that topic." ) return "\n".join(lines)