Add self-contained conversation memory system
Browse files- memory_system.py +140 -0
memory_system.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Conversation Memory System for ScottzillaSystems
|
| 3 |
+
Self-contained — no external APIs needed
|
| 4 |
+
Uses SQLite + sentence-transformers for local embeddings
|
| 5 |
+
Replaces the fraudulent MemPalace project with a real working system.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import json
|
| 10 |
+
import sqlite3
|
| 11 |
+
import hashlib
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from typing import List, Dict, Optional
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
from sentence_transformers import SentenceTransformer
|
| 17 |
+
EMBEDDING_MODEL = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
|
| 18 |
+
USE_VECTOR_SEARCH = True
|
| 19 |
+
except Exception:
|
| 20 |
+
EMBEDDING_MODEL = None
|
| 21 |
+
USE_VECTOR_SEARCH = False
|
| 22 |
+
print("[Memory] sentence-transformers not available, using text search fallback")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ConversationMemory:
|
| 26 |
+
def __init__(self, db_path: str = "./memory_db/conversations.db", user_id: str = "scottzilla"):
|
| 27 |
+
self.db_path = db_path
|
| 28 |
+
self.user_id = user_id
|
| 29 |
+
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
| 30 |
+
self.conn = sqlite3.connect(db_path)
|
| 31 |
+
self._init_db()
|
| 32 |
+
|
| 33 |
+
def _init_db(self):
|
| 34 |
+
self.conn.execute("""
|
| 35 |
+
CREATE TABLE IF NOT EXISTS memories (
|
| 36 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 37 |
+
user_id TEXT NOT NULL,
|
| 38 |
+
thread_id TEXT NOT NULL,
|
| 39 |
+
timestamp TEXT NOT NULL,
|
| 40 |
+
role TEXT NOT NULL,
|
| 41 |
+
content TEXT NOT NULL,
|
| 42 |
+
content_hash TEXT NOT NULL,
|
| 43 |
+
metadata TEXT,
|
| 44 |
+
embedding BLOB
|
| 45 |
+
)
|
| 46 |
+
""")
|
| 47 |
+
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_user_thread ON memories(user_id, thread_id, timestamp)")
|
| 48 |
+
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_content ON memories(content)")
|
| 49 |
+
self.conn.commit()
|
| 50 |
+
|
| 51 |
+
def _get_embedding(self, text: str) -> Optional[bytes]:
|
| 52 |
+
if not USE_VECTOR_SEARCH or EMBEDDING_MODEL is None:
|
| 53 |
+
return None
|
| 54 |
+
try:
|
| 55 |
+
embedding = EMBEDDING_MODEL.encode(text, convert_to_numpy=True)
|
| 56 |
+
return embedding.tobytes()
|
| 57 |
+
except Exception as e:
|
| 58 |
+
print(f"[Memory] Embedding error: {e}")
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
def save_message(self, role: str, content: str, thread_id: str, metadata: Optional[Dict] = None) -> Dict:
|
| 62 |
+
timestamp = datetime.utcnow().isoformat()
|
| 63 |
+
content_hash = hashlib.sha256(content.encode()).hexdigest()[:16]
|
| 64 |
+
embedding = self._get_embedding(content)
|
| 65 |
+
meta_json = json.dumps(metadata or {})
|
| 66 |
+
cursor = self.conn.execute(
|
| 67 |
+
"INSERT INTO memories (user_id, thread_id, timestamp, role, content, content_hash, metadata, embedding) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
| 68 |
+
(self.user_id, thread_id, timestamp, role, content, content_hash, meta_json, embedding)
|
| 69 |
+
)
|
| 70 |
+
self.conn.commit()
|
| 71 |
+
return {"id": cursor.lastrowid, "thread_id": thread_id, "timestamp": timestamp, "role": role, "content": content, "metadata": metadata or {}}
|
| 72 |
+
|
| 73 |
+
def save_conversation(self, messages: List[Dict], thread_id: str, title: Optional[str] = None) -> List[Dict]:
|
| 74 |
+
results = []
|
| 75 |
+
for msg in messages:
|
| 76 |
+
result = self.save_message(
|
| 77 |
+
role=msg.get("role", "unknown"),
|
| 78 |
+
content=msg.get("content", ""),
|
| 79 |
+
thread_id=thread_id,
|
| 80 |
+
metadata={"title": title, **msg.get("metadata", {})}
|
| 81 |
+
)
|
| 82 |
+
results.append(result)
|
| 83 |
+
return results
|
| 84 |
+
|
| 85 |
+
def get_thread(self, thread_id: str, limit: int = 1000) -> List[Dict]:
|
| 86 |
+
cursor = self.conn.execute(
|
| 87 |
+
"SELECT id, timestamp, role, content, metadata FROM memories WHERE user_id = ? AND thread_id = ? ORDER BY timestamp ASC LIMIT ?",
|
| 88 |
+
(self.user_id, thread_id, limit)
|
| 89 |
+
)
|
| 90 |
+
rows = cursor.fetchall()
|
| 91 |
+
return [{"id": row[0], "timestamp": row[1], "role": row[2], "content": row[3], "metadata": json.loads(row[4])} for row in rows]
|
| 92 |
+
|
| 93 |
+
def search(self, query: str, thread_id: Optional[str] = None, limit: int = 20) -> List[Dict]:
|
| 94 |
+
if thread_id:
|
| 95 |
+
cursor = self.conn.execute(
|
| 96 |
+
"SELECT id, timestamp, role, content, metadata FROM memories WHERE user_id = ? AND thread_id = ? AND content LIKE ? ORDER BY timestamp DESC LIMIT ?",
|
| 97 |
+
(self.user_id, thread_id, f"%{query}%", limit)
|
| 98 |
+
)
|
| 99 |
+
rows = cursor.fetchall()
|
| 100 |
+
return [{"id": r[0], "timestamp": r[1], "role": r[2], "content": r[3], "metadata": json.loads(r[4])} for r in rows]
|
| 101 |
+
else:
|
| 102 |
+
cursor = self.conn.execute(
|
| 103 |
+
"SELECT id, timestamp, role, content, metadata, thread_id FROM memories WHERE user_id = ? AND content LIKE ? ORDER BY timestamp DESC LIMIT ?",
|
| 104 |
+
(self.user_id, f"%{query}%", limit)
|
| 105 |
+
)
|
| 106 |
+
rows = cursor.fetchall()
|
| 107 |
+
return [{"id": r[0], "timestamp": r[1], "role": r[2], "content": r[3], "metadata": json.loads(r[4]), "thread_id": r[5]} for r in rows]
|
| 108 |
+
|
| 109 |
+
def get_all_threads(self) -> List[Dict]:
|
| 110 |
+
cursor = self.conn.execute(
|
| 111 |
+
"SELECT thread_id, COUNT(*) as msg_count, MIN(timestamp) as started, MAX(timestamp) as last_msg FROM memories WHERE user_id = ? GROUP BY thread_id ORDER BY last_msg DESC",
|
| 112 |
+
(self.user_id,)
|
| 113 |
+
)
|
| 114 |
+
rows = cursor.fetchall()
|
| 115 |
+
return [{"thread_id": row[0], "message_count": row[1], "started": row[2], "last_message": row[3]} for row in rows]
|
| 116 |
+
|
| 117 |
+
def export_to_json(self, filepath: str, thread_id: Optional[str] = None):
|
| 118 |
+
if thread_id:
|
| 119 |
+
memories = self.get_thread(thread_id)
|
| 120 |
+
else:
|
| 121 |
+
cursor = self.conn.execute("SELECT id, timestamp, role, content, metadata, thread_id FROM memories WHERE user_id = ? ORDER BY timestamp", (self.user_id,))
|
| 122 |
+
memories = [{"id": r[0], "timestamp": r[1], "role": r[2], "content": r[3], "metadata": json.loads(r[4]), "thread_id": r[5]} for r in cursor.fetchall()]
|
| 123 |
+
with open(filepath, 'w') as f:
|
| 124 |
+
json.dump(memories, f, indent=2)
|
| 125 |
+
print(f"[Memory] Exported {len(memories)} memories to {filepath}")
|
| 126 |
+
|
| 127 |
+
def export_to_markdown(self, filepath: str, thread_id: str):
|
| 128 |
+
memories = self.get_thread(thread_id)
|
| 129 |
+
with open(filepath, 'w') as f:
|
| 130 |
+
f.write(f"# Conversation: {thread_id}\n\n*Exported: {datetime.utcnow().isoformat()}*\n\n---\n\n")
|
| 131 |
+
for mem in memories:
|
| 132 |
+
role = mem.get("role", "unknown")
|
| 133 |
+
timestamp = mem.get("timestamp", "unknown")
|
| 134 |
+
content = mem.get("content", "")
|
| 135 |
+
emoji = "👤" if role == "user" else "🤖" if role == "assistant" else "📝"
|
| 136 |
+
f.write(f"### {emoji} {role.title()} *({timestamp})*\n\n{content}\n\n---\n\n")
|
| 137 |
+
print(f"[Memory] Exported conversation to {filepath}")
|
| 138 |
+
|
| 139 |
+
def close(self):
|
| 140 |
+
self.conn.close()
|