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)