youclaw 4.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,440 @@
1
+ """
2
+ YouClaw Memory Manager
3
+ Persistent conversation memory and context management using SQLite.
4
+ """
5
+
6
+ import aiosqlite
7
+ import asyncio
8
+ import logging
9
+ import json
10
+ from typing import List, Dict, Optional
11
+ from datetime import datetime
12
+ from .config import config
13
+ from .vector_manager import VectorManager
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class MemoryManager:
19
+ """Manages persistent conversation memory across platforms"""
20
+
21
+ def __init__(self):
22
+ self.db_path = config.bot.database_path
23
+ self.max_context = config.bot.max_context_messages
24
+ self.db: Optional[aiosqlite.Connection] = None
25
+ self.vector_manager = VectorManager(self.db_path)
26
+
27
+ async def initialize(self):
28
+ """Initialize the database and create tables"""
29
+ self.db = await aiosqlite.connect(self.db_path)
30
+ await self.vector_manager.initialize()
31
+
32
+ # Create conversations table
33
+ await self.db.execute("""
34
+ CREATE TABLE IF NOT EXISTS conversations (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ platform TEXT NOT NULL,
37
+ user_id TEXT NOT NULL,
38
+ channel_id TEXT,
39
+ role TEXT NOT NULL,
40
+ content TEXT NOT NULL,
41
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
42
+ metadata TEXT
43
+ )
44
+ """)
45
+
46
+ # Create user profile table
47
+ await self.db.execute("""
48
+ CREATE TABLE IF NOT EXISTS user_profiles (
49
+ platform TEXT NOT NULL,
50
+ user_id TEXT NOT NULL,
51
+ name TEXT,
52
+ interests TEXT,
53
+ onboarding_completed INTEGER DEFAULT 0,
54
+ last_updated DATETIME DEFAULT CURRENT_TIMESTAMP,
55
+ PRIMARY KEY (platform, user_id)
56
+ )
57
+ """)
58
+
59
+ # Create user preferences table
60
+ await self.db.execute("""
61
+ CREATE TABLE IF NOT EXISTS user_preferences (
62
+ platform TEXT NOT NULL,
63
+ user_id TEXT NOT NULL,
64
+ preference_key TEXT NOT NULL,
65
+ preference_value TEXT,
66
+ PRIMARY KEY (platform, user_id, preference_key)
67
+ )
68
+ """)
69
+
70
+ # Create global settings table
71
+ await self.db.execute("""
72
+ CREATE TABLE IF NOT EXISTS global_settings (
73
+ setting_key TEXT PRIMARY KEY,
74
+ setting_value TEXT,
75
+ last_updated DATETIME DEFAULT CURRENT_TIMESTAMP
76
+ )
77
+ """)
78
+
79
+ # Create users table for dashboard auth
80
+ await self.db.execute("""
81
+ CREATE TABLE IF NOT EXISTS users (
82
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
83
+ username TEXT UNIQUE NOT NULL,
84
+ password_hash TEXT NOT NULL,
85
+ role TEXT DEFAULT 'user',
86
+ linked_platform TEXT,
87
+ linked_user_id TEXT,
88
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
89
+ )
90
+ """)
91
+
92
+ # Create user secrets table (for individual tokens/keys)
93
+ await self.db.execute("""
94
+ CREATE TABLE IF NOT EXISTS user_secrets (
95
+ platform TEXT NOT NULL,
96
+ user_id TEXT NOT NULL,
97
+ secret_key TEXT NOT NULL,
98
+ secret_value TEXT,
99
+ last_updated DATETIME DEFAULT CURRENT_TIMESTAMP,
100
+ PRIMARY KEY (platform, user_id, secret_key)
101
+ )
102
+ """)
103
+
104
+ # Initialize default settings if they don't exist
105
+ defaults = [
106
+ ('search_enabled', 'true'),
107
+ ('personality_enabled', 'true'),
108
+ ('onboarding_enabled', 'true'),
109
+ ('discord_enabled', 'false'),
110
+ ('telegram_enabled', 'true'),
111
+ ('discord_token', ''),
112
+ ('telegram_token', '')
113
+ ]
114
+ for key, val in defaults:
115
+ await self.db.execute("INSERT OR IGNORE INTO global_settings (setting_key, setting_value) VALUES (?, ?)", (key, val))
116
+
117
+ # Create indexes for faster queries
118
+ await self.db.execute("""
119
+ CREATE INDEX IF NOT EXISTS idx_conversations_user
120
+ ON conversations(platform, user_id, timestamp DESC)
121
+ """)
122
+
123
+ await self.db.commit()
124
+ logger.info(f"Memory manager initialized: {self.db_path}")
125
+
126
+ def _hash_password(self, password: str) -> str:
127
+ """Securely hash a password for storage"""
128
+ import hashlib
129
+ return hashlib.sha256(password.encode()).hexdigest()
130
+
131
+ async def create_user(self, username: str, password: str, role: str = 'admin') -> bool:
132
+ """Create a new dashboard user. Everyone is admin in this personal version."""
133
+ try:
134
+ pw_hash = self._hash_password(password)
135
+ await self.db.execute(
136
+ "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)",
137
+ (username, pw_hash, role)
138
+ )
139
+ await self.db.commit()
140
+ return True
141
+ except Exception as e:
142
+ logger.error(f"Failed to create user: {e}")
143
+ return False
144
+
145
+ async def verify_user(self, username: str, password: str) -> Optional[Dict]:
146
+ """Verify user credentials and return user info with token"""
147
+ pw_hash = self._hash_password(password)
148
+ async with self.db.execute(
149
+ "SELECT id, username, role, linked_platform, linked_user_id FROM users WHERE username = ? AND password_hash = ?",
150
+ (username, pw_hash)
151
+ ) as cursor:
152
+ row = await cursor.fetchone()
153
+ if row:
154
+ # Generate a session token based on credentials and a deployment-specific secret
155
+ import hashlib
156
+ token_base = f"{username}{pw_hash}{config.bot.prefix}"
157
+ token = hashlib.sha256(token_base.encode()).hexdigest()
158
+
159
+ return {
160
+ "id": row[0],
161
+ "username": row[1],
162
+ "role": row[2],
163
+ "linked_platform": row[3],
164
+ "linked_user_id": row[4],
165
+ "token": token
166
+ }
167
+ return None
168
+
169
+ async def link_account(self, username: str, platform: str, user_id: str):
170
+ """Link a dashboard user to a Telegram/Discord identity"""
171
+ await self.db.execute(
172
+ "UPDATE users SET linked_platform = ?, linked_user_id = ? WHERE username = ?",
173
+ (platform, user_id, username)
174
+ )
175
+ await self.db.commit()
176
+ logger.info(f"Linked dashboard user {username} to {platform}:{user_id}")
177
+
178
+ async def get_linked_identity(self, username: str) -> Optional[tuple]:
179
+ """Get the platform:id linked to a username"""
180
+ async with self.db.execute(
181
+ "SELECT linked_platform, linked_user_id FROM users WHERE username = ?",
182
+ (username,)
183
+ ) as cursor:
184
+ row = await cursor.fetchone()
185
+ if row and row[0] and row[1]:
186
+ return row[0], row[1]
187
+ return None, None
188
+
189
+ async def close(self):
190
+ """Close the database connection"""
191
+ if self.db:
192
+ await self.db.close()
193
+ logger.info("Memory manager closed")
194
+
195
+ async def add_message(
196
+ self,
197
+ platform: str,
198
+ user_id: str,
199
+ role: str,
200
+ content: str,
201
+ channel_id: Optional[str] = None,
202
+ metadata: Optional[Dict] = None
203
+ ):
204
+ """
205
+ Add a message to conversation history.
206
+
207
+ Args:
208
+ platform: Platform name (discord, telegram)
209
+ user_id: User identifier
210
+ role: Message role (user, assistant, system)
211
+ content: Message content
212
+ channel_id: Optional channel/chat identifier
213
+ metadata: Optional metadata dict
214
+ """
215
+ metadata_json = json.dumps(metadata) if metadata else None
216
+
217
+ await self.db.execute("""
218
+ INSERT INTO conversations
219
+ (platform, user_id, channel_id, role, content, metadata)
220
+ VALUES (?, ?, ?, ?, ?, ?)
221
+ """, (platform, user_id, channel_id, role, content, metadata_json))
222
+
223
+ await self.db.commit()
224
+
225
+ # Phase 1: Semantic Indexing
226
+ try:
227
+ # We need the last inserted ID
228
+ async with self.db.execute("SELECT last_insert_rowid()") as cursor:
229
+ message_id = (await cursor.fetchone())[0]
230
+ # Trigger embedding in background to not block the chat response
231
+ asyncio.create_task(self.vector_manager.save_embedding(message_id, content))
232
+ except Exception as ve:
233
+ logger.error(f"Failed to trigger semantic indexing: {ve}")
234
+
235
+ async def get_conversation_history(
236
+ self,
237
+ platform: str,
238
+ user_id: str,
239
+ channel_id: Optional[str] = None,
240
+ limit: Optional[int] = None
241
+ ) -> List[Dict[str, str]]:
242
+ """
243
+ Get conversation history for a user.
244
+
245
+ Args:
246
+ platform: Platform name
247
+ user_id: User identifier
248
+ channel_id: Optional channel filter
249
+ limit: Max number of messages (defaults to max_context)
250
+
251
+ Returns:
252
+ List of message dicts with 'role' and 'content'
253
+ """
254
+ limit = limit or self.max_context
255
+
256
+ query = """
257
+ SELECT role, content FROM conversations
258
+ WHERE platform = ? AND user_id = ?
259
+ """
260
+ params = [platform, user_id]
261
+
262
+ if channel_id:
263
+ query += " AND channel_id = ?"
264
+ params.append(channel_id)
265
+
266
+ query += " ORDER BY timestamp DESC LIMIT ?"
267
+ params.append(limit)
268
+
269
+ async with self.db.execute(query, params) as cursor:
270
+ rows = await cursor.fetchall()
271
+
272
+ # Reverse to get chronological order
273
+ messages = [
274
+ {"role": row[0], "content": row[1]}
275
+ for row in reversed(rows)
276
+ ]
277
+
278
+ return messages
279
+
280
+ async def clear_conversation(
281
+ self,
282
+ platform: str,
283
+ user_id: str,
284
+ channel_id: Optional[str] = None
285
+ ):
286
+ """Clear conversation history for a user"""
287
+ query = "DELETE FROM conversations WHERE platform = ? AND user_id = ?"
288
+ params = [platform, user_id]
289
+
290
+ if channel_id:
291
+ query += " AND channel_id = ?"
292
+ params.append(channel_id)
293
+
294
+ await self.db.execute(query, params)
295
+ await self.db.commit()
296
+ logger.info(f"Cleared conversation for {platform}:{user_id}")
297
+
298
+ async def set_user_preference(
299
+ self,
300
+ platform: str,
301
+ user_id: str,
302
+ key: str,
303
+ value: str
304
+ ):
305
+ """Set a user preference"""
306
+ await self.db.execute("""
307
+ INSERT OR REPLACE INTO user_preferences
308
+ (platform, user_id, preference_key, preference_value)
309
+ VALUES (?, ?, ?, ?)
310
+ """, (platform, user_id, key, value))
311
+
312
+ await self.db.commit()
313
+
314
+ async def get_user_preference(
315
+ self,
316
+ platform: str,
317
+ user_id: str,
318
+ key: str,
319
+ default: Optional[str] = None
320
+ ) -> Optional[str]:
321
+ """Get a user preference"""
322
+ async with self.db.execute("""
323
+ SELECT preference_value FROM user_preferences
324
+ WHERE platform = ? AND user_id = ? AND preference_key = ?
325
+ """, (platform, user_id, key)) as cursor:
326
+ row = await cursor.fetchone()
327
+ return row[0] if row else default
328
+
329
+ async def get_user_profile(self, platform: str, user_id: str) -> Dict:
330
+ """Get user profile information"""
331
+ async with self.db.execute("""
332
+ SELECT name, interests, onboarding_completed FROM user_profiles
333
+ WHERE platform = ? AND user_id = ?
334
+ """, (platform, user_id)) as cursor:
335
+ row = await cursor.fetchone()
336
+ if row:
337
+ return {
338
+ "name": row[0],
339
+ "interests": row[1],
340
+ "onboarding_completed": bool(row[2])
341
+ }
342
+ return {"name": None, "interests": None, "onboarding_completed": False}
343
+
344
+ async def update_user_profile(self, platform: str, user_id: str, **kwargs):
345
+ """Update user profile information"""
346
+ fields = []
347
+ values = []
348
+ for key, value in kwargs.items():
349
+ if key in ['name', 'interests', 'onboarding_completed']:
350
+ if key == 'onboarding_completed':
351
+ value = 1 if value else 0
352
+ fields.append(f"{key} = ?")
353
+ values.append(value)
354
+
355
+ if not fields:
356
+ return
357
+
358
+ values.extend([platform, user_id])
359
+
360
+ # Try to update first
361
+ query = f"UPDATE user_profiles SET {', '.join(fields)}, last_updated = CURRENT_TIMESTAMP WHERE platform = ? AND user_id = ?"
362
+ cursor = await self.db.execute(query, values)
363
+
364
+ if cursor.rowcount == 0:
365
+ # If no rows updated, insert new profile
366
+ insert_fields = ['platform', 'user_id'] + list(kwargs.keys())
367
+ insert_placeholders = ['?'] * len(insert_fields)
368
+ insert_values = [platform, user_id] + [1 if k == 'onboarding_completed' and v else v for k, v in kwargs.items()]
369
+
370
+ await self.db.execute(f"""
371
+ INSERT INTO user_profiles ({', '.join(insert_fields)})
372
+ VALUES ({', '.join(insert_placeholders)})
373
+ """, insert_values)
374
+
375
+ await self.db.commit()
376
+
377
+ async def get_global_setting(self, key: str, default: str = None) -> str:
378
+ """Get a global setting"""
379
+ async with self.db.execute("SELECT setting_value FROM global_settings WHERE setting_key = ?", (key,)) as cursor:
380
+ row = await cursor.fetchone()
381
+ return row[0] if row else default
382
+
383
+ async def set_global_setting(self, key: str, value: str):
384
+ """Set a global setting"""
385
+ await self.db.execute("""
386
+ INSERT OR REPLACE INTO global_settings (setting_key, setting_value, last_updated)
387
+ VALUES (?, ?, CURRENT_TIMESTAMP)
388
+ """, (key, str(value)))
389
+ await self.db.commit()
390
+
391
+ async def get_user_secret(self, platform: str, user_id: str, key: str, default: str = None) -> str:
392
+ """Get a user-specific secret (e.g., personal API key)"""
393
+ async with self.db.execute("""
394
+ SELECT secret_value FROM user_secrets
395
+ WHERE platform = ? AND user_id = ? AND secret_key = ?
396
+ """, (platform, user_id, key)) as cursor:
397
+ row = await cursor.fetchone()
398
+ return row[0] if row else default
399
+
400
+ async def set_user_secret(self, platform: str, user_id: str, key: str, value: str):
401
+ """Set a user-specific secret"""
402
+ await self.db.execute("""
403
+ INSERT OR REPLACE INTO user_secrets (platform, user_id, secret_key, secret_value, last_updated)
404
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
405
+ """, (platform, user_id, key, str(value)))
406
+ await self.db.commit()
407
+
408
+ async def get_stats(self) -> Dict:
409
+ """Get database statistics"""
410
+ async with self.db.execute("""
411
+ SELECT COUNT(*) FROM conversations
412
+ """) as cursor:
413
+ total_messages = (await cursor.fetchone())[0]
414
+
415
+ async with self.db.execute("""
416
+ SELECT COUNT(DISTINCT user_id) FROM conversations
417
+ """) as cursor:
418
+ unique_users = (await cursor.fetchone())[0]
419
+
420
+ return {
421
+ "total_messages": total_messages,
422
+ "unique_users": unique_users,
423
+ "database_path": self.db_path
424
+ }
425
+
426
+ async def get_semantic_context(self, query: str, limit: int = 5) -> str:
427
+ """Get semantic context as a formatted string for LLM"""
428
+ results = await self.vector_manager.search_semantic(query, limit=limit)
429
+ if not results:
430
+ return ""
431
+
432
+ context_parts = ["### SEMANTIC MEMORY (PAST CONTEXT) ###"]
433
+ for res in results:
434
+ context_parts.append(f"[{res['timestamp']}] {res['role'].upper()}: {res['content']}")
435
+
436
+ return "\n".join(context_parts) + "\n"
437
+
438
+
439
+ # Global memory manager instance
440
+ memory_manager = MemoryManager()