rubra-v3 / memory_engine.py
getalvi's picture
Upload memory_engine.py
f3aa68e verified
Raw
History Blame Contribute Delete
5.98 kB
"""
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)