omni-cortex 1.12.0__py3-none-any.whl → 1.12.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/dashboard/backend/chat_service.py +57 -3
  2. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/dashboard/backend/database.py +1430 -1094
  3. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/dashboard/backend/main.py +1592 -1381
  4. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/dashboard/backend/models.py +370 -285
  5. {omni_cortex-1.12.0.dist-info → omni_cortex-1.12.1.dist-info}/METADATA +1 -1
  6. omni_cortex-1.12.1.dist-info/RECORD +26 -0
  7. omni_cortex-1.12.0.dist-info/RECORD +0 -26
  8. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/dashboard/backend/.env.example +0 -0
  9. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/dashboard/backend/backfill_summaries.py +0 -0
  10. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/dashboard/backend/image_service.py +0 -0
  11. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/dashboard/backend/logging_config.py +0 -0
  12. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/dashboard/backend/project_config.py +0 -0
  13. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/dashboard/backend/project_scanner.py +0 -0
  14. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/dashboard/backend/prompt_security.py +0 -0
  15. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/dashboard/backend/pyproject.toml +0 -0
  16. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/dashboard/backend/security.py +0 -0
  17. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/dashboard/backend/uv.lock +0 -0
  18. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/dashboard/backend/websocket_manager.py +0 -0
  19. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/hooks/post_tool_use.py +0 -0
  20. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/hooks/pre_tool_use.py +0 -0
  21. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/hooks/session_utils.py +0 -0
  22. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/hooks/stop.py +0 -0
  23. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/hooks/subagent_stop.py +0 -0
  24. {omni_cortex-1.12.0.data → omni_cortex-1.12.1.data}/data/share/omni-cortex/hooks/user_prompt.py +0 -0
  25. {omni_cortex-1.12.0.dist-info → omni_cortex-1.12.1.dist-info}/WHEEL +0 -0
  26. {omni_cortex-1.12.0.dist-info → omni_cortex-1.12.1.dist-info}/entry_points.txt +0 -0
  27. {omni_cortex-1.12.0.dist-info → omni_cortex-1.12.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,1381 +1,1592 @@
1
- """FastAPI backend for Omni-Cortex Web Dashboard."""
2
- # Trigger reload for relationship graph column fix
3
-
4
- import asyncio
5
- import json
6
- import os
7
- import traceback
8
- from contextlib import asynccontextmanager
9
- from datetime import datetime
10
- from pathlib import Path
11
- from typing import Optional
12
-
13
- import uvicorn
14
- from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect, Request, Depends
15
- from fastapi.middleware.cors import CORSMiddleware
16
- from fastapi.staticfiles import StaticFiles
17
- from fastapi.responses import FileResponse, Response
18
- from starlette.middleware.base import BaseHTTPMiddleware
19
- from watchdog.events import FileSystemEventHandler
20
- from watchdog.observers import Observer
21
-
22
- # Rate limiting imports (optional - graceful degradation if not installed)
23
- try:
24
- from slowapi import Limiter, _rate_limit_exceeded_handler
25
- from slowapi.util import get_remote_address
26
- from slowapi.errors import RateLimitExceeded
27
- RATE_LIMITING_AVAILABLE = True
28
- except ImportError:
29
- RATE_LIMITING_AVAILABLE = False
30
- Limiter = None
31
-
32
- from database import (
33
- bulk_update_memory_status,
34
- create_memory,
35
- delete_memory,
36
- ensure_migrations,
37
- get_activities,
38
- get_activity_detail,
39
- get_activity_heatmap,
40
- get_all_tags,
41
- get_command_usage,
42
- get_mcp_usage,
43
- get_memories,
44
- get_memories_needing_review,
45
- get_memory_by_id,
46
- get_memory_growth,
47
- get_memory_stats,
48
- get_recent_sessions,
49
- get_relationship_graph,
50
- get_relationships,
51
- get_sessions,
52
- get_skill_usage,
53
- get_timeline,
54
- get_tool_usage,
55
- get_type_distribution,
56
- search_memories,
57
- update_memory,
58
- )
59
- from logging_config import log_success, log_error
60
- from models import (
61
- AggregateChatRequest,
62
- AggregateMemoryRequest,
63
- AggregateStatsRequest,
64
- AggregateStatsResponse,
65
- ChatRequest,
66
- ChatResponse,
67
- ConversationSaveRequest,
68
- ConversationSaveResponse,
69
- FilterParams,
70
- MemoryCreateRequest,
71
- MemoryUpdate,
72
- ProjectInfo,
73
- ProjectRegistration,
74
- BatchImageGenerationRequest,
75
- BatchImageGenerationResponse,
76
- ImageRefineRequest,
77
- SingleImageRequestModel,
78
- SingleImageResponseModel,
79
- )
80
- from project_config import (
81
- load_config,
82
- add_registered_project,
83
- remove_registered_project,
84
- toggle_favorite,
85
- add_scan_directory,
86
- remove_scan_directory,
87
- )
88
- from project_scanner import scan_projects
89
- from websocket_manager import manager
90
- import chat_service
91
- from image_service import image_service, ImagePreset, SingleImageRequest
92
- from security import PathValidator, get_cors_config, IS_PRODUCTION
93
-
94
-
95
- class SecurityHeadersMiddleware(BaseHTTPMiddleware):
96
- """Add security headers to all responses."""
97
-
98
- async def dispatch(self, request: Request, call_next) -> Response:
99
- response = await call_next(request)
100
-
101
- # Prevent MIME type sniffing
102
- response.headers["X-Content-Type-Options"] = "nosniff"
103
-
104
- # Prevent clickjacking
105
- response.headers["X-Frame-Options"] = "DENY"
106
-
107
- # XSS protection (legacy browsers)
108
- response.headers["X-XSS-Protection"] = "1; mode=block"
109
-
110
- # Content Security Policy
111
- response.headers["Content-Security-Policy"] = (
112
- "default-src 'self'; "
113
- "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " # Vue needs these
114
- "style-src 'self' 'unsafe-inline'; " # Tailwind needs inline
115
- "img-src 'self' data: blob: https:; " # Allow AI-generated images
116
- "connect-src 'self' ws: wss: https://generativelanguage.googleapis.com; "
117
- "font-src 'self'; "
118
- "frame-ancestors 'none';"
119
- )
120
-
121
- # HSTS (only in production with HTTPS)
122
- if IS_PRODUCTION and os.getenv("SSL_CERTFILE"):
123
- response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
124
-
125
- return response
126
-
127
-
128
- def validate_project_path(project: str = Query(..., description="Path to the database file")) -> Path:
129
- """Validate project database path - dependency for endpoints."""
130
- try:
131
- return PathValidator.validate_project_path(project)
132
- except ValueError as e:
133
- raise HTTPException(status_code=400, detail=str(e))
134
-
135
-
136
- class DatabaseChangeHandler(FileSystemEventHandler):
137
- """Handle database file changes for real-time updates."""
138
-
139
- def __init__(self, ws_manager, loop):
140
- self.ws_manager = ws_manager
141
- self.loop = loop
142
- self._debounce_task: Optional[asyncio.Task] = None
143
- self._last_path: Optional[str] = None
144
- self._last_activity_count: dict[str, int] = {}
145
-
146
- def on_modified(self, event):
147
- if event.src_path.endswith("cortex.db") or event.src_path.endswith("global.db"):
148
- # Debounce rapid changes
149
- self._last_path = event.src_path
150
- if self._debounce_task is None or self._debounce_task.done():
151
- self._debounce_task = asyncio.run_coroutine_threadsafe(
152
- self._debounced_notify(), self.loop
153
- )
154
-
155
- async def _debounced_notify(self):
156
- await asyncio.sleep(0.3) # Reduced from 0.5s for faster updates
157
- if self._last_path:
158
- db_path = self._last_path
159
-
160
- # Broadcast general database change
161
- await self.ws_manager.broadcast("database_changed", {"path": db_path})
162
-
163
- # Fetch and broadcast latest activities (IndyDevDan pattern)
164
- try:
165
- # Get recent activities
166
- recent = get_activities(db_path, limit=5, offset=0)
167
- if recent:
168
- # Broadcast each new activity
169
- for activity in recent:
170
- await self.ws_manager.broadcast_activity_logged(
171
- db_path,
172
- activity if isinstance(activity, dict) else activity.model_dump()
173
- )
174
-
175
- # Also broadcast session update
176
- sessions = get_recent_sessions(db_path, limit=1)
177
- if sessions:
178
- session = sessions[0]
179
- await self.ws_manager.broadcast_session_updated(
180
- db_path,
181
- session if isinstance(session, dict) else dict(session)
182
- )
183
- except Exception as e:
184
- print(f"[WS] Error broadcasting activities: {e}")
185
-
186
-
187
- # File watcher
188
- observer: Optional[Observer] = None
189
-
190
-
191
- @asynccontextmanager
192
- async def lifespan(app: FastAPI):
193
- """Manage file watcher lifecycle."""
194
- global observer
195
- loop = asyncio.get_event_loop()
196
- handler = DatabaseChangeHandler(manager, loop)
197
- observer = Observer()
198
-
199
- # Watch common project directories
200
- watch_paths = [
201
- Path.home() / ".omni-cortex",
202
- Path("D:/Projects"),
203
- ]
204
-
205
- for watch_path in watch_paths:
206
- if watch_path.exists():
207
- observer.schedule(handler, str(watch_path), recursive=True)
208
- print(f"[Watcher] Monitoring: {watch_path}")
209
-
210
- observer.start()
211
- print("[Server] File watcher started")
212
-
213
- yield
214
-
215
- observer.stop()
216
- observer.join()
217
- print("[Server] File watcher stopped")
218
-
219
-
220
- # FastAPI app
221
- app = FastAPI(
222
- title="Omni-Cortex Dashboard",
223
- description="Web dashboard for viewing and managing Omni-Cortex memories",
224
- version="0.1.0",
225
- lifespan=lifespan,
226
- )
227
-
228
- # Add security headers middleware (MUST come before CORS)
229
- app.add_middleware(SecurityHeadersMiddleware)
230
-
231
- # Rate limiting (if available)
232
- if RATE_LIMITING_AVAILABLE:
233
- limiter = Limiter(key_func=get_remote_address)
234
- app.state.limiter = limiter
235
- app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
236
- else:
237
- limiter = None
238
-
239
-
240
- def rate_limit(limit_string: str):
241
- """Decorator for conditional rate limiting.
242
-
243
- Returns the actual limiter decorator if available, otherwise a no-op.
244
- Usage: @rate_limit("10/minute")
245
- """
246
- if limiter is not None:
247
- return limiter.limit(limit_string)
248
- # No-op decorator when rate limiting is not available
249
- def noop_decorator(func):
250
- return func
251
- return noop_decorator
252
-
253
- # CORS configuration (environment-aware)
254
- cors_config = get_cors_config()
255
- app.add_middleware(
256
- CORSMiddleware,
257
- allow_origins=cors_config["allow_origins"],
258
- allow_credentials=True,
259
- allow_methods=cors_config["allow_methods"],
260
- allow_headers=cors_config["allow_headers"],
261
- )
262
-
263
- # Static files for production build
264
- DASHBOARD_DIR = Path(__file__).parent.parent
265
- DIST_DIR = DASHBOARD_DIR / "frontend" / "dist"
266
-
267
-
268
- def setup_static_files():
269
- """Mount static files if dist directory exists (production build)."""
270
- if DIST_DIR.exists():
271
- # Mount assets directory
272
- assets_dir = DIST_DIR / "assets"
273
- if assets_dir.exists():
274
- app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
275
- print(f"[Static] Serving assets from: {assets_dir}")
276
-
277
-
278
- # Call setup at module load
279
- setup_static_files()
280
-
281
-
282
- # --- REST Endpoints ---
283
-
284
-
285
- @app.get("/api/projects", response_model=list[ProjectInfo])
286
- async def list_projects():
287
- """List all discovered omni-cortex project databases."""
288
- return scan_projects()
289
-
290
-
291
- # --- Project Management Endpoints ---
292
-
293
-
294
- @app.get("/api/projects/config")
295
- async def get_project_config():
296
- """Get project configuration (scan dirs, counts)."""
297
- config = load_config()
298
- return {
299
- "scan_directories": config.scan_directories,
300
- "registered_count": len(config.registered_projects),
301
- "favorites_count": len(config.favorites),
302
- }
303
-
304
-
305
- @app.post("/api/projects/register")
306
- async def register_project(body: ProjectRegistration):
307
- """Manually register a project by path."""
308
- success = add_registered_project(body.path, body.display_name)
309
- if not success:
310
- raise HTTPException(400, "Invalid path or already registered")
311
- return {"success": True}
312
-
313
-
314
- @app.delete("/api/projects/register")
315
- async def unregister_project(path: str = Query(..., description="Project path to unregister")):
316
- """Remove a registered project."""
317
- success = remove_registered_project(path)
318
- if not success:
319
- raise HTTPException(404, "Project not found")
320
- return {"success": True}
321
-
322
-
323
- @app.post("/api/projects/favorite")
324
- async def toggle_project_favorite(path: str = Query(..., description="Project path to toggle favorite")):
325
- """Toggle favorite status for a project."""
326
- is_favorite = toggle_favorite(path)
327
- return {"is_favorite": is_favorite}
328
-
329
-
330
- @app.post("/api/projects/scan-directories")
331
- async def add_scan_dir(directory: str = Query(..., description="Directory path to add")):
332
- """Add a directory to auto-scan list."""
333
- success = add_scan_directory(directory)
334
- if not success:
335
- raise HTTPException(400, "Invalid directory or already added")
336
- return {"success": True}
337
-
338
-
339
- @app.delete("/api/projects/scan-directories")
340
- async def remove_scan_dir(directory: str = Query(..., description="Directory path to remove")):
341
- """Remove a directory from auto-scan list."""
342
- success = remove_scan_directory(directory)
343
- if not success:
344
- raise HTTPException(404, "Directory not found")
345
- return {"success": True}
346
-
347
-
348
- @app.post("/api/projects/refresh")
349
- async def refresh_projects():
350
- """Force rescan of all project directories."""
351
- projects = scan_projects()
352
- return {"count": len(projects)}
353
-
354
-
355
- # --- Aggregate Multi-Project Endpoints ---
356
-
357
-
358
- @app.post("/api/aggregate/memories")
359
- @rate_limit("50/minute")
360
- async def get_aggregate_memories(request: AggregateMemoryRequest):
361
- """Get memories from multiple projects with project attribution."""
362
- try:
363
- all_memories = []
364
- filters = request.filters or FilterParams()
365
-
366
- for project_path in request.projects:
367
- if not Path(project_path).exists():
368
- continue
369
-
370
- try:
371
- memories = get_memories(project_path, filters)
372
- # Add project attribution to each memory
373
- for m in memories:
374
- m_dict = m.model_dump()
375
- m_dict['source_project'] = project_path
376
- # Extract project name from path
377
- project_dir = Path(project_path).parent
378
- m_dict['source_project_name'] = project_dir.name
379
- all_memories.append(m_dict)
380
- except Exception as e:
381
- log_error(f"/api/aggregate/memories (project: {project_path})", e)
382
- continue
383
-
384
- # Sort by last_accessed or created_at (convert to str to handle mixed tz-aware/naive)
385
- all_memories.sort(
386
- key=lambda x: str(x.get('last_accessed') or x.get('created_at') or ''),
387
- reverse=True
388
- )
389
-
390
- # Apply pagination
391
- start = filters.offset
392
- end = start + filters.limit
393
- return all_memories[start:end]
394
- except Exception as e:
395
- log_error("/api/aggregate/memories", e)
396
- raise HTTPException(status_code=500, detail=str(e))
397
-
398
-
399
- @app.post("/api/aggregate/stats", response_model=AggregateStatsResponse)
400
- @rate_limit("50/minute")
401
- async def get_aggregate_stats(request: AggregateStatsRequest):
402
- """Get combined statistics across multiple projects."""
403
- try:
404
- total_count = 0
405
- total_access = 0
406
- importance_sum = 0
407
- by_type = {}
408
- by_status = {}
409
-
410
- for project_path in request.projects:
411
- if not Path(project_path).exists():
412
- continue
413
-
414
- try:
415
- stats = get_memory_stats(project_path)
416
- total_count += stats.total_count
417
- total_access += stats.total_access_count
418
-
419
- # Weighted average for importance
420
- project_count = stats.total_count
421
- project_avg_importance = stats.avg_importance
422
- importance_sum += project_avg_importance * project_count
423
-
424
- # Aggregate by_type
425
- for type_name, count in stats.by_type.items():
426
- by_type[type_name] = by_type.get(type_name, 0) + count
427
-
428
- # Aggregate by_status
429
- for status, count in stats.by_status.items():
430
- by_status[status] = by_status.get(status, 0) + count
431
- except Exception as e:
432
- log_error(f"/api/aggregate/stats (project: {project_path})", e)
433
- continue
434
-
435
- return AggregateStatsResponse(
436
- total_count=total_count,
437
- total_access_count=total_access,
438
- avg_importance=round(importance_sum / total_count, 1) if total_count > 0 else 0,
439
- by_type=by_type,
440
- by_status=by_status,
441
- project_count=len(request.projects),
442
- )
443
- except Exception as e:
444
- log_error("/api/aggregate/stats", e)
445
- raise HTTPException(status_code=500, detail=str(e))
446
-
447
-
448
- @app.post("/api/aggregate/tags")
449
- @rate_limit("50/minute")
450
- async def get_aggregate_tags(request: AggregateStatsRequest):
451
- """Get combined tags across multiple projects."""
452
- try:
453
- tag_counts = {}
454
-
455
- for project_path in request.projects:
456
- if not Path(project_path).exists():
457
- continue
458
-
459
- try:
460
- tags = get_all_tags(project_path)
461
- for tag in tags:
462
- tag_name = tag['name']
463
- tag_counts[tag_name] = tag_counts.get(tag_name, 0) + tag['count']
464
- except Exception as e:
465
- log_error(f"/api/aggregate/tags (project: {project_path})", e)
466
- continue
467
-
468
- # Return sorted by count
469
- return sorted(
470
- [{'name': k, 'count': v} for k, v in tag_counts.items()],
471
- key=lambda x: x['count'],
472
- reverse=True
473
- )
474
- except Exception as e:
475
- log_error("/api/aggregate/tags", e)
476
- raise HTTPException(status_code=500, detail=str(e))
477
-
478
-
479
- @app.post("/api/aggregate/chat", response_model=ChatResponse)
480
- @rate_limit("10/minute")
481
- async def chat_across_projects(request: AggregateChatRequest):
482
- """Ask AI about memories across multiple projects."""
483
- try:
484
- if not chat_service.is_available():
485
- raise HTTPException(
486
- status_code=503,
487
- detail="Chat service not available. Set GEMINI_API_KEY environment variable."
488
- )
489
-
490
- all_sources = []
491
-
492
- # Gather relevant memories from each project
493
- for project_path in request.projects:
494
- if not Path(project_path).exists():
495
- continue
496
-
497
- try:
498
- memories = search_memories(
499
- project_path,
500
- request.question,
501
- limit=request.max_memories_per_project
502
- )
503
-
504
- for m in memories:
505
- project_dir = Path(project_path).parent
506
- source = {
507
- 'id': m.id,
508
- 'type': m.memory_type,
509
- 'content_preview': m.content[:200],
510
- 'tags': m.tags,
511
- 'project_path': project_path,
512
- 'project_name': project_dir.name,
513
- }
514
- all_sources.append(source)
515
- except Exception as e:
516
- log_error(f"/api/aggregate/chat (project: {project_path})", e)
517
- continue
518
-
519
- if not all_sources:
520
- return ChatResponse(
521
- answer="No relevant memories found across the selected projects.",
522
- sources=[],
523
- )
524
-
525
- # Build context with project attribution
526
- context = "\n\n".join([
527
- f"[From: {s['project_name']}] {s['content_preview']}"
528
- for s in all_sources
529
- ])
530
-
531
- # Query AI with attributed context
532
- answer = await chat_service.generate_response(request.question, context)
533
-
534
- log_success("/api/aggregate/chat", projects=len(request.projects), sources=len(all_sources))
535
-
536
- return ChatResponse(
537
- answer=answer,
538
- sources=[ChatSource(**s) for s in all_sources],
539
- )
540
- except HTTPException:
541
- raise
542
- except Exception as e:
543
- log_error("/api/aggregate/chat", e)
544
- raise HTTPException(status_code=500, detail=str(e))
545
-
546
-
547
- @app.get("/api/memories")
548
- @rate_limit("100/minute")
549
- async def list_memories(
550
- project: str = Query(..., description="Path to the database file"),
551
- memory_type: Optional[str] = Query(None, alias="type"),
552
- status: Optional[str] = None,
553
- tags: Optional[str] = None,
554
- search: Optional[str] = None,
555
- min_importance: Optional[int] = None,
556
- max_importance: Optional[int] = None,
557
- sort_by: str = "last_accessed",
558
- sort_order: str = "desc",
559
- limit: int = 50,
560
- offset: int = 0,
561
- ):
562
- """Get memories with filtering and pagination."""
563
- try:
564
- if not Path(project).exists():
565
- log_error("/api/memories", FileNotFoundError("Database not found"), project=project)
566
- raise HTTPException(status_code=404, detail="Database not found")
567
-
568
- filters = FilterParams(
569
- memory_type=memory_type,
570
- status=status,
571
- tags=tags.split(",") if tags else None,
572
- search=search,
573
- min_importance=min_importance,
574
- max_importance=max_importance,
575
- sort_by=sort_by,
576
- sort_order=sort_order,
577
- limit=limit,
578
- offset=offset,
579
- )
580
-
581
- memories = get_memories(project, filters)
582
- log_success("/api/memories", count=len(memories), offset=offset, filters=bool(search or memory_type))
583
- return memories
584
- except Exception as e:
585
- log_error("/api/memories", e, project=project)
586
- raise
587
-
588
-
589
- @app.post("/api/memories")
590
- @rate_limit("30/minute")
591
- async def create_memory_endpoint(
592
- request: MemoryCreateRequest,
593
- project: str = Query(..., description="Path to the database file"),
594
- ):
595
- """Create a new memory."""
596
- try:
597
- if not Path(project).exists():
598
- log_error("/api/memories POST", FileNotFoundError("Database not found"), project=project)
599
- raise HTTPException(status_code=404, detail="Database not found")
600
-
601
- # Create the memory
602
- memory_id = create_memory(
603
- db_path=project,
604
- content=request.content,
605
- memory_type=request.memory_type,
606
- context=request.context,
607
- tags=request.tags if request.tags else None,
608
- importance_score=request.importance_score,
609
- )
610
-
611
- # Fetch the created memory to return it
612
- created_memory = get_memory_by_id(project, memory_id)
613
-
614
- # Broadcast to WebSocket clients
615
- await manager.broadcast("memory_created", created_memory.model_dump(by_alias=True))
616
-
617
- log_success("/api/memories POST", memory_id=memory_id, type=request.memory_type)
618
- return created_memory
619
- except HTTPException:
620
- raise
621
- except Exception as e:
622
- import traceback
623
- print(f"[DEBUG] create_memory_endpoint error: {type(e).__name__}: {e}")
624
- traceback.print_exc()
625
- log_error("/api/memories POST", e, project=project)
626
- raise
627
-
628
-
629
- # NOTE: These routes MUST be defined before /api/memories/{memory_id} to avoid path conflicts
630
- @app.get("/api/memories/needs-review")
631
- async def get_memories_needing_review_endpoint(
632
- project: str = Query(..., description="Path to the database file"),
633
- days_threshold: int = 30,
634
- limit: int = 50,
635
- ):
636
- """Get memories that may need freshness review."""
637
- if not Path(project).exists():
638
- raise HTTPException(status_code=404, detail="Database not found")
639
-
640
- return get_memories_needing_review(project, days_threshold, limit)
641
-
642
-
643
- @app.post("/api/memories/bulk-update-status")
644
- async def bulk_update_status_endpoint(
645
- project: str = Query(..., description="Path to the database file"),
646
- memory_ids: list[str] = [],
647
- status: str = "fresh",
648
- ):
649
- """Update status for multiple memories at once."""
650
- if not Path(project).exists():
651
- raise HTTPException(status_code=404, detail="Database not found")
652
-
653
- valid_statuses = ["fresh", "needs_review", "outdated", "archived"]
654
- if status not in valid_statuses:
655
- raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
656
-
657
- count = bulk_update_memory_status(project, memory_ids, status)
658
-
659
- # Notify connected clients
660
- await manager.broadcast("memories_bulk_updated", {"count": count, "status": status})
661
-
662
- return {"updated_count": count, "status": status}
663
-
664
-
665
- @app.get("/api/memories/{memory_id}")
666
- async def get_memory(
667
- memory_id: str,
668
- project: str = Query(..., description="Path to the database file"),
669
- ):
670
- """Get a single memory by ID."""
671
- if not Path(project).exists():
672
- raise HTTPException(status_code=404, detail="Database not found")
673
-
674
- memory = get_memory_by_id(project, memory_id)
675
- if not memory:
676
- raise HTTPException(status_code=404, detail="Memory not found")
677
- return memory
678
-
679
-
680
- @app.put("/api/memories/{memory_id}")
681
- async def update_memory_endpoint(
682
- memory_id: str,
683
- updates: MemoryUpdate,
684
- project: str = Query(..., description="Path to the database file"),
685
- ):
686
- """Update a memory."""
687
- try:
688
- if not Path(project).exists():
689
- log_error("/api/memories/update", FileNotFoundError("Database not found"), memory_id=memory_id)
690
- raise HTTPException(status_code=404, detail="Database not found")
691
-
692
- updated = update_memory(project, memory_id, updates)
693
- if not updated:
694
- log_error("/api/memories/update", ValueError("Memory not found"), memory_id=memory_id)
695
- raise HTTPException(status_code=404, detail="Memory not found")
696
-
697
- # Notify connected clients
698
- await manager.broadcast("memory_updated", updated.model_dump(by_alias=True))
699
- log_success("/api/memories/update", memory_id=memory_id, fields_updated=len(updates.model_dump(exclude_unset=True)))
700
- return updated
701
- except HTTPException:
702
- raise
703
- except Exception as e:
704
- log_error("/api/memories/update", e, memory_id=memory_id)
705
- raise
706
-
707
-
708
- @app.delete("/api/memories/{memory_id}")
709
- async def delete_memory_endpoint(
710
- memory_id: str,
711
- project: str = Query(..., description="Path to the database file"),
712
- ):
713
- """Delete a memory."""
714
- try:
715
- if not Path(project).exists():
716
- log_error("/api/memories/delete", FileNotFoundError("Database not found"), memory_id=memory_id)
717
- raise HTTPException(status_code=404, detail="Database not found")
718
-
719
- deleted = delete_memory(project, memory_id)
720
- if not deleted:
721
- log_error("/api/memories/delete", ValueError("Memory not found"), memory_id=memory_id)
722
- raise HTTPException(status_code=404, detail="Memory not found")
723
-
724
- # Notify connected clients
725
- await manager.broadcast("memory_deleted", {"id": memory_id})
726
- log_success("/api/memories/delete", memory_id=memory_id)
727
- return {"message": "Memory deleted", "id": memory_id}
728
- except HTTPException:
729
- raise
730
- except Exception as e:
731
- log_error("/api/memories/delete", e, memory_id=memory_id)
732
- raise
733
-
734
-
735
- @app.get("/api/memories/stats/summary")
736
- async def memory_stats(
737
- project: str = Query(..., description="Path to the database file"),
738
- ):
739
- """Get memory statistics."""
740
- if not Path(project).exists():
741
- raise HTTPException(status_code=404, detail="Database not found")
742
-
743
- return get_memory_stats(project)
744
-
745
-
746
- @app.get("/api/search")
747
- async def search(
748
- q: str = Query(..., min_length=1),
749
- project: str = Query(..., description="Path to the database file"),
750
- limit: int = 20,
751
- ):
752
- """Search memories."""
753
- if not Path(project).exists():
754
- raise HTTPException(status_code=404, detail="Database not found")
755
-
756
- return search_memories(project, q, limit)
757
-
758
-
759
- @app.get("/api/activities")
760
- async def list_activities(
761
- project: str = Query(..., description="Path to the database file"),
762
- event_type: Optional[str] = None,
763
- tool_name: Optional[str] = None,
764
- limit: int = 100,
765
- offset: int = 0,
766
- ):
767
- """Get activity log entries."""
768
- if not Path(project).exists():
769
- raise HTTPException(status_code=404, detail="Database not found")
770
-
771
- # Ensure migrations are applied (adds summary columns if missing)
772
- ensure_migrations(project)
773
-
774
- return get_activities(project, event_type, tool_name, limit, offset)
775
-
776
-
777
- @app.get("/api/timeline")
778
- async def get_timeline_view(
779
- project: str = Query(..., description="Path to the database file"),
780
- hours: int = 24,
781
- include_memories: bool = True,
782
- include_activities: bool = True,
783
- ):
784
- """Get timeline of recent activity."""
785
- if not Path(project).exists():
786
- raise HTTPException(status_code=404, detail="Database not found")
787
-
788
- return get_timeline(project, hours, include_memories, include_activities)
789
-
790
-
791
- @app.get("/api/tags")
792
- async def list_tags(
793
- project: str = Query(..., description="Path to the database file"),
794
- ):
795
- """Get all tags with counts."""
796
- if not Path(project).exists():
797
- raise HTTPException(status_code=404, detail="Database not found")
798
-
799
- return get_all_tags(project)
800
-
801
-
802
- @app.get("/api/types")
803
- async def list_types(
804
- project: str = Query(..., description="Path to the database file"),
805
- ):
806
- """Get memory type distribution."""
807
- if not Path(project).exists():
808
- raise HTTPException(status_code=404, detail="Database not found")
809
-
810
- return get_type_distribution(project)
811
-
812
-
813
- @app.get("/api/sessions")
814
- async def list_sessions(
815
- project: str = Query(..., description="Path to the database file"),
816
- limit: int = 20,
817
- ):
818
- """Get recent sessions."""
819
- if not Path(project).exists():
820
- raise HTTPException(status_code=404, detail="Database not found")
821
-
822
- return get_sessions(project, limit)
823
-
824
-
825
- # --- Stats Endpoints for Charts ---
826
-
827
-
828
- @app.get("/api/stats/activity-heatmap")
829
- async def get_activity_heatmap_endpoint(
830
- project: str = Query(..., description="Path to the database file"),
831
- days: int = 90,
832
- ):
833
- """Get activity counts grouped by day for heatmap visualization."""
834
- if not Path(project).exists():
835
- raise HTTPException(status_code=404, detail="Database not found")
836
-
837
- return get_activity_heatmap(project, days)
838
-
839
-
840
- @app.get("/api/stats/tool-usage")
841
- async def get_tool_usage_endpoint(
842
- project: str = Query(..., description="Path to the database file"),
843
- limit: int = 10,
844
- ):
845
- """Get tool usage statistics."""
846
- if not Path(project).exists():
847
- raise HTTPException(status_code=404, detail="Database not found")
848
-
849
- return get_tool_usage(project, limit)
850
-
851
-
852
- @app.get("/api/stats/memory-growth")
853
- async def get_memory_growth_endpoint(
854
- project: str = Query(..., description="Path to the database file"),
855
- days: int = 30,
856
- ):
857
- """Get memory creation over time."""
858
- if not Path(project).exists():
859
- raise HTTPException(status_code=404, detail="Database not found")
860
-
861
- return get_memory_growth(project, days)
862
-
863
-
864
- # --- Command Analytics Endpoints ---
865
-
866
-
867
- @app.get("/api/stats/command-usage")
868
- async def get_command_usage_endpoint(
869
- project: str = Query(..., description="Path to the database file"),
870
- scope: Optional[str] = Query(None, description="Filter by scope: 'universal' or 'project'"),
871
- days: int = Query(30, ge=1, le=365),
872
- ):
873
- """Get slash command usage statistics."""
874
- if not Path(project).exists():
875
- raise HTTPException(status_code=404, detail="Database not found")
876
-
877
- return get_command_usage(project, scope, days)
878
-
879
-
880
- @app.get("/api/stats/skill-usage")
881
- async def get_skill_usage_endpoint(
882
- project: str = Query(..., description="Path to the database file"),
883
- scope: Optional[str] = Query(None, description="Filter by scope: 'universal' or 'project'"),
884
- days: int = Query(30, ge=1, le=365),
885
- ):
886
- """Get skill usage statistics."""
887
- if not Path(project).exists():
888
- raise HTTPException(status_code=404, detail="Database not found")
889
-
890
- return get_skill_usage(project, scope, days)
891
-
892
-
893
- @app.get("/api/stats/mcp-usage")
894
- async def get_mcp_usage_endpoint(
895
- project: str = Query(..., description="Path to the database file"),
896
- days: int = Query(30, ge=1, le=365),
897
- ):
898
- """Get MCP server usage statistics."""
899
- if not Path(project).exists():
900
- raise HTTPException(status_code=404, detail="Database not found")
901
-
902
- return get_mcp_usage(project, days)
903
-
904
-
905
- @app.get("/api/activities/{activity_id}")
906
- async def get_activity_detail_endpoint(
907
- activity_id: str,
908
- project: str = Query(..., description="Path to the database file"),
909
- ):
910
- """Get full activity details including complete input/output."""
911
- if not Path(project).exists():
912
- raise HTTPException(status_code=404, detail="Database not found")
913
-
914
- # Ensure migrations are applied
915
- ensure_migrations(project)
916
-
917
- activity = get_activity_detail(project, activity_id)
918
- if not activity:
919
- raise HTTPException(status_code=404, detail="Activity not found")
920
-
921
- return activity
922
-
923
-
924
- @app.post("/api/activities/backfill-summaries")
925
- async def backfill_activity_summaries_endpoint(
926
- project: str = Query(..., description="Path to the database file"),
927
- ):
928
- """Generate summaries for existing activities that don't have them."""
929
- if not Path(project).exists():
930
- raise HTTPException(status_code=404, detail="Database not found")
931
-
932
- try:
933
- from backfill_summaries import backfill_all
934
- results = backfill_all(project)
935
- return {
936
- "success": True,
937
- "summaries_updated": results["summaries"],
938
- "mcp_servers_updated": results["mcp_servers"],
939
- }
940
- except Exception as e:
941
- raise HTTPException(status_code=500, detail=f"Backfill failed: {str(e)}")
942
-
943
-
944
- # --- Session Context Endpoints ---
945
-
946
-
947
- @app.get("/api/sessions/recent")
948
- async def get_recent_sessions_endpoint(
949
- project: str = Query(..., description="Path to the database file"),
950
- limit: int = 5,
951
- ):
952
- """Get recent sessions with summaries."""
953
- if not Path(project).exists():
954
- raise HTTPException(status_code=404, detail="Database not found")
955
-
956
- return get_recent_sessions(project, limit)
957
-
958
-
959
- # --- Relationship Graph Endpoints ---
960
-
961
-
962
- @app.get("/api/relationships")
963
- async def get_relationships_endpoint(
964
- project: str = Query(..., description="Path to the database file"),
965
- memory_id: Optional[str] = None,
966
- ):
967
- """Get memory relationships for graph visualization."""
968
- if not Path(project).exists():
969
- raise HTTPException(status_code=404, detail="Database not found")
970
-
971
- return get_relationships(project, memory_id)
972
-
973
-
974
- @app.get("/api/relationships/graph")
975
- async def get_relationship_graph_endpoint(
976
- project: str = Query(..., description="Path to the database file"),
977
- center_id: Optional[str] = None,
978
- depth: int = 2,
979
- ):
980
- """Get graph data centered on a memory with configurable depth."""
981
- if not Path(project).exists():
982
- raise HTTPException(status_code=404, detail="Database not found")
983
-
984
- return get_relationship_graph(project, center_id, depth)
985
-
986
-
987
- # --- Chat Endpoint ---
988
-
989
-
990
- @app.get("/api/chat/status")
991
- async def chat_status():
992
- """Check if chat service is available."""
993
- return {
994
- "available": chat_service.is_available(),
995
- "message": "Chat is available" if chat_service.is_available() else "Set GEMINI_API_KEY environment variable to enable chat",
996
- }
997
-
998
-
999
- @app.post("/api/chat", response_model=ChatResponse)
1000
- @rate_limit("10/minute")
1001
- async def chat_with_memories(
1002
- request: ChatRequest,
1003
- project: str = Query(..., description="Path to the database file"),
1004
- ):
1005
- """Ask a natural language question about memories."""
1006
- try:
1007
- if not Path(project).exists():
1008
- log_error("/api/chat", FileNotFoundError("Database not found"), question=request.question[:50])
1009
- raise HTTPException(status_code=404, detail="Database not found")
1010
-
1011
- result = await chat_service.ask_about_memories(
1012
- project,
1013
- request.question,
1014
- request.max_memories,
1015
- )
1016
-
1017
- log_success("/api/chat", question_len=len(request.question), sources=len(result.get("sources", [])))
1018
- return ChatResponse(**result)
1019
- except HTTPException:
1020
- raise
1021
- except Exception as e:
1022
- log_error("/api/chat", e, question=request.question[:50])
1023
- raise
1024
-
1025
-
1026
- @app.get("/api/chat/stream")
1027
- @rate_limit("10/minute")
1028
- async def stream_chat(
1029
- project: str = Query(..., description="Path to the database file"),
1030
- question: str = Query(..., description="The question to ask"),
1031
- max_memories: int = Query(10, ge=1, le=50),
1032
- ):
1033
- """SSE endpoint for streaming chat responses."""
1034
- from fastapi.responses import StreamingResponse
1035
-
1036
- if not Path(project).exists():
1037
- raise HTTPException(status_code=404, detail="Database not found")
1038
-
1039
- async def event_generator():
1040
- try:
1041
- async for event in chat_service.stream_ask_about_memories(project, question, max_memories):
1042
- yield f"data: {json.dumps(event)}\n\n"
1043
- except Exception as e:
1044
- yield f"data: {json.dumps({'type': 'error', 'data': str(e)})}\n\n"
1045
-
1046
- return StreamingResponse(
1047
- event_generator(),
1048
- media_type="text/event-stream",
1049
- headers={
1050
- "Cache-Control": "no-cache",
1051
- "Connection": "keep-alive",
1052
- "X-Accel-Buffering": "no",
1053
- }
1054
- )
1055
-
1056
-
1057
- @app.post("/api/chat/save", response_model=ConversationSaveResponse)
1058
- async def save_chat_conversation(
1059
- request: ConversationSaveRequest,
1060
- project: str = Query(..., description="Path to the database file"),
1061
- ):
1062
- """Save a chat conversation as a memory."""
1063
- try:
1064
- if not Path(project).exists():
1065
- log_error("/api/chat/save", FileNotFoundError("Database not found"))
1066
- raise HTTPException(status_code=404, detail="Database not found")
1067
-
1068
- result = await chat_service.save_conversation(
1069
- project,
1070
- [msg.model_dump() for msg in request.messages],
1071
- request.referenced_memory_ids,
1072
- request.importance or 60,
1073
- )
1074
-
1075
- log_success("/api/chat/save", memory_id=result["memory_id"], messages=len(request.messages))
1076
- return ConversationSaveResponse(**result)
1077
- except HTTPException:
1078
- raise
1079
- except Exception as e:
1080
- log_error("/api/chat/save", e)
1081
- raise
1082
-
1083
-
1084
- # --- Image Generation Endpoints ---
1085
-
1086
-
1087
- @app.get("/api/image/status")
1088
- async def get_image_status():
1089
- """Check if image generation is available."""
1090
- return {
1091
- "available": image_service.is_available(),
1092
- "message": "Image generation ready" if image_service.is_available()
1093
- else "Configure GEMINI_API_KEY and install google-genai for image generation",
1094
- }
1095
-
1096
-
1097
- @app.get("/api/image/presets")
1098
- async def get_image_presets():
1099
- """Get available image preset templates."""
1100
- return {"presets": image_service.get_presets()}
1101
-
1102
-
1103
- @app.post("/api/image/generate-batch", response_model=BatchImageGenerationResponse)
1104
- @rate_limit("5/minute")
1105
- async def generate_images_batch(
1106
- request: BatchImageGenerationRequest,
1107
- db_path: str = Query(..., alias="project", description="Path to the database file"),
1108
- ):
1109
- """Generate multiple images with different presets/prompts."""
1110
- # Validate image count
1111
- if len(request.images) not in [1, 2, 4]:
1112
- return BatchImageGenerationResponse(
1113
- success=False,
1114
- errors=["Must request 1, 2, or 4 images"]
1115
- )
1116
-
1117
- # Build memory context
1118
- memory_context = ""
1119
- if request.memory_ids:
1120
- memory_context = image_service.build_memory_context(db_path, request.memory_ids)
1121
-
1122
- # Build chat context
1123
- chat_context = image_service.build_chat_context(request.chat_messages)
1124
-
1125
- # Convert request models to internal format
1126
- image_requests = [
1127
- SingleImageRequest(
1128
- preset=ImagePreset(img.preset),
1129
- custom_prompt=img.custom_prompt,
1130
- aspect_ratio=img.aspect_ratio,
1131
- image_size=img.image_size
1132
- )
1133
- for img in request.images
1134
- ]
1135
-
1136
- result = await image_service.generate_batch(
1137
- requests=image_requests,
1138
- memory_context=memory_context,
1139
- chat_context=chat_context,
1140
- use_search_grounding=request.use_search_grounding
1141
- )
1142
-
1143
- return BatchImageGenerationResponse(
1144
- success=result.success,
1145
- images=[
1146
- SingleImageResponseModel(
1147
- success=img.success,
1148
- image_data=img.image_data,
1149
- text_response=img.text_response,
1150
- thought_signature=img.thought_signature,
1151
- image_id=img.image_id,
1152
- error=img.error,
1153
- index=img.index
1154
- )
1155
- for img in result.images
1156
- ],
1157
- errors=result.errors
1158
- )
1159
-
1160
-
1161
- @app.post("/api/image/refine", response_model=SingleImageResponseModel)
1162
- @rate_limit("5/minute")
1163
- async def refine_image(request: ImageRefineRequest):
1164
- """Refine an existing generated image with a new prompt."""
1165
- result = await image_service.refine_image(
1166
- image_id=request.image_id,
1167
- refinement_prompt=request.refinement_prompt,
1168
- aspect_ratio=request.aspect_ratio,
1169
- image_size=request.image_size
1170
- )
1171
-
1172
- return SingleImageResponseModel(
1173
- success=result.success,
1174
- image_data=result.image_data,
1175
- text_response=result.text_response,
1176
- thought_signature=result.thought_signature,
1177
- image_id=result.image_id,
1178
- error=result.error
1179
- )
1180
-
1181
-
1182
- @app.post("/api/image/clear-conversation")
1183
- async def clear_image_conversation(image_id: Optional[str] = None):
1184
- """Clear image conversation history. If image_id provided, clear only that image."""
1185
- image_service.clear_conversation(image_id)
1186
- return {"status": "cleared", "image_id": image_id}
1187
-
1188
-
1189
- # --- WebSocket Endpoint ---
1190
-
1191
-
1192
- @app.websocket("/ws")
1193
- async def websocket_endpoint(websocket: WebSocket):
1194
- """WebSocket endpoint for real-time updates."""
1195
- client_id = await manager.connect(websocket)
1196
- try:
1197
- # Send initial connection confirmation
1198
- await manager.send_to_client(client_id, "connected", {"client_id": client_id})
1199
-
1200
- # Keep connection alive and handle messages
1201
- while True:
1202
- data = await websocket.receive_text()
1203
- # Echo back for ping/pong
1204
- if data == "ping":
1205
- await manager.send_to_client(client_id, "pong", {})
1206
- except WebSocketDisconnect:
1207
- await manager.disconnect(client_id)
1208
- except Exception as e:
1209
- print(f"[WS] Error: {e}")
1210
- await manager.disconnect(client_id)
1211
-
1212
-
1213
- # --- Export Endpoints ---
1214
-
1215
-
1216
- @app.get("/api/export")
1217
- async def export_memories(
1218
- project: str = Query(..., description="Path to the database file"),
1219
- format: str = Query("json", description="Export format: json, markdown, csv"),
1220
- memory_ids: Optional[str] = Query(None, description="Comma-separated memory IDs to export, or all if empty"),
1221
- include_relationships: bool = Query(True, description="Include memory relationships"),
1222
- ):
1223
- """Export memories to specified format."""
1224
- from fastapi.responses import Response
1225
- import csv
1226
- import io
1227
-
1228
- if not Path(project).exists():
1229
- raise HTTPException(status_code=404, detail="Database not found")
1230
-
1231
- # Get memories
1232
- if memory_ids:
1233
- ids = memory_ids.split(",")
1234
- memories = [get_memory_by_id(project, mid) for mid in ids if mid.strip()]
1235
- memories = [m for m in memories if m is not None]
1236
- else:
1237
- from models import FilterParams
1238
- filters = FilterParams(limit=1000, offset=0, sort_by="created_at", sort_order="desc")
1239
- memories = get_memories(project, filters)
1240
-
1241
- # Get relationships if requested
1242
- relationships = []
1243
- if include_relationships:
1244
- relationships = get_relationships(project)
1245
-
1246
- if format == "json":
1247
- export_data = {
1248
- "exported_at": datetime.now().isoformat(),
1249
- "project": project,
1250
- "memory_count": len(memories),
1251
- "memories": [m.model_dump(by_alias=True) for m in memories],
1252
- "relationships": relationships if include_relationships else [],
1253
- }
1254
- return Response(
1255
- content=json.dumps(export_data, indent=2, default=str),
1256
- media_type="application/json",
1257
- headers={"Content-Disposition": f"attachment; filename=memories_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"}
1258
- )
1259
-
1260
- elif format == "markdown":
1261
- md_lines = [
1262
- f"# Omni-Cortex Memory Export",
1263
- f"",
1264
- f"**Exported:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
1265
- f"**Total Memories:** {len(memories)}",
1266
- f"",
1267
- "---",
1268
- "",
1269
- ]
1270
- for m in memories:
1271
- md_lines.extend([
1272
- f"## {m.type.title()}: {m.content[:50]}{'...' if len(m.content) > 50 else ''}",
1273
- f"",
1274
- f"**ID:** `{m.id}`",
1275
- f"**Type:** {m.type}",
1276
- f"**Status:** {m.status}",
1277
- f"**Importance:** {m.importance_score}",
1278
- f"**Created:** {m.created_at}",
1279
- f"**Tags:** {', '.join(m.tags) if m.tags else 'None'}",
1280
- f"",
1281
- "### Content",
1282
- f"",
1283
- m.content,
1284
- f"",
1285
- "### Context",
1286
- f"",
1287
- m.context or "_No context_",
1288
- f"",
1289
- "---",
1290
- "",
1291
- ])
1292
- return Response(
1293
- content="\n".join(md_lines),
1294
- media_type="text/markdown",
1295
- headers={"Content-Disposition": f"attachment; filename=memories_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md"}
1296
- )
1297
-
1298
- elif format == "csv":
1299
- output = io.StringIO()
1300
- writer = csv.writer(output)
1301
- writer.writerow(["id", "type", "status", "importance", "content", "context", "tags", "created_at", "last_accessed"])
1302
- for m in memories:
1303
- writer.writerow([
1304
- m.id,
1305
- m.type,
1306
- m.status,
1307
- m.importance_score,
1308
- m.content,
1309
- m.context or "",
1310
- ",".join(m.tags) if m.tags else "",
1311
- m.created_at,
1312
- m.last_accessed or "",
1313
- ])
1314
- return Response(
1315
- content=output.getvalue(),
1316
- media_type="text/csv",
1317
- headers={"Content-Disposition": f"attachment; filename=memories_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"}
1318
- )
1319
-
1320
- else:
1321
- raise HTTPException(status_code=400, detail=f"Unsupported format: {format}. Use json, markdown, or csv.")
1322
-
1323
-
1324
- # --- Health Check ---
1325
-
1326
-
1327
- @app.get("/health")
1328
- async def health_check():
1329
- """Health check endpoint."""
1330
- return {
1331
- "status": "healthy",
1332
- "websocket_connections": manager.connection_count,
1333
- }
1334
-
1335
-
1336
- # --- Static File Serving (SPA) ---
1337
- # These routes must come AFTER all API routes
1338
-
1339
-
1340
- @app.get("/")
1341
- async def serve_root():
1342
- """Serve the frontend index.html."""
1343
- index_file = DIST_DIR / "index.html"
1344
- if index_file.exists():
1345
- return FileResponse(str(index_file))
1346
- return {"message": "Omni-Cortex Dashboard API", "docs": "/docs"}
1347
-
1348
-
1349
- @app.get("/{path:path}")
1350
- async def serve_spa(path: str):
1351
- """Catch-all route to serve SPA for client-side routing with path traversal protection."""
1352
- # Skip API routes and known paths
1353
- if path.startswith(("api/", "ws", "health", "docs", "openapi", "redoc")):
1354
- raise HTTPException(status_code=404, detail="Not found")
1355
-
1356
- # Check if it's a static file (with path traversal protection)
1357
- safe_path = PathValidator.is_safe_static_path(DIST_DIR, path)
1358
- if safe_path:
1359
- return FileResponse(str(safe_path))
1360
-
1361
- # Otherwise serve index.html for SPA routing
1362
- index_file = DIST_DIR / "index.html"
1363
- if index_file.exists():
1364
- return FileResponse(str(index_file))
1365
-
1366
- raise HTTPException(status_code=404, detail="Not found")
1367
-
1368
-
1369
- def run():
1370
- """Run the dashboard server."""
1371
- uvicorn.run(
1372
- "main:app",
1373
- host="0.0.0.0",
1374
- port=8765,
1375
- reload=True,
1376
- reload_dirs=[str(Path(__file__).parent)],
1377
- )
1378
-
1379
-
1380
- if __name__ == "__main__":
1381
- run()
1
+ """FastAPI backend for Omni-Cortex Web Dashboard."""
2
+ # Trigger reload for relationship graph column fix
3
+
4
+ import asyncio
5
+ import json
6
+ import os
7
+ import traceback
8
+ from contextlib import asynccontextmanager
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ import uvicorn
14
+ from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect, Request, Depends
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+ from fastapi.staticfiles import StaticFiles
17
+ from fastapi.responses import FileResponse, Response
18
+ from starlette.middleware.base import BaseHTTPMiddleware
19
+ from watchdog.events import FileSystemEventHandler
20
+ from watchdog.observers import Observer
21
+
22
+ # Rate limiting imports (optional - graceful degradation if not installed)
23
+ try:
24
+ from slowapi import Limiter, _rate_limit_exceeded_handler
25
+ from slowapi.util import get_remote_address
26
+ from slowapi.errors import RateLimitExceeded
27
+ RATE_LIMITING_AVAILABLE = True
28
+ except ImportError:
29
+ RATE_LIMITING_AVAILABLE = False
30
+ Limiter = None
31
+
32
+ from database import (
33
+ bulk_update_memory_status,
34
+ create_memory,
35
+ delete_memory,
36
+ delete_user_message,
37
+ delete_user_messages_bulk,
38
+ ensure_migrations,
39
+ get_activities,
40
+ get_activity_detail,
41
+ get_activity_heatmap,
42
+ get_all_tags,
43
+ get_command_usage,
44
+ get_mcp_usage,
45
+ get_memories,
46
+ get_memories_needing_review,
47
+ get_memory_by_id,
48
+ get_memory_growth,
49
+ get_memory_stats,
50
+ get_recent_sessions,
51
+ get_relationship_graph,
52
+ get_relationships,
53
+ get_sessions,
54
+ get_skill_usage,
55
+ get_style_profile,
56
+ get_style_samples,
57
+ get_timeline,
58
+ get_tool_usage,
59
+ get_type_distribution,
60
+ get_user_message_count,
61
+ get_user_messages,
62
+ search_memories,
63
+ update_memory,
64
+ )
65
+ from logging_config import log_success, log_error
66
+ from models import (
67
+ AggregateChatRequest,
68
+ AggregateMemoryRequest,
69
+ AggregateStatsRequest,
70
+ AggregateStatsResponse,
71
+ BatchImageGenerationRequest,
72
+ BatchImageGenerationResponse,
73
+ BulkDeleteRequest,
74
+ ChatRequest,
75
+ ChatResponse,
76
+ ConversationSaveRequest,
77
+ ConversationSaveResponse,
78
+ FilterParams,
79
+ ImageRefineRequest,
80
+ MemoryCreateRequest,
81
+ MemoryUpdate,
82
+ ProjectInfo,
83
+ ProjectRegistration,
84
+ SingleImageRequestModel,
85
+ SingleImageResponseModel,
86
+ StyleProfile,
87
+ StyleSample,
88
+ UserMessage,
89
+ UserMessagesResponse,
90
+ )
91
+ from project_config import (
92
+ load_config,
93
+ add_registered_project,
94
+ remove_registered_project,
95
+ toggle_favorite,
96
+ add_scan_directory,
97
+ remove_scan_directory,
98
+ )
99
+ from project_scanner import scan_projects
100
+ from websocket_manager import manager
101
+ import chat_service
102
+ from image_service import image_service, ImagePreset, SingleImageRequest
103
+ from security import PathValidator, get_cors_config, IS_PRODUCTION
104
+
105
+
106
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
107
+ """Add security headers to all responses."""
108
+
109
+ async def dispatch(self, request: Request, call_next) -> Response:
110
+ response = await call_next(request)
111
+
112
+ # Prevent MIME type sniffing
113
+ response.headers["X-Content-Type-Options"] = "nosniff"
114
+
115
+ # Prevent clickjacking
116
+ response.headers["X-Frame-Options"] = "DENY"
117
+
118
+ # XSS protection (legacy browsers)
119
+ response.headers["X-XSS-Protection"] = "1; mode=block"
120
+
121
+ # Content Security Policy
122
+ response.headers["Content-Security-Policy"] = (
123
+ "default-src 'self'; "
124
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " # Vue needs these
125
+ "style-src 'self' 'unsafe-inline'; " # Tailwind needs inline
126
+ "img-src 'self' data: blob: https:; " # Allow AI-generated images
127
+ "connect-src 'self' ws: wss: https://generativelanguage.googleapis.com; "
128
+ "font-src 'self'; "
129
+ "frame-ancestors 'none';"
130
+ )
131
+
132
+ # HSTS (only in production with HTTPS)
133
+ if IS_PRODUCTION and os.getenv("SSL_CERTFILE"):
134
+ response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
135
+
136
+ return response
137
+
138
+
139
+ def validate_project_path(project: str = Query(..., description="Path to the database file")) -> Path:
140
+ """Validate project database path - dependency for endpoints."""
141
+ try:
142
+ return PathValidator.validate_project_path(project)
143
+ except ValueError as e:
144
+ raise HTTPException(status_code=400, detail=str(e))
145
+
146
+
147
+ class DatabaseChangeHandler(FileSystemEventHandler):
148
+ """Handle database file changes for real-time updates."""
149
+
150
+ def __init__(self, ws_manager, loop):
151
+ self.ws_manager = ws_manager
152
+ self.loop = loop
153
+ self._debounce_task: Optional[asyncio.Task] = None
154
+ self._last_path: Optional[str] = None
155
+ self._last_activity_count: dict[str, int] = {}
156
+
157
+ def on_modified(self, event):
158
+ if event.src_path.endswith("cortex.db") or event.src_path.endswith("global.db"):
159
+ # Debounce rapid changes
160
+ self._last_path = event.src_path
161
+ if self._debounce_task is None or self._debounce_task.done():
162
+ self._debounce_task = asyncio.run_coroutine_threadsafe(
163
+ self._debounced_notify(), self.loop
164
+ )
165
+
166
+ async def _debounced_notify(self):
167
+ await asyncio.sleep(0.3) # Reduced from 0.5s for faster updates
168
+ if self._last_path:
169
+ db_path = self._last_path
170
+
171
+ # Broadcast general database change
172
+ await self.ws_manager.broadcast("database_changed", {"path": db_path})
173
+
174
+ # Fetch and broadcast latest activities (IndyDevDan pattern)
175
+ try:
176
+ # Get recent activities
177
+ recent = get_activities(db_path, limit=5, offset=0)
178
+ if recent:
179
+ # Broadcast each new activity
180
+ for activity in recent:
181
+ await self.ws_manager.broadcast_activity_logged(
182
+ db_path,
183
+ activity if isinstance(activity, dict) else activity.model_dump()
184
+ )
185
+
186
+ # Also broadcast session update
187
+ sessions = get_recent_sessions(db_path, limit=1)
188
+ if sessions:
189
+ session = sessions[0]
190
+ await self.ws_manager.broadcast_session_updated(
191
+ db_path,
192
+ session if isinstance(session, dict) else dict(session)
193
+ )
194
+ except Exception as e:
195
+ print(f"[WS] Error broadcasting activities: {e}")
196
+
197
+
198
+ # File watcher
199
+ observer: Optional[Observer] = None
200
+
201
+
202
+ @asynccontextmanager
203
+ async def lifespan(app: FastAPI):
204
+ """Manage file watcher lifecycle."""
205
+ global observer
206
+ loop = asyncio.get_event_loop()
207
+ handler = DatabaseChangeHandler(manager, loop)
208
+ observer = Observer()
209
+
210
+ # Watch common project directories
211
+ watch_paths = [
212
+ Path.home() / ".omni-cortex",
213
+ Path("D:/Projects"),
214
+ ]
215
+
216
+ for watch_path in watch_paths:
217
+ if watch_path.exists():
218
+ observer.schedule(handler, str(watch_path), recursive=True)
219
+ print(f"[Watcher] Monitoring: {watch_path}")
220
+
221
+ observer.start()
222
+ print("[Server] File watcher started")
223
+
224
+ yield
225
+
226
+ observer.stop()
227
+ observer.join()
228
+ print("[Server] File watcher stopped")
229
+
230
+
231
+ # FastAPI app
232
+ app = FastAPI(
233
+ title="Omni-Cortex Dashboard",
234
+ description="Web dashboard for viewing and managing Omni-Cortex memories",
235
+ version="0.1.0",
236
+ lifespan=lifespan,
237
+ )
238
+
239
+ # Add security headers middleware (MUST come before CORS)
240
+ app.add_middleware(SecurityHeadersMiddleware)
241
+
242
+ # Rate limiting (if available)
243
+ if RATE_LIMITING_AVAILABLE:
244
+ limiter = Limiter(key_func=get_remote_address)
245
+ app.state.limiter = limiter
246
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
247
+ else:
248
+ limiter = None
249
+
250
+
251
+ def rate_limit(limit_string: str):
252
+ """Decorator for conditional rate limiting.
253
+
254
+ Returns the actual limiter decorator if available, otherwise a no-op.
255
+ Usage: @rate_limit("10/minute")
256
+ """
257
+ if limiter is not None:
258
+ return limiter.limit(limit_string)
259
+ # No-op decorator when rate limiting is not available
260
+ def noop_decorator(func):
261
+ return func
262
+ return noop_decorator
263
+
264
+ # CORS configuration (environment-aware)
265
+ cors_config = get_cors_config()
266
+ app.add_middleware(
267
+ CORSMiddleware,
268
+ allow_origins=cors_config["allow_origins"],
269
+ allow_credentials=True,
270
+ allow_methods=cors_config["allow_methods"],
271
+ allow_headers=cors_config["allow_headers"],
272
+ )
273
+
274
+ # Static files for production build
275
+ DASHBOARD_DIR = Path(__file__).parent.parent
276
+ DIST_DIR = DASHBOARD_DIR / "frontend" / "dist"
277
+
278
+
279
+ def setup_static_files():
280
+ """Mount static files if dist directory exists (production build)."""
281
+ if DIST_DIR.exists():
282
+ # Mount assets directory
283
+ assets_dir = DIST_DIR / "assets"
284
+ if assets_dir.exists():
285
+ app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
286
+ print(f"[Static] Serving assets from: {assets_dir}")
287
+
288
+
289
+ # Call setup at module load
290
+ setup_static_files()
291
+
292
+
293
+ # --- REST Endpoints ---
294
+
295
+
296
+ @app.get("/api/projects", response_model=list[ProjectInfo])
297
+ async def list_projects():
298
+ """List all discovered omni-cortex project databases."""
299
+ return scan_projects()
300
+
301
+
302
+ # --- Project Management Endpoints ---
303
+
304
+
305
+ @app.get("/api/projects/config")
306
+ async def get_project_config():
307
+ """Get project configuration (scan dirs, counts)."""
308
+ config = load_config()
309
+ return {
310
+ "scan_directories": config.scan_directories,
311
+ "registered_count": len(config.registered_projects),
312
+ "favorites_count": len(config.favorites),
313
+ }
314
+
315
+
316
+ @app.post("/api/projects/register")
317
+ async def register_project(body: ProjectRegistration):
318
+ """Manually register a project by path."""
319
+ success = add_registered_project(body.path, body.display_name)
320
+ if not success:
321
+ raise HTTPException(400, "Invalid path or already registered")
322
+ return {"success": True}
323
+
324
+
325
+ @app.delete("/api/projects/register")
326
+ async def unregister_project(path: str = Query(..., description="Project path to unregister")):
327
+ """Remove a registered project."""
328
+ success = remove_registered_project(path)
329
+ if not success:
330
+ raise HTTPException(404, "Project not found")
331
+ return {"success": True}
332
+
333
+
334
+ @app.post("/api/projects/favorite")
335
+ async def toggle_project_favorite(path: str = Query(..., description="Project path to toggle favorite")):
336
+ """Toggle favorite status for a project."""
337
+ is_favorite = toggle_favorite(path)
338
+ return {"is_favorite": is_favorite}
339
+
340
+
341
+ @app.post("/api/projects/scan-directories")
342
+ async def add_scan_dir(directory: str = Query(..., description="Directory path to add")):
343
+ """Add a directory to auto-scan list."""
344
+ success = add_scan_directory(directory)
345
+ if not success:
346
+ raise HTTPException(400, "Invalid directory or already added")
347
+ return {"success": True}
348
+
349
+
350
+ @app.delete("/api/projects/scan-directories")
351
+ async def remove_scan_dir(directory: str = Query(..., description="Directory path to remove")):
352
+ """Remove a directory from auto-scan list."""
353
+ success = remove_scan_directory(directory)
354
+ if not success:
355
+ raise HTTPException(404, "Directory not found")
356
+ return {"success": True}
357
+
358
+
359
+ @app.post("/api/projects/refresh")
360
+ async def refresh_projects():
361
+ """Force rescan of all project directories."""
362
+ projects = scan_projects()
363
+ return {"count": len(projects)}
364
+
365
+
366
+ # --- Aggregate Multi-Project Endpoints ---
367
+
368
+
369
+ @app.post("/api/aggregate/memories")
370
+ @rate_limit("50/minute")
371
+ async def get_aggregate_memories(request: AggregateMemoryRequest):
372
+ """Get memories from multiple projects with project attribution."""
373
+ try:
374
+ all_memories = []
375
+ filters = request.filters or FilterParams()
376
+
377
+ for project_path in request.projects:
378
+ if not Path(project_path).exists():
379
+ continue
380
+
381
+ try:
382
+ memories = get_memories(project_path, filters)
383
+ # Add project attribution to each memory
384
+ for m in memories:
385
+ m_dict = m.model_dump()
386
+ m_dict['source_project'] = project_path
387
+ # Extract project name from path
388
+ project_dir = Path(project_path).parent
389
+ m_dict['source_project_name'] = project_dir.name
390
+ all_memories.append(m_dict)
391
+ except Exception as e:
392
+ log_error(f"/api/aggregate/memories (project: {project_path})", e)
393
+ continue
394
+
395
+ # Sort by last_accessed or created_at (convert to str to handle mixed tz-aware/naive)
396
+ all_memories.sort(
397
+ key=lambda x: str(x.get('last_accessed') or x.get('created_at') or ''),
398
+ reverse=True
399
+ )
400
+
401
+ # Apply pagination
402
+ start = filters.offset
403
+ end = start + filters.limit
404
+ return all_memories[start:end]
405
+ except Exception as e:
406
+ log_error("/api/aggregate/memories", e)
407
+ raise HTTPException(status_code=500, detail=str(e))
408
+
409
+
410
+ @app.post("/api/aggregate/stats", response_model=AggregateStatsResponse)
411
+ @rate_limit("50/minute")
412
+ async def get_aggregate_stats(request: AggregateStatsRequest):
413
+ """Get combined statistics across multiple projects."""
414
+ try:
415
+ total_count = 0
416
+ total_access = 0
417
+ importance_sum = 0
418
+ by_type = {}
419
+ by_status = {}
420
+
421
+ for project_path in request.projects:
422
+ if not Path(project_path).exists():
423
+ continue
424
+
425
+ try:
426
+ stats = get_memory_stats(project_path)
427
+ total_count += stats.total_count
428
+ total_access += stats.total_access_count
429
+
430
+ # Weighted average for importance
431
+ project_count = stats.total_count
432
+ project_avg_importance = stats.avg_importance
433
+ importance_sum += project_avg_importance * project_count
434
+
435
+ # Aggregate by_type
436
+ for type_name, count in stats.by_type.items():
437
+ by_type[type_name] = by_type.get(type_name, 0) + count
438
+
439
+ # Aggregate by_status
440
+ for status, count in stats.by_status.items():
441
+ by_status[status] = by_status.get(status, 0) + count
442
+ except Exception as e:
443
+ log_error(f"/api/aggregate/stats (project: {project_path})", e)
444
+ continue
445
+
446
+ return AggregateStatsResponse(
447
+ total_count=total_count,
448
+ total_access_count=total_access,
449
+ avg_importance=round(importance_sum / total_count, 1) if total_count > 0 else 0,
450
+ by_type=by_type,
451
+ by_status=by_status,
452
+ project_count=len(request.projects),
453
+ )
454
+ except Exception as e:
455
+ log_error("/api/aggregate/stats", e)
456
+ raise HTTPException(status_code=500, detail=str(e))
457
+
458
+
459
+ @app.post("/api/aggregate/tags")
460
+ @rate_limit("50/minute")
461
+ async def get_aggregate_tags(request: AggregateStatsRequest):
462
+ """Get combined tags across multiple projects."""
463
+ try:
464
+ tag_counts = {}
465
+
466
+ for project_path in request.projects:
467
+ if not Path(project_path).exists():
468
+ continue
469
+
470
+ try:
471
+ tags = get_all_tags(project_path)
472
+ for tag in tags:
473
+ tag_name = tag['name']
474
+ tag_counts[tag_name] = tag_counts.get(tag_name, 0) + tag['count']
475
+ except Exception as e:
476
+ log_error(f"/api/aggregate/tags (project: {project_path})", e)
477
+ continue
478
+
479
+ # Return sorted by count
480
+ return sorted(
481
+ [{'name': k, 'count': v} for k, v in tag_counts.items()],
482
+ key=lambda x: x['count'],
483
+ reverse=True
484
+ )
485
+ except Exception as e:
486
+ log_error("/api/aggregate/tags", e)
487
+ raise HTTPException(status_code=500, detail=str(e))
488
+
489
+
490
+ @app.post("/api/aggregate/chat", response_model=ChatResponse)
491
+ @rate_limit("10/minute")
492
+ async def chat_across_projects(request: AggregateChatRequest):
493
+ """Ask AI about memories across multiple projects."""
494
+ try:
495
+ if not chat_service.is_available():
496
+ raise HTTPException(
497
+ status_code=503,
498
+ detail="Chat service not available. Set GEMINI_API_KEY environment variable."
499
+ )
500
+
501
+ all_sources = []
502
+
503
+ # Gather relevant memories from each project
504
+ for project_path in request.projects:
505
+ if not Path(project_path).exists():
506
+ continue
507
+
508
+ try:
509
+ memories = search_memories(
510
+ project_path,
511
+ request.question,
512
+ limit=request.max_memories_per_project
513
+ )
514
+
515
+ for m in memories:
516
+ project_dir = Path(project_path).parent
517
+ source = {
518
+ 'id': m.id,
519
+ 'type': m.memory_type,
520
+ 'content_preview': m.content[:200],
521
+ 'tags': m.tags,
522
+ 'project_path': project_path,
523
+ 'project_name': project_dir.name,
524
+ }
525
+ all_sources.append(source)
526
+ except Exception as e:
527
+ log_error(f"/api/aggregate/chat (project: {project_path})", e)
528
+ continue
529
+
530
+ if not all_sources:
531
+ return ChatResponse(
532
+ answer="No relevant memories found across the selected projects.",
533
+ sources=[],
534
+ )
535
+
536
+ # Build context with project attribution
537
+ context = "\n\n".join([
538
+ f"[From: {s['project_name']}] {s['content_preview']}"
539
+ for s in all_sources
540
+ ])
541
+
542
+ # Query AI with attributed context
543
+ answer = await chat_service.generate_response(request.question, context)
544
+
545
+ log_success("/api/aggregate/chat", projects=len(request.projects), sources=len(all_sources))
546
+
547
+ return ChatResponse(
548
+ answer=answer,
549
+ sources=[ChatSource(**s) for s in all_sources],
550
+ )
551
+ except HTTPException:
552
+ raise
553
+ except Exception as e:
554
+ log_error("/api/aggregate/chat", e)
555
+ raise HTTPException(status_code=500, detail=str(e))
556
+
557
+
558
+ @app.get("/api/memories")
559
+ @rate_limit("100/minute")
560
+ async def list_memories(
561
+ project: str = Query(..., description="Path to the database file"),
562
+ memory_type: Optional[str] = Query(None, alias="type"),
563
+ status: Optional[str] = None,
564
+ tags: Optional[str] = None,
565
+ search: Optional[str] = None,
566
+ min_importance: Optional[int] = None,
567
+ max_importance: Optional[int] = None,
568
+ sort_by: str = "last_accessed",
569
+ sort_order: str = "desc",
570
+ limit: int = 50,
571
+ offset: int = 0,
572
+ ):
573
+ """Get memories with filtering and pagination."""
574
+ try:
575
+ if not Path(project).exists():
576
+ log_error("/api/memories", FileNotFoundError("Database not found"), project=project)
577
+ raise HTTPException(status_code=404, detail="Database not found")
578
+
579
+ filters = FilterParams(
580
+ memory_type=memory_type,
581
+ status=status,
582
+ tags=tags.split(",") if tags else None,
583
+ search=search,
584
+ min_importance=min_importance,
585
+ max_importance=max_importance,
586
+ sort_by=sort_by,
587
+ sort_order=sort_order,
588
+ limit=limit,
589
+ offset=offset,
590
+ )
591
+
592
+ memories = get_memories(project, filters)
593
+ log_success("/api/memories", count=len(memories), offset=offset, filters=bool(search or memory_type))
594
+ return memories
595
+ except Exception as e:
596
+ log_error("/api/memories", e, project=project)
597
+ raise
598
+
599
+
600
+ @app.post("/api/memories")
601
+ @rate_limit("30/minute")
602
+ async def create_memory_endpoint(
603
+ request: MemoryCreateRequest,
604
+ project: str = Query(..., description="Path to the database file"),
605
+ ):
606
+ """Create a new memory."""
607
+ try:
608
+ if not Path(project).exists():
609
+ log_error("/api/memories POST", FileNotFoundError("Database not found"), project=project)
610
+ raise HTTPException(status_code=404, detail="Database not found")
611
+
612
+ # Create the memory
613
+ memory_id = create_memory(
614
+ db_path=project,
615
+ content=request.content,
616
+ memory_type=request.memory_type,
617
+ context=request.context,
618
+ tags=request.tags if request.tags else None,
619
+ importance_score=request.importance_score,
620
+ )
621
+
622
+ # Fetch the created memory to return it
623
+ created_memory = get_memory_by_id(project, memory_id)
624
+
625
+ # Broadcast to WebSocket clients
626
+ await manager.broadcast("memory_created", created_memory.model_dump(by_alias=True))
627
+
628
+ log_success("/api/memories POST", memory_id=memory_id, type=request.memory_type)
629
+ return created_memory
630
+ except HTTPException:
631
+ raise
632
+ except Exception as e:
633
+ import traceback
634
+ print(f"[DEBUG] create_memory_endpoint error: {type(e).__name__}: {e}")
635
+ traceback.print_exc()
636
+ log_error("/api/memories POST", e, project=project)
637
+ raise
638
+
639
+
640
+ # NOTE: These routes MUST be defined before /api/memories/{memory_id} to avoid path conflicts
641
+ @app.get("/api/memories/needs-review")
642
+ async def get_memories_needing_review_endpoint(
643
+ project: str = Query(..., description="Path to the database file"),
644
+ days_threshold: int = 30,
645
+ limit: int = 50,
646
+ ):
647
+ """Get memories that may need freshness review."""
648
+ if not Path(project).exists():
649
+ raise HTTPException(status_code=404, detail="Database not found")
650
+
651
+ return get_memories_needing_review(project, days_threshold, limit)
652
+
653
+
654
+ @app.post("/api/memories/bulk-update-status")
655
+ async def bulk_update_status_endpoint(
656
+ project: str = Query(..., description="Path to the database file"),
657
+ memory_ids: list[str] = [],
658
+ status: str = "fresh",
659
+ ):
660
+ """Update status for multiple memories at once."""
661
+ if not Path(project).exists():
662
+ raise HTTPException(status_code=404, detail="Database not found")
663
+
664
+ valid_statuses = ["fresh", "needs_review", "outdated", "archived"]
665
+ if status not in valid_statuses:
666
+ raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
667
+
668
+ count = bulk_update_memory_status(project, memory_ids, status)
669
+
670
+ # Notify connected clients
671
+ await manager.broadcast("memories_bulk_updated", {"count": count, "status": status})
672
+
673
+ return {"updated_count": count, "status": status}
674
+
675
+
676
+ @app.get("/api/memories/{memory_id}")
677
+ async def get_memory(
678
+ memory_id: str,
679
+ project: str = Query(..., description="Path to the database file"),
680
+ ):
681
+ """Get a single memory by ID."""
682
+ if not Path(project).exists():
683
+ raise HTTPException(status_code=404, detail="Database not found")
684
+
685
+ memory = get_memory_by_id(project, memory_id)
686
+ if not memory:
687
+ raise HTTPException(status_code=404, detail="Memory not found")
688
+ return memory
689
+
690
+
691
+ @app.put("/api/memories/{memory_id}")
692
+ async def update_memory_endpoint(
693
+ memory_id: str,
694
+ updates: MemoryUpdate,
695
+ project: str = Query(..., description="Path to the database file"),
696
+ ):
697
+ """Update a memory."""
698
+ try:
699
+ if not Path(project).exists():
700
+ log_error("/api/memories/update", FileNotFoundError("Database not found"), memory_id=memory_id)
701
+ raise HTTPException(status_code=404, detail="Database not found")
702
+
703
+ updated = update_memory(project, memory_id, updates)
704
+ if not updated:
705
+ log_error("/api/memories/update", ValueError("Memory not found"), memory_id=memory_id)
706
+ raise HTTPException(status_code=404, detail="Memory not found")
707
+
708
+ # Notify connected clients
709
+ await manager.broadcast("memory_updated", updated.model_dump(by_alias=True))
710
+ log_success("/api/memories/update", memory_id=memory_id, fields_updated=len(updates.model_dump(exclude_unset=True)))
711
+ return updated
712
+ except HTTPException:
713
+ raise
714
+ except Exception as e:
715
+ log_error("/api/memories/update", e, memory_id=memory_id)
716
+ raise
717
+
718
+
719
+ @app.delete("/api/memories/{memory_id}")
720
+ async def delete_memory_endpoint(
721
+ memory_id: str,
722
+ project: str = Query(..., description="Path to the database file"),
723
+ ):
724
+ """Delete a memory."""
725
+ try:
726
+ if not Path(project).exists():
727
+ log_error("/api/memories/delete", FileNotFoundError("Database not found"), memory_id=memory_id)
728
+ raise HTTPException(status_code=404, detail="Database not found")
729
+
730
+ deleted = delete_memory(project, memory_id)
731
+ if not deleted:
732
+ log_error("/api/memories/delete", ValueError("Memory not found"), memory_id=memory_id)
733
+ raise HTTPException(status_code=404, detail="Memory not found")
734
+
735
+ # Notify connected clients
736
+ await manager.broadcast("memory_deleted", {"id": memory_id})
737
+ log_success("/api/memories/delete", memory_id=memory_id)
738
+ return {"message": "Memory deleted", "id": memory_id}
739
+ except HTTPException:
740
+ raise
741
+ except Exception as e:
742
+ log_error("/api/memories/delete", e, memory_id=memory_id)
743
+ raise
744
+
745
+
746
+ @app.get("/api/memories/stats/summary")
747
+ async def memory_stats(
748
+ project: str = Query(..., description="Path to the database file"),
749
+ ):
750
+ """Get memory statistics."""
751
+ if not Path(project).exists():
752
+ raise HTTPException(status_code=404, detail="Database not found")
753
+
754
+ return get_memory_stats(project)
755
+
756
+
757
+ @app.get("/api/search")
758
+ async def search(
759
+ q: str = Query(..., min_length=1),
760
+ project: str = Query(..., description="Path to the database file"),
761
+ limit: int = 20,
762
+ ):
763
+ """Search memories."""
764
+ if not Path(project).exists():
765
+ raise HTTPException(status_code=404, detail="Database not found")
766
+
767
+ return search_memories(project, q, limit)
768
+
769
+
770
+ @app.get("/api/activities")
771
+ async def list_activities(
772
+ project: str = Query(..., description="Path to the database file"),
773
+ event_type: Optional[str] = None,
774
+ tool_name: Optional[str] = None,
775
+ limit: int = 100,
776
+ offset: int = 0,
777
+ ):
778
+ """Get activity log entries."""
779
+ if not Path(project).exists():
780
+ raise HTTPException(status_code=404, detail="Database not found")
781
+
782
+ # Ensure migrations are applied (adds summary columns if missing)
783
+ ensure_migrations(project)
784
+
785
+ return get_activities(project, event_type, tool_name, limit, offset)
786
+
787
+
788
+ @app.get("/api/timeline")
789
+ async def get_timeline_view(
790
+ project: str = Query(..., description="Path to the database file"),
791
+ hours: int = 24,
792
+ include_memories: bool = True,
793
+ include_activities: bool = True,
794
+ ):
795
+ """Get timeline of recent activity."""
796
+ if not Path(project).exists():
797
+ raise HTTPException(status_code=404, detail="Database not found")
798
+
799
+ return get_timeline(project, hours, include_memories, include_activities)
800
+
801
+
802
+ @app.get("/api/tags")
803
+ async def list_tags(
804
+ project: str = Query(..., description="Path to the database file"),
805
+ ):
806
+ """Get all tags with counts."""
807
+ if not Path(project).exists():
808
+ raise HTTPException(status_code=404, detail="Database not found")
809
+
810
+ return get_all_tags(project)
811
+
812
+
813
+ @app.get("/api/types")
814
+ async def list_types(
815
+ project: str = Query(..., description="Path to the database file"),
816
+ ):
817
+ """Get memory type distribution."""
818
+ if not Path(project).exists():
819
+ raise HTTPException(status_code=404, detail="Database not found")
820
+
821
+ return get_type_distribution(project)
822
+
823
+
824
+ @app.get("/api/sessions")
825
+ async def list_sessions(
826
+ project: str = Query(..., description="Path to the database file"),
827
+ limit: int = 20,
828
+ ):
829
+ """Get recent sessions."""
830
+ if not Path(project).exists():
831
+ raise HTTPException(status_code=404, detail="Database not found")
832
+
833
+ return get_sessions(project, limit)
834
+
835
+
836
+ # --- Stats Endpoints for Charts ---
837
+
838
+
839
+ @app.get("/api/stats/activity-heatmap")
840
+ async def get_activity_heatmap_endpoint(
841
+ project: str = Query(..., description="Path to the database file"),
842
+ days: int = 90,
843
+ ):
844
+ """Get activity counts grouped by day for heatmap visualization."""
845
+ if not Path(project).exists():
846
+ raise HTTPException(status_code=404, detail="Database not found")
847
+
848
+ return get_activity_heatmap(project, days)
849
+
850
+
851
+ @app.get("/api/stats/tool-usage")
852
+ async def get_tool_usage_endpoint(
853
+ project: str = Query(..., description="Path to the database file"),
854
+ limit: int = 10,
855
+ ):
856
+ """Get tool usage statistics."""
857
+ if not Path(project).exists():
858
+ raise HTTPException(status_code=404, detail="Database not found")
859
+
860
+ return get_tool_usage(project, limit)
861
+
862
+
863
+ @app.get("/api/stats/memory-growth")
864
+ async def get_memory_growth_endpoint(
865
+ project: str = Query(..., description="Path to the database file"),
866
+ days: int = 30,
867
+ ):
868
+ """Get memory creation over time."""
869
+ if not Path(project).exists():
870
+ raise HTTPException(status_code=404, detail="Database not found")
871
+
872
+ return get_memory_growth(project, days)
873
+
874
+
875
+ # --- Command Analytics Endpoints ---
876
+
877
+
878
+ @app.get("/api/stats/command-usage")
879
+ async def get_command_usage_endpoint(
880
+ project: str = Query(..., description="Path to the database file"),
881
+ scope: Optional[str] = Query(None, description="Filter by scope: 'universal' or 'project'"),
882
+ days: int = Query(30, ge=1, le=365),
883
+ ):
884
+ """Get slash command usage statistics."""
885
+ if not Path(project).exists():
886
+ raise HTTPException(status_code=404, detail="Database not found")
887
+
888
+ return get_command_usage(project, scope, days)
889
+
890
+
891
+ @app.get("/api/stats/skill-usage")
892
+ async def get_skill_usage_endpoint(
893
+ project: str = Query(..., description="Path to the database file"),
894
+ scope: Optional[str] = Query(None, description="Filter by scope: 'universal' or 'project'"),
895
+ days: int = Query(30, ge=1, le=365),
896
+ ):
897
+ """Get skill usage statistics."""
898
+ if not Path(project).exists():
899
+ raise HTTPException(status_code=404, detail="Database not found")
900
+
901
+ return get_skill_usage(project, scope, days)
902
+
903
+
904
+ @app.get("/api/stats/mcp-usage")
905
+ async def get_mcp_usage_endpoint(
906
+ project: str = Query(..., description="Path to the database file"),
907
+ days: int = Query(30, ge=1, le=365),
908
+ ):
909
+ """Get MCP server usage statistics."""
910
+ if not Path(project).exists():
911
+ raise HTTPException(status_code=404, detail="Database not found")
912
+
913
+ return get_mcp_usage(project, days)
914
+
915
+
916
+ @app.get("/api/activities/{activity_id}")
917
+ async def get_activity_detail_endpoint(
918
+ activity_id: str,
919
+ project: str = Query(..., description="Path to the database file"),
920
+ ):
921
+ """Get full activity details including complete input/output."""
922
+ if not Path(project).exists():
923
+ raise HTTPException(status_code=404, detail="Database not found")
924
+
925
+ # Ensure migrations are applied
926
+ ensure_migrations(project)
927
+
928
+ activity = get_activity_detail(project, activity_id)
929
+ if not activity:
930
+ raise HTTPException(status_code=404, detail="Activity not found")
931
+
932
+ return activity
933
+
934
+
935
+ @app.post("/api/activities/backfill-summaries")
936
+ async def backfill_activity_summaries_endpoint(
937
+ project: str = Query(..., description="Path to the database file"),
938
+ ):
939
+ """Generate summaries for existing activities that don't have them."""
940
+ if not Path(project).exists():
941
+ raise HTTPException(status_code=404, detail="Database not found")
942
+
943
+ try:
944
+ from backfill_summaries import backfill_all
945
+ results = backfill_all(project)
946
+ return {
947
+ "success": True,
948
+ "summaries_updated": results["summaries"],
949
+ "mcp_servers_updated": results["mcp_servers"],
950
+ }
951
+ except Exception as e:
952
+ raise HTTPException(status_code=500, detail=f"Backfill failed: {str(e)}")
953
+
954
+
955
+ # --- Session Context Endpoints ---
956
+
957
+
958
+ @app.get("/api/sessions/recent")
959
+ async def get_recent_sessions_endpoint(
960
+ project: str = Query(..., description="Path to the database file"),
961
+ limit: int = 5,
962
+ ):
963
+ """Get recent sessions with summaries."""
964
+ if not Path(project).exists():
965
+ raise HTTPException(status_code=404, detail="Database not found")
966
+
967
+ return get_recent_sessions(project, limit)
968
+
969
+
970
+ # --- Relationship Graph Endpoints ---
971
+
972
+
973
+ @app.get("/api/relationships")
974
+ async def get_relationships_endpoint(
975
+ project: str = Query(..., description="Path to the database file"),
976
+ memory_id: Optional[str] = None,
977
+ ):
978
+ """Get memory relationships for graph visualization."""
979
+ if not Path(project).exists():
980
+ raise HTTPException(status_code=404, detail="Database not found")
981
+
982
+ return get_relationships(project, memory_id)
983
+
984
+
985
+ @app.get("/api/relationships/graph")
986
+ async def get_relationship_graph_endpoint(
987
+ project: str = Query(..., description="Path to the database file"),
988
+ center_id: Optional[str] = None,
989
+ depth: int = 2,
990
+ ):
991
+ """Get graph data centered on a memory with configurable depth."""
992
+ if not Path(project).exists():
993
+ raise HTTPException(status_code=404, detail="Database not found")
994
+
995
+ return get_relationship_graph(project, center_id, depth)
996
+
997
+
998
+ # --- Chat Endpoint ---
999
+
1000
+
1001
+ @app.get("/api/chat/status")
1002
+ async def chat_status():
1003
+ """Check if chat service is available."""
1004
+ return {
1005
+ "available": chat_service.is_available(),
1006
+ "message": "Chat is available" if chat_service.is_available() else "Set GEMINI_API_KEY environment variable to enable chat",
1007
+ }
1008
+
1009
+
1010
+ @app.post("/api/chat", response_model=ChatResponse)
1011
+ @rate_limit("10/minute")
1012
+ async def chat_with_memories(
1013
+ request: ChatRequest,
1014
+ project: str = Query(..., description="Path to the database file"),
1015
+ ):
1016
+ """Ask a natural language question about memories."""
1017
+ try:
1018
+ if not Path(project).exists():
1019
+ log_error("/api/chat", FileNotFoundError("Database not found"), question=request.question[:50])
1020
+ raise HTTPException(status_code=404, detail="Database not found")
1021
+
1022
+ # Fetch style profile if style mode enabled
1023
+ style_context = None
1024
+ if request.use_style:
1025
+ try:
1026
+ style_context = get_style_profile(project)
1027
+ except Exception:
1028
+ pass # Graceful fallback if no style data
1029
+
1030
+ result = await chat_service.ask_about_memories(
1031
+ project,
1032
+ request.question,
1033
+ request.max_memories,
1034
+ style_context,
1035
+ )
1036
+
1037
+ log_success("/api/chat", question_len=len(request.question), sources=len(result.get("sources", [])))
1038
+ return ChatResponse(**result)
1039
+ except HTTPException:
1040
+ raise
1041
+ except Exception as e:
1042
+ log_error("/api/chat", e, question=request.question[:50])
1043
+ raise
1044
+
1045
+
1046
+ @app.get("/api/chat/stream")
1047
+ @rate_limit("10/minute")
1048
+ async def stream_chat(
1049
+ project: str = Query(..., description="Path to the database file"),
1050
+ question: str = Query(..., description="The question to ask"),
1051
+ max_memories: int = Query(10, ge=1, le=50),
1052
+ use_style: bool = Query(False, description="Use user's communication style"),
1053
+ ):
1054
+ """SSE endpoint for streaming chat responses."""
1055
+ from fastapi.responses import StreamingResponse
1056
+
1057
+ if not Path(project).exists():
1058
+ raise HTTPException(status_code=404, detail="Database not found")
1059
+
1060
+ # Fetch style profile if style mode enabled
1061
+ style_context = None
1062
+ if use_style:
1063
+ try:
1064
+ style_context = get_style_profile(project)
1065
+ except Exception:
1066
+ pass # Graceful fallback if no style data
1067
+
1068
+ async def event_generator():
1069
+ try:
1070
+ async for event in chat_service.stream_ask_about_memories(project, question, max_memories, style_context):
1071
+ yield f"data: {json.dumps(event)}\n\n"
1072
+ except Exception as e:
1073
+ yield f"data: {json.dumps({'type': 'error', 'data': str(e)})}\n\n"
1074
+
1075
+ return StreamingResponse(
1076
+ event_generator(),
1077
+ media_type="text/event-stream",
1078
+ headers={
1079
+ "Cache-Control": "no-cache",
1080
+ "Connection": "keep-alive",
1081
+ "X-Accel-Buffering": "no",
1082
+ }
1083
+ )
1084
+
1085
+
1086
+ @app.post("/api/chat/save", response_model=ConversationSaveResponse)
1087
+ async def save_chat_conversation(
1088
+ request: ConversationSaveRequest,
1089
+ project: str = Query(..., description="Path to the database file"),
1090
+ ):
1091
+ """Save a chat conversation as a memory."""
1092
+ try:
1093
+ if not Path(project).exists():
1094
+ log_error("/api/chat/save", FileNotFoundError("Database not found"))
1095
+ raise HTTPException(status_code=404, detail="Database not found")
1096
+
1097
+ result = await chat_service.save_conversation(
1098
+ project,
1099
+ [msg.model_dump() for msg in request.messages],
1100
+ request.referenced_memory_ids,
1101
+ request.importance or 60,
1102
+ )
1103
+
1104
+ log_success("/api/chat/save", memory_id=result["memory_id"], messages=len(request.messages))
1105
+ return ConversationSaveResponse(**result)
1106
+ except HTTPException:
1107
+ raise
1108
+ except Exception as e:
1109
+ log_error("/api/chat/save", e)
1110
+ raise
1111
+
1112
+
1113
+ # --- Image Generation Endpoints ---
1114
+
1115
+
1116
+ @app.get("/api/image/status")
1117
+ async def get_image_status():
1118
+ """Check if image generation is available."""
1119
+ return {
1120
+ "available": image_service.is_available(),
1121
+ "message": "Image generation ready" if image_service.is_available()
1122
+ else "Configure GEMINI_API_KEY and install google-genai for image generation",
1123
+ }
1124
+
1125
+
1126
+ @app.get("/api/image/presets")
1127
+ async def get_image_presets():
1128
+ """Get available image preset templates."""
1129
+ return {"presets": image_service.get_presets()}
1130
+
1131
+
1132
+ @app.post("/api/image/generate-batch", response_model=BatchImageGenerationResponse)
1133
+ @rate_limit("5/minute")
1134
+ async def generate_images_batch(
1135
+ request: BatchImageGenerationRequest,
1136
+ db_path: str = Query(..., alias="project", description="Path to the database file"),
1137
+ ):
1138
+ """Generate multiple images with different presets/prompts."""
1139
+ # Validate image count
1140
+ if len(request.images) not in [1, 2, 4]:
1141
+ return BatchImageGenerationResponse(
1142
+ success=False,
1143
+ errors=["Must request 1, 2, or 4 images"]
1144
+ )
1145
+
1146
+ # Build memory context
1147
+ memory_context = ""
1148
+ if request.memory_ids:
1149
+ memory_context = image_service.build_memory_context(db_path, request.memory_ids)
1150
+
1151
+ # Build chat context
1152
+ chat_context = image_service.build_chat_context(request.chat_messages)
1153
+
1154
+ # Convert request models to internal format
1155
+ image_requests = [
1156
+ SingleImageRequest(
1157
+ preset=ImagePreset(img.preset),
1158
+ custom_prompt=img.custom_prompt,
1159
+ aspect_ratio=img.aspect_ratio,
1160
+ image_size=img.image_size
1161
+ )
1162
+ for img in request.images
1163
+ ]
1164
+
1165
+ result = await image_service.generate_batch(
1166
+ requests=image_requests,
1167
+ memory_context=memory_context,
1168
+ chat_context=chat_context,
1169
+ use_search_grounding=request.use_search_grounding
1170
+ )
1171
+
1172
+ return BatchImageGenerationResponse(
1173
+ success=result.success,
1174
+ images=[
1175
+ SingleImageResponseModel(
1176
+ success=img.success,
1177
+ image_data=img.image_data,
1178
+ text_response=img.text_response,
1179
+ thought_signature=img.thought_signature,
1180
+ image_id=img.image_id,
1181
+ error=img.error,
1182
+ index=img.index
1183
+ )
1184
+ for img in result.images
1185
+ ],
1186
+ errors=result.errors
1187
+ )
1188
+
1189
+
1190
+ @app.post("/api/image/refine", response_model=SingleImageResponseModel)
1191
+ @rate_limit("5/minute")
1192
+ async def refine_image(request: ImageRefineRequest):
1193
+ """Refine an existing generated image with a new prompt."""
1194
+ result = await image_service.refine_image(
1195
+ image_id=request.image_id,
1196
+ refinement_prompt=request.refinement_prompt,
1197
+ aspect_ratio=request.aspect_ratio,
1198
+ image_size=request.image_size
1199
+ )
1200
+
1201
+ return SingleImageResponseModel(
1202
+ success=result.success,
1203
+ image_data=result.image_data,
1204
+ text_response=result.text_response,
1205
+ thought_signature=result.thought_signature,
1206
+ image_id=result.image_id,
1207
+ error=result.error
1208
+ )
1209
+
1210
+
1211
+ @app.post("/api/image/clear-conversation")
1212
+ async def clear_image_conversation(image_id: Optional[str] = None):
1213
+ """Clear image conversation history. If image_id provided, clear only that image."""
1214
+ image_service.clear_conversation(image_id)
1215
+ return {"status": "cleared", "image_id": image_id}
1216
+
1217
+
1218
+ # --- User Messages & Style Profile Endpoints ---
1219
+
1220
+
1221
+ @app.get("/api/user-messages", response_model=UserMessagesResponse)
1222
+ @rate_limit("100/minute")
1223
+ async def list_user_messages(
1224
+ project: str = Query(..., description="Path to the database file"),
1225
+ session_id: Optional[str] = None,
1226
+ search: Optional[str] = None,
1227
+ has_code_blocks: Optional[bool] = None,
1228
+ has_questions: Optional[bool] = None,
1229
+ has_commands: Optional[bool] = None,
1230
+ tone_filter: Optional[str] = None,
1231
+ sort_by: str = "timestamp",
1232
+ sort_order: str = "desc",
1233
+ limit: int = Query(50, ge=1, le=500),
1234
+ offset: int = Query(0, ge=0),
1235
+ ):
1236
+ """Get user messages with filtering and pagination.
1237
+
1238
+ Filter options:
1239
+ - session_id: Filter by session
1240
+ - search: Search in message content
1241
+ - has_code_blocks: Filter by presence of code blocks
1242
+ - has_questions: Filter by presence of questions
1243
+ - has_commands: Filter by slash commands
1244
+ - tone_filter: Filter by tone indicator (polite, urgent, technical, casual, direct, inquisitive)
1245
+ """
1246
+ try:
1247
+ if not Path(project).exists():
1248
+ raise HTTPException(status_code=404, detail="Database not found")
1249
+
1250
+ messages = get_user_messages(
1251
+ project,
1252
+ session_id=session_id,
1253
+ search=search,
1254
+ has_code_blocks=has_code_blocks,
1255
+ has_questions=has_questions,
1256
+ has_commands=has_commands,
1257
+ tone_filter=tone_filter,
1258
+ sort_by=sort_by,
1259
+ sort_order=sort_order,
1260
+ limit=limit,
1261
+ offset=offset,
1262
+ )
1263
+
1264
+ total_count = get_user_message_count(project, session_id=session_id)
1265
+
1266
+ log_success("/api/user-messages", count=len(messages), total=total_count)
1267
+ return UserMessagesResponse(
1268
+ messages=[UserMessage(**m) for m in messages],
1269
+ total_count=total_count,
1270
+ limit=limit,
1271
+ offset=offset,
1272
+ )
1273
+ except HTTPException:
1274
+ raise
1275
+ except Exception as e:
1276
+ log_error("/api/user-messages", e)
1277
+ raise HTTPException(status_code=500, detail=str(e))
1278
+
1279
+
1280
+ @app.delete("/api/user-messages/{message_id}")
1281
+ async def delete_single_user_message(
1282
+ message_id: str,
1283
+ project: str = Query(..., description="Path to the database file"),
1284
+ ):
1285
+ """Delete a single user message by ID."""
1286
+ try:
1287
+ if not Path(project).exists():
1288
+ raise HTTPException(status_code=404, detail="Database not found")
1289
+
1290
+ deleted = delete_user_message(project, message_id)
1291
+ if not deleted:
1292
+ raise HTTPException(status_code=404, detail="Message not found")
1293
+
1294
+ log_success("/api/user-messages/delete", message_id=message_id)
1295
+ return {"message": "Message deleted", "id": message_id}
1296
+ except HTTPException:
1297
+ raise
1298
+ except Exception as e:
1299
+ log_error("/api/user-messages/delete", e)
1300
+ raise HTTPException(status_code=500, detail=str(e))
1301
+
1302
+
1303
+ @app.post("/api/user-messages/bulk-delete")
1304
+ async def delete_user_messages_bulk_endpoint(
1305
+ request: BulkDeleteRequest,
1306
+ project: str = Query(..., description="Path to the database file"),
1307
+ ):
1308
+ """Delete multiple user messages at once."""
1309
+ try:
1310
+ if not Path(project).exists():
1311
+ raise HTTPException(status_code=404, detail="Database not found")
1312
+
1313
+ count = delete_user_messages_bulk(project, request.message_ids)
1314
+
1315
+ log_success("/api/user-messages/bulk-delete", deleted_count=count)
1316
+ return {"message": f"Deleted {count} messages", "deleted_count": count}
1317
+ except HTTPException:
1318
+ raise
1319
+ except Exception as e:
1320
+ log_error("/api/user-messages/bulk-delete", e)
1321
+ raise HTTPException(status_code=500, detail=str(e))
1322
+
1323
+
1324
+ @app.get("/api/style-profile")
1325
+ async def get_style_profile_endpoint(
1326
+ project: str = Query(..., description="Path to the database file"),
1327
+ project_path: Optional[str] = Query(None, description="Project-specific profile path, or None for global"),
1328
+ ):
1329
+ """Get user style profile for style analysis.
1330
+
1331
+ Returns aggregated style metrics including:
1332
+ - Average word/char counts
1333
+ - Common phrases
1334
+ - Formality score
1335
+ - Question/command frequencies
1336
+ - Greeting and instruction patterns
1337
+ """
1338
+ try:
1339
+ if not Path(project).exists():
1340
+ raise HTTPException(status_code=404, detail="Database not found")
1341
+
1342
+ profile = get_style_profile(project, project_path=project_path)
1343
+
1344
+ if not profile:
1345
+ # Return empty profile structure if none exists
1346
+ return {
1347
+ "id": None,
1348
+ "project_path": project_path,
1349
+ "total_messages": 0,
1350
+ "avg_word_count": None,
1351
+ "avg_char_count": None,
1352
+ "common_phrases": [],
1353
+ "vocabulary_richness": None,
1354
+ "formality_score": None,
1355
+ "question_frequency": None,
1356
+ "command_frequency": None,
1357
+ "code_block_frequency": None,
1358
+ "punctuation_style": None,
1359
+ "greeting_patterns": [],
1360
+ "instruction_style": None,
1361
+ "sample_messages": [],
1362
+ "created_at": None,
1363
+ "updated_at": None,
1364
+ }
1365
+
1366
+ log_success("/api/style-profile", has_profile=True)
1367
+ return profile
1368
+ except HTTPException:
1369
+ raise
1370
+ except Exception as e:
1371
+ log_error("/api/style-profile", e)
1372
+ raise HTTPException(status_code=500, detail=str(e))
1373
+
1374
+
1375
+ @app.get("/api/style-samples", response_model=list[StyleSample])
1376
+ async def get_style_samples_endpoint(
1377
+ project: str = Query(..., description="Path to the database file"),
1378
+ limit: int = Query(10, ge=1, le=50),
1379
+ ):
1380
+ """Get sample user messages for style analysis preview.
1381
+
1382
+ Returns a diverse selection of messages showcasing different writing styles,
1383
+ including recent messages, messages with code blocks, and longer messages.
1384
+ """
1385
+ try:
1386
+ if not Path(project).exists():
1387
+ raise HTTPException(status_code=404, detail="Database not found")
1388
+
1389
+ samples = get_style_samples(project, limit=limit)
1390
+
1391
+ log_success("/api/style-samples", count=len(samples))
1392
+ return [StyleSample(**s) for s in samples]
1393
+ except HTTPException:
1394
+ raise
1395
+ except Exception as e:
1396
+ log_error("/api/style-samples", e)
1397
+ raise HTTPException(status_code=500, detail=str(e))
1398
+
1399
+
1400
+ # --- WebSocket Endpoint ---
1401
+
1402
+
1403
+ @app.websocket("/ws")
1404
+ async def websocket_endpoint(websocket: WebSocket):
1405
+ """WebSocket endpoint for real-time updates."""
1406
+ client_id = await manager.connect(websocket)
1407
+ try:
1408
+ # Send initial connection confirmation
1409
+ await manager.send_to_client(client_id, "connected", {"client_id": client_id})
1410
+
1411
+ # Keep connection alive and handle messages
1412
+ while True:
1413
+ data = await websocket.receive_text()
1414
+ # Echo back for ping/pong
1415
+ if data == "ping":
1416
+ await manager.send_to_client(client_id, "pong", {})
1417
+ except WebSocketDisconnect:
1418
+ await manager.disconnect(client_id)
1419
+ except Exception as e:
1420
+ print(f"[WS] Error: {e}")
1421
+ await manager.disconnect(client_id)
1422
+
1423
+
1424
+ # --- Export Endpoints ---
1425
+
1426
+
1427
+ @app.get("/api/export")
1428
+ async def export_memories(
1429
+ project: str = Query(..., description="Path to the database file"),
1430
+ format: str = Query("json", description="Export format: json, markdown, csv"),
1431
+ memory_ids: Optional[str] = Query(None, description="Comma-separated memory IDs to export, or all if empty"),
1432
+ include_relationships: bool = Query(True, description="Include memory relationships"),
1433
+ ):
1434
+ """Export memories to specified format."""
1435
+ from fastapi.responses import Response
1436
+ import csv
1437
+ import io
1438
+
1439
+ if not Path(project).exists():
1440
+ raise HTTPException(status_code=404, detail="Database not found")
1441
+
1442
+ # Get memories
1443
+ if memory_ids:
1444
+ ids = memory_ids.split(",")
1445
+ memories = [get_memory_by_id(project, mid) for mid in ids if mid.strip()]
1446
+ memories = [m for m in memories if m is not None]
1447
+ else:
1448
+ from models import FilterParams
1449
+ filters = FilterParams(limit=1000, offset=0, sort_by="created_at", sort_order="desc")
1450
+ memories = get_memories(project, filters)
1451
+
1452
+ # Get relationships if requested
1453
+ relationships = []
1454
+ if include_relationships:
1455
+ relationships = get_relationships(project)
1456
+
1457
+ if format == "json":
1458
+ export_data = {
1459
+ "exported_at": datetime.now().isoformat(),
1460
+ "project": project,
1461
+ "memory_count": len(memories),
1462
+ "memories": [m.model_dump(by_alias=True) for m in memories],
1463
+ "relationships": relationships if include_relationships else [],
1464
+ }
1465
+ return Response(
1466
+ content=json.dumps(export_data, indent=2, default=str),
1467
+ media_type="application/json",
1468
+ headers={"Content-Disposition": f"attachment; filename=memories_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"}
1469
+ )
1470
+
1471
+ elif format == "markdown":
1472
+ md_lines = [
1473
+ f"# Omni-Cortex Memory Export",
1474
+ f"",
1475
+ f"**Exported:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
1476
+ f"**Total Memories:** {len(memories)}",
1477
+ f"",
1478
+ "---",
1479
+ "",
1480
+ ]
1481
+ for m in memories:
1482
+ md_lines.extend([
1483
+ f"## {m.type.title()}: {m.content[:50]}{'...' if len(m.content) > 50 else ''}",
1484
+ f"",
1485
+ f"**ID:** `{m.id}`",
1486
+ f"**Type:** {m.type}",
1487
+ f"**Status:** {m.status}",
1488
+ f"**Importance:** {m.importance_score}",
1489
+ f"**Created:** {m.created_at}",
1490
+ f"**Tags:** {', '.join(m.tags) if m.tags else 'None'}",
1491
+ f"",
1492
+ "### Content",
1493
+ f"",
1494
+ m.content,
1495
+ f"",
1496
+ "### Context",
1497
+ f"",
1498
+ m.context or "_No context_",
1499
+ f"",
1500
+ "---",
1501
+ "",
1502
+ ])
1503
+ return Response(
1504
+ content="\n".join(md_lines),
1505
+ media_type="text/markdown",
1506
+ headers={"Content-Disposition": f"attachment; filename=memories_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md"}
1507
+ )
1508
+
1509
+ elif format == "csv":
1510
+ output = io.StringIO()
1511
+ writer = csv.writer(output)
1512
+ writer.writerow(["id", "type", "status", "importance", "content", "context", "tags", "created_at", "last_accessed"])
1513
+ for m in memories:
1514
+ writer.writerow([
1515
+ m.id,
1516
+ m.type,
1517
+ m.status,
1518
+ m.importance_score,
1519
+ m.content,
1520
+ m.context or "",
1521
+ ",".join(m.tags) if m.tags else "",
1522
+ m.created_at,
1523
+ m.last_accessed or "",
1524
+ ])
1525
+ return Response(
1526
+ content=output.getvalue(),
1527
+ media_type="text/csv",
1528
+ headers={"Content-Disposition": f"attachment; filename=memories_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"}
1529
+ )
1530
+
1531
+ else:
1532
+ raise HTTPException(status_code=400, detail=f"Unsupported format: {format}. Use json, markdown, or csv.")
1533
+
1534
+
1535
+ # --- Health Check ---
1536
+
1537
+
1538
+ @app.get("/health")
1539
+ async def health_check():
1540
+ """Health check endpoint."""
1541
+ return {
1542
+ "status": "healthy",
1543
+ "websocket_connections": manager.connection_count,
1544
+ }
1545
+
1546
+
1547
+ # --- Static File Serving (SPA) ---
1548
+ # These routes must come AFTER all API routes
1549
+
1550
+
1551
+ @app.get("/")
1552
+ async def serve_root():
1553
+ """Serve the frontend index.html."""
1554
+ index_file = DIST_DIR / "index.html"
1555
+ if index_file.exists():
1556
+ return FileResponse(str(index_file))
1557
+ return {"message": "Omni-Cortex Dashboard API", "docs": "/docs"}
1558
+
1559
+
1560
+ @app.get("/{path:path}")
1561
+ async def serve_spa(path: str):
1562
+ """Catch-all route to serve SPA for client-side routing with path traversal protection."""
1563
+ # Skip API routes and known paths
1564
+ if path.startswith(("api/", "ws", "health", "docs", "openapi", "redoc")):
1565
+ raise HTTPException(status_code=404, detail="Not found")
1566
+
1567
+ # Check if it's a static file (with path traversal protection)
1568
+ safe_path = PathValidator.is_safe_static_path(DIST_DIR, path)
1569
+ if safe_path:
1570
+ return FileResponse(str(safe_path))
1571
+
1572
+ # Otherwise serve index.html for SPA routing
1573
+ index_file = DIST_DIR / "index.html"
1574
+ if index_file.exists():
1575
+ return FileResponse(str(index_file))
1576
+
1577
+ raise HTTPException(status_code=404, detail="Not found")
1578
+
1579
+
1580
+ def run():
1581
+ """Run the dashboard server."""
1582
+ uvicorn.run(
1583
+ "main:app",
1584
+ host="0.0.0.0",
1585
+ port=8765,
1586
+ reload=True,
1587
+ reload_dirs=[str(Path(__file__).parent)],
1588
+ )
1589
+
1590
+
1591
+ if __name__ == "__main__":
1592
+ run()