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/CHANGELOG.md +46 -0
- package/README.md +21 -0
- package/docs/ARCHITECTURE-V2.5.md +190 -0
- package/mcp_server.py +115 -14
- package/package.json +1 -1
- package/src/agent_registry.py +385 -0
- package/src/db_connection_manager.py +532 -0
- package/src/event_bus.py +555 -0
- package/src/memory_store_v2.py +626 -471
- package/src/provenance_tracker.py +322 -0
- package/src/subscription_manager.py +399 -0
- package/src/trust_scorer.py +456 -0
- package/src/webhook_dispatcher.py +229 -0
- package/ui/app.js +425 -0
- package/ui/index.html +147 -1
- package/ui/js/agents.js +192 -0
- package/ui/js/clusters.js +80 -0
- package/ui/js/core.js +230 -0
- package/ui/js/events.js +178 -0
- package/ui/js/graph.js +32 -0
- package/ui/js/init.js +31 -0
- package/ui/js/memories.js +149 -0
- package/ui/js/modal.js +139 -0
- package/ui/js/patterns.js +93 -0
- package/ui/js/profiles.js +202 -0
- package/ui/js/search.js +59 -0
- package/ui/js/settings.js +167 -0
- package/ui/js/timeline.js +32 -0
- package/ui_server.py +69 -1665
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.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
32
|
-
import json
|
|
33
|
-
import asyncio
|
|
34
|
-
import gzip
|
|
35
|
-
import io
|
|
29
|
+
import sys
|
|
36
30
|
from pathlib import Path
|
|
37
|
-
from
|
|
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
|
|
34
|
+
from fastapi import FastAPI
|
|
43
35
|
from fastapi.staticfiles import StaticFiles
|
|
44
|
-
from fastapi.responses import HTMLResponse
|
|
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
|
-
#
|
|
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.
|
|
74
|
-
description="
|
|
75
|
-
version="2.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
79
|
+
# Register Route Modules
|
|
146
80
|
# ============================================================================
|
|
147
81
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
215
|
-
<
|
|
216
|
-
<
|
|
217
|
-
|
|
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.
|
|
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
|
-
#
|
|
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.
|
|
1683
|
-
async def
|
|
1684
|
-
"""
|
|
1685
|
-
|
|
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.
|
|
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:
|
|
1771
|
-
print(f" UI
|
|
1772
|
-
print(f"
|
|
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
|
|
1775
|
-
print(f"
|
|
1776
|
-
print(f"
|
|
1777
|
-
print(f"
|
|
1778
|
-
print(f"
|
|
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
|
|
1782
|
-
|
|
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)
|