Spaces:
Running
Running
Download agent/agent_memory.py from DeepImagix/self-trained2: direct link, hf CLI and curl.
- Browser
- Download file 15.7 kB
-
https://huggingface.co/spaces/DeepImagix/self-trained2/resolve/main/agent/agent_memory.py
- Command line
-
hf download hf://spaces/DeepImagix/self-trained2/agent/agent_memory.py
-
curl -L -o agent_memory.py https://huggingface.co/spaces/DeepImagix/self-trained2/resolve/main/agent/agent_memory.py
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) | |