Spaces:
Running
Running
File size: 15,690 Bytes
4d30276 7309ad4 4d30276 7309ad4 4d30276 7309ad4 4d30276 7309ad4 4d30276 7309ad4 4d30276 7309ad4 4d30276 7309ad4 4d30276 7309ad4 4d30276 7309ad4 4d30276 7309ad4 4d30276 7309ad4 4d30276 7309ad4 4d30276 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 | """
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)
|