self-trained2 / agent /agent_memory.py
DeepImagix's picture
Upload 6 files
7309ad4 verified
Raw History Blame Contribute Delete
15.7 kB
"""
NeuraPrompt Agent β€” Memory & Skills Module (v1.0)
==================================================
Separate database (neuraprompt_agent) β€” does NOT mix with chat history or Kype.
Collections:
neuraprompt_agent.conversations β€” last 5 messages per user
neuraprompt_agent.user_profiles β€” user facts (name, timezone, preferences)
neuraprompt_agent.skills β€” learned skills, per-user
Design principles:
* Memory is capped at 5 messages (not 10) to keep the system prompt small.
* Skills are NOT loaded into the system prompt automatically. The AI decides
whether to check skills via a tool-like mechanism (the memory_context
string includes a SKILLS AVAILABLE indicator, and the AI can request them).
* Both memory and skills trigger UI indicators:
- "πŸ‘Ύ New skill learned" when a skill is saved
- "πŸ‘Ύ Memory recalled" when memory was used in the response
Env vars:
MONGO_URI β€” MongoDB connection string (reuses main.py's if available)
"""
from __future__ import annotations
import os
import re
import time
import logging
from datetime import datetime, timezone
from typing import Optional, Dict, Any, List
log = logging.getLogger("agent.memory")
# ── MONGODB (reuses main.py's connection, separate database) ──────────────────
_db = None
def _get_db():
"""Get the neuraprompt_agent database. Reuses main.py's MongoClient."""
global _db
if _db is not None:
return _db
try:
import sys
main_mod = sys.modules.get("main")
if main_mod and hasattr(main_mod, "mongo_client"):
_db = main_mod.mongo_client["neuraprompt_agent"]
return _db
except Exception:
pass
# Fallback: own connection
try:
from pymongo import MongoClient
from pymongo.server_api import ServerApi
MONGO_URL = os.environ.get("MONGO_URI", "")
client = MongoClient(
MONGO_URL, ssl=True,
tlsAllowInvalidCertificates=False,
tlsCAFile="/etc/ssl/certs/ca-certificates.crt",
server_api=ServerApi("1"),
)
_db = client["neuraprompt_agent"]
return _db
except Exception as e:
log.error(f"[AgentMemory] DB connection failed: {e}")
return None
# ── MEMORY (last 5 messages) ──────────────────────────────────────────────────
MAX_MESSAGES = 5
def load_memory(user_id: str) -> Dict[str, Any]:
"""Load the last 5 messages + user profile for a user.
Returns {messages: [], profile: {}} β€” empty if no history."""
db = _get_db()
if db is None or not user_id:
return {"messages": [], "profile": {}}
result = {"messages": [], "profile": {}}
try:
# Load conversation (last 5 messages)
conv = db.conversations.find_one({"user_id": user_id})
if conv and conv.get("messages"):
result["messages"] = conv["messages"][-MAX_MESSAGES:]
# Load user profile (facts)
profile = db.user_profiles.find_one({"user_id": user_id})
if profile:
result["profile"] = {
k: v for k, v in profile.items()
if k not in ("_id", "user_id", "created_at", "last_updated")
}
except Exception as e:
log.warning(f"[AgentMemory] load_memory failed: {e}")
return result
def save_memory(user_id: str, user_message: str, agent_response: str):
"""Save a user message + agent response to conversation history.
Keeps only the last 5 messages (trims older ones)."""
db = _get_db()
if db is None or not user_id:
return
try:
now = datetime.now(timezone.utc)
new_msgs = [
{"role": "user", "content": user_message[:500], "timestamp": now.isoformat()},
{"role": "assistant", "content": agent_response[:500], "timestamp": now.isoformat()},
]
# Atomically push new messages and trim to last 5
existing = db.conversations.find_one({"user_id": user_id})
if existing:
all_msgs = existing.get("messages", []) + new_msgs
# Keep last MAX_MESSAGES
trimmed = all_msgs[-MAX_MESSAGES:]
db.conversations.update_one(
{"user_id": user_id},
{"$set": {"messages": trimmed, "last_updated": now.isoformat()}},
)
else:
db.conversations.insert_one({
"user_id": user_id,
"messages": new_msgs,
"created_at": now.isoformat(),
"last_updated": now.isoformat(),
})
except Exception as e:
log.warning(f"[AgentMemory] save_memory failed: {e}")
def update_user_profile(user_id: str, facts: Dict[str, Any]):
"""Update user profile with learned facts (name, timezone, preferences)."""
db = _get_db()
if db is None or not user_id or not facts:
return
try:
now = datetime.now(timezone.utc)
db.user_profiles.update_one(
{"user_id": user_id},
{"$set": {**facts, "last_updated": now.isoformat()}},
upsert=True,
)
except Exception as e:
log.warning(f"[AgentMemory] update_user_profile failed: {e}")
# ── SKILLS (per-user, AI-decided loading) ─────────────────────────────────────
MAX_SKILLS = 20
def get_skills_list(user_id: str) -> List[str]:
"""Get a list of skill NAMES (not content) for the system prompt.
The AI sees the names and can decide whether to request details."""
db = _get_db()
if db is None or not user_id:
return []
try:
skills = list(db.skills.find({"user_id": user_id}, {"skill_name": 1, "_id": 0}))
return [s["skill_name"] for s in skills[:MAX_SKILLS]]
except Exception as e:
log.warning(f"[AgentMemory] get_skills_list failed: {e}")
return []
def get_skill_detail(user_id: str, skill_name: str) -> Optional[str]:
"""Get the full body/description of a specific skill (when AI requests it).
Prefers skill_body, then when_to_use+description, then skill_description."""
db = _get_db()
if db is None or not user_id:
return None
try:
skill = db.skills.find_one({"user_id": user_id, "skill_name": skill_name})
if not skill:
return None
body = (skill.get("skill_body") or "").strip()
if body:
when = (skill.get("when_to_use") or "").strip()
if when:
return f"**When to use:** {when}\n\n{body}"
return body
desc = skill.get("skill_description", "") or ""
when = (skill.get("when_to_use") or "").strip()
if when and desc:
return f"**When to use:** {when}\n\n{desc}"
return desc or when or None
except Exception as e:
log.warning(f"[AgentMemory] get_skill_detail failed: {e}")
return None
def save_skill(
user_id: str,
skill_name: str,
skill_description: str,
when_to_use: str = "",
skill_body: str = "",
) -> bool:
"""Save a new skill. Returns True if it's actually new (not a duplicate).
Supports richer fields: when_to_use + skill_body (falls back to skill_description)."""
db = _get_db()
if db is None or not user_id or not skill_name:
return False
try:
now = datetime.now(timezone.utc).isoformat()
fields = {
"skill_description": skill_description,
"when_to_use": when_to_use or "",
"skill_body": skill_body or skill_description or "",
"updated_at": now,
}
# Check if this skill already exists (by name)
existing = db.skills.find_one({"user_id": user_id, "skill_name": skill_name})
if existing:
changed = any(existing.get(k) != v for k, v in fields.items() if k != "updated_at")
if changed:
db.skills.update_one(
{"user_id": user_id, "skill_name": skill_name},
{"$set": fields},
)
return False # Not new
# Check skill count β€” if at max, remove oldest
count = db.skills.count_documents({"user_id": user_id})
if count >= MAX_SKILLS:
oldest = db.skills.find_one({"user_id": user_id}, sort=[("learned_at", 1)])
if oldest:
db.skills.delete_one({"_id": oldest["_id"]})
# Insert new skill
db.skills.insert_one({
"user_id": user_id,
"skill_name": skill_name,
"skill_description": skill_description,
"when_to_use": when_to_use or "",
"skill_body": skill_body or skill_description or "",
"learned_at": now,
})
log.info(f"[AgentMemory] New skill saved for {user_id}: {skill_name}")
return True # New skill
except Exception as e:
log.warning(f"[AgentMemory] save_skill failed: {e}")
return False
def match_skills_for_query(user_id: str, query: str, limit: int = 2) -> List[Dict[str, Any]]:
"""Match builtins + user skills against a query.
Returns list of dicts: {name, when_to_use, body, source}."""
results: List[Dict[str, Any]] = []
try:
from .skills_catalog import match_builtins
except ImportError:
try:
from skills_catalog import match_builtins # type: ignore
except ImportError:
match_builtins = lambda q, limit=2: [] # type: ignore
for sk in match_builtins(query, limit=limit):
results.append({
"name": sk["name"],
"when_to_use": sk.get("when_to_use", ""),
"body": sk.get("body", ""),
"source": "builtin",
})
if user_id and query:
db = _get_db()
if db is not None:
try:
q_lower = query.lower()
q_tokens = set(re.findall(r"[a-z0-9_]{3,}", q_lower))
scored = []
for doc in db.skills.find({"user_id": user_id}):
name = (doc.get("skill_name") or "").lower()
when = (doc.get("when_to_use") or doc.get("skill_description") or "").lower()
score = 0
if name and (name in q_lower or name.replace("_", " ") in q_lower):
score += 6
when_tokens = set(re.findall(r"[a-z0-9_]{3,}", when))
score += min(8, len(when_tokens & q_tokens) * 2)
if score >= 4:
body = doc.get("skill_body") or doc.get("skill_description") or ""
scored.append((score, {
"name": doc.get("skill_name", ""),
"when_to_use": doc.get("when_to_use") or "",
"body": body,
"source": "user",
}))
scored.sort(key=lambda x: -x[0])
for _, item in scored[:limit]:
# Avoid duplicate names already filled by builtins
if not any(r["name"] == item["name"] for r in results):
results.append(item)
except Exception as e:
log.warning(f"[AgentMemory] match_skills_for_query failed: {e}")
return results[: max(limit, 2)]
# ── SKILL EXTRACTION (lightweight, post-response) ─────────────────────────────
# Patterns that indicate a learnable preference
SKILL_PATTERNS = [
# Language/framework preferences
(r'(?:i prefer|i like|i always use|i use)\s+(python|javascript|typescript|react|vue|angular|django|flask|fastapi|tailwind|bootstrap|node\.?js|rust|go\b|java\b|c\+\+)',
"prefers_{0}", "User prefers using {0}"),
# Code style
(r'(?:i prefer|i like).{0,20}(?:dark mode|light mode|tabs|spaces|camelcase|snake_case)',
"code_style", "User mentioned code style preference"),
# Timezone
(r'(?:my timezone|i.?m in|my time zone).{0,30}(africa|europe|america|asia|[A-Z]{2,4}[-+]?\d{0,2})',
"timezone", "User timezone info"),
# Project context
(r'(?:my project|my app|my website|i.?m building|i.?m working on).{0,50}',
"project_context", "User mentioned a project they're working on"),
]
def extract_skills(user_message: str, agent_response: str, user_id: str) -> List[Dict[str, str]]:
"""Extract potential skills from the conversation. Returns list of {name, description}.
Only extracts from the USER's message, not the agent's response."""
skills_found = []
for pattern, name_template, desc_template in SKILL_PATTERNS:
matches = re.findall(pattern, user_message, re.IGNORECASE)
for match in matches:
match_str = match if isinstance(match, str) else match[0] if match else ""
if match_str:
skill_name = name_template.format(match_str.lower().replace(" ", "_").replace(".", ""))
skill_desc = desc_template.format(match_str)
is_new = save_skill(user_id, skill_name, skill_desc)
if is_new:
skills_found.append({"name": skill_name, "description": skill_desc})
return skills_found
# ── CONTEXT BUILDER (builds the memory_context string for the system prompt) ──
def build_memory_context(user_id: str, query: str = "") -> str:
"""Build a compact memory context string for the system prompt.
Includes last 5 messages (short) + skill names.
If query is provided, also injects up to 2 auto-matched Active skill bodies."""
mem = load_memory(user_id)
parts = []
# User profile (very compact)
profile = mem.get("profile", {})
if profile:
facts = [f"{k}: {v}" for k, v in profile.items() if k != "messages"]
if facts:
parts.append("User profile: " + ", ".join(facts[:5]))
# Recent messages (very compact β€” just role + first 100 chars)
messages = mem.get("messages", [])
if messages:
recent = []
for msg in messages[-MAX_MESSAGES:]:
role = msg.get("role", "?")
content = msg.get("content", "")[:100]
recent.append(f"{role}: {content}")
parts.append("Recent conversation:\n" + "\n".join(recent))
# Skills (names only β€” AI decides if it needs details)
skills = get_skills_list(user_id)
# Also surface builtin names so the model knows they exist
try:
from .skills_catalog import BUILTIN_SKILLS
except ImportError:
try:
from skills_catalog import BUILTIN_SKILLS # type: ignore
except ImportError:
BUILTIN_SKILLS = []
builtin_names = [s["name"] for s in BUILTIN_SKILLS]
name_bits = []
if builtin_names:
name_bits.append("builtins: " + ", ".join(builtin_names))
if skills:
name_bits.append("learned: " + ", ".join(skills))
if name_bits:
parts.append("Learned skills (available if needed): " + " | ".join(name_bits))
# Active skills β€” auto-inject up to 2 matched full bodies for the current query
if query:
matched = match_skills_for_query(user_id, query, limit=2)
if matched:
active = ["Active skills (follow these):"]
for m in matched[:2]:
active.append(f"### {m['name']}\n{m.get('body') or m.get('when_to_use') or ''}")
parts.append("\n".join(active))
if not parts:
return ""
return "\n".join(parts)