superlocalmemory 2.4.2 → 2.5.0

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.
package/ui_server.py CHANGED
@@ -12,72 +12,56 @@ Attribution must be preserved in all copies or derivatives.
12
12
  """
13
13
 
14
14
  """
15
- SuperLocalMemory V2.2.0 - FastAPI UI Server with WebSocket Support
16
- Comprehensive REST and WebSocket API for memory visualization and real-time updates.
17
-
18
- Features:
19
- - Full REST API for memory CRUD operations
20
- - WebSocket support for real-time memory updates
21
- - Profile management and switching
22
- - Import/Export functionality
23
- - Advanced search with filters
24
- - Cluster detail views
25
- - Timeline aggregation (day/week/month)
26
- - CORS enabled for cross-origin requests
27
- - Response compression
28
- - Comprehensive error handling
15
+ SuperLocalMemory V2.5.0 - FastAPI UI Server
16
+ App initialization, middleware, static mount, and router registration.
17
+
18
+ All route handlers live in routes/ directory:
19
+ routes/memories.py — /api/memories, /api/graph, /api/search, /api/clusters
20
+ routes/stats.py — /api/stats, /api/timeline, /api/patterns
21
+ routes/profiles.py — /api/profiles (CRUD + switch)
22
+ routes/backup.py — /api/backup (status, create, configure, list)
23
+ routes/data_io.py — /api/export, /api/import
24
+ routes/events.py — /events/stream (SSE), /api/events [v2.5]
25
+ routes/agents.py — /api/agents, /api/trust [v2.5]
26
+ routes/ws.py — /ws/updates (WebSocket)
29
27
  """
30
28
 
31
- import sqlite3
32
- import json
33
- import asyncio
34
- import gzip
35
- import io
29
+ import sys
36
30
  from pathlib import Path
37
- from typing import Optional, List, Dict, Any, Set
38
- from datetime import datetime, timedelta
39
- from collections import defaultdict
31
+ from datetime import datetime
40
32
 
41
33
  try:
42
- from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect, UploadFile, File
34
+ from fastapi import FastAPI
43
35
  from fastapi.staticfiles import StaticFiles
44
- from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
36
+ from fastapi.responses import HTMLResponse
45
37
  from fastapi.middleware.cors import CORSMiddleware
46
38
  from fastapi.middleware.gzip import GZipMiddleware
47
- from pydantic import BaseModel, Field, validator
48
39
  import uvicorn
49
- FASTAPI_AVAILABLE = True
50
40
  except ImportError:
51
- FASTAPI_AVAILABLE = False
52
41
  raise ImportError(
53
42
  "FastAPI dependencies not installed. "
54
43
  "Install with: pip install 'fastapi[all]' uvicorn websockets"
55
44
  )
56
45
 
57
- # Import local modules
58
- import sys
46
+ # Add src/ and routes/ to path
59
47
  sys.path.insert(0, str(Path(__file__).parent / "src"))
60
-
61
- from memory_store_v2 import MemoryStoreV2
62
- from graph_engine import GraphEngine
63
- from pattern_learner import PatternLearner
48
+ sys.path.insert(0, str(Path(__file__).parent))
64
49
 
65
50
  # Configuration
66
51
  MEMORY_DIR = Path.home() / ".claude-memory"
67
52
  DB_PATH = MEMORY_DIR / "memory.db"
68
53
  UI_DIR = Path(__file__).parent / "ui"
69
- PROFILES_DIR = MEMORY_DIR / "profiles"
70
54
 
71
55
  # Initialize FastAPI application
72
56
  app = FastAPI(
73
- title="SuperLocalMemory V2.2.0 UI Server",
74
- description="Knowledge Graph Visualization with Real-Time Updates",
75
- version="2.2.0",
57
+ title="SuperLocalMemory V2.5.0 UI Server",
58
+ description="Real-Time Memory Dashboard with Event Bus, Agent Registry, and Trust Scoring",
59
+ version="2.5.0",
76
60
  docs_url="/api/docs",
77
61
  redoc_url="/api/redoc"
78
62
  )
79
63
 
80
- # Add CORS middleware (for web UI development)
64
+ # Middleware
81
65
  app.add_middleware(
82
66
  CORSMiddleware,
83
67
  allow_origins=["http://localhost:*", "http://127.0.0.1:*"],
@@ -85,121 +69,42 @@ app.add_middleware(
85
69
  allow_methods=["*"],
86
70
  allow_headers=["*"],
87
71
  )
88
-
89
- # Add GZip compression middleware
90
72
  app.add_middleware(GZipMiddleware, minimum_size=1000)
91
73
 
92
- # WebSocket connection manager
93
- class ConnectionManager:
94
- """Manages WebSocket connections for real-time updates."""
95
-
96
- def __init__(self):
97
- self.active_connections: Set[WebSocket] = set()
98
-
99
- async def connect(self, websocket: WebSocket):
100
- """Accept and register a WebSocket connection."""
101
- await websocket.accept()
102
- self.active_connections.add(websocket)
103
-
104
- def disconnect(self, websocket: WebSocket):
105
- """Remove a WebSocket connection."""
106
- self.active_connections.discard(websocket)
107
-
108
- async def broadcast(self, message: dict):
109
- """Broadcast message to all connected clients."""
110
- disconnected = set()
111
- for connection in self.active_connections:
112
- try:
113
- await connection.send_json(message)
114
- except Exception:
115
- disconnected.add(connection)
116
-
117
- # Clean up disconnected clients
118
- self.active_connections -= disconnected
119
-
120
- manager = ConnectionManager()
121
-
122
74
  # Mount static files (UI directory)
123
75
  UI_DIR.mkdir(exist_ok=True)
124
76
  app.mount("/static", StaticFiles(directory=str(UI_DIR)), name="static")
125
77
 
126
-
127
- # ============================================================================
128
- # Profile Helper
129
- # ============================================================================
130
-
131
- def get_active_profile() -> str:
132
- """Read the active profile from profiles.json. Falls back to 'default'."""
133
- config_file = MEMORY_DIR / "profiles.json"
134
- if config_file.exists():
135
- try:
136
- with open(config_file, 'r') as f:
137
- pconfig = json.load(f)
138
- return pconfig.get('active_profile', 'default')
139
- except (json.JSONDecodeError, IOError):
140
- pass
141
- return 'default'
142
-
143
-
144
78
  # ============================================================================
145
- # Request/Response Models
79
+ # Register Route Modules
146
80
  # ============================================================================
147
81
 
148
- class SearchRequest(BaseModel):
149
- """Advanced search request model."""
150
- query: str = Field(..., min_length=1, max_length=1000)
151
- limit: int = Field(default=10, ge=1, le=100)
152
- min_score: float = Field(default=0.3, ge=0.0, le=1.0)
153
- category: Optional[str] = None
154
- project_name: Optional[str] = None
155
- cluster_id: Optional[int] = None
156
- date_from: Optional[str] = None # ISO format: YYYY-MM-DD
157
- date_to: Optional[str] = None
158
-
159
- class MemoryFilter(BaseModel):
160
- """Memory filtering options."""
161
- category: Optional[str] = None
162
- project_name: Optional[str] = None
163
- cluster_id: Optional[int] = None
164
- min_importance: Optional[int] = Field(None, ge=1, le=10)
165
- tags: Optional[List[str]] = None
82
+ from routes.memories import router as memories_router
83
+ from routes.stats import router as stats_router
84
+ from routes.profiles import router as profiles_router
85
+ from routes.backup import router as backup_router
86
+ from routes.data_io import router as data_io_router
87
+ from routes.events import router as events_router, register_event_listener
88
+ from routes.agents import router as agents_router
89
+ from routes.ws import router as ws_router, manager as ws_manager
166
90
 
167
- class ProfileSwitch(BaseModel):
168
- """Profile switching request."""
169
- profile_name: str = Field(..., min_length=1, max_length=50)
170
-
171
- class TimelineParams(BaseModel):
172
- """Timeline aggregation parameters."""
173
- days: int = Field(default=30, ge=1, le=365)
174
- group_by: str = Field(default="day", pattern="^(day|week|month)$")
91
+ app.include_router(memories_router)
92
+ app.include_router(stats_router)
93
+ app.include_router(profiles_router)
94
+ app.include_router(backup_router)
95
+ app.include_router(data_io_router)
96
+ app.include_router(events_router)
97
+ app.include_router(agents_router)
98
+ app.include_router(ws_router)
175
99
 
100
+ # Wire WebSocket manager into routes that need broadcast capability
101
+ import routes.profiles as _profiles_mod
102
+ import routes.data_io as _data_io_mod
103
+ _profiles_mod.ws_manager = ws_manager
104
+ _data_io_mod.ws_manager = ws_manager
176
105
 
177
106
  # ============================================================================
178
- # Database Helper Functions
179
- # ============================================================================
180
-
181
- def get_db_connection():
182
- """Get database connection with attribution header."""
183
- if not DB_PATH.exists():
184
- raise HTTPException(
185
- status_code=500,
186
- detail="Memory database not found. Run 'memory-init' to initialize."
187
- )
188
- return sqlite3.connect(DB_PATH)
189
-
190
- def dict_factory(cursor, row):
191
- """Convert SQLite row to dictionary."""
192
- fields = [column[0] for column in cursor.description]
193
- return {key: value for key, value in zip(fields, row)}
194
-
195
- def validate_profile_name(name: str) -> bool:
196
- """Validate profile name (alphanumeric, underscore, hyphen only)."""
197
- import re
198
- return bool(re.match(r'^[a-zA-Z0-9_-]+$', name))
199
-
200
-
201
- # ============================================================================
202
- # API Endpoints - Basic Routes
107
+ # Basic Routes (root page + health check)
203
108
  # ============================================================================
204
109
 
205
110
  @app.get("/", response_class=HTMLResponse)
@@ -210,146 +115,11 @@ async def root():
210
115
  return """
211
116
  <!DOCTYPE html>
212
117
  <html>
213
- <head>
214
- <title>SuperLocalMemory V2.2.0</title>
215
- <meta charset="utf-8">
216
- <style>
217
- body {
218
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
219
- padding: 40px;
220
- max-width: 1200px;
221
- margin: 0 auto;
222
- background: #f5f5f5;
223
- }
224
- .header {
225
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
226
- color: white;
227
- padding: 30px;
228
- border-radius: 8px;
229
- margin-bottom: 30px;
230
- }
231
- h1 { margin: 0; font-size: 2em; }
232
- h2 { color: #333; margin-top: 30px; }
233
- ul { line-height: 1.8; }
234
- a { color: #667eea; text-decoration: none; }
235
- a:hover { text-decoration: underline; }
236
- .endpoint {
237
- background: white;
238
- padding: 10px 15px;
239
- margin: 5px 0;
240
- border-radius: 4px;
241
- border-left: 3px solid #667eea;
242
- }
243
- .badge {
244
- display: inline-block;
245
- padding: 3px 8px;
246
- background: #667eea;
247
- color: white;
248
- border-radius: 3px;
249
- font-size: 0.8em;
250
- margin-left: 10px;
251
- }
252
- footer {
253
- margin-top: 50px;
254
- padding-top: 20px;
255
- border-top: 2px solid #ddd;
256
- color: #666;
257
- text-align: center;
258
- }
259
- </style>
260
- </head>
261
- <body>
262
- <div class="header">
263
- <h1>SuperLocalMemory V2.2.0 UI Server</h1>
264
- <p>FastAPI Backend with WebSocket Support</p>
265
- </div>
266
-
267
- <h2>Available Endpoints</h2>
268
-
269
- <div class="endpoint">
270
- <a href="/api/docs">/api/docs</a>
271
- <span class="badge">Interactive</span>
272
- <p>Swagger UI - Interactive API Documentation</p>
273
- </div>
274
-
275
- <div class="endpoint">
276
- <a href="/api/stats">/api/stats</a>
277
- <span class="badge">GET</span>
278
- <p>System statistics and overview</p>
279
- </div>
280
-
281
- <div class="endpoint">
282
- <a href="/api/memories">/api/memories</a>
283
- <span class="badge">GET</span>
284
- <p>List and filter memories</p>
285
- </div>
286
-
287
- <div class="endpoint">
288
- <a href="/api/graph">/api/graph</a>
289
- <span class="badge">GET</span>
290
- <p>Knowledge graph data for visualization</p>
291
- </div>
292
-
293
- <div class="endpoint">
294
- <a href="/api/timeline">/api/timeline</a>
295
- <span class="badge">GET</span>
296
- <p>Timeline view with day/week/month aggregation</p>
297
- </div>
298
-
299
- <div class="endpoint">
300
- <a href="/api/patterns">/api/patterns</a>
301
- <span class="badge">GET</span>
302
- <p>Learned patterns and preferences</p>
303
- </div>
304
-
305
- <div class="endpoint">
306
- <a href="/api/clusters">/api/clusters</a>
307
- <span class="badge">GET</span>
308
- <p>Cluster information and themes</p>
309
- </div>
310
-
311
- <div class="endpoint">
312
- /api/clusters/{id} <span class="badge">GET</span>
313
- <p>Detailed cluster view with members</p>
314
- </div>
315
-
316
- <div class="endpoint">
317
- /api/search <span class="badge">POST</span>
318
- <p>Advanced semantic search</p>
319
- </div>
320
-
321
- <div class="endpoint">
322
- <a href="/api/profiles">/api/profiles</a>
323
- <span class="badge">GET</span>
324
- <p>List available memory profiles</p>
325
- </div>
326
-
327
- <div class="endpoint">
328
- /api/profiles/{name}/switch <span class="badge">POST</span>
329
- <p>Switch active memory profile</p>
330
- </div>
331
-
332
- <div class="endpoint">
333
- <a href="/api/export">/api/export</a>
334
- <span class="badge">GET</span>
335
- <p>Export memories as JSON</p>
336
- </div>
337
-
338
- <div class="endpoint">
339
- /api/import <span class="badge">POST</span>
340
- <p>Import memories from JSON file</p>
341
- </div>
342
-
343
- <div class="endpoint">
344
- /ws/updates <span class="badge">WebSocket</span>
345
- <p>Real-time memory updates stream</p>
346
- </div>
347
-
348
- <footer>
349
- <p><strong>SuperLocalMemory V2.2.0</strong></p>
350
- <p>Copyright (c) 2026 Varun Pratap Bhardwaj</p>
351
- <p>Licensed under MIT License</p>
352
- </footer>
118
+ <head><title>SuperLocalMemory V2.5.0</title></head>
119
+ <body style="font-family: Arial; padding: 40px;">
120
+ <h1>SuperLocalMemory V2.5.0 UI Server Running</h1>
121
+ <p>UI not found. Check ui/index.html</p>
122
+ <p><a href="/api/docs">API Documentation</a></p>
353
123
  </body>
354
124
  </html>
355
125
  """
@@ -361,1378 +131,20 @@ async def health_check():
361
131
  """Health check endpoint."""
362
132
  return {
363
133
  "status": "healthy",
364
- "version": "2.2.0",
134
+ "version": "2.5.0",
365
135
  "database": "connected" if DB_PATH.exists() else "missing",
366
136
  "timestamp": datetime.now().isoformat()
367
137
  }
368
138
 
369
139
 
370
140
  # ============================================================================
371
- # API Endpoints - Memory Management
372
- # ============================================================================
373
-
374
- @app.get("/api/memories")
375
- async def get_memories(
376
- category: Optional[str] = None,
377
- project_name: Optional[str] = None,
378
- cluster_id: Optional[int] = None,
379
- min_importance: Optional[int] = None,
380
- tags: Optional[str] = None, # Comma-separated
381
- limit: int = Query(50, ge=1, le=200),
382
- offset: int = Query(0, ge=0)
383
- ):
384
- """
385
- List memories with optional filtering and pagination.
386
-
387
- Query Parameters:
388
- - category: Filter by category
389
- - project_name: Filter by project
390
- - cluster_id: Filter by cluster
391
- - min_importance: Minimum importance score (1-10)
392
- - tags: Comma-separated tag list
393
- - limit: Maximum results (default 50, max 200)
394
- - offset: Pagination offset
395
-
396
- Returns:
397
- - memories: List of memory objects
398
- - total: Total count matching filters
399
- - limit: Applied limit
400
- - offset: Applied offset
401
- """
402
- try:
403
- conn = get_db_connection()
404
- conn.row_factory = dict_factory
405
- cursor = conn.cursor()
406
-
407
- active_profile = get_active_profile()
408
-
409
- # Build dynamic query
410
- query = """
411
- SELECT
412
- id, content, summary, category, project_name, project_path,
413
- importance, cluster_id, depth, access_count, parent_id,
414
- created_at, updated_at, last_accessed, tags, memory_type
415
- FROM memories
416
- WHERE profile = ?
417
- """
418
- params = [active_profile]
419
-
420
- if category:
421
- query += " AND category = ?"
422
- params.append(category)
423
-
424
- if project_name:
425
- query += " AND project_name = ?"
426
- params.append(project_name)
427
-
428
- if cluster_id is not None:
429
- query += " AND cluster_id = ?"
430
- params.append(cluster_id)
431
-
432
- if min_importance:
433
- query += " AND importance >= ?"
434
- params.append(min_importance)
435
-
436
- if tags:
437
- tag_list = [t.strip() for t in tags.split(',')]
438
- for tag in tag_list:
439
- query += " AND tags LIKE ?"
440
- params.append(f'%{tag}%')
441
-
442
- query += " ORDER BY updated_at DESC LIMIT ? OFFSET ?"
443
- params.extend([limit, offset])
444
-
445
- cursor.execute(query, params)
446
- memories = cursor.fetchall()
447
-
448
- # Get total count
449
- count_query = "SELECT COUNT(*) as total FROM memories WHERE profile = ?"
450
- count_params = [active_profile]
451
-
452
- if category:
453
- count_query += " AND category = ?"
454
- count_params.append(category)
455
- if project_name:
456
- count_query += " AND project_name = ?"
457
- count_params.append(project_name)
458
- if cluster_id is not None:
459
- count_query += " AND cluster_id = ?"
460
- count_params.append(cluster_id)
461
- if min_importance:
462
- count_query += " AND importance >= ?"
463
- count_params.append(min_importance)
464
-
465
- cursor.execute(count_query, count_params)
466
- total = cursor.fetchone()['total']
467
-
468
- conn.close()
469
-
470
- return {
471
- "memories": memories,
472
- "total": total,
473
- "limit": limit,
474
- "offset": offset,
475
- "has_more": (offset + limit) < total
476
- }
477
-
478
- except Exception as e:
479
- raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
480
-
481
-
482
- @app.get("/api/graph")
483
- async def get_graph(
484
- max_nodes: int = Query(100, ge=10, le=500),
485
- min_importance: int = Query(1, ge=1, le=10)
486
- ):
487
- """
488
- Get knowledge graph data for D3.js force-directed visualization.
489
-
490
- Parameters:
491
- - max_nodes: Maximum nodes to return (default 100, max 500)
492
- - min_importance: Minimum importance filter (default 1)
493
-
494
- Returns:
495
- - nodes: List of memory nodes with metadata
496
- - links: List of edges between memories
497
- - clusters: Cluster information
498
- - metadata: Graph statistics
499
- """
500
- try:
501
- conn = get_db_connection()
502
- conn.row_factory = dict_factory
503
- cursor = conn.cursor()
504
-
505
- active_profile = get_active_profile()
506
-
507
- # Get nodes (memories with graph data)
508
- cursor.execute("""
509
- SELECT
510
- m.id, m.content, m.summary, m.category,
511
- m.cluster_id, m.importance, m.project_name,
512
- m.created_at, m.tags,
513
- gn.entities
514
- FROM memories m
515
- LEFT JOIN graph_nodes gn ON m.id = gn.memory_id
516
- WHERE m.importance >= ? AND m.profile = ?
517
- ORDER BY m.importance DESC, m.updated_at DESC
518
- LIMIT ?
519
- """, (min_importance, active_profile, max_nodes))
520
- nodes = cursor.fetchall()
521
-
522
- # Parse entities JSON and create previews
523
- for node in nodes:
524
- if node['entities']:
525
- try:
526
- node['entities'] = json.loads(node['entities'])
527
- except:
528
- node['entities'] = []
529
- else:
530
- node['entities'] = []
531
-
532
- # Create content preview
533
- if node['content']:
534
- node['content_preview'] = (
535
- node['content'][:100] + "..."
536
- if len(node['content']) > 100
537
- else node['content']
538
- )
539
-
540
- # Get edges
541
- memory_ids = [n['id'] for n in nodes]
542
- if memory_ids:
543
- placeholders = ','.join('?' * len(memory_ids))
544
- cursor.execute(f"""
545
- SELECT
546
- source_memory_id as source,
547
- target_memory_id as target,
548
- weight,
549
- relationship_type,
550
- shared_entities
551
- FROM graph_edges
552
- WHERE source_memory_id IN ({placeholders})
553
- AND target_memory_id IN ({placeholders})
554
- ORDER BY weight DESC
555
- """, memory_ids + memory_ids)
556
- links = cursor.fetchall()
557
-
558
- # Parse shared entities
559
- for link in links:
560
- if link['shared_entities']:
561
- try:
562
- link['shared_entities'] = json.loads(link['shared_entities'])
563
- except:
564
- link['shared_entities'] = []
565
- else:
566
- links = []
567
-
568
- # Get cluster information
569
- cursor.execute("""
570
- SELECT
571
- cluster_id,
572
- COUNT(*) as size,
573
- AVG(importance) as avg_importance
574
- FROM memories
575
- WHERE cluster_id IS NOT NULL AND profile = ?
576
- GROUP BY cluster_id
577
- """, (active_profile,))
578
- clusters = cursor.fetchall()
579
-
580
- conn.close()
581
-
582
- return {
583
- "nodes": nodes,
584
- "links": links,
585
- "clusters": clusters,
586
- "metadata": {
587
- "node_count": len(nodes),
588
- "edge_count": len(links),
589
- "cluster_count": len(clusters),
590
- "filters_applied": {
591
- "max_nodes": max_nodes,
592
- "min_importance": min_importance
593
- }
594
- }
595
- }
596
-
597
- except Exception as e:
598
- raise HTTPException(status_code=500, detail=f"Graph error: {str(e)}")
599
-
600
-
601
- @app.get("/api/timeline")
602
- async def get_timeline(
603
- days: int = Query(30, ge=1, le=365),
604
- group_by: str = Query("day", pattern="^(day|week|month)$")
605
- ):
606
- """
607
- Get temporal view of memory creation with flexible grouping.
608
-
609
- Parameters:
610
- - days: Number of days to look back (default 30, max 365)
611
- - group_by: Aggregation period ('day', 'week', 'month')
612
-
613
- Returns:
614
- - timeline: Aggregated memory counts by period
615
- - category_trend: Category breakdown over time
616
- - period_stats: Statistics for the period
617
- """
618
- try:
619
- conn = get_db_connection()
620
- conn.row_factory = dict_factory
621
- cursor = conn.cursor()
622
-
623
- # Determine date grouping SQL
624
- if group_by == "day":
625
- date_group = "DATE(created_at)"
626
- elif group_by == "week":
627
- date_group = "strftime('%Y-W%W', created_at)"
628
- else: # month
629
- date_group = "strftime('%Y-%m', created_at)"
630
-
631
- active_profile = get_active_profile()
632
-
633
- # Timeline aggregates
634
- cursor.execute(f"""
635
- SELECT
636
- {date_group} as period,
637
- COUNT(*) as count,
638
- AVG(importance) as avg_importance,
639
- MIN(importance) as min_importance,
640
- MAX(importance) as max_importance,
641
- GROUP_CONCAT(DISTINCT category) as categories
642
- FROM memories
643
- WHERE created_at >= datetime('now', '-' || ? || ' days')
644
- AND profile = ?
645
- GROUP BY {date_group}
646
- ORDER BY period DESC
647
- """, (days, active_profile))
648
- timeline = cursor.fetchall()
649
-
650
- # Category trend over time
651
- cursor.execute(f"""
652
- SELECT
653
- {date_group} as period,
654
- category,
655
- COUNT(*) as count
656
- FROM memories
657
- WHERE created_at >= datetime('now', '-' || ? || ' days')
658
- AND category IS NOT NULL AND profile = ?
659
- GROUP BY {date_group}, category
660
- ORDER BY period DESC, count DESC
661
- """, (days, active_profile))
662
- category_trend = cursor.fetchall()
663
-
664
- # Period statistics
665
- cursor.execute("""
666
- SELECT
667
- COUNT(*) as total_memories,
668
- COUNT(DISTINCT category) as categories_used,
669
- COUNT(DISTINCT project_name) as projects_active,
670
- AVG(importance) as avg_importance
671
- FROM memories
672
- WHERE created_at >= datetime('now', '-' || ? || ' days')
673
- AND profile = ?
674
- """, (days, active_profile))
675
- period_stats = cursor.fetchone()
676
-
677
- conn.close()
678
-
679
- return {
680
- "timeline": timeline,
681
- "category_trend": category_trend,
682
- "period_stats": period_stats,
683
- "parameters": {
684
- "days": days,
685
- "group_by": group_by
686
- }
687
- }
688
-
689
- except Exception as e:
690
- raise HTTPException(status_code=500, detail=f"Timeline error: {str(e)}")
691
-
692
-
693
- @app.get("/api/clusters")
694
- async def get_clusters():
695
- """
696
- Get cluster information with member counts, themes, and statistics.
697
-
698
- Returns:
699
- - clusters: List of clusters with metadata
700
- - total_clusters: Total number of clusters
701
- - unclustered_count: Memories without cluster assignment
702
- """
703
- try:
704
- conn = get_db_connection()
705
- conn.row_factory = dict_factory
706
- cursor = conn.cursor()
707
-
708
- active_profile = get_active_profile()
709
-
710
- # Get cluster statistics with hierarchy and summaries
711
- cursor.execute("""
712
- SELECT
713
- m.cluster_id,
714
- COUNT(*) as member_count,
715
- AVG(m.importance) as avg_importance,
716
- MIN(m.importance) as min_importance,
717
- MAX(m.importance) as max_importance,
718
- GROUP_CONCAT(DISTINCT m.category) as categories,
719
- GROUP_CONCAT(DISTINCT m.project_name) as projects,
720
- MIN(m.created_at) as first_memory,
721
- MAX(m.created_at) as latest_memory,
722
- gc.summary,
723
- gc.parent_cluster_id,
724
- gc.depth
725
- FROM memories m
726
- LEFT JOIN graph_clusters gc ON m.cluster_id = gc.id
727
- WHERE m.cluster_id IS NOT NULL AND m.profile = ?
728
- GROUP BY m.cluster_id
729
- ORDER BY COALESCE(gc.depth, 0) ASC, member_count DESC
730
- """, (active_profile,))
731
- clusters = cursor.fetchall()
732
-
733
- # Get dominant entities per cluster
734
- for cluster in clusters:
735
- cluster_id = cluster['cluster_id']
736
-
737
- # Aggregate entities from all members
738
- cursor.execute("""
739
- SELECT gn.entities
740
- FROM graph_nodes gn
741
- JOIN memories m ON gn.memory_id = m.id
742
- WHERE m.cluster_id = ?
743
- """, (cluster_id,))
744
-
745
- all_entities = []
746
- for row in cursor.fetchall():
747
- if row['entities']:
748
- try:
749
- entities = json.loads(row['entities'])
750
- all_entities.extend(entities)
751
- except:
752
- pass
753
-
754
- # Count and get top entities
755
- from collections import Counter
756
- entity_counts = Counter(all_entities)
757
- cluster['top_entities'] = [
758
- {"entity": e, "count": c}
759
- for e, c in entity_counts.most_common(10)
760
- ]
761
-
762
- # Get unclustered count
763
- cursor.execute("""
764
- SELECT COUNT(*) as count
765
- FROM memories
766
- WHERE cluster_id IS NULL AND profile = ?
767
- """, (active_profile,))
768
- unclustered = cursor.fetchone()['count']
769
-
770
- conn.close()
771
-
772
- return {
773
- "clusters": clusters,
774
- "total_clusters": len(clusters),
775
- "unclustered_count": unclustered
776
- }
777
-
778
- except Exception as e:
779
- raise HTTPException(status_code=500, detail=f"Cluster error: {str(e)}")
780
-
781
-
782
- @app.get("/api/clusters/{cluster_id}")
783
- async def get_cluster_detail(
784
- cluster_id: int,
785
- limit: int = Query(50, ge=1, le=200)
786
- ):
787
- """
788
- Get detailed view of a specific cluster.
789
-
790
- Parameters:
791
- - cluster_id: Cluster ID to retrieve
792
- - limit: Maximum members to return
793
-
794
- Returns:
795
- - cluster_info: Cluster metadata and statistics
796
- - members: List of memories in the cluster
797
- - connections: Internal edges within cluster
798
- """
799
- try:
800
- conn = get_db_connection()
801
- conn.row_factory = dict_factory
802
- cursor = conn.cursor()
803
-
804
- # Get cluster members
805
- cursor.execute("""
806
- SELECT
807
- m.id, m.content, m.summary, m.category,
808
- m.project_name, m.importance, m.created_at,
809
- m.tags, gn.entities
810
- FROM memories m
811
- LEFT JOIN graph_nodes gn ON m.id = gn.memory_id
812
- WHERE m.cluster_id = ?
813
- ORDER BY m.importance DESC, m.created_at DESC
814
- LIMIT ?
815
- """, (cluster_id, limit))
816
- members = cursor.fetchall()
817
-
818
- if not members:
819
- raise HTTPException(status_code=404, detail="Cluster not found")
820
-
821
- # Parse entities
822
- for member in members:
823
- if member['entities']:
824
- try:
825
- member['entities'] = json.loads(member['entities'])
826
- except:
827
- member['entities'] = []
828
-
829
- # Get cluster statistics
830
- cursor.execute("""
831
- SELECT
832
- COUNT(*) as total_members,
833
- AVG(importance) as avg_importance,
834
- COUNT(DISTINCT category) as category_count,
835
- COUNT(DISTINCT project_name) as project_count
836
- FROM memories
837
- WHERE cluster_id = ?
838
- """, (cluster_id,))
839
- stats = cursor.fetchone()
840
-
841
- # Get internal connections
842
- member_ids = [m['id'] for m in members]
843
- if member_ids:
844
- placeholders = ','.join('?' * len(member_ids))
845
- cursor.execute(f"""
846
- SELECT
847
- source_memory_id as source,
848
- target_memory_id as target,
849
- weight,
850
- shared_entities
851
- FROM graph_edges
852
- WHERE source_memory_id IN ({placeholders})
853
- AND target_memory_id IN ({placeholders})
854
- """, member_ids + member_ids)
855
- connections = cursor.fetchall()
856
- else:
857
- connections = []
858
-
859
- conn.close()
860
-
861
- return {
862
- "cluster_info": {
863
- "cluster_id": cluster_id,
864
- **stats
865
- },
866
- "members": members,
867
- "connections": connections
868
- }
869
-
870
- except HTTPException:
871
- raise
872
- except Exception as e:
873
- raise HTTPException(status_code=500, detail=f"Cluster detail error: {str(e)}")
874
-
875
-
876
- @app.get("/api/patterns")
877
- async def get_patterns():
878
- """
879
- Get learned patterns from Pattern Learner (Layer 4).
880
-
881
- Returns:
882
- - patterns: Grouped patterns by type
883
- - total_patterns: Total pattern count
884
- - pattern_types: List of pattern types found
885
- - confidence_stats: Confidence distribution
886
- """
887
- try:
888
- conn = get_db_connection()
889
- conn.row_factory = dict_factory
890
- cursor = conn.cursor()
891
-
892
- # Check if identity_patterns table exists
893
- cursor.execute("""
894
- SELECT name FROM sqlite_master
895
- WHERE type='table' AND name='identity_patterns'
896
- """)
897
-
898
- if not cursor.fetchone():
899
- return {
900
- "patterns": {},
901
- "total_patterns": 0,
902
- "pattern_types": [],
903
- "message": "Pattern learning not initialized. Run pattern learning first."
904
- }
905
-
906
- active_profile = get_active_profile()
907
-
908
- cursor.execute("""
909
- SELECT
910
- pattern_type, key, value, confidence,
911
- evidence_count, updated_at as last_updated
912
- FROM identity_patterns
913
- WHERE profile = ?
914
- ORDER BY confidence DESC, evidence_count DESC
915
- """, (active_profile,))
916
- patterns = cursor.fetchall()
917
-
918
- # Parse value JSON
919
- for pattern in patterns:
920
- if pattern['value']:
921
- try:
922
- pattern['value'] = json.loads(pattern['value'])
923
- except:
924
- pass
925
-
926
- # Group by type
927
- grouped = defaultdict(list)
928
- for pattern in patterns:
929
- grouped[pattern['pattern_type']].append(pattern)
930
-
931
- # Confidence statistics
932
- confidences = [p['confidence'] for p in patterns]
933
- confidence_stats = {
934
- "avg": sum(confidences) / len(confidences) if confidences else 0,
935
- "min": min(confidences) if confidences else 0,
936
- "max": max(confidences) if confidences else 0
937
- }
938
-
939
- conn.close()
940
-
941
- return {
942
- "patterns": dict(grouped),
943
- "total_patterns": len(patterns),
944
- "pattern_types": list(grouped.keys()),
945
- "confidence_stats": confidence_stats
946
- }
947
-
948
- except Exception as e:
949
- return {
950
- "patterns": {},
951
- "total_patterns": 0,
952
- "error": str(e)
953
- }
954
-
955
-
956
- @app.get("/api/stats")
957
- async def get_stats():
958
- """
959
- Get comprehensive system statistics.
960
-
961
- Returns:
962
- - overview: Basic counts and metrics
963
- - categories: Category breakdown
964
- - projects: Project breakdown
965
- - importance_distribution: Importance score distribution
966
- - recent_activity: Recent memory statistics
967
- - graph_stats: Graph-specific metrics
968
- """
969
- try:
970
- conn = get_db_connection()
971
- conn.row_factory = dict_factory
972
- cursor = conn.cursor()
973
-
974
- active_profile = get_active_profile()
975
-
976
- # Basic counts (profile-filtered)
977
- cursor.execute("SELECT COUNT(*) as total FROM memories WHERE profile = ?", (active_profile,))
978
- total_memories = cursor.fetchone()['total']
979
-
980
- cursor.execute("SELECT COUNT(*) as total FROM sessions")
981
- total_sessions = cursor.fetchone()['total']
982
-
983
- cursor.execute("SELECT COUNT(DISTINCT cluster_id) as total FROM memories WHERE cluster_id IS NOT NULL AND profile = ?", (active_profile,))
984
- total_clusters = cursor.fetchone()['total']
985
-
986
- cursor.execute("""
987
- SELECT COUNT(*) as total FROM graph_nodes gn
988
- JOIN memories m ON gn.memory_id = m.id
989
- WHERE m.profile = ?
990
- """, (active_profile,))
991
- total_graph_nodes = cursor.fetchone()['total']
992
-
993
- cursor.execute("""
994
- SELECT COUNT(*) as total FROM graph_edges ge
995
- JOIN memories m ON ge.source_memory_id = m.id
996
- WHERE m.profile = ?
997
- """, (active_profile,))
998
- total_graph_edges = cursor.fetchone()['total']
999
-
1000
- # Category breakdown
1001
- cursor.execute("""
1002
- SELECT category, COUNT(*) as count
1003
- FROM memories
1004
- WHERE category IS NOT NULL
1005
- GROUP BY category
1006
- ORDER BY count DESC
1007
- LIMIT 10
1008
- """)
1009
- categories = cursor.fetchall()
1010
-
1011
- # Project breakdown
1012
- cursor.execute("""
1013
- SELECT project_name, COUNT(*) as count
1014
- FROM memories
1015
- WHERE project_name IS NOT NULL
1016
- GROUP BY project_name
1017
- ORDER BY count DESC
1018
- LIMIT 10
1019
- """)
1020
- projects = cursor.fetchall()
1021
-
1022
- # Recent activity (last 7 days)
1023
- cursor.execute("""
1024
- SELECT COUNT(*) as count
1025
- FROM memories
1026
- WHERE created_at >= datetime('now', '-7 days')
1027
- """)
1028
- recent_memories = cursor.fetchone()['count']
1029
-
1030
- # Importance distribution
1031
- cursor.execute("""
1032
- SELECT importance, COUNT(*) as count
1033
- FROM memories
1034
- GROUP BY importance
1035
- ORDER BY importance DESC
1036
- """)
1037
- importance_dist = cursor.fetchall()
1038
-
1039
- # Database size
1040
- db_size = DB_PATH.stat().st_size if DB_PATH.exists() else 0
1041
-
1042
- # Graph density (edges / potential edges)
1043
- if total_graph_nodes > 1:
1044
- max_edges = (total_graph_nodes * (total_graph_nodes - 1)) / 2
1045
- density = total_graph_edges / max_edges if max_edges > 0 else 0
1046
- else:
1047
- density = 0
1048
-
1049
- conn.close()
1050
-
1051
- return {
1052
- "overview": {
1053
- "total_memories": total_memories,
1054
- "total_sessions": total_sessions,
1055
- "total_clusters": total_clusters,
1056
- "graph_nodes": total_graph_nodes,
1057
- "graph_edges": total_graph_edges,
1058
- "db_size_mb": round(db_size / (1024 * 1024), 2),
1059
- "recent_memories_7d": recent_memories
1060
- },
1061
- "categories": categories,
1062
- "projects": projects,
1063
- "importance_distribution": importance_dist,
1064
- "graph_stats": {
1065
- "density": round(density, 4),
1066
- "avg_degree": round(2 * total_graph_edges / total_graph_nodes, 2) if total_graph_nodes > 0 else 0
1067
- }
1068
- }
1069
-
1070
- except Exception as e:
1071
- raise HTTPException(status_code=500, detail=f"Stats error: {str(e)}")
1072
-
1073
-
1074
- @app.post("/api/search")
1075
- async def search_memories(request: SearchRequest):
1076
- """
1077
- Advanced semantic search with filters.
1078
-
1079
- Request body:
1080
- - query: Search query (required)
1081
- - limit: Max results (default 10, max 100)
1082
- - min_score: Minimum similarity score (default 0.3)
1083
- - category: Optional category filter
1084
- - project_name: Optional project filter
1085
- - cluster_id: Optional cluster filter
1086
- - date_from: Optional start date (YYYY-MM-DD)
1087
- - date_to: Optional end date (YYYY-MM-DD)
1088
-
1089
- Returns:
1090
- - results: Matching memories with scores
1091
- - query: Original query
1092
- - total: Result count
1093
- - filters_applied: Applied filters
1094
- """
1095
- try:
1096
- store = MemoryStoreV2(DB_PATH)
1097
- results = store.search(
1098
- query=request.query,
1099
- limit=request.limit * 2 # Get more, then filter
1100
- )
1101
-
1102
- # Apply additional filters
1103
- filtered = []
1104
- for result in results:
1105
- # Score filter
1106
- if result.get('score', 0) < request.min_score:
1107
- continue
1108
-
1109
- # Category filter
1110
- if request.category and result.get('category') != request.category:
1111
- continue
1112
-
1113
- # Project filter
1114
- if request.project_name and result.get('project_name') != request.project_name:
1115
- continue
1116
-
1117
- # Cluster filter
1118
- if request.cluster_id is not None and result.get('cluster_id') != request.cluster_id:
1119
- continue
1120
-
1121
- # Date filters
1122
- if request.date_from:
1123
- created = result.get('created_at', '')
1124
- if created < request.date_from:
1125
- continue
1126
-
1127
- if request.date_to:
1128
- created = result.get('created_at', '')
1129
- if created > request.date_to:
1130
- continue
1131
-
1132
- filtered.append(result)
1133
-
1134
- if len(filtered) >= request.limit:
1135
- break
1136
-
1137
- return {
1138
- "query": request.query,
1139
- "results": filtered,
1140
- "total": len(filtered),
1141
- "filters_applied": {
1142
- "category": request.category,
1143
- "project_name": request.project_name,
1144
- "cluster_id": request.cluster_id,
1145
- "date_from": request.date_from,
1146
- "date_to": request.date_to,
1147
- "min_score": request.min_score
1148
- }
1149
- }
1150
-
1151
- except Exception as e:
1152
- raise HTTPException(status_code=500, detail=f"Search error: {str(e)}")
1153
-
1154
-
1155
- # ============================================================================
1156
- # API Endpoints - Profile Management
1157
- # ============================================================================
1158
-
1159
- @app.get("/api/profiles")
1160
- async def list_profiles():
1161
- """
1162
- List available memory profiles (column-based).
1163
-
1164
- Returns:
1165
- - profiles: List of profiles with memory counts
1166
- - active_profile: Currently active profile
1167
- - total_profiles: Profile count
1168
- """
1169
- try:
1170
- config_file = MEMORY_DIR / "profiles.json"
1171
- if config_file.exists():
1172
- with open(config_file, 'r') as f:
1173
- config = json.load(f)
1174
- else:
1175
- config = {'profiles': {'default': {'name': 'default', 'description': 'Default memory profile'}}, 'active_profile': 'default'}
1176
-
1177
- active = config.get('active_profile', 'default')
1178
- profiles = []
1179
-
1180
- conn = get_db_connection()
1181
- cursor = conn.cursor()
1182
-
1183
- for name, info in config.get('profiles', {}).items():
1184
- cursor.execute("SELECT COUNT(*) FROM memories WHERE profile = ?", (name,))
1185
- count = cursor.fetchone()[0]
1186
- profiles.append({
1187
- "name": name,
1188
- "description": info.get('description', ''),
1189
- "memory_count": count,
1190
- "created_at": info.get('created_at', ''),
1191
- "last_used": info.get('last_used', ''),
1192
- "is_active": name == active
1193
- })
1194
-
1195
- conn.close()
1196
-
1197
- return {
1198
- "profiles": profiles,
1199
- "active_profile": active,
1200
- "total_profiles": len(profiles)
1201
- }
1202
-
1203
- except Exception as e:
1204
- raise HTTPException(status_code=500, detail=f"Profile list error: {str(e)}")
1205
-
1206
-
1207
- @app.post("/api/profiles/{name}/switch")
1208
- async def switch_profile(name: str):
1209
- """
1210
- Switch active memory profile (column-based, instant).
1211
-
1212
- Parameters:
1213
- - name: Profile name to switch to
1214
-
1215
- Returns:
1216
- - success: Switch status
1217
- - active_profile: New active profile
1218
- - previous_profile: Previously active profile
1219
- - memory_count: Memories in new profile
1220
- """
1221
- try:
1222
- if not validate_profile_name(name):
1223
- raise HTTPException(
1224
- status_code=400,
1225
- detail="Invalid profile name. Use alphanumeric, underscore, or hyphen only."
1226
- )
1227
-
1228
- config_file = MEMORY_DIR / "profiles.json"
1229
- if config_file.exists():
1230
- with open(config_file, 'r') as f:
1231
- config = json.load(f)
1232
- else:
1233
- config = {'profiles': {'default': {'name': 'default', 'description': 'Default memory profile'}}, 'active_profile': 'default'}
1234
-
1235
- if name not in config.get('profiles', {}):
1236
- raise HTTPException(
1237
- status_code=404,
1238
- detail=f"Profile '{name}' not found. Available: {', '.join(config.get('profiles', {}).keys())}"
1239
- )
1240
-
1241
- previous = config.get('active_profile', 'default')
1242
- config['active_profile'] = name
1243
- config['profiles'][name]['last_used'] = datetime.now().isoformat()
1244
-
1245
- with open(config_file, 'w') as f:
1246
- json.dump(config, f, indent=2)
1247
-
1248
- # Get memory count for new profile
1249
- conn = get_db_connection()
1250
- cursor = conn.cursor()
1251
- cursor.execute("SELECT COUNT(*) FROM memories WHERE profile = ?", (name,))
1252
- count = cursor.fetchone()[0]
1253
- conn.close()
1254
-
1255
- # Broadcast profile switch to WebSocket clients
1256
- await manager.broadcast({
1257
- "type": "profile_switched",
1258
- "profile": name,
1259
- "previous": previous,
1260
- "memory_count": count,
1261
- "timestamp": datetime.now().isoformat()
1262
- })
1263
-
1264
- return {
1265
- "success": True,
1266
- "active_profile": name,
1267
- "previous_profile": previous,
1268
- "memory_count": count,
1269
- "message": f"Switched to profile '{name}' ({count} memories). Changes take effect immediately."
1270
- }
1271
-
1272
- except HTTPException:
1273
- raise
1274
- except Exception as e:
1275
- raise HTTPException(status_code=500, detail=f"Profile switch error: {str(e)}")
1276
-
1277
-
1278
- @app.post("/api/profiles/create")
1279
- async def create_profile(body: ProfileSwitch):
1280
- """
1281
- Create a new memory profile.
1282
-
1283
- Parameters:
1284
- - profile_name: Name for the new profile
1285
-
1286
- Returns:
1287
- - success: Creation status
1288
- - profile: Created profile name
1289
- """
1290
- try:
1291
- name = body.profile_name
1292
- if not validate_profile_name(name):
1293
- raise HTTPException(status_code=400, detail="Invalid profile name")
1294
-
1295
- config_file = MEMORY_DIR / "profiles.json"
1296
- if config_file.exists():
1297
- with open(config_file, 'r') as f:
1298
- config = json.load(f)
1299
- else:
1300
- config = {'profiles': {'default': {'name': 'default', 'description': 'Default memory profile'}}, 'active_profile': 'default'}
1301
-
1302
- if name in config.get('profiles', {}):
1303
- raise HTTPException(status_code=409, detail=f"Profile '{name}' already exists")
1304
-
1305
- config['profiles'][name] = {
1306
- 'name': name,
1307
- 'description': f'Memory profile: {name}',
1308
- 'created_at': datetime.now().isoformat(),
1309
- 'last_used': None
1310
- }
1311
-
1312
- with open(config_file, 'w') as f:
1313
- json.dump(config, f, indent=2)
1314
-
1315
- return {
1316
- "success": True,
1317
- "profile": name,
1318
- "message": f"Profile '{name}' created"
1319
- }
1320
-
1321
- except HTTPException:
1322
- raise
1323
- except Exception as e:
1324
- raise HTTPException(status_code=500, detail=f"Profile create error: {str(e)}")
1325
-
1326
-
1327
- @app.delete("/api/profiles/{name}")
1328
- async def delete_profile(name: str):
1329
- """
1330
- Delete a profile. Moves its memories to 'default'.
1331
- """
1332
- try:
1333
- if name == 'default':
1334
- raise HTTPException(status_code=400, detail="Cannot delete 'default' profile")
1335
-
1336
- config_file = MEMORY_DIR / "profiles.json"
1337
- with open(config_file, 'r') as f:
1338
- config = json.load(f)
1339
-
1340
- if name not in config.get('profiles', {}):
1341
- raise HTTPException(status_code=404, detail=f"Profile '{name}' not found")
1342
-
1343
- if config.get('active_profile') == name:
1344
- raise HTTPException(status_code=400, detail="Cannot delete active profile. Switch first.")
1345
-
1346
- # Move memories to default
1347
- conn = get_db_connection()
1348
- cursor = conn.cursor()
1349
- cursor.execute("UPDATE memories SET profile = 'default' WHERE profile = ?", (name,))
1350
- moved = cursor.rowcount
1351
- conn.commit()
1352
- conn.close()
1353
-
1354
- del config['profiles'][name]
1355
- with open(config_file, 'w') as f:
1356
- json.dump(config, f, indent=2)
1357
-
1358
- return {
1359
- "success": True,
1360
- "message": f"Profile '{name}' deleted. {moved} memories moved to 'default'."
1361
- }
1362
-
1363
- except HTTPException:
1364
- raise
1365
- except Exception as e:
1366
- raise HTTPException(status_code=500, detail=f"Profile delete error: {str(e)}")
1367
-
1368
-
1369
- # ============================================================================
1370
- # API Endpoints - Import/Export
1371
- # ============================================================================
1372
-
1373
- @app.get("/api/export")
1374
- async def export_memories(
1375
- format: str = Query("json", pattern="^(json|jsonl)$"),
1376
- category: Optional[str] = None,
1377
- project_name: Optional[str] = None
1378
- ):
1379
- """
1380
- Export memories as JSON or JSONL.
1381
-
1382
- Parameters:
1383
- - format: Export format ('json' or 'jsonl')
1384
- - category: Optional category filter
1385
- - project_name: Optional project filter
1386
-
1387
- Returns:
1388
- - Downloadable JSON file with memories
1389
- """
1390
- try:
1391
- conn = get_db_connection()
1392
- conn.row_factory = dict_factory
1393
- cursor = conn.cursor()
1394
-
1395
- # Build query with filters
1396
- query = "SELECT * FROM memories WHERE 1=1"
1397
- params = []
1398
-
1399
- if category:
1400
- query += " AND category = ?"
1401
- params.append(category)
1402
-
1403
- if project_name:
1404
- query += " AND project_name = ?"
1405
- params.append(project_name)
1406
-
1407
- query += " ORDER BY created_at"
1408
-
1409
- cursor.execute(query, params)
1410
- memories = cursor.fetchall()
1411
- conn.close()
1412
-
1413
- # Format export
1414
- if format == "jsonl":
1415
- # JSON Lines format
1416
- content = "\n".join(json.dumps(m) for m in memories)
1417
- media_type = "application/x-ndjson"
1418
- else:
1419
- # Standard JSON
1420
- content = json.dumps({
1421
- "version": "2.2.0",
1422
- "exported_at": datetime.now().isoformat(),
1423
- "total_memories": len(memories),
1424
- "filters": {
1425
- "category": category,
1426
- "project_name": project_name
1427
- },
1428
- "memories": memories
1429
- }, indent=2)
1430
- media_type = "application/json"
1431
-
1432
- # Compress if large
1433
- if len(content) > 10000:
1434
- compressed = gzip.compress(content.encode())
1435
- return StreamingResponse(
1436
- io.BytesIO(compressed),
1437
- media_type="application/gzip",
1438
- headers={
1439
- "Content-Disposition": f"attachment; filename=memories_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{format}.gz"
1440
- }
1441
- )
1442
- else:
1443
- return StreamingResponse(
1444
- io.BytesIO(content.encode()),
1445
- media_type=media_type,
1446
- headers={
1447
- "Content-Disposition": f"attachment; filename=memories_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{format}"
1448
- }
1449
- )
1450
-
1451
- except Exception as e:
1452
- raise HTTPException(status_code=500, detail=f"Export error: {str(e)}")
1453
-
1454
-
1455
- @app.post("/api/import")
1456
- async def import_memories(file: UploadFile = File(...)):
1457
- """
1458
- Import memories from JSON file.
1459
-
1460
- Parameters:
1461
- - file: JSON file containing memories
1462
-
1463
- Returns:
1464
- - success: Import status
1465
- - imported_count: Number of memories imported
1466
- - skipped_count: Number of duplicates skipped
1467
- - errors: List of import errors
1468
- """
1469
- try:
1470
- # Read file content
1471
- content = await file.read()
1472
-
1473
- # Handle gzip compressed files
1474
- if file.filename.endswith('.gz'):
1475
- content = gzip.decompress(content)
1476
-
1477
- # Parse JSON
1478
- try:
1479
- data = json.loads(content)
1480
- except json.JSONDecodeError as e:
1481
- raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}")
1482
-
1483
- # Extract memories array
1484
- if isinstance(data, dict) and 'memories' in data:
1485
- memories = data['memories']
1486
- elif isinstance(data, list):
1487
- memories = data
1488
- else:
1489
- raise HTTPException(status_code=400, detail="Invalid format: expected 'memories' array")
1490
-
1491
- # Import memories
1492
- store = MemoryStoreV2(DB_PATH)
1493
- imported = 0
1494
- skipped = 0
1495
- errors = []
1496
-
1497
- for idx, memory in enumerate(memories):
1498
- try:
1499
- # Validate required fields
1500
- if 'content' not in memory:
1501
- errors.append(f"Memory {idx}: missing 'content' field")
1502
- continue
1503
-
1504
- # Add memory
1505
- store.add_memory(
1506
- content=memory.get('content'),
1507
- summary=memory.get('summary'),
1508
- project_path=memory.get('project_path'),
1509
- project_name=memory.get('project_name'),
1510
- tags=memory.get('tags', '').split(',') if memory.get('tags') else None,
1511
- category=memory.get('category'),
1512
- importance=memory.get('importance', 5)
1513
- )
1514
- imported += 1
1515
-
1516
- # Broadcast update to WebSocket clients
1517
- await manager.broadcast({
1518
- "type": "memory_added",
1519
- "memory_id": imported,
1520
- "timestamp": datetime.now().isoformat()
1521
- })
1522
-
1523
- except Exception as e:
1524
- if "UNIQUE constraint failed" in str(e):
1525
- skipped += 1
1526
- else:
1527
- errors.append(f"Memory {idx}: {str(e)}")
1528
-
1529
- return {
1530
- "success": True,
1531
- "imported_count": imported,
1532
- "skipped_count": skipped,
1533
- "total_processed": len(memories),
1534
- "errors": errors[:10] # Limit error list
1535
- }
1536
-
1537
- except HTTPException:
1538
- raise
1539
- except Exception as e:
1540
- raise HTTPException(status_code=500, detail=f"Import error: {str(e)}")
1541
-
1542
-
1543
- # ============================================================================
1544
- # API Endpoints - Backup Management
1545
- # ============================================================================
1546
-
1547
- class BackupConfigRequest(BaseModel):
1548
- """Backup configuration update request."""
1549
- interval_hours: Optional[int] = Field(None, ge=1, le=8760)
1550
- max_backups: Optional[int] = Field(None, ge=1, le=100)
1551
- enabled: Optional[bool] = None
1552
-
1553
-
1554
- @app.get("/api/backup/status")
1555
- async def backup_status():
1556
- """
1557
- Get auto-backup system status.
1558
-
1559
- Returns:
1560
- - enabled: Whether auto-backup is active
1561
- - interval_display: Human-readable interval
1562
- - last_backup: Timestamp of last backup
1563
- - next_backup: When next backup is due
1564
- - backup_count: Number of existing backups
1565
- - total_size_mb: Total backup storage used
1566
- """
1567
- try:
1568
- from auto_backup import AutoBackup
1569
- backup = AutoBackup()
1570
- return backup.get_status()
1571
- except ImportError:
1572
- raise HTTPException(
1573
- status_code=501,
1574
- detail="Auto-backup module not installed. Update SuperLocalMemory to v2.4.0+."
1575
- )
1576
- except Exception as e:
1577
- raise HTTPException(status_code=500, detail=f"Backup status error: {str(e)}")
1578
-
1579
-
1580
- @app.post("/api/backup/create")
1581
- async def backup_create():
1582
- """
1583
- Create a manual backup of memory.db immediately.
1584
-
1585
- Returns:
1586
- - success: Whether backup was created
1587
- - filename: Name of the backup file
1588
- - status: Updated backup system status
1589
- """
1590
- try:
1591
- from auto_backup import AutoBackup
1592
- backup = AutoBackup()
1593
- filename = backup.create_backup(label='manual')
1594
-
1595
- if filename:
1596
- return {
1597
- "success": True,
1598
- "filename": filename,
1599
- "message": f"Backup created: {filename}",
1600
- "status": backup.get_status()
1601
- }
1602
- else:
1603
- return {
1604
- "success": False,
1605
- "message": "Backup failed — database may not exist",
1606
- "status": backup.get_status()
1607
- }
1608
- except ImportError:
1609
- raise HTTPException(
1610
- status_code=501,
1611
- detail="Auto-backup module not installed. Update SuperLocalMemory to v2.4.0+."
1612
- )
1613
- except Exception as e:
1614
- raise HTTPException(status_code=500, detail=f"Backup create error: {str(e)}")
1615
-
1616
-
1617
- @app.post("/api/backup/configure")
1618
- async def backup_configure(request: BackupConfigRequest):
1619
- """
1620
- Update auto-backup configuration.
1621
-
1622
- Request body (all optional):
1623
- - interval_hours: Hours between backups (24=daily, 168=weekly)
1624
- - max_backups: Maximum backup files to retain
1625
- - enabled: Enable/disable auto-backup
1626
-
1627
- Returns:
1628
- - Updated backup status
1629
- """
1630
- try:
1631
- from auto_backup import AutoBackup
1632
- backup = AutoBackup()
1633
- result = backup.configure(
1634
- interval_hours=request.interval_hours,
1635
- max_backups=request.max_backups,
1636
- enabled=request.enabled
1637
- )
1638
- return {
1639
- "success": True,
1640
- "message": "Backup configuration updated",
1641
- "status": result
1642
- }
1643
- except ImportError:
1644
- raise HTTPException(
1645
- status_code=501,
1646
- detail="Auto-backup module not installed. Update SuperLocalMemory to v2.4.0+."
1647
- )
1648
- except Exception as e:
1649
- raise HTTPException(status_code=500, detail=f"Backup configure error: {str(e)}")
1650
-
1651
-
1652
- @app.get("/api/backup/list")
1653
- async def backup_list():
1654
- """
1655
- List all available backups.
1656
-
1657
- Returns:
1658
- - backups: List of backup files with metadata (filename, size, age, created)
1659
- - count: Total number of backups
1660
- """
1661
- try:
1662
- from auto_backup import AutoBackup
1663
- backup = AutoBackup()
1664
- backups = backup.list_backups()
1665
- return {
1666
- "backups": backups,
1667
- "count": len(backups)
1668
- }
1669
- except ImportError:
1670
- raise HTTPException(
1671
- status_code=501,
1672
- detail="Auto-backup module not installed. Update SuperLocalMemory to v2.4.0+."
1673
- )
1674
- except Exception as e:
1675
- raise HTTPException(status_code=500, detail=f"Backup list error: {str(e)}")
1676
-
1677
-
1678
- # ============================================================================
1679
- # WebSocket Endpoint - Real-Time Updates
141
+ # Startup Events
1680
142
  # ============================================================================
1681
143
 
1682
- @app.websocket("/ws/updates")
1683
- async def websocket_updates(websocket: WebSocket):
1684
- """
1685
- WebSocket endpoint for real-time memory updates.
1686
-
1687
- Broadcasts events:
1688
- - memory_added: New memory created
1689
- - memory_updated: Memory modified
1690
- - cluster_updated: Cluster recalculated
1691
- - system_stats: Periodic statistics update
1692
- """
1693
- await manager.connect(websocket)
1694
-
1695
- try:
1696
- # Send initial connection confirmation
1697
- await websocket.send_json({
1698
- "type": "connected",
1699
- "message": "WebSocket connection established",
1700
- "timestamp": datetime.now().isoformat()
1701
- })
1702
-
1703
- # Keep connection alive and handle incoming messages
1704
- while True:
1705
- try:
1706
- # Receive message from client (ping/pong, commands, etc.)
1707
- data = await websocket.receive_json()
1708
-
1709
- # Handle client requests
1710
- if data.get('type') == 'ping':
1711
- await websocket.send_json({
1712
- "type": "pong",
1713
- "timestamp": datetime.now().isoformat()
1714
- })
1715
-
1716
- elif data.get('type') == 'get_stats':
1717
- # Send current stats
1718
- stats = await get_stats()
1719
- await websocket.send_json({
1720
- "type": "stats_update",
1721
- "data": stats,
1722
- "timestamp": datetime.now().isoformat()
1723
- })
1724
-
1725
- except WebSocketDisconnect:
1726
- break
1727
- except Exception as e:
1728
- await websocket.send_json({
1729
- "type": "error",
1730
- "message": str(e),
1731
- "timestamp": datetime.now().isoformat()
1732
- })
1733
-
1734
- finally:
1735
- manager.disconnect(websocket)
144
+ @app.on_event("startup")
145
+ async def startup_event():
146
+ """Register Event Bus listener for SSE bridge on startup."""
147
+ register_event_listener()
1736
148
 
1737
149
 
1738
150
  # ============================================================================
@@ -1741,15 +153,14 @@ async def websocket_updates(websocket: WebSocket):
1741
153
 
1742
154
  if __name__ == "__main__":
1743
155
  import argparse
156
+ import socket
157
+
1744
158
  parser = argparse.ArgumentParser(description="SuperLocalMemory V2 - Web Dashboard")
1745
159
  parser.add_argument("--port", type=int, default=8765, help="Port to run on (default 8765)")
1746
160
  parser.add_argument("--profile", type=str, default=None, help="Memory profile to use")
1747
161
  args = parser.parse_args()
1748
162
 
1749
- import socket
1750
-
1751
163
  def find_available_port(preferred):
1752
- """Try preferred port, then scan next 20 ports."""
1753
164
  for port in [preferred] + list(range(preferred + 1, preferred + 20)):
1754
165
  try:
1755
166
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
@@ -1764,26 +175,19 @@ if __name__ == "__main__":
1764
175
  print(f"\n Port {args.port} in use — using {ui_port} instead\n")
1765
176
 
1766
177
  print("=" * 70)
1767
- print(" SuperLocalMemory V2.4.1 - FastAPI UI Server")
178
+ print(" SuperLocalMemory V2.5.0 - Web Dashboard")
1768
179
  print(" Copyright (c) 2026 Varun Pratap Bhardwaj")
1769
180
  print("=" * 70)
1770
- print(f" Database: {DB_PATH}")
1771
- print(f" UI Directory: {UI_DIR}")
1772
- print(f" Profiles: {PROFILES_DIR}")
181
+ print(f" Database: {DB_PATH}")
182
+ print(f" UI: {UI_DIR}")
183
+ print(f" Routes: 8 modules, 28 endpoints")
1773
184
  print("=" * 70)
1774
- print(f"\n Server URLs:")
1775
- print(f" - Main UI: http://localhost:{ui_port}")
1776
- print(f" - API Docs: http://localhost:{ui_port}/api/docs")
1777
- print(f" - Health Check: http://localhost:{ui_port}/health")
1778
- print(f" - WebSocket: ws://localhost:{ui_port}/ws/updates")
185
+ print(f"\n Dashboard: http://localhost:{ui_port}")
186
+ print(f" API Docs: http://localhost:{ui_port}/api/docs")
187
+ print(f" Health: http://localhost:{ui_port}/health")
188
+ print(f" SSE Stream: http://localhost:{ui_port}/events/stream")
189
+ print(f" WebSocket: ws://localhost:{ui_port}/ws/updates")
1779
190
  print("\n Press Ctrl+C to stop\n")
1780
191
 
1781
- # SECURITY: Bind to localhost only to prevent unauthorized network access
1782
- # For remote access, use a reverse proxy (nginx/caddy) with authentication
1783
- uvicorn.run(
1784
- app,
1785
- host="127.0.0.1", # localhost only - NEVER use 0.0.0.0 without auth
1786
- port=ui_port,
1787
- log_level="info",
1788
- access_log=True
1789
- )
192
+ # SECURITY: Bind to localhost only
193
+ uvicorn.run(app, host="127.0.0.1", port=ui_port, log_level="info", access_log=True)