| """ |
| 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 |
|
|
| _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", |
| ] |
|
|
| |
| _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 |
|
|
| 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) |
|
|