enhanced-mcp-memory 1.2.1__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.
database.py ADDED
@@ -0,0 +1,751 @@
1
+ """
2
+ SQLite3 Database Manager for MCP Memory and Task Management
3
+ Handles memories, knowledge graphs, tasks, and project data
4
+ """
5
+
6
+ import sqlite3
7
+ from sqlite3 import Cursor
8
+ import json
9
+ import uuid
10
+ import time
11
+ from datetime import datetime, timedelta
12
+ from typing import List, Dict, Optional, Any, Union
13
+ from pathlib import Path
14
+ import logging
15
+ from functools import wraps
16
+
17
+ def retry_on_failure(max_retries=3, delay=1.0):
18
+ """Decorator to retry database operations on failure"""
19
+ def decorator(func):
20
+ @wraps(func)
21
+ def wrapper(*args, **kwargs):
22
+ for attempt in range(max_retries):
23
+ try:
24
+ return func(*args, **kwargs)
25
+ except Exception as e:
26
+ if attempt == max_retries - 1:
27
+ raise
28
+ logging.warning(f"Attempt {attempt + 1} failed: {e}. Retrying in {delay}s...")
29
+ time.sleep(delay * (2 ** attempt)) # Exponential backoff
30
+ return None
31
+ return wrapper
32
+ return decorator
33
+
34
+ class DatabaseManager:
35
+ def __init__(self, db_path: str = "mcp_memory.db"):
36
+ """Initialize database connection and create tables if they don't exist"""
37
+ self.db_path = db_path
38
+ self.connection: Optional[sqlite3.Connection] = None
39
+ self.setup_database()
40
+
41
+ def _check_connection(self) -> bool:
42
+ """Check if database connection is valid"""
43
+ if not self.connection:
44
+ logging.error("Database connection not established")
45
+ return False
46
+ return True
47
+
48
+ def setup_database(self) -> None:
49
+ """Create database connection and initialize tables"""
50
+ try:
51
+ self.connection = sqlite3.connect(self.db_path, check_same_thread=False)
52
+ self.connection.row_factory = sqlite3.Row # Enable column access by name
53
+ self.create_tables()
54
+ logging.info(f"Database initialized at {self.db_path}")
55
+ except Exception as e:
56
+ logging.error(f"Failed to initialize database: {e}")
57
+ raise
58
+
59
+ def create_tables(self) -> None:
60
+ """Create all necessary tables for the MCP memory system"""
61
+ if not self._check_connection() or not self.connection:
62
+ return
63
+
64
+ cursor: Cursor = self.connection.cursor()
65
+
66
+ # Check if we need to migrate the schema
67
+ self._migrate_schema(cursor)
68
+
69
+ # Projects table - tracks different coding projects
70
+ cursor.execute("""
71
+ CREATE TABLE IF NOT EXISTS projects (
72
+ id TEXT PRIMARY KEY,
73
+ name TEXT NOT NULL,
74
+ path TEXT,
75
+ description TEXT,
76
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
77
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
78
+ metadata TEXT -- JSON metadata
79
+ )
80
+ """)
81
+
82
+ # Memories table - stores contextual information and learning
83
+ cursor.execute("""
84
+ CREATE TABLE IF NOT EXISTS memories (
85
+ id TEXT PRIMARY KEY,
86
+ project_id TEXT,
87
+ type TEXT NOT NULL, -- 'code', 'conversation', 'decision', 'pattern', 'error'
88
+ title TEXT NOT NULL,
89
+ content TEXT NOT NULL,
90
+ embedding_vector TEXT, -- JSON array for semantic search
91
+ content_hash TEXT, -- Hash for duplicate detection
92
+ file_path TEXT, -- Associated file path
93
+ importance_score REAL DEFAULT 0.5,
94
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
95
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
96
+ accessed_count INTEGER DEFAULT 0,
97
+ last_accessed TIMESTAMP,
98
+ tags TEXT, -- JSON array of tags
99
+ metadata TEXT, -- JSON metadata
100
+ FOREIGN KEY (project_id) REFERENCES projects(id)
101
+ )
102
+ """)
103
+
104
+ # Tasks table - tracks development tasks and TODOs
105
+ cursor.execute("""
106
+ CREATE TABLE IF NOT EXISTS tasks (
107
+ id TEXT PRIMARY KEY,
108
+ project_id TEXT,
109
+ title TEXT NOT NULL,
110
+ description TEXT,
111
+ status TEXT DEFAULT 'pending', -- 'pending', 'in_progress', 'completed', 'cancelled'
112
+ priority TEXT DEFAULT 'medium', -- 'low', 'medium', 'high', 'critical'
113
+ category TEXT, -- 'bug', 'feature', 'refactor', 'docs', 'test'
114
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
115
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
116
+ due_date TIMESTAMP,
117
+ estimated_hours REAL,
118
+ actual_hours REAL,
119
+ tags TEXT, -- JSON array
120
+ metadata TEXT, -- JSON metadata
121
+ FOREIGN KEY (project_id) REFERENCES projects(id)
122
+ )
123
+ """)
124
+
125
+ # Knowledge Graph - relationships between memories, tasks, and concepts
126
+ cursor.execute("""
127
+ CREATE TABLE IF NOT EXISTS knowledge_relationships (
128
+ id TEXT PRIMARY KEY,
129
+ from_type TEXT NOT NULL, -- 'memory', 'task', 'project', 'concept'
130
+ from_id TEXT NOT NULL,
131
+ to_type TEXT NOT NULL,
132
+ to_id TEXT NOT NULL,
133
+ relationship_type TEXT NOT NULL, -- 'depends_on', 'relates_to', 'conflicts_with', 'implements', 'references'
134
+ strength REAL DEFAULT 1.0, -- relationship strength (0.0 to 1.0)
135
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
136
+ metadata TEXT -- JSON metadata
137
+ )
138
+ """)
139
+
140
+ # Sessions table - tracks AI interaction sessions
141
+ cursor.execute("""
142
+ CREATE TABLE IF NOT EXISTS sessions (
143
+ id TEXT PRIMARY KEY,
144
+ project_id TEXT,
145
+ started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
146
+ ended_at TIMESTAMP,
147
+ interaction_count INTEGER DEFAULT 0,
148
+ context_summary TEXT,
149
+ FOREIGN KEY (project_id) REFERENCES projects(id)
150
+ )
151
+ """)
152
+
153
+ # Context layers - different context layers for memory retrieval
154
+ cursor.execute("""
155
+ CREATE TABLE IF NOT EXISTS context_layers (
156
+ id TEXT PRIMARY KEY,
157
+ name TEXT NOT NULL,
158
+ description TEXT,
159
+ query_pattern TEXT, -- Pattern for automatic activation
160
+ priority INTEGER DEFAULT 1,
161
+ is_active BOOLEAN DEFAULT 1,
162
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
163
+ )
164
+ """)
165
+
166
+ # Create comprehensive indexes for better performance
167
+ # Memory indexes
168
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_memories_project_id ON memories(project_id)")
169
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type)")
170
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance_score DESC)")
171
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_memories_created_at ON memories(created_at DESC)")
172
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_memories_last_accessed ON memories(last_accessed DESC)")
173
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_memories_content_hash ON memories(content_hash)")
174
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_memories_file_path ON memories(file_path)")
175
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_memories_accessed_count ON memories(accessed_count DESC)")
176
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_memories_composite ON memories(project_id, type, importance_score DESC)")
177
+
178
+ # Task indexes
179
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id)")
180
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)")
181
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority)")
182
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at DESC)")
183
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_tasks_category ON tasks(category)")
184
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_tasks_composite ON tasks(project_id, status, priority)")
185
+
186
+ # Knowledge relationship indexes
187
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_relationships_from ON knowledge_relationships(from_type, from_id)")
188
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_relationships_to ON knowledge_relationships(to_type, to_id)")
189
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_relationships_type ON knowledge_relationships(relationship_type)")
190
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_relationships_strength ON knowledge_relationships(strength DESC)")
191
+
192
+ # Session indexes
193
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_sessions_project_id ON sessions(project_id)")
194
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at DESC)")
195
+
196
+ # Context layer indexes
197
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_context_layers_active ON context_layers(is_active)")
198
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_context_layers_priority ON context_layers(priority DESC)")
199
+
200
+ # Notifications table - stores system notifications
201
+ cursor.execute("""
202
+ CREATE TABLE IF NOT EXISTS notifications (
203
+ id TEXT PRIMARY KEY,
204
+ project_id TEXT,
205
+ type TEXT NOT NULL, -- 'system', 'task', 'memory', 'alert'
206
+ title TEXT NOT NULL,
207
+ message TEXT NOT NULL,
208
+ is_read BOOLEAN DEFAULT 0,
209
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
210
+ metadata TEXT, -- JSON metadata
211
+ FOREIGN KEY (project_id) REFERENCES projects(id)
212
+ )
213
+ """)
214
+
215
+ # Create indexes for notifications
216
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_notifications_project ON notifications(project_id)")
217
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(is_read)")
218
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications(created_at)")
219
+
220
+ self.connection.commit()
221
+ logging.info("Database tables created successfully")
222
+
223
+ def _migrate_schema(self, cursor: Cursor) -> None:
224
+ """Migrate database schema to add new columns if they don't exist"""
225
+ try:
226
+ # Check if content_hash column exists in memories table
227
+ cursor.execute("PRAGMA table_info(memories)")
228
+ columns = [row[1] for row in cursor.fetchall()]
229
+
230
+ if 'content_hash' not in columns:
231
+ cursor.execute("ALTER TABLE memories ADD COLUMN content_hash TEXT")
232
+ logging.info("Added content_hash column to memories table")
233
+
234
+ if 'file_path' not in columns:
235
+ cursor.execute("ALTER TABLE memories ADD COLUMN file_path TEXT")
236
+ logging.info("Added file_path column to memories table")
237
+
238
+ if 'embedding_vector' not in columns:
239
+ cursor.execute("ALTER TABLE memories ADD COLUMN embedding_vector TEXT")
240
+ logging.info("Added embedding_vector column to memories table")
241
+
242
+ # Update accessed_count to INTEGER if it's not already
243
+ if 'accessed_count' in columns:
244
+ # Check current type and update if needed
245
+ cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='memories'")
246
+ table_sql = cursor.fetchone()[0]
247
+ if 'accessed_count REAL' in table_sql or 'accessed_count TEXT' in table_sql:
248
+ # Create a temporary table with correct schema
249
+ cursor.execute("""CREATE TABLE memories_temp AS
250
+ SELECT id, project_id, type, title, content,
251
+ CAST(accessed_count AS INTEGER) as accessed_count,
252
+ importance_score, created_at, updated_at,
253
+ last_accessed, tags, metadata
254
+ FROM memories""")
255
+ cursor.execute("DROP TABLE memories")
256
+ cursor.execute("ALTER TABLE memories_temp RENAME TO memories")
257
+ logging.info("Updated accessed_count column type to INTEGER")
258
+
259
+ except Exception as e:
260
+ logging.warning(f"Schema migration warning: {e}")
261
+ # Continue anyway - the table creation will handle missing tables
262
+
263
+ def get_or_create_project(self, name: str, path: Optional[str] = None,
264
+ description: Optional[str] = None) -> str:
265
+ """Get existing project or create new one"""
266
+ if not self._check_connection() or not self.connection:
267
+ return ""
268
+
269
+ cursor: Cursor = self.connection.cursor()
270
+
271
+ # Try to find existing project by name or path
272
+ if path:
273
+ cursor.execute("SELECT id FROM projects WHERE path = ? OR name = ?", (path, name))
274
+ else:
275
+ cursor.execute("SELECT id FROM projects WHERE name = ?", (name,))
276
+
277
+ result = cursor.fetchone()
278
+ if result:
279
+ return result['id']
280
+
281
+ # Create new project
282
+ project_id = str(uuid.uuid4())
283
+ cursor.execute("""
284
+ INSERT INTO projects (id, name, path, description)
285
+ VALUES (?, ?, ?, ?)
286
+ """, (project_id, name, path, description))
287
+ self.connection.commit()
288
+ return project_id
289
+
290
+ @retry_on_failure()
291
+ def add_memory(self, project_id: str, memory_type: str, title: str, content: str,
292
+ tags: Optional[List[str]] = None, importance_score: float = 0.5,
293
+ metadata: Optional[Dict[str, Any]] = None) -> str:
294
+ """Add a new memory to the database"""
295
+ if not self._check_connection() or not self.connection:
296
+ return ""
297
+
298
+ cursor: Cursor = self.connection.cursor()
299
+ memory_id = str(uuid.uuid4())
300
+
301
+ cursor.execute("""
302
+ INSERT INTO memories (id, project_id, type, title, content, importance_score, tags, metadata)
303
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
304
+ """, (
305
+ memory_id, project_id, memory_type, title, content,
306
+ importance_score, json.dumps(tags or []), json.dumps(metadata or {})
307
+ ))
308
+ self.connection.commit()
309
+ return memory_id
310
+
311
+ @retry_on_failure()
312
+ def get_memories(self, project_id: Optional[str] = None, memory_type: Optional[str] = None,
313
+ limit: int = 50, sort_by: str = "created_at") -> List[Dict[str, Any]]:
314
+ """Retrieve memories with optional filters"""
315
+ if not self._check_connection() or not self.connection:
316
+ return []
317
+
318
+ cursor: Cursor = self.connection.cursor()
319
+
320
+ query = "SELECT * FROM memories WHERE 1=1"
321
+ params = []
322
+
323
+ if project_id:
324
+ query += " AND project_id = ?"
325
+ params.append(project_id)
326
+
327
+ if memory_type:
328
+ query += " AND type = ?"
329
+ params.append(memory_type)
330
+
331
+ query += f" ORDER BY {sort_by} DESC LIMIT ?"
332
+ params.append(limit)
333
+
334
+ cursor.execute(query, params)
335
+ return [dict(row) for row in cursor.fetchall()]
336
+
337
+ @retry_on_failure()
338
+ def add_task(self, project_id: str, title: str, description: Optional[str] = None,
339
+ priority: str = "medium", category: Optional[str] = None,
340
+ tags: Optional[List[str]] = None, metadata: Optional[Dict[str, Any]] = None) -> str:
341
+ """Add a new task"""
342
+ if not self._check_connection() or not self.connection:
343
+ return ""
344
+
345
+ cursor: Cursor = self.connection.cursor()
346
+ task_id = str(uuid.uuid4())
347
+
348
+ cursor.execute("""
349
+ INSERT INTO tasks (id, project_id, title, description, priority, category, tags, metadata)
350
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
351
+ """, (
352
+ task_id, project_id, title, description, priority, category,
353
+ json.dumps(tags or []), json.dumps(metadata or {})
354
+ ))
355
+ self.connection.commit()
356
+ return task_id
357
+
358
+ @retry_on_failure()
359
+ def get_tasks(self, project_id: Optional[str] = None, status: Optional[str] = None,
360
+ limit: int = 50) -> List[Dict[str, Any]]:
361
+ """Retrieve tasks with optional filters"""
362
+ if not self._check_connection() or not self.connection:
363
+ return []
364
+
365
+ cursor: Cursor = self.connection.cursor()
366
+
367
+ query = "SELECT * FROM tasks WHERE 1=1"
368
+ params = []
369
+
370
+ if project_id:
371
+ query += " AND project_id = ?"
372
+ params.append(project_id)
373
+
374
+ if status:
375
+ query += " AND status = ?"
376
+ params.append(status)
377
+
378
+ query += " ORDER BY priority DESC, created_at DESC LIMIT ?"
379
+ params.append(limit)
380
+
381
+ cursor.execute(query, params)
382
+ return [dict(row) for row in cursor.fetchall()]
383
+
384
+ def update_task_status(self, task_id: str, status: str) -> bool:
385
+ """Update task status"""
386
+ if not self._check_connection() or not self.connection:
387
+ return False
388
+
389
+ cursor: Cursor = self.connection.cursor()
390
+ cursor.execute("""
391
+ UPDATE tasks SET status = ?, updated_at = CURRENT_TIMESTAMP
392
+ WHERE id = ?
393
+ """, (status, task_id))
394
+ self.connection.commit()
395
+ return cursor.rowcount > 0
396
+
397
+ def add_relationship(self, from_type: str, from_id: str, to_type: str,
398
+ to_id: str, relationship_type: str, strength: float = 1.0) -> str:
399
+ """Add a relationship in the knowledge graph"""
400
+ if not self._check_connection() or not self.connection:
401
+ return ""
402
+
403
+ cursor: Cursor = self.connection.cursor()
404
+ rel_id = str(uuid.uuid4())
405
+
406
+ cursor.execute("""
407
+ INSERT INTO knowledge_relationships
408
+ (id, from_type, from_id, to_type, to_id, relationship_type, strength)
409
+ VALUES (?, ?, ?, ?, ?, ?, ?)
410
+ """, (rel_id, from_type, from_id, to_type, to_id, relationship_type, strength))
411
+ self.connection.commit()
412
+ return rel_id
413
+
414
+ def get_related_items(self, item_type: str, item_id: str) -> List[Dict[str, Any]]:
415
+ """Get all items related to a specific item"""
416
+ if not self._check_connection() or not self.connection:
417
+ return []
418
+
419
+ cursor: Cursor = self.connection.cursor()
420
+ cursor.execute("""
421
+ SELECT * FROM knowledge_relationships
422
+ WHERE (from_type = ? AND from_id = ?) OR (to_type = ? AND to_id = ?)
423
+ ORDER BY strength DESC
424
+ """, (item_type, item_id, item_type, item_id))
425
+ return [dict(row) for row in cursor.fetchall()]
426
+
427
+ def search_memories(self, query: str, project_id: Optional[str] = None, limit: int = 10) -> List[Dict[str, Any]]:
428
+ """Simple text search in memories"""
429
+ if not self._check_connection() or not self.connection:
430
+ return []
431
+
432
+ cursor: Cursor = self.connection.cursor()
433
+
434
+ search_query = """
435
+ SELECT * FROM memories
436
+ WHERE (title LIKE ? OR content LIKE ?)
437
+ """
438
+ params = [f"%{query}%", f"%{query}%"]
439
+
440
+ if project_id:
441
+ search_query += " AND project_id = ?"
442
+ params.append(project_id)
443
+
444
+ search_query += " ORDER BY importance_score DESC, created_at DESC LIMIT ?"
445
+ params.append(limit)
446
+
447
+ cursor.execute(search_query, params)
448
+ return [dict(row) for row in cursor.fetchall()]
449
+
450
+ def update_memory_access(self, memory_id: str) -> None:
451
+ """Update memory access count and timestamp (handles string count)"""
452
+ if not self._check_connection() or not self.connection:
453
+ return
454
+
455
+ try:
456
+ cursor: Cursor = self.connection.cursor()
457
+ if not cursor:
458
+ return
459
+
460
+ # First get current count as string
461
+ cursor.execute("SELECT accessed_count FROM memories WHERE id = ?", (memory_id,))
462
+ result = cursor.fetchone()
463
+ if not result:
464
+ return
465
+
466
+ current_count = int(result['accessed_count']) if result['accessed_count'] else 0
467
+ new_count = str(current_count + 1)
468
+
469
+ # Update with new string count
470
+ cursor.execute("""
471
+ UPDATE memories
472
+ SET accessed_count = ?, last_accessed = CURRENT_TIMESTAMP
473
+ WHERE id = ?
474
+ """, (new_count, memory_id))
475
+ self.connection.commit()
476
+ except Exception as e:
477
+ logging.error(f"Error updating memory access: {e}")
478
+
479
+ def get_project_summary(self, project_id: str) -> Dict[str, Union[Dict[str, str], str]]:
480
+ """Get a summary of project statistics with all string values"""
481
+ if not self._check_connection() or not self.connection:
482
+ return {
483
+ "project": {},
484
+ "memory_counts": {},
485
+ "task_counts": {},
486
+ "total_memories": "0",
487
+ "total_tasks": "0"
488
+ }
489
+
490
+ cursor: Cursor = self.connection.cursor()
491
+ if not cursor:
492
+ return {
493
+ "project": {},
494
+ "memory_counts": {},
495
+ "task_counts": {},
496
+ "total_memories": "0",
497
+ "total_tasks": "0"
498
+ }
499
+
500
+ # Get project info - convert all values to strings
501
+ cursor.execute("SELECT * FROM projects WHERE id = ?", (project_id,))
502
+ project = {str(k): str(v) if v is not None else ""
503
+ for k, v in dict(cursor.fetchone() or {}).items()}
504
+
505
+ # Get memory counts - cast counts to TEXT in SQL
506
+ cursor.execute("""
507
+ SELECT type, CAST(COUNT(*) AS TEXT) as count FROM memories
508
+ WHERE project_id = ? GROUP BY type
509
+ """, (project_id,))
510
+ memory_counts = {str(row['type']): str(row['count']) for row in cursor.fetchall()}
511
+
512
+ # Get task counts - cast counts to TEXT in SQL
513
+ cursor.execute("""
514
+ SELECT status, CAST(COUNT(*) AS TEXT) as count FROM tasks
515
+ WHERE project_id = ? GROUP BY status
516
+ """, (project_id,))
517
+ task_counts = {str(row['status']): str(row['count']) for row in cursor.fetchall()}
518
+
519
+ # Calculate totals using string values
520
+ total_memories = str(sum(int(count) for count in memory_counts.values()))
521
+ total_tasks = str(sum(int(count) for count in task_counts.values()))
522
+
523
+ return {
524
+ "project": project,
525
+ "memory_counts": memory_counts,
526
+ "task_counts": task_counts,
527
+ "total_memories": total_memories,
528
+ "total_tasks": total_tasks
529
+ }
530
+
531
+ def close(self) -> None:
532
+ """Close database connection"""
533
+ if self.connection:
534
+ self.connection.close()
535
+ self.connection = None
536
+
537
+ # ==================== NOTIFICATION METHODS ====================
538
+
539
+ def add_notification(self, project_id: str, notification_type: str,
540
+ title: str, message: str, metadata: Optional[Dict[str, Any]] = None) -> str:
541
+ """Add a new notification"""
542
+ if not self._check_connection() or not self.connection:
543
+ return ""
544
+
545
+ cursor: Cursor = self.connection.cursor()
546
+ notification_id = str(uuid.uuid4())
547
+
548
+ cursor.execute("""
549
+ INSERT INTO notifications (id, project_id, type, title, message, metadata)
550
+ VALUES (?, ?, ?, ?, ?, ?)
551
+ """, (
552
+ notification_id, project_id, notification_type, title, message,
553
+ json.dumps(metadata or {})
554
+ ))
555
+ self.connection.commit()
556
+ return notification_id
557
+
558
+ def get_notifications(self, project_id: Optional[str] = None, is_read: Optional[bool] = None,
559
+ limit: int = 50) -> List[Dict[str, Any]]:
560
+ """Retrieve notifications with optional filters"""
561
+ if not self._check_connection() or not self.connection:
562
+ return []
563
+
564
+ cursor: Cursor = self.connection.cursor()
565
+
566
+ query = "SELECT * FROM notifications WHERE 1=1"
567
+ params = []
568
+
569
+ if project_id:
570
+ query += " AND project_id = ?"
571
+ params.append(project_id)
572
+
573
+ if is_read is not None:
574
+ query += " AND is_read = ?"
575
+ params.append(int(is_read))
576
+
577
+ query += " ORDER BY created_at DESC LIMIT ?"
578
+ params.append(limit)
579
+
580
+ cursor.execute(query, params)
581
+ return [dict(row) for row in cursor.fetchall()]
582
+
583
+ def mark_notification_read(self, notification_id: str) -> bool:
584
+ """Mark a notification as read"""
585
+ if not self._check_connection() or not self.connection:
586
+ return False
587
+
588
+ cursor: Cursor = self.connection.cursor()
589
+ cursor.execute("""
590
+ UPDATE notifications SET is_read = 1 WHERE id = ?
591
+ """, (notification_id,))
592
+ self.connection.commit()
593
+ return cursor.rowcount > 0
594
+
595
+ def clear_notifications(self, project_id: Optional[str] = None) -> int:
596
+ """Clear notifications (optionally for a specific project)"""
597
+ if not self._check_connection() or not self.connection:
598
+ return 0
599
+
600
+ cursor: Cursor = self.connection.cursor()
601
+ if project_id:
602
+ cursor.execute("DELETE FROM notifications WHERE project_id = ?", (project_id,))
603
+ else:
604
+ cursor.execute("DELETE FROM notifications")
605
+ self.connection.commit()
606
+ return cursor.rowcount
607
+
608
+ # ==================== CLEANUP AND OPTIMIZATION METHODS ====================
609
+
610
+ def cleanup_old_data(self, days_old: int = 30) -> Dict[str, int]:
611
+ """Clean up old memories, logs, and completed tasks"""
612
+ if not self._check_connection() or not self.connection:
613
+ return {"memories_deleted": 0, "tasks_deleted": 0, "notifications_deleted": 0}
614
+
615
+ try:
616
+ cutoff_date = datetime.now() - timedelta(days=days_old)
617
+ cursor: Cursor = self.connection.cursor()
618
+
619
+ # Clean old memories with low importance
620
+ cursor.execute("""
621
+ DELETE FROM memories
622
+ WHERE created_at < ? AND importance_score < 0.3
623
+ """, (cutoff_date,))
624
+ memories_deleted = cursor.rowcount
625
+
626
+ # Clean completed tasks older than cutoff
627
+ cursor.execute("""
628
+ DELETE FROM tasks
629
+ WHERE status = 'completed' AND updated_at < ?
630
+ """, (cutoff_date,))
631
+ tasks_deleted = cursor.rowcount
632
+
633
+ # Clean old read notifications
634
+ cursor.execute("""
635
+ DELETE FROM notifications
636
+ WHERE is_read = 1 AND created_at < ?
637
+ """, (cutoff_date,))
638
+ notifications_deleted = cursor.rowcount
639
+
640
+ self.connection.commit()
641
+
642
+ return {
643
+ "memories_deleted": memories_deleted,
644
+ "tasks_deleted": tasks_deleted,
645
+ "notifications_deleted": notifications_deleted
646
+ }
647
+ except Exception as e:
648
+ logging.error(f"Error during cleanup: {e}")
649
+ return {"memories_deleted": 0, "tasks_deleted": 0, "notifications_deleted": 0}
650
+
651
+ def optimize_memories(self) -> Dict[str, int]:
652
+ """Analyze and optimize memory storage by removing duplicates"""
653
+ if not self._check_connection() or not self.connection:
654
+ return {"duplicates_merged": 0, "orphaned_relationships": 0}
655
+
656
+ try:
657
+ cursor: Cursor = self.connection.cursor()
658
+
659
+ # Find duplicate memories (same content)
660
+ cursor.execute("""
661
+ SELECT content, COUNT(*) as count, GROUP_CONCAT(id) as ids
662
+ FROM memories
663
+ GROUP BY content
664
+ HAVING count > 1
665
+ """)
666
+
667
+ duplicates = cursor.fetchall()
668
+ merged_count = 0
669
+
670
+ for dup in duplicates:
671
+ ids = dup['ids'].split(',')
672
+ if len(ids) > 1:
673
+ # Keep the most recent, delete others
674
+ ids_to_delete = ids[1:] # Keep first (most recent due to ORDER BY)
675
+ placeholders = ','.join(['?'] * len(ids_to_delete))
676
+ cursor.execute(f"""
677
+ DELETE FROM memories
678
+ WHERE id IN ({placeholders})
679
+ """, ids_to_delete)
680
+ merged_count += cursor.rowcount
681
+
682
+ # Clean up orphaned relationships
683
+ cursor.execute("""
684
+ DELETE FROM knowledge_relationships
685
+ WHERE (from_type = 'memory' AND from_id NOT IN (SELECT id FROM memories))
686
+ OR (to_type = 'memory' AND to_id NOT IN (SELECT id FROM memories))
687
+ OR (from_type = 'task' AND from_id NOT IN (SELECT id FROM tasks))
688
+ OR (to_type = 'task' AND to_id NOT IN (SELECT id FROM tasks))
689
+ """)
690
+ orphaned_relationships = cursor.rowcount
691
+
692
+ self.connection.commit()
693
+
694
+ return {
695
+ "duplicates_merged": merged_count,
696
+ "orphaned_relationships": orphaned_relationships
697
+ }
698
+ except Exception as e:
699
+ logging.error(f"Error during optimization: {e}")
700
+ return {"duplicates_merged": 0, "orphaned_relationships": 0}
701
+
702
+ def get_database_stats(self) -> Dict[str, Any]:
703
+ """Get comprehensive database statistics"""
704
+ if not self._check_connection() or not self.connection:
705
+ return {}
706
+
707
+ try:
708
+ cursor: Cursor = self.connection.cursor()
709
+ stats = {}
710
+
711
+ # Table counts
712
+ tables = ['projects', 'memories', 'tasks', 'knowledge_relationships', 'sessions', 'notifications']
713
+ for table in tables:
714
+ cursor.execute(f"SELECT COUNT(*) as count FROM {table}")
715
+ stats[f"{table}_count"] = cursor.fetchone()['count']
716
+
717
+ # Database size
718
+ cursor.execute("SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()")
719
+ stats['database_size_bytes'] = cursor.fetchone()['size']
720
+
721
+ # Memory statistics
722
+ cursor.execute("""
723
+ SELECT
724
+ AVG(importance_score) as avg_importance,
725
+ MAX(accessed_count) as max_accessed,
726
+ COUNT(DISTINCT type) as memory_types
727
+ FROM memories
728
+ """)
729
+ memory_stats = cursor.fetchone()
730
+ if memory_stats:
731
+ stats.update({
732
+ 'avg_memory_importance': memory_stats['avg_importance'] or 0,
733
+ 'max_memory_accessed': memory_stats['max_accessed'] or 0,
734
+ 'memory_types_count': memory_stats['memory_types'] or 0
735
+ })
736
+
737
+ # Task statistics
738
+ cursor.execute("""
739
+ SELECT
740
+ status,
741
+ COUNT(*) as count
742
+ FROM tasks
743
+ GROUP BY status
744
+ """)
745
+ task_stats = {row['status']: row['count'] for row in cursor.fetchall()}
746
+ stats['task_status_breakdown'] = task_stats
747
+
748
+ return stats
749
+ except Exception as e:
750
+ logging.error(f"Error getting database stats: {e}")
751
+ return {}