clsplusplus 4.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. clsplusplus/__init__.py +31 -0
  2. clsplusplus/api.py +1596 -0
  3. clsplusplus/auth.py +74 -0
  4. clsplusplus/cli.py +715 -0
  5. clsplusplus/client.py +462 -0
  6. clsplusplus/config.py +116 -0
  7. clsplusplus/cost_model.py +51 -0
  8. clsplusplus/demo_llm.py +133 -0
  9. clsplusplus/demo_llm_calls.py +100 -0
  10. clsplusplus/demo_local.py +515 -0
  11. clsplusplus/embeddings.py +52 -0
  12. clsplusplus/idempotency.py +66 -0
  13. clsplusplus/integration_service.py +256 -0
  14. clsplusplus/jwt_utils.py +39 -0
  15. clsplusplus/local_routes.py +781 -0
  16. clsplusplus/main.py +21 -0
  17. clsplusplus/memory_cycle.py +216 -0
  18. clsplusplus/memory_phase.py +3541 -0
  19. clsplusplus/memory_service.py +1323 -0
  20. clsplusplus/metrics.py +184 -0
  21. clsplusplus/middleware.py +325 -0
  22. clsplusplus/models.py +430 -0
  23. clsplusplus/permissions.py +54 -0
  24. clsplusplus/plasticity.py +148 -0
  25. clsplusplus/rate_limit.py +53 -0
  26. clsplusplus/rbac_service.py +86 -0
  27. clsplusplus/reconsolidation.py +71 -0
  28. clsplusplus/sleep_cycle.py +109 -0
  29. clsplusplus/stores/__init__.py +13 -0
  30. clsplusplus/stores/base.py +43 -0
  31. clsplusplus/stores/integration_store.py +648 -0
  32. clsplusplus/stores/l0_working_buffer.py +103 -0
  33. clsplusplus/stores/l1_indexing_store.py +427 -0
  34. clsplusplus/stores/l2_schema_graph.py +231 -0
  35. clsplusplus/stores/l3_deep_recess.py +182 -0
  36. clsplusplus/stores/l3_postgres.py +183 -0
  37. clsplusplus/stores/rbac_store.py +327 -0
  38. clsplusplus/stores/user_store.py +255 -0
  39. clsplusplus/stripe_service.py +136 -0
  40. clsplusplus/temporal.py +613 -0
  41. clsplusplus/test_suite.py +587 -0
  42. clsplusplus/tiers.py +109 -0
  43. clsplusplus/tracer.py +226 -0
  44. clsplusplus/usage.py +130 -0
  45. clsplusplus/user_embeddings.py +1636 -0
  46. clsplusplus/user_service.py +256 -0
  47. clsplusplus/webhook_dispatcher.py +229 -0
  48. clsplusplus-4.0.0.dist-info/METADATA +262 -0
  49. clsplusplus-4.0.0.dist-info/RECORD +53 -0
  50. clsplusplus-4.0.0.dist-info/WHEEL +5 -0
  51. clsplusplus-4.0.0.dist-info/entry_points.txt +2 -0
  52. clsplusplus-4.0.0.dist-info/licenses/LICENSE +201 -0
  53. clsplusplus-4.0.0.dist-info/top_level.txt +1 -0
clsplusplus/api.py ADDED
@@ -0,0 +1,1596 @@
1
+ """CLS++ REST API - FastAPI application."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import time as _time
6
+ import uuid as _uuid_mod
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from typing import Optional, List
10
+
11
+ import os
12
+ from pathlib import Path as FilePath
13
+
14
+ from fastapi import FastAPI, HTTPException, Path, Query, Request, Body
15
+ from pydantic import BaseModel
16
+ from fastapi.middleware.cors import CORSMiddleware
17
+ from fastapi.responses import FileResponse, JSONResponse
18
+ from fastapi.staticfiles import StaticFiles
19
+
20
+ from clsplusplus.config import Settings
21
+ from clsplusplus.integration_service import IntegrationService
22
+ from clsplusplus.memory_service import MemoryService
23
+ from clsplusplus.user_service import UserService
24
+ from clsplusplus.middleware import AuthMiddleware, QuotaMiddleware, RateLimitMiddleware, RequestIdMiddleware, TracingMiddleware
25
+ from clsplusplus.tracer import tracer
26
+ from clsplusplus.models import (
27
+ AdjudicateRequest,
28
+ ApiKeyCreate,
29
+ DemoChatRequest,
30
+ ForgetRequest,
31
+ HealthResponse,
32
+ IntegrationCreate,
33
+ MemoryCycleRequest,
34
+ ReadRequest,
35
+ ReadResponse,
36
+ TierUpgradeRequest,
37
+ UserLoginRequest,
38
+ UserProfileUpdateRequest,
39
+ UserRegisterRequest,
40
+ WebhookCreate,
41
+ WriteRequest,
42
+ )
43
+ from clsplusplus.models import _validate_item_id as validate_item_id
44
+ from clsplusplus.models import _validate_namespace as validate_namespace
45
+ from clsplusplus.sleep_cycle import SleepOrchestrator
46
+
47
+
48
+ def create_app(settings: Optional[Settings] = None) -> FastAPI:
49
+ """Create FastAPI application."""
50
+ settings = settings or Settings()
51
+ memory_service = MemoryService(settings)
52
+ sleep_orchestrator = SleepOrchestrator(settings, engine=memory_service.engine)
53
+ integration_service = IntegrationService(settings)
54
+ user_service = UserService(settings)
55
+
56
+ app = FastAPI(
57
+ title="CLS++ API",
58
+ description="Brain-inspired, model-agnostic persistent memory for LLMs",
59
+ version="1.5.0",
60
+ docs_url="/docs",
61
+ redoc_url="/redoc",
62
+ )
63
+
64
+ app.add_middleware(
65
+ CORSMiddleware,
66
+ allow_origins=["*"],
67
+ allow_credentials=True,
68
+ allow_methods=["GET", "POST", "DELETE", "OPTIONS", "HEAD"],
69
+ allow_headers=["*"],
70
+ expose_headers=["*"],
71
+ )
72
+ # Middleware execution order: outermost (added last) runs first.
73
+ # TracingMiddleware → RequestId → RateLimit → Auth → Quota → route handler
74
+ app.add_middleware(QuotaMiddleware, settings=settings)
75
+ app.add_middleware(AuthMiddleware, settings=settings)
76
+ app.add_middleware(RateLimitMiddleware, settings=settings)
77
+ app.add_middleware(RequestIdMiddleware)
78
+ app.add_middleware(TracingMiddleware) # outermost: traces every /v1/* request
79
+
80
+ # Structured error handler (blueprint: error messages that teach)
81
+ @app.exception_handler(HTTPException)
82
+ async def http_exception_handler(request: Request, exc: HTTPException):
83
+ detail = exc.detail
84
+ if isinstance(detail, str):
85
+ content = {
86
+ "error": "request_error",
87
+ "message": detail,
88
+ "status_code": exc.status_code,
89
+ }
90
+ if exc.status_code == 401:
91
+ content["fix"] = "Add Authorization: Bearer <api_key> header"
92
+ content["docs"] = "https://github.com/rajamohan1950/CLSplusplus/wiki/API-Reference"
93
+ elif exc.status_code == 429:
94
+ content["fix"] = f"Retry after {request.headers.get('Retry-After', 60)} seconds or upgrade plan"
95
+ content["docs"] = "https://github.com/rajamohan1950/CLSplusplus/wiki/SaaS-and-Pricing"
96
+ elif exc.status_code == 422:
97
+ content["fix"] = "Check request body against API schema"
98
+ content["docs"] = "/docs"
99
+ else:
100
+ content = {"error": "request_error", "message": str(detail), "status_code": exc.status_code}
101
+ return JSONResponse(status_code=exc.status_code, content=content)
102
+
103
+ def _ns_query(default: str = "default") -> str:
104
+ return Query(default=default, min_length=1, max_length=64)
105
+
106
+ def _trace_id(request: Request) -> str:
107
+ """Return the trace ID already set by TracingMiddleware, or generate one."""
108
+ return (
109
+ getattr(request.state, "trace_id", None)
110
+ or request.headers.get("x-trace-id")
111
+ or request.headers.get("x-request-id")
112
+ or getattr(request.state, "request_id", None)
113
+ or str(__import__("uuid").uuid4())
114
+ )
115
+
116
+ # Detect website directory (in Docker: /app/website, local dev: ../website relative to src)
117
+ _website_dir = os.environ.get("CLS_WEBSITE_DIR")
118
+ if not _website_dir:
119
+ _candidate = FilePath(__file__).resolve().parent.parent.parent / "website"
120
+ if _candidate.is_dir():
121
+ _website_dir = str(_candidate)
122
+
123
+ @app.get("/")
124
+ async def root():
125
+ """Serve index.html if website is bundled, otherwise API info JSON."""
126
+ if _website_dir:
127
+ index = FilePath(_website_dir) / "index.html"
128
+ if index.exists():
129
+ return FileResponse(str(index), media_type="text/html")
130
+ return {
131
+ "name": "CLS++ API",
132
+ "version": "0.1.0",
133
+ "docs": "/docs",
134
+ "health": "/v1/memory/health",
135
+ }
136
+
137
+ @app.get("/health")
138
+ async def health_check():
139
+ """Quick health check for Render/load balancers — must return 200 fast."""
140
+ return {"status": "ok", "version": "1.5.0"}
141
+
142
+ @app.get("/v1/health")
143
+ async def v1_health():
144
+ """Quick liveness probe. chat.js and other clients call this on startup."""
145
+ return {"status": "ok", "version": getattr(settings, "version", "0.7")}
146
+
147
+ # Per-user metrics emitter (shared across all endpoints)
148
+ from clsplusplus.metrics import MetricsEmitter
149
+ _metrics = MetricsEmitter(settings)
150
+ memory_service._metrics = _metrics # Wire metrics into memory service
151
+
152
+ async def _record_usage(operation: str, request: Request):
153
+ """Fire-and-forget usage tracking. Must never crash a user request."""
154
+ try:
155
+ api_key = getattr(request.state, "api_key", None)
156
+ if api_key and settings.track_usage:
157
+ from clsplusplus.usage import record_usage, record_operation
158
+ await record_usage(api_key, operation, settings)
159
+ await record_operation(api_key, settings)
160
+ # Per-user metrics (emit even without track_usage for admin visibility)
161
+ user_id = getattr(request.state, "user_id", None)
162
+ if user_id:
163
+ await _metrics.emit(user_id, operation)
164
+ except Exception:
165
+ pass # Usage tracking failure must not affect user responses
166
+
167
+ @app.post("/v1/memory/write")
168
+ async def write_memory(req: WriteRequest, request: Request):
169
+ """Write memory. Flows to L0, promotes to L1 if score warrants."""
170
+ tid = _trace_id(request)
171
+ with tracer.span(tid, "api.write", "api",
172
+ input=req.text[:200],
173
+ namespace=req.namespace, source=req.source) as api_hop:
174
+ item = await memory_service.write(req, trace_id=tid)
175
+ tracer.add_metadata(tid, api_hop,
176
+ output=f"item_id={str(item.id)[:8]}… level={item.store_level.value}")
177
+ await _record_usage("write", request)
178
+ return {"id": item.id, "store_level": item.store_level.value, "text": item.text, "trace_id": tid}
179
+
180
+ @app.post("/v1/memories/encode")
181
+ async def encode_memory(req: WriteRequest, request: Request):
182
+ """Product alias: POST /memories/encode -> write."""
183
+ tid = _trace_id(request)
184
+ with tracer.span(tid, "api.encode", "api",
185
+ input=req.text[:200],
186
+ namespace=req.namespace) as api_hop:
187
+ item = await memory_service.write(req, trace_id=tid)
188
+ tracer.add_metadata(tid, api_hop,
189
+ output=f"item_id={str(item.id)[:8]}… level={item.store_level.value}")
190
+ await _record_usage("encode", request)
191
+ return {"id": item.id, "store_level": item.store_level.value, "text": item.text, "trace_id": tid}
192
+
193
+ @app.post("/v1/memory/read", response_model=ReadResponse)
194
+ async def read_memory(req: ReadRequest, request: Request):
195
+ """Read memories by semantic query across all stores."""
196
+ tid = _trace_id(request)
197
+ with tracer.span(tid, "api.read", "api",
198
+ input=req.query[:200],
199
+ namespace=req.namespace, limit=req.limit) as api_hop:
200
+ result = await memory_service.read(req, trace_id=tid)
201
+ items = result.items or []
202
+ preview = items[0].text[:80] if items else "no results"
203
+ tracer.add_metadata(tid, api_hop, output=f"{len(items)} items: {preview}")
204
+ await _record_usage("read", request)
205
+ result.trace_id = tid
206
+ return result
207
+
208
+ @app.post("/v1/memories/retrieve", response_model=ReadResponse)
209
+ async def retrieve_memories(req: ReadRequest, request: Request):
210
+ """Product alias: POST /memories/retrieve -> read."""
211
+ tid = _trace_id(request)
212
+ with tracer.span(tid, "api.retrieve", "api",
213
+ input=req.query[:200],
214
+ namespace=req.namespace) as api_hop:
215
+ result = await memory_service.read(req, trace_id=tid)
216
+ items = result.items or []
217
+ tracer.add_metadata(tid, api_hop, output=f"{len(items)} items")
218
+ await _record_usage("retrieve", request)
219
+ result.trace_id = tid
220
+ return result
221
+
222
+ @app.post("/v1/memories/search", response_model=ReadResponse)
223
+ async def search_memories(req: ReadRequest, request: Request):
224
+ """Resource-oriented: POST /memories/search -> read."""
225
+ tid = _trace_id(request)
226
+ with tracer.span(tid, "api.search", "api",
227
+ input=req.query[:200],
228
+ namespace=req.namespace) as api_hop:
229
+ result = await memory_service.read(req, trace_id=tid)
230
+ items = result.items or []
231
+ tracer.add_metadata(tid, api_hop, output=f"{len(items)} items")
232
+ await _record_usage("retrieve", request)
233
+ result.trace_id = tid
234
+ return result
235
+
236
+ @app.get("/v1/memory/item/{item_id}")
237
+ async def get_item(
238
+ item_id: str = Path(..., min_length=1, max_length=64),
239
+ namespace: str = _ns_query(),
240
+ ):
241
+ """Get full item with lineage and versions."""
242
+ try:
243
+ validate_item_id(item_id)
244
+ validate_namespace(namespace)
245
+ except ValueError as e:
246
+ raise HTTPException(status_code=400, detail=str(e))
247
+ item = await memory_service.get_item(item_id, namespace)
248
+ if not item:
249
+ raise HTTPException(status_code=404, detail="Item not found")
250
+ return item.to_dict()
251
+
252
+ @app.post("/v1/memories/prewarm")
253
+ async def prewarm_namespace(request: Request, namespace: str = _ns_query()):
254
+ """Pre-load a namespace into memory so the first user request is instant.
255
+
256
+ Call this at application startup for active namespaces. Returns immediately
257
+ — loading happens in the background. Idempotent (safe to call repeatedly).
258
+ """
259
+ try:
260
+ validate_namespace(namespace)
261
+ except ValueError as e:
262
+ raise HTTPException(status_code=400, detail=str(e))
263
+ await memory_service.prewarm(namespace)
264
+ already = namespace in memory_service._loaded_namespaces
265
+ await _record_usage("prewarm", request)
266
+ return {"status": "loaded" if already else "loading", "namespace": namespace}
267
+
268
+ @app.post("/v1/memory/sleep")
269
+ async def trigger_sleep(request: Request, namespace: str = _ns_query()):
270
+ """Trigger nightly sleep cycle (admin)."""
271
+ try:
272
+ validate_namespace(namespace)
273
+ except ValueError as e:
274
+ raise HTTPException(status_code=400, detail=str(e))
275
+ report = await sleep_orchestrator.run(namespace)
276
+ await _record_usage("consolidation", request)
277
+ return report
278
+
279
+ @app.post("/v1/memories/consolidate")
280
+ async def consolidate_memories(request: Request, namespace: str = _ns_query()):
281
+ """Product alias: POST /memories/consolidate -> sleep."""
282
+ try:
283
+ validate_namespace(namespace)
284
+ except ValueError as e:
285
+ raise HTTPException(status_code=400, detail=str(e))
286
+ report = await sleep_orchestrator.run(namespace)
287
+ await _record_usage("consolidation", request)
288
+ return report
289
+
290
+ @app.delete("/v1/memory/forget")
291
+ async def forget_memory(req: ForgetRequest, request: Request):
292
+ """Delete a memory by ID (RTBF)."""
293
+ deleted = await memory_service.delete(req.item_id, req.namespace)
294
+ if not deleted:
295
+ raise HTTPException(status_code=404, detail="Item not found")
296
+ await _record_usage("delete", request)
297
+ return {"deleted": True, "item_id": req.item_id}
298
+
299
+ @app.delete("/v1/memories/forget")
300
+ async def forget_memory_alias(req: ForgetRequest, request: Request):
301
+ """Product alias: DELETE /memories/forget."""
302
+ deleted = await memory_service.delete(req.item_id, req.namespace)
303
+ if not deleted:
304
+ raise HTTPException(status_code=404, detail="Item not found")
305
+ await _record_usage("delete", request)
306
+ return {"deleted": True, "item_id": req.item_id}
307
+
308
+ @app.post("/v1/memory/adjudicate_conflict")
309
+ async def adjudicate_conflict(req: AdjudicateRequest, request: Request):
310
+ """Submit conflicting fact + evidence for reconsolidation gate."""
311
+ result = await memory_service.adjudicate(
312
+ new_text=req.new_fact,
313
+ namespace=req.namespace,
314
+ evidence=req.evidence,
315
+ existing_item_id=req.existing_item_id,
316
+ )
317
+ # If the returned item is the same as the existing one, quorum was not met
318
+ decision = "rejected" if (req.existing_item_id and result.id == req.existing_item_id) else "accepted"
319
+ await _record_usage("adjudication", request)
320
+ return {"decision": decision, "new_id": result.id}
321
+
322
+ @app.get("/v1/demo/status")
323
+ async def demo_status():
324
+ """Check which LLM keys are configured (for debugging)."""
325
+ return {
326
+ "claude": bool(getattr(settings, "anthropic_api_key", None)),
327
+ "openai": bool(getattr(settings, "openai_api_key", None)),
328
+ "gemini": bool(getattr(settings, "google_api_key", None)),
329
+ }
330
+
331
+ @app.post("/v1/demo/chat")
332
+ async def demo_chat(req: DemoChatRequest, request: Request):
333
+ """
334
+ Real LLM demo: Claude, OpenAI, or Gemini with shared CLS++ memory.
335
+ Requires CLS_ANTHROPIC_API_KEY, CLS_OPENAI_API_KEY, CLS_GOOGLE_API_KEY in env.
336
+ """
337
+ from clsplusplus.demo_llm import chat_with_llm
338
+
339
+ if req.model not in ("claude", "openai", "gemini"):
340
+ raise HTTPException(status_code=400, detail="model must be claude, openai, or gemini")
341
+
342
+ tid = _trace_id(request)
343
+ try:
344
+ with tracer.span(tid, "api.demo_chat", "api",
345
+ input=req.message[:200],
346
+ model=req.model, namespace=req.namespace) as api_hop:
347
+ reply = await chat_with_llm(
348
+ memory_service, settings, req.model, req.message.strip(), req.namespace,
349
+ trace_id=tid,
350
+ )
351
+ tracer.add_metadata(tid, api_hop, output=reply[:200])
352
+ return {"model": req.model, "reply": reply}
353
+ except Exception as e:
354
+ import logging
355
+ logging.getLogger(__name__).error("Demo chat error: %s", e)
356
+ raise HTTPException(
357
+ status_code=500,
358
+ detail="Demo error: An internal error occurred. Check server logs.",
359
+ )
360
+
361
+ @app.post("/v1/demo/memory-cycle")
362
+ async def memory_cycle(req: MemoryCycleRequest):
363
+ """Run full memory lifecycle: encode → retrieve → augment → cross-session.
364
+
365
+ Proves memory persists across models and sessions.
366
+ """
367
+ for m in req.models:
368
+ if m not in ("claude", "openai", "gemini"):
369
+ raise HTTPException(status_code=400, detail=f"Invalid model: {m}. Use claude, openai, or gemini.")
370
+
371
+ from clsplusplus.memory_cycle import run_memory_cycle
372
+
373
+ try:
374
+ result = await run_memory_cycle(
375
+ memory_service, settings,
376
+ statements=req.statements,
377
+ queries=req.queries,
378
+ models=req.models,
379
+ namespace=req.namespace,
380
+ )
381
+ return result
382
+ except Exception as e:
383
+ import logging
384
+ logging.getLogger(__name__).error("Memory cycle error: %s", e)
385
+ raise HTTPException(
386
+ status_code=500,
387
+ detail="Memory cycle error: An internal error occurred. Check server logs.",
388
+ )
389
+
390
+ @app.get("/v1/memory/health", response_model=HealthResponse)
391
+ async def health():
392
+ """Composite health + per-store metrics."""
393
+ h = await memory_service.health()
394
+ stores = dict(h["stores"])
395
+ if h["status"] == "degraded" and "localhost" in str(stores):
396
+ stores["_hint"] = {
397
+ "status": "info",
398
+ "store": "Setup",
399
+ "error": "Add CLS_REDIS_URL and CLS_DATABASE_URL in Render Dashboard.",
400
+ }
401
+ return HealthResponse(status=h["status"], stores=stores)
402
+
403
+ @app.get("/v1/health/score", response_model=HealthResponse)
404
+ async def health_score():
405
+ """Product alias: GET /health/score -> memory health."""
406
+ return await health()
407
+
408
+ @app.get("/v1/memories/knowledge", response_model=ReadResponse)
409
+ async def query_knowledge(
410
+ request: Request,
411
+ query: str = Query(..., min_length=1, max_length=4096),
412
+ namespace: str = _ns_query(),
413
+ limit: int = Query(default=10, ge=1, le=100),
414
+ ):
415
+ """Product alias: GET /knowledge - query L2/L3 (neocortical) only."""
416
+ try:
417
+ validate_namespace(namespace)
418
+ except ValueError as e:
419
+ raise HTTPException(status_code=400, detail=str(e))
420
+ from clsplusplus.models import ReadRequest, StoreLevel
421
+
422
+ req = ReadRequest(
423
+ query=query,
424
+ namespace=namespace,
425
+ limit=limit,
426
+ store_levels=[StoreLevel.L2, StoreLevel.L3],
427
+ )
428
+ result = await memory_service.read(req)
429
+ await _record_usage("knowledge", request)
430
+ return result
431
+
432
+ @app.delete("/v1/memories/{item_id}")
433
+ async def forget_memory_by_id(
434
+ request: Request,
435
+ item_id: str = Path(..., min_length=1, max_length=64),
436
+ namespace: str = _ns_query(),
437
+ ):
438
+ """Resource-oriented: DELETE /memories/{id} (forget)."""
439
+ try:
440
+ validate_item_id(item_id)
441
+ validate_namespace(namespace)
442
+ except ValueError as e:
443
+ raise HTTPException(status_code=400, detail=str(e))
444
+ deleted = await memory_service.delete(item_id, namespace)
445
+ if not deleted:
446
+ raise HTTPException(status_code=404, detail="Item not found")
447
+ await _record_usage("delete", request)
448
+ return {"deleted": True, "item_id": item_id}
449
+
450
+ @app.get("/v1/usage")
451
+ async def usage_endpoint(request: Request):
452
+ """Usage metrics for current period with tier info. Requires API key when auth enabled."""
453
+ from clsplusplus.auth import get_api_key_from_request
454
+ api_key = getattr(request.state, "api_key", None) or get_api_key_from_request(request.headers.get("Authorization"))
455
+ if settings.require_api_key and not api_key:
456
+ raise HTTPException(status_code=401, detail="API key required")
457
+ from clsplusplus.tiers import get_tier, get_quota_status
458
+ tier = get_tier(settings)
459
+ return await get_quota_status(api_key or "anonymous", tier, settings)
460
+
461
+ @app.get("/v1/billing/usage")
462
+ async def billing_usage(request: Request):
463
+ """Billing API: usage for current period (alias for /v1/usage)."""
464
+ return await usage_endpoint(request)
465
+
466
+ # =========================================================================
467
+ # User Auth API — Registration, login, Google OAuth, profile
468
+ # =========================================================================
469
+
470
+ def _set_session_cookie(response: JSONResponse, token: str) -> JSONResponse:
471
+ response.set_cookie(
472
+ key="cls_session",
473
+ value=token,
474
+ httponly=True,
475
+ secure=False, # Set True in production (HTTPS)
476
+ samesite="lax",
477
+ max_age=7 * 86400, # 7 days
478
+ path="/",
479
+ )
480
+ return response
481
+
482
+ @app.post("/v1/auth/register")
483
+ async def register_user(req: UserRegisterRequest):
484
+ """Register a new user with email and password."""
485
+ try:
486
+ user, token = await user_service.register(req.email, req.password, req.name)
487
+ except ValueError as e:
488
+ raise HTTPException(status_code=400, detail=str(e))
489
+ except Exception as e:
490
+ raise HTTPException(status_code=500, detail=f"Registration failed: {type(e).__name__}: {str(e)[:200]}")
491
+ response = JSONResponse(content=user)
492
+ return _set_session_cookie(response, token)
493
+
494
+ @app.post("/v1/auth/login")
495
+ async def login_user(req: UserLoginRequest):
496
+ """Login with email and password."""
497
+ try:
498
+ user, token = await user_service.login(req.email, req.password)
499
+ except ValueError as e:
500
+ raise HTTPException(status_code=401, detail=str(e))
501
+ except Exception as e:
502
+ raise HTTPException(status_code=500, detail="Login service unavailable")
503
+ response = JSONResponse(content=user)
504
+ return _set_session_cookie(response, token)
505
+
506
+ @app.post("/v1/auth/logout")
507
+ async def logout_user():
508
+ """Clear session cookie."""
509
+ response = JSONResponse(content={"detail": "Logged out"})
510
+ response.delete_cookie("cls_session", path="/")
511
+ return response
512
+
513
+ @app.get("/v1/auth/me")
514
+ async def get_current_user(request: Request):
515
+ """Get current authenticated user from JWT cookie."""
516
+ user_id = getattr(request.state, "user_id", None)
517
+ if not user_id:
518
+ raise HTTPException(status_code=401, detail="Not authenticated")
519
+ try:
520
+ user = await user_service.get_user(user_id)
521
+ except Exception:
522
+ raise HTTPException(status_code=500, detail="User service unavailable")
523
+ if not user:
524
+ raise HTTPException(status_code=404, detail="User not found")
525
+ return user
526
+
527
+ @app.get("/v1/auth/google")
528
+ async def google_auth_redirect(request: Request, redirect: str = "/dashboard.html"):
529
+ """Redirect to Google OAuth consent screen."""
530
+ from urllib.parse import urlencode
531
+ if not settings.google_client_id:
532
+ raise HTTPException(status_code=501, detail="Google OAuth not configured")
533
+ callback_url = str(request.base_url).rstrip("/") + "/v1/auth/google/callback"
534
+ # Force HTTPS when behind reverse proxy (Render, Cloudflare, etc.)
535
+ if request.headers.get("x-forwarded-proto") == "https" or "onrender.com" in callback_url:
536
+ callback_url = callback_url.replace("http://", "https://", 1)
537
+ params = urlencode({
538
+ "client_id": settings.google_client_id,
539
+ "redirect_uri": callback_url,
540
+ "response_type": "code",
541
+ "scope": "openid email profile",
542
+ "access_type": "offline",
543
+ "state": redirect,
544
+ })
545
+ from starlette.responses import RedirectResponse
546
+ return RedirectResponse(f"https://accounts.google.com/o/oauth2/v2/auth?{params}")
547
+
548
+ @app.get("/v1/auth/google/callback")
549
+ async def google_auth_callback(request: Request, code: str = "", state: str = "/dashboard.html"):
550
+ """Handle Google OAuth callback — exchange code, create/login user, redirect."""
551
+ if not code:
552
+ raise HTTPException(status_code=400, detail="Missing authorization code")
553
+ callback_url = str(request.base_url).rstrip("/") + "/v1/auth/google/callback"
554
+ # Force HTTPS when behind reverse proxy (Render, Cloudflare, etc.)
555
+ if request.headers.get("x-forwarded-proto") == "https" or "onrender.com" in callback_url:
556
+ callback_url = callback_url.replace("http://", "https://", 1)
557
+ try:
558
+ user, token = await user_service.google_auth(code, callback_url)
559
+ except ValueError as e:
560
+ raise HTTPException(status_code=400, detail=str(e))
561
+ except Exception:
562
+ raise HTTPException(status_code=500, detail="Google auth service unavailable")
563
+ from starlette.responses import RedirectResponse
564
+ response = RedirectResponse(state or "/dashboard.html")
565
+ _set_session_cookie(response, token)
566
+ return response
567
+
568
+ # =========================================================================
569
+ # User Dashboard API — Per-user usage and tier management
570
+ # =========================================================================
571
+
572
+ @app.get("/v1/user/usage")
573
+ async def user_usage(request: Request):
574
+ """Usage metrics for the authenticated user."""
575
+ user_id = getattr(request.state, "user_id", None)
576
+ if not user_id:
577
+ raise HTTPException(status_code=401, detail="Not authenticated")
578
+ user = await user_service.get_user(user_id)
579
+ if not user:
580
+ raise HTTPException(status_code=404, detail="User not found")
581
+ from clsplusplus.tiers import Tier, get_quota_status
582
+ tier = Tier(user["tier"])
583
+ namespace = f"user-{user_id[:8]}"
584
+ return await get_quota_status(namespace, tier, settings)
585
+
586
+ @app.post("/v1/user/upgrade")
587
+ async def upgrade_tier(req: TierUpgradeRequest, request: Request):
588
+ """Change user tier (upgrade or downgrade)."""
589
+ user_id = getattr(request.state, "user_id", None)
590
+ if not user_id:
591
+ raise HTTPException(status_code=401, detail="Not authenticated")
592
+ try:
593
+ user = await user_service.update_tier(user_id, req.tier)
594
+ except ValueError as e:
595
+ raise HTTPException(status_code=400, detail=str(e))
596
+ except Exception:
597
+ raise HTTPException(status_code=500, detail="Upgrade service unavailable")
598
+ return user
599
+
600
+ @app.get("/v1/user/usage/history")
601
+ async def user_usage_history(request: Request):
602
+ """Usage history for the last 6 months for the authenticated user."""
603
+ user_id = getattr(request.state, "user_id", None)
604
+ if not user_id:
605
+ raise HTTPException(status_code=401, detail="Not authenticated")
606
+ namespace = f"user-{user_id[:8]}"
607
+ from clsplusplus.usage import get_usage_history
608
+ try:
609
+ return await get_usage_history(namespace, months=6, settings=settings)
610
+ except Exception:
611
+ return []
612
+
613
+ @app.get("/v1/user/integrations")
614
+ async def user_integrations(request: Request):
615
+ """List integrations for the authenticated user's namespace."""
616
+ user_id = getattr(request.state, "user_id", None)
617
+ if not user_id:
618
+ raise HTTPException(status_code=401, detail="Not authenticated")
619
+ namespace = f"user-{user_id[:8]}"
620
+ try:
621
+ integrations = await integration_service.list_all(namespace)
622
+ return {"integrations": [i.model_dump(mode="json") if hasattr(i, "model_dump") else i for i in integrations]}
623
+ except Exception:
624
+ return {"integrations": []}
625
+
626
+ @app.patch("/v1/user/profile")
627
+ async def update_profile(req: UserProfileUpdateRequest, request: Request):
628
+ """Update user profile (name, email, password)."""
629
+ user_id = getattr(request.state, "user_id", None)
630
+ if not user_id:
631
+ raise HTTPException(status_code=401, detail="Not authenticated")
632
+ try:
633
+ user = await user_service.update_profile(
634
+ user_id=user_id,
635
+ name=req.name,
636
+ email=req.email,
637
+ password=req.password,
638
+ current_password=req.current_password,
639
+ )
640
+ except ValueError as e:
641
+ raise HTTPException(status_code=400, detail=str(e))
642
+ except Exception:
643
+ raise HTTPException(status_code=500, detail="Profile update failed")
644
+ return user
645
+
646
+ # =========================================================================
647
+ # Billing — Stripe Checkout & Customer Portal
648
+ # =========================================================================
649
+
650
+ @app.post("/v1/billing/checkout")
651
+ async def billing_checkout(req: TierUpgradeRequest, request: Request):
652
+ """Create a Stripe Checkout session for tier upgrade."""
653
+ user_id = getattr(request.state, "user_id", None)
654
+ if not user_id:
655
+ raise HTTPException(status_code=401, detail="Not authenticated")
656
+ try:
657
+ from clsplusplus.stripe_service import create_checkout_session
658
+ session_url = await create_checkout_session(
659
+ user_id=user_id,
660
+ tier=req.tier,
661
+ settings=settings,
662
+ )
663
+ return {"url": session_url}
664
+ except ValueError as e:
665
+ raise HTTPException(status_code=400, detail=str(e))
666
+ except Exception as e:
667
+ logger.error("Stripe checkout error: %s", e)
668
+ raise HTTPException(status_code=500, detail="Billing service unavailable")
669
+
670
+ @app.get("/v1/billing/portal")
671
+ async def billing_portal(request: Request):
672
+ """Create a Stripe Customer Portal session for subscription management."""
673
+ user_id = getattr(request.state, "user_id", None)
674
+ if not user_id:
675
+ raise HTTPException(status_code=401, detail="Not authenticated")
676
+ try:
677
+ from clsplusplus.stripe_service import create_portal_session
678
+ portal_url = await create_portal_session(
679
+ user_id=user_id,
680
+ settings=settings,
681
+ )
682
+ return {"url": portal_url}
683
+ except ValueError as e:
684
+ raise HTTPException(status_code=400, detail=str(e))
685
+ except Exception as e:
686
+ logger.error("Stripe portal error: %s", e)
687
+ raise HTTPException(status_code=500, detail="Billing service unavailable")
688
+
689
+ @app.post("/v1/billing/webhook")
690
+ async def billing_webhook(request: Request):
691
+ """Handle Stripe webhook events."""
692
+ payload = await request.body()
693
+ sig = request.headers.get("stripe-signature", "")
694
+ try:
695
+ from clsplusplus.stripe_service import handle_webhook
696
+ await handle_webhook(
697
+ payload=payload,
698
+ sig=sig,
699
+ settings=settings,
700
+ user_service=user_service,
701
+ )
702
+ return {"status": "ok"}
703
+ except ValueError as e:
704
+ raise HTTPException(status_code=400, detail=str(e))
705
+ except Exception as e:
706
+ logger.error("Stripe webhook error: %s", e)
707
+ raise HTTPException(status_code=500, detail="Webhook processing failed")
708
+
709
+ # =========================================================================
710
+ # Admin Dashboard API — Protected by is_admin flag in JWT
711
+ # =========================================================================
712
+
713
+ @app.get("/admin/metrics/summary")
714
+ async def admin_summary(request: Request):
715
+ """Top-bar KPIs: Total Users, Revenue, Cost, Margin %."""
716
+ _require_admin(request)
717
+ try:
718
+ from clsplusplus.tiers import Tier, TIER_PRICES
719
+ from clsplusplus.cost_model import compute_cost
720
+
721
+ tier_counts = await user_service.store.count_users_by_tier()
722
+ total_users = sum(tier_counts.values())
723
+ paying_users = total_users - tier_counts.get("free", 0)
724
+
725
+ monthly_revenue = sum(
726
+ tier_counts.get(t.value, 0) * TIER_PRICES[t]
727
+ for t in Tier
728
+ )
729
+
730
+ aggregate = await _metrics.get_aggregate_metrics()
731
+ monthly_cost = compute_cost(aggregate)
732
+ margin = ((monthly_revenue - monthly_cost) / monthly_revenue * 100) if monthly_revenue > 0 else 0
733
+
734
+ return {
735
+ "total_users": total_users,
736
+ "paying_users": paying_users,
737
+ "free_users": tier_counts.get("free", 0),
738
+ "monthly_revenue": round(monthly_revenue, 2),
739
+ "monthly_cost": round(monthly_cost, 4),
740
+ "margin_percent": round(margin, 1),
741
+ "tier_counts": tier_counts,
742
+ }
743
+ except Exception as e:
744
+ raise HTTPException(status_code=500, detail=f"Metrics unavailable: {str(e)[:200]}")
745
+
746
+ @app.get("/admin/metrics/signups")
747
+ async def admin_signups(request: Request):
748
+ """Daily signup counts for the last 90 days."""
749
+ _require_admin(request)
750
+ try:
751
+ signups = await user_service.store.daily_signups(days=90)
752
+ return {"signups": signups}
753
+ except Exception as e:
754
+ raise HTTPException(status_code=500, detail=f"Signup data unavailable: {str(e)[:200]}")
755
+
756
+ @app.get("/admin/metrics/revenue")
757
+ async def admin_revenue(request: Request):
758
+ """MRR, ARR, and simple linear forecast."""
759
+ _require_admin(request)
760
+ try:
761
+ from clsplusplus.tiers import Tier, TIER_PRICES
762
+ tier_counts = await user_service.store.count_users_by_tier()
763
+
764
+ mrr = sum(
765
+ tier_counts.get(t.value, 0) * TIER_PRICES[t]
766
+ for t in Tier
767
+ )
768
+ arr = mrr * 12
769
+
770
+ # Simple forecast: assume current MRR for remaining FY months
771
+ now = datetime.now()
772
+ months_remaining = 12 - now.month
773
+ fy_projected = arr # Simplified: current run rate
774
+
775
+ return {
776
+ "mrr": round(mrr, 2),
777
+ "arr": round(arr, 2),
778
+ "fy_projected": round(fy_projected, 2),
779
+ "months_remaining": months_remaining,
780
+ "tier_counts": tier_counts,
781
+ }
782
+ except Exception as e:
783
+ raise HTTPException(status_code=500, detail=f"Revenue data unavailable: {str(e)[:200]}")
784
+
785
+ @app.get("/admin/metrics/operations")
786
+ async def admin_operations(request: Request):
787
+ """Aggregate metering points across all users for current period."""
788
+ _require_admin(request)
789
+ try:
790
+ aggregate = await _metrics.get_aggregate_metrics()
791
+ from clsplusplus.cost_model import compute_cost
792
+ total_cost = compute_cost(aggregate)
793
+
794
+ return {
795
+ "period": datetime.utcnow().strftime("%Y-%m"),
796
+ "metrics": aggregate,
797
+ "total_cost": round(total_cost, 4),
798
+ }
799
+ except Exception as e:
800
+ raise HTTPException(status_code=500, detail=f"Operations data unavailable: {str(e)[:200]}")
801
+
802
+ @app.get("/admin/metrics/users")
803
+ async def admin_users(request: Request):
804
+ """Per-user breakdown: tier, operations, cost, revenue."""
805
+ _require_admin(request)
806
+ try:
807
+ from clsplusplus.tiers import Tier, TIER_PRICES
808
+ from clsplusplus.cost_model import compute_cost
809
+
810
+ users = await user_service.list_users(limit=500)
811
+ result = []
812
+ for u in users:
813
+ user_metrics = await _metrics.get_user_metrics(u["id"])
814
+ user_cost = compute_cost(user_metrics)
815
+ user_revenue = TIER_PRICES.get(Tier(u["tier"]), 0)
816
+ ops = sum(user_metrics.values())
817
+ result.append({
818
+ "id": u["id"],
819
+ "email": u["email"],
820
+ "name": u["name"],
821
+ "tier": u["tier"],
822
+ "operations": ops,
823
+ "cost": round(user_cost, 4),
824
+ "revenue": round(user_revenue, 2),
825
+ "created_at": u["created_at"],
826
+ })
827
+
828
+ return {"users": result}
829
+ except Exception as e:
830
+ raise HTTPException(status_code=500, detail=f"User data unavailable: {str(e)[:200]}")
831
+
832
+ def _require_admin(request: Request):
833
+ """Helper to enforce admin access on endpoints."""
834
+ if not getattr(request.state, "is_admin", False):
835
+ raise HTTPException(status_code=403, detail="Admin access required")
836
+
837
+ @app.get("/admin/metrics/user/{user_id}")
838
+ async def admin_user_detail(request: Request, user_id: str = Path(...)):
839
+ """Get detailed metrics for a specific user (admin only)."""
840
+ _require_admin(request)
841
+ try:
842
+ user = await user_service.get_user(user_id)
843
+ if not user:
844
+ raise HTTPException(status_code=404, detail="User not found")
845
+ user_metrics = await _metrics.get_user_metrics(user_id)
846
+ from clsplusplus.cost_model import compute_cost
847
+ from clsplusplus.tiers import Tier, TIER_PRICES
848
+ user_cost = compute_cost(user_metrics)
849
+ user_revenue = TIER_PRICES.get(Tier(user["tier"]), 0)
850
+ return {
851
+ "user": user,
852
+ "metrics": user_metrics,
853
+ "cost": round(user_cost, 4),
854
+ "revenue": round(user_revenue, 2),
855
+ }
856
+ except HTTPException:
857
+ raise
858
+ except Exception as e:
859
+ raise HTTPException(status_code=500, detail=f"User metrics unavailable: {str(e)[:200]}")
860
+
861
+ @app.get("/admin/metrics/extension")
862
+ async def admin_extension(request: Request):
863
+ """Browser extension analytics: installs, DAU/WAU/MAU, site usage."""
864
+ _require_admin(request)
865
+ try:
866
+ return await _metrics.get_extension_analytics()
867
+ except Exception as e:
868
+ raise HTTPException(status_code=500, detail=f"Extension analytics unavailable: {str(e)[:200]}")
869
+
870
+ @app.get("/v1/stats/extension")
871
+ async def public_extension_stats():
872
+ """Public endpoint — returns extension install/active counts for social proof."""
873
+ try:
874
+ data = await _metrics.get_extension_analytics()
875
+ return {"installs": data.get("installs_this_month", 0), "dau": data.get("dau", 0), "mau": data.get("mau", 0)}
876
+ except Exception:
877
+ return {"installs": 0, "dau": 0, "mau": 0}
878
+
879
+ @app.get("/admin/metrics/storage")
880
+ async def admin_storage(request: Request):
881
+ """Storage metering: item counts across L0/L1/L2, namespaces."""
882
+ _require_admin(request)
883
+ try:
884
+ l0_items = sum(len(items) for items in memory_service.engine._items.values())
885
+ l0_namespaces = len(memory_service.engine._items)
886
+ loaded_namespaces = len(memory_service._loaded_namespaces)
887
+
888
+ # L1 count (if DB available)
889
+ l1_count = 0
890
+ l1_namespaces = 0
891
+ try:
892
+ ns_list = await memory_service.l1.list_namespaces()
893
+ l1_namespaces = len(ns_list)
894
+ for ns in ns_list[:50]: # Cap to avoid slow query
895
+ l1_count += await memory_service.l1.count(ns)
896
+ except Exception:
897
+ pass
898
+
899
+ return {
900
+ "l0_items": l0_items,
901
+ "l0_namespaces": l0_namespaces,
902
+ "l1_items": l1_count,
903
+ "l1_namespaces": l1_namespaces,
904
+ "loaded_namespaces": loaded_namespaces,
905
+ }
906
+ except Exception as e:
907
+ raise HTTPException(status_code=500, detail=f"Storage metrics unavailable: {str(e)[:200]}")
908
+
909
+ # =========================================================================
910
+ # RBAC Admin API — Roles, Groups, Users, Permissions
911
+ # =========================================================================
912
+
913
+ from clsplusplus.rbac_service import RBACService, ALL_SCOPES
914
+ _rbac = RBACService(settings)
915
+
916
+ @app.get("/admin/rbac/scopes")
917
+ async def list_scopes(request: Request):
918
+ _require_admin(request)
919
+ return {"scopes": sorted(ALL_SCOPES)}
920
+
921
+ @app.get("/admin/rbac/roles")
922
+ async def list_roles(request: Request):
923
+ _require_admin(request)
924
+ return {"roles": await _rbac.store.list_roles()}
925
+
926
+ @app.post("/admin/rbac/roles")
927
+ async def create_role(request: Request):
928
+ _require_admin(request)
929
+ body = await request.json()
930
+ role = await _rbac.store.create_role(body["name"], body.get("description", ""), body.get("scopes", []))
931
+ return role
932
+
933
+ @app.put("/admin/rbac/roles/{role_id}")
934
+ async def update_role(request: Request, role_id: str = Path(...)):
935
+ _require_admin(request)
936
+ body = await request.json()
937
+ ok = await _rbac.store.update_role(role_id, body.get("description"), body.get("scopes"))
938
+ if not ok:
939
+ raise HTTPException(status_code=404, detail="Role not found or is a system role")
940
+ return {"ok": True}
941
+
942
+ @app.delete("/admin/rbac/roles/{role_id}")
943
+ async def delete_role(request: Request, role_id: str = Path(...)):
944
+ _require_admin(request)
945
+ ok = await _rbac.store.delete_role(role_id)
946
+ if not ok:
947
+ raise HTTPException(status_code=400, detail="Cannot delete system role")
948
+ return {"ok": True}
949
+
950
+ @app.get("/admin/rbac/groups")
951
+ async def list_groups(request: Request):
952
+ _require_admin(request)
953
+ return {"groups": await _rbac.store.list_groups()}
954
+
955
+ @app.post("/admin/rbac/groups")
956
+ async def create_group(request: Request):
957
+ _require_admin(request)
958
+ body = await request.json()
959
+ group = await _rbac.store.create_group(body["name"], body.get("description", ""))
960
+ return group
961
+
962
+ @app.put("/admin/rbac/groups/{group_id}")
963
+ async def update_group(request: Request, group_id: str = Path(...)):
964
+ _require_admin(request)
965
+ body = await request.json()
966
+ ok = await _rbac.store.update_group(group_id, body.get("name"), body.get("description"))
967
+ if not ok:
968
+ raise HTTPException(status_code=404, detail="Group not found")
969
+ return {"ok": True}
970
+
971
+ @app.delete("/admin/rbac/groups/{group_id}")
972
+ async def delete_group(request: Request, group_id: str = Path(...)):
973
+ _require_admin(request)
974
+ ok = await _rbac.store.delete_group(group_id)
975
+ if not ok:
976
+ raise HTTPException(status_code=404, detail="Group not found")
977
+ return {"ok": True}
978
+
979
+ @app.get("/admin/rbac/groups/{group_id}/roles")
980
+ async def get_group_roles(request: Request, group_id: str = Path(...)):
981
+ _require_admin(request)
982
+ return {"roles": await _rbac.store.get_group_roles(group_id)}
983
+
984
+ @app.post("/admin/rbac/groups/{group_id}/roles")
985
+ async def add_group_role(request: Request, group_id: str = Path(...)):
986
+ _require_admin(request)
987
+ body = await request.json()
988
+ await _rbac.store.add_group_role(group_id, body["role_id"])
989
+ await _rbac.invalidate_group_cache(group_id)
990
+ return {"ok": True}
991
+
992
+ @app.delete("/admin/rbac/groups/{group_id}/roles/{role_id}")
993
+ async def remove_group_role(request: Request, group_id: str = Path(...), role_id: str = Path(...)):
994
+ _require_admin(request)
995
+ await _rbac.store.remove_group_role(group_id, role_id)
996
+ await _rbac.invalidate_group_cache(group_id)
997
+ return {"ok": True}
998
+
999
+ @app.get("/admin/rbac/groups/{group_id}/members")
1000
+ async def get_group_members(request: Request, group_id: str = Path(...)):
1001
+ _require_admin(request)
1002
+ return {"members": await _rbac.store.get_group_members(group_id)}
1003
+
1004
+ @app.post("/admin/rbac/groups/{group_id}/members")
1005
+ async def add_group_member(request: Request, group_id: str = Path(...)):
1006
+ _require_admin(request)
1007
+ body = await request.json()
1008
+ await _rbac.store.add_group_member(group_id, body["user_id"])
1009
+ await _rbac.invalidate_cache(body["user_id"])
1010
+ return {"ok": True}
1011
+
1012
+ @app.delete("/admin/rbac/groups/{group_id}/members/{user_id}")
1013
+ async def remove_group_member(request: Request, group_id: str = Path(...), user_id: str = Path(...)):
1014
+ _require_admin(request)
1015
+ await _rbac.store.remove_group_member(group_id, user_id)
1016
+ await _rbac.invalidate_cache(user_id)
1017
+ return {"ok": True}
1018
+
1019
+ @app.get("/admin/rbac/users/{user_id}/roles")
1020
+ async def get_user_roles(request: Request, user_id: str = Path(...)):
1021
+ _require_admin(request)
1022
+ return {"roles": await _rbac.store.get_user_roles(user_id)}
1023
+
1024
+ @app.post("/admin/rbac/users/{user_id}/roles")
1025
+ async def add_user_role(request: Request, user_id: str = Path(...)):
1026
+ _require_admin(request)
1027
+ body = await request.json()
1028
+ await _rbac.store.add_user_role(user_id, body["role_id"])
1029
+ await _rbac.invalidate_cache(user_id)
1030
+ return {"ok": True}
1031
+
1032
+ @app.delete("/admin/rbac/users/{user_id}/roles/{role_id}")
1033
+ async def remove_user_role(request: Request, user_id: str = Path(...), role_id: str = Path(...)):
1034
+ _require_admin(request)
1035
+ await _rbac.store.remove_user_role(user_id, role_id)
1036
+ await _rbac.invalidate_cache(user_id)
1037
+ return {"ok": True}
1038
+
1039
+ @app.get("/admin/rbac/users/{user_id}/permissions")
1040
+ async def get_user_permissions(request: Request, user_id: str = Path(...)):
1041
+ _require_admin(request)
1042
+ return {"permissions": await _rbac.store.get_user_permissions(user_id)}
1043
+
1044
+ @app.post("/admin/rbac/users/{user_id}/permissions")
1045
+ async def set_user_permission(request: Request, user_id: str = Path(...)):
1046
+ _require_admin(request)
1047
+ body = await request.json()
1048
+ perm = await _rbac.store.set_user_permission(user_id, body["scope"], body.get("granted", True))
1049
+ await _rbac.invalidate_cache(user_id)
1050
+ return perm
1051
+
1052
+ @app.delete("/admin/rbac/users/{user_id}/permissions/{permission_id}")
1053
+ async def remove_user_permission(request: Request, user_id: str = Path(...), permission_id: str = Path(...)):
1054
+ _require_admin(request)
1055
+ await _rbac.store.remove_user_permission(user_id, permission_id)
1056
+ await _rbac.invalidate_cache(user_id)
1057
+ return {"ok": True}
1058
+
1059
+ @app.get("/admin/rbac/users/{user_id}/effective")
1060
+ async def get_effective_scopes(request: Request, user_id: str = Path(...)):
1061
+ _require_admin(request)
1062
+ scopes = await _rbac.get_effective_scopes(user_id)
1063
+ groups = await _rbac.store.get_user_groups(user_id)
1064
+ roles = await _rbac.store.get_user_roles(user_id)
1065
+ overrides = await _rbac.store.get_user_permissions(user_id)
1066
+ return {
1067
+ "scopes": sorted(scopes),
1068
+ "groups": groups,
1069
+ "direct_roles": roles,
1070
+ "overrides": overrides,
1071
+ }
1072
+
1073
+ # =========================================================================
1074
+ # Integration Management API — Self-service integration endpoints
1075
+ # =========================================================================
1076
+
1077
+ @app.post("/v1/integrations")
1078
+ async def create_integration(req: IntegrationCreate):
1079
+ """Register a new integration. Returns integration + first API key."""
1080
+ integration, api_key = await integration_service.register(req)
1081
+ return {
1082
+ "integration": integration.model_dump(mode="json"),
1083
+ "api_key": api_key.model_dump(mode="json"),
1084
+ "_hint": "Save your API key now — it won't be shown again.",
1085
+ }
1086
+
1087
+ @app.get("/v1/integrations")
1088
+ async def list_integrations(namespace: str = _ns_query()):
1089
+ """List all integrations for a namespace."""
1090
+ try:
1091
+ validate_namespace(namespace)
1092
+ except ValueError as e:
1093
+ raise HTTPException(status_code=400, detail=str(e))
1094
+ items = await integration_service.list_all(namespace)
1095
+ return {"integrations": [i.model_dump(mode="json") for i in items]}
1096
+
1097
+ @app.get("/v1/integrations/{integration_id}")
1098
+ async def get_integration(
1099
+ integration_id: str = Path(..., min_length=1, max_length=64),
1100
+ ):
1101
+ """Get integration details."""
1102
+ result = await integration_service.get(integration_id)
1103
+ if not result:
1104
+ raise HTTPException(status_code=404, detail="Integration not found")
1105
+ return result.model_dump(mode="json")
1106
+
1107
+ @app.delete("/v1/integrations/{integration_id}")
1108
+ async def delete_integration(
1109
+ integration_id: str = Path(..., min_length=1, max_length=64),
1110
+ ):
1111
+ """Deactivate an integration (revokes all keys, disables webhooks)."""
1112
+ deleted = await integration_service.delete(integration_id)
1113
+ if not deleted:
1114
+ raise HTTPException(status_code=404, detail="Integration not found")
1115
+ return {"deleted": True, "integration_id": integration_id}
1116
+
1117
+ # --- API Keys ---
1118
+
1119
+ @app.post("/v1/integrations/{integration_id}/keys")
1120
+ async def create_api_key(
1121
+ req: ApiKeyCreate,
1122
+ integration_id: str = Path(..., min_length=1, max_length=64),
1123
+ ):
1124
+ """Create a new scoped API key. Key is shown only once."""
1125
+ try:
1126
+ result = await integration_service.create_key(integration_id, req)
1127
+ except ValueError as e:
1128
+ raise HTTPException(status_code=400, detail=str(e))
1129
+ if not result:
1130
+ raise HTTPException(status_code=404, detail="Integration not found")
1131
+ return {
1132
+ "api_key": result.model_dump(mode="json"),
1133
+ "_hint": "Save your API key now — it won't be shown again.",
1134
+ }
1135
+
1136
+ @app.get("/v1/integrations/{integration_id}/keys")
1137
+ async def list_api_keys(
1138
+ integration_id: str = Path(..., min_length=1, max_length=64),
1139
+ ):
1140
+ """List API keys for an integration (keys are masked)."""
1141
+ keys = await integration_service.list_keys(integration_id)
1142
+ return {"keys": [k.model_dump(mode="json") for k in keys]}
1143
+
1144
+ @app.post("/v1/integrations/{integration_id}/keys/{key_id}/rotate")
1145
+ async def rotate_api_key(
1146
+ integration_id: str = Path(..., min_length=1, max_length=64),
1147
+ key_id: str = Path(..., min_length=1, max_length=64),
1148
+ ):
1149
+ """Rotate an API key. Old key has 24h grace period."""
1150
+ result = await integration_service.rotate_key(key_id)
1151
+ if not result:
1152
+ raise HTTPException(status_code=404, detail="API key not found or already revoked")
1153
+ return {
1154
+ "new_key": result.model_dump(mode="json"),
1155
+ "_hint": "Old key is valid for 24 more hours. Save the new key now.",
1156
+ }
1157
+
1158
+ @app.delete("/v1/integrations/{integration_id}/keys/{key_id}")
1159
+ async def revoke_api_key(
1160
+ integration_id: str = Path(..., min_length=1, max_length=64),
1161
+ key_id: str = Path(..., min_length=1, max_length=64),
1162
+ ):
1163
+ """Revoke an API key immediately."""
1164
+ revoked = await integration_service.revoke_key(key_id)
1165
+ if not revoked:
1166
+ raise HTTPException(status_code=404, detail="API key not found or already revoked")
1167
+ return {"revoked": True, "key_id": key_id}
1168
+
1169
+ # --- Webhooks ---
1170
+
1171
+ @app.post("/v1/integrations/{integration_id}/webhooks")
1172
+ async def create_webhook(
1173
+ req: WebhookCreate,
1174
+ integration_id: str = Path(..., min_length=1, max_length=64),
1175
+ ):
1176
+ """Subscribe to webhook events. Signing secret shown only once."""
1177
+ try:
1178
+ result = await integration_service.subscribe_webhook(integration_id, req)
1179
+ except ValueError as e:
1180
+ raise HTTPException(status_code=400, detail=str(e))
1181
+ if not result:
1182
+ raise HTTPException(status_code=404, detail="Integration not found")
1183
+ return {
1184
+ "webhook": result.model_dump(mode="json"),
1185
+ "_hint": "Save your webhook signing secret now — it won't be shown again.",
1186
+ }
1187
+
1188
+ @app.get("/v1/integrations/{integration_id}/webhooks")
1189
+ async def list_webhooks(
1190
+ integration_id: str = Path(..., min_length=1, max_length=64),
1191
+ ):
1192
+ """List webhook subscriptions for an integration."""
1193
+ webhooks = await integration_service.list_webhooks(integration_id)
1194
+ return {"webhooks": [w.model_dump(mode="json") for w in webhooks]}
1195
+
1196
+ @app.delete("/v1/integrations/{integration_id}/webhooks/{webhook_id}")
1197
+ async def delete_webhook(
1198
+ integration_id: str = Path(..., min_length=1, max_length=64),
1199
+ webhook_id: str = Path(..., min_length=1, max_length=64),
1200
+ ):
1201
+ """Unsubscribe from webhook events."""
1202
+ deleted = await integration_service.unsubscribe_webhook(webhook_id)
1203
+ if not deleted:
1204
+ raise HTTPException(status_code=404, detail="Webhook not found")
1205
+ return {"deleted": True, "webhook_id": webhook_id}
1206
+
1207
+ # --- Audit Events ---
1208
+
1209
+ @app.get("/v1/integrations/{integration_id}/events")
1210
+ async def list_integration_events(
1211
+ integration_id: str = Path(..., min_length=1, max_length=64),
1212
+ limit: int = Query(default=50, ge=1, le=200),
1213
+ ):
1214
+ """Get audit log for an integration."""
1215
+ events = await integration_service.get_events(integration_id, limit)
1216
+ return {"events": [e.model_dump(mode="json") for e in events]}
1217
+
1218
+ # =========================================================================
1219
+ # Chat Session API — /v1/chat/sessions
1220
+ # =========================================================================
1221
+
1222
+ @dataclass
1223
+ class _ChatMsg:
1224
+ role: str # "user" | "assistant"
1225
+ content: str
1226
+ memory_used: bool = False
1227
+ memory_count: int = 0
1228
+
1229
+ @dataclass
1230
+ class _ChatSession:
1231
+ session_id: str
1232
+ name: str
1233
+ namespace: str
1234
+ messages: list = field(default_factory=list)
1235
+ created_at: float = field(default_factory=_time.time)
1236
+
1237
+ # In-memory session store — persists until server restart
1238
+ _sessions: dict = {}
1239
+ _session_counter: list = [0] # mutable counter via list trick
1240
+
1241
+ def _pick_model() -> str:
1242
+ """Return first available LLM model based on configured API keys."""
1243
+ if getattr(settings, "anthropic_api_key", None):
1244
+ return "claude"
1245
+ if getattr(settings, "openai_api_key", None):
1246
+ return "openai"
1247
+ if getattr(settings, "google_api_key", None):
1248
+ return "gemini"
1249
+ return "claude"
1250
+
1251
+ def _phase_dynamics(namespace: str) -> dict:
1252
+ """Build the thermodynamic debug snapshot for a namespace."""
1253
+ raw_items = memory_service.engine._items.get(namespace, [])
1254
+ floor = memory_service.engine.STRENGTH_FLOOR
1255
+ tau_c1 = memory_service.engine.TAU_C1
1256
+ event_counter = memory_service.engine._event_counter
1257
+
1258
+ total_F = 0.0
1259
+ liquid_count = 0
1260
+ gas_count = 0
1261
+ item_list = []
1262
+
1263
+ for item in raw_items:
1264
+ d = item.to_debug_dict(strength_floor=floor)
1265
+ total_F += d["free_energy"]
1266
+ if d["phase"] in ("liquid", "solid", "glass"):
1267
+ liquid_count += 1
1268
+ else:
1269
+ gas_count += 1
1270
+ item_list.append(d)
1271
+
1272
+ # Most relevant first (highest consolidation_strength)
1273
+ item_list.sort(key=lambda x: x["consolidation_strength"], reverse=True)
1274
+ n = len(raw_items)
1275
+ rho = n / max(n + tau_c1, 1e-9)
1276
+
1277
+ return {
1278
+ "memory_density_rho": round(rho, 6),
1279
+ "global_event_counter": event_counter,
1280
+ "total_free_energy": round(total_F, 4),
1281
+ "tau_c1": tau_c1,
1282
+ "liquid_count": liquid_count,
1283
+ "gas_count": gas_count,
1284
+ "items": item_list[:20], # top 20 by strength
1285
+ }
1286
+
1287
+ class _ChatSessionCreateReq(BaseModel):
1288
+ # namespace = the user's persistent brain.
1289
+ # All sessions for the same user must pass the same namespace so memory
1290
+ # flows across conversations and models. Defaults to "default" so
1291
+ # direct API callers work without extra plumbing.
1292
+ namespace: str = "default"
1293
+
1294
+ class _ChatMsgReq(BaseModel):
1295
+ message: str
1296
+
1297
+ @app.post("/v1/chat/sessions")
1298
+ async def create_chat_session(req: _ChatSessionCreateReq):
1299
+ """Create a new chat session bound to the caller's persistent namespace.
1300
+
1301
+ The namespace is the user's identity boundary — all sessions for the same
1302
+ user must use the same namespace so memory accumulates across conversations
1303
+ and across LLM models.
1304
+ """
1305
+ try:
1306
+ validate_namespace(req.namespace)
1307
+ except ValueError as e:
1308
+ raise HTTPException(status_code=400, detail=str(e))
1309
+ sid = str(_uuid_mod.uuid4())
1310
+ _session_counter[0] += 1
1311
+ name = f"Chat {_session_counter[0]}"
1312
+ _sessions[sid] = _ChatSession(session_id=sid, name=name, namespace=req.namespace)
1313
+ return {"session_id": sid, "name": name, "namespace": req.namespace}
1314
+
1315
+ @app.get("/v1/chat/sessions/{session_id}")
1316
+ async def get_chat_session(session_id: str = Path(..., min_length=1, max_length=64)):
1317
+ """Return a session with its full message history."""
1318
+ sess = _sessions.get(session_id)
1319
+ if not sess:
1320
+ raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
1321
+ return {
1322
+ "session_id": sess.session_id,
1323
+ "name": sess.name,
1324
+ "namespace": sess.namespace,
1325
+ "messages": [
1326
+ {"role": m.role, "content": m.content,
1327
+ "memory_used": m.memory_used, "memory_count": m.memory_count}
1328
+ for m in sess.messages
1329
+ ],
1330
+ }
1331
+
1332
+ @app.delete("/v1/chat/sessions/{session_id}")
1333
+ async def delete_chat_session(session_id: str = Path(..., min_length=1, max_length=64)):
1334
+ """Delete a session (removes history but not memory)."""
1335
+ if session_id not in _sessions:
1336
+ raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
1337
+ del _sessions[session_id]
1338
+ return {"deleted": True, "session_id": session_id}
1339
+
1340
+ @app.post("/v1/chat/sessions/{session_id}/message")
1341
+ async def chat_session_message(
1342
+ req: _ChatMsgReq,
1343
+ request: Request,
1344
+ session_id: str = Path(..., min_length=1, max_length=64),
1345
+ ):
1346
+ """Send a user message. Returns LLM reply + full debug snapshot."""
1347
+ from clsplusplus.demo_llm_calls import call_claude, call_openai, call_gemini
1348
+
1349
+ sess = _sessions.get(session_id)
1350
+ if not sess:
1351
+ raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
1352
+
1353
+ tid = _trace_id(request)
1354
+ user_text = req.message.strip()
1355
+ if not user_text:
1356
+ raise HTTPException(status_code=400, detail="message must not be empty")
1357
+
1358
+ ns = sess.namespace
1359
+ model = _pick_model()
1360
+
1361
+ # 1. Store user message to memory (skip pure questions)
1362
+ def _is_question(t: str) -> bool:
1363
+ t = t.strip().lower()
1364
+ return "?" in t or any(t.startswith(w) for w in
1365
+ ("what", "who", "where", "when", "how", "which", "is my", "do you", "can you", "tell me"))
1366
+
1367
+ if not _is_question(user_text):
1368
+ try:
1369
+ from clsplusplus.models import WriteRequest as _WriteReq
1370
+ await memory_service.write(
1371
+ _WriteReq(text=user_text, namespace=ns, source="user", salience=0.8),
1372
+ trace_id=tid,
1373
+ )
1374
+ except Exception:
1375
+ pass
1376
+
1377
+ # 2. Search memory for relevant context
1378
+ memory_hits = []
1379
+ try:
1380
+ from clsplusplus.models import ReadRequest as _ReadReq
1381
+ read_resp = await memory_service.read(
1382
+ _ReadReq(query=user_text, namespace=ns, limit=8),
1383
+ trace_id=tid,
1384
+ )
1385
+ memory_hits = [i.text for i in (read_resp.items or [])]
1386
+ except Exception:
1387
+ pass
1388
+
1389
+ memory_used = len(memory_hits) > 0
1390
+ memory_count = len(memory_hits)
1391
+
1392
+ # 3. Build conversation history (last 6 turns)
1393
+ history_lines = []
1394
+ for m in sess.messages[-6:]:
1395
+ prefix = "User" if m.role == "user" else "Assistant"
1396
+ history_lines.append(f"{prefix}: {m.content}")
1397
+ conv_block = "\n".join(history_lines)
1398
+ history_line_count = len(history_lines)
1399
+
1400
+ # 4. Build augmented system prompt
1401
+ mem_block = ("\n".join(f"- {t}" for t in memory_hits)
1402
+ if memory_hits else "No prior memory context.")
1403
+ augmented_prompt = (
1404
+ "You are a helpful, friendly assistant with persistent memory.\n\n"
1405
+ f"Relevant memory context:\n{mem_block}\n\n"
1406
+ + (f"Conversation so far:\n{conv_block}\n\n" if conv_block else "")
1407
+ + "Answer naturally. Use memory context only when relevant."
1408
+ )
1409
+
1410
+ # 5. Call LLM
1411
+ reply = "No LLM configured."
1412
+ try:
1413
+ if model == "claude":
1414
+ reply = await call_claude(settings, augmented_prompt, user_text)
1415
+ elif model == "openai":
1416
+ reply = await call_openai(settings, augmented_prompt, user_text)
1417
+ elif model == "gemini":
1418
+ reply = await call_gemini(settings, augmented_prompt, user_text)
1419
+ except Exception as exc:
1420
+ reply = f"LLM error: {exc}"
1421
+
1422
+ # 6. Store assistant reply to memory
1423
+ try:
1424
+ from clsplusplus.models import WriteRequest as _WriteReq
1425
+ await memory_service.write(
1426
+ _WriteReq(text=f"Assistant replied: {reply[:400]}",
1427
+ namespace=ns, source=f"assistant.{model}", salience=0.6),
1428
+ trace_id=tid,
1429
+ )
1430
+ except Exception:
1431
+ pass
1432
+
1433
+ # 7. Persist messages in session
1434
+ sess.messages.append(_ChatMsg(role="user", content=user_text))
1435
+ sess.messages.append(_ChatMsg(
1436
+ role="assistant", content=reply,
1437
+ memory_used=memory_used, memory_count=memory_count,
1438
+ ))
1439
+
1440
+ # 8. Build debug snapshot
1441
+ debug_info = {
1442
+ "model_used": model,
1443
+ "user_message": user_text,
1444
+ "memory_searched": memory_hits,
1445
+ "conversation_history_lines": history_line_count,
1446
+ "augmented_prompt": augmented_prompt,
1447
+ "phase_dynamics": _phase_dynamics(ns),
1448
+ }
1449
+
1450
+ await _record_usage("chat_message", request)
1451
+
1452
+ return {
1453
+ "reply": reply,
1454
+ "memory_used": memory_used,
1455
+ "memory_count": memory_count,
1456
+ "debug": debug_info,
1457
+ }
1458
+
1459
+ # =========================================================================
1460
+ # Trace / Call Graph API
1461
+ # =========================================================================
1462
+
1463
+ @app.get("/v1/memory/traces")
1464
+ async def memory_traces(
1465
+ trace_id: Optional[str] = Query(default=None, description="If set, return full call graph for this id"),
1466
+ limit: int = Query(default=50, ge=1, le=200),
1467
+ ):
1468
+ """Single endpoint: list recent traces, or one trace tree when trace_id is set."""
1469
+ if trace_id and trace_id.strip():
1470
+ trace = tracer.get(trace_id.strip())
1471
+ if not trace:
1472
+ raise HTTPException(
1473
+ status_code=404,
1474
+ detail=f"Trace {trace_id} not found (max {tracer.MAX_TRACES} traces kept in memory)",
1475
+ )
1476
+ return trace.to_dict()
1477
+ return {"traces": tracer.list_recent(limit)}
1478
+
1479
+ @app.get("/v1/memory/namespaces")
1480
+ async def list_namespaces():
1481
+ """Return all namespaces that have items in the PhaseMemoryEngine."""
1482
+ ns_list = []
1483
+ for ns, items in memory_service.engine._items.items():
1484
+ if not items:
1485
+ continue
1486
+ floor = memory_service.engine.STRENGTH_FLOOR
1487
+ phases = {"gas": 0, "liquid": 0, "solid": 0, "glass": 0}
1488
+ for item in items:
1489
+ d = item.to_debug_dict(strength_floor=floor)
1490
+ phases[d["phase"]] += 1
1491
+ # most-recently written item
1492
+ latest = max(items, key=lambda i: i.birth_order)
1493
+ ns_list.append({
1494
+ "namespace": ns,
1495
+ "total": len(items),
1496
+ "phases": phases,
1497
+ "latest_text": (latest.fact.raw_text or "")[:60],
1498
+ "latest_birth_order": latest.birth_order,
1499
+ })
1500
+ # Sort by most recently active first
1501
+ ns_list.sort(key=lambda x: x["latest_birth_order"], reverse=True)
1502
+ return {"namespaces": ns_list}
1503
+
1504
+ @app.get("/v1/memory/phases")
1505
+ async def memory_phases(namespace: str = Query(default="default")):
1506
+ """Return items grouped by thermodynamic phase (gas/liquid/solid/glass).
1507
+
1508
+ Used by the live Memory Phase visualiser in the Trace UI.
1509
+ Returns the 30 most recent items per phase, sorted by birth_order desc.
1510
+ """
1511
+ validate_namespace(namespace)
1512
+ raw_items = memory_service.engine._items.get(namespace, [])
1513
+ floor = memory_service.engine.STRENGTH_FLOOR
1514
+
1515
+ phases: dict[str, list] = {"gas": [], "liquid": [], "solid": [], "glass": []}
1516
+ for item in raw_items:
1517
+ d = item.to_debug_dict(strength_floor=floor)
1518
+ # Attach indexed tokens (first 12, human-readable)
1519
+ d["tokens"] = item.indexed_tokens[:12]
1520
+ # Attach schema absorption count if solid/glass
1521
+ if item.schema_meta is not None:
1522
+ d["absorbed_count"] = len(item.schema_meta.H_history)
1523
+ else:
1524
+ d["absorbed_count"] = 0
1525
+ phases[d["phase"]].append(d)
1526
+
1527
+ # Sort each phase by birth_order descending (most recent first)
1528
+ max_birth = max((x["birth_order"] for v in phases.values() for x in v), default=0)
1529
+ for phase_items in phases.values():
1530
+ phase_items.sort(key=lambda x: x["birth_order"], reverse=True)
1531
+
1532
+ return {
1533
+ "namespace": namespace,
1534
+ "max_birth_order": max_birth,
1535
+ "total": len(raw_items),
1536
+ "phases": {
1537
+ p: {"items": items[:30], "total": len(items)}
1538
+ for p, items in phases.items()
1539
+ },
1540
+ }
1541
+
1542
+ _api_logger = logging.getLogger(__name__)
1543
+
1544
+ @app.on_event("startup")
1545
+ async def startup():
1546
+ """Boot sequence:
1547
+ 1. Preload every known namespace from L1 into PhaseMemoryEngine so the
1548
+ first request for any user is instant (no cold-start lag).
1549
+ 2. Re-tune IVFFlat lists for the current row count (1M-ready).
1550
+ 3. Start the periodic hippocampal replay loop (every 5 minutes).
1551
+ """
1552
+ # ── 1. Warm up all persisted namespaces from L1 ────────────────────
1553
+ await memory_service.startup_preload()
1554
+
1555
+ # ── 2. Periodic hippocampal replay (consolidation / sleep cycle) ───
1556
+ async def _periodic_replay():
1557
+ while True:
1558
+ await asyncio.sleep(300) # 5 minutes
1559
+ for ns in list(memory_service.engine._items.keys()):
1560
+ try:
1561
+ rehearsed = memory_service.engine.recall_long_tail(ns, batch_size=50)
1562
+ if rehearsed:
1563
+ _api_logger.debug(
1564
+ "Periodic recall_long_tail ns=%s rehearsed=%d", ns, rehearsed
1565
+ )
1566
+ except Exception:
1567
+ pass
1568
+
1569
+ asyncio.create_task(_periodic_replay())
1570
+
1571
+ @app.on_event("shutdown")
1572
+ async def shutdown():
1573
+ """Cleanly close all connection pools on shutdown."""
1574
+ for store in [memory_service.l1, memory_service.l2]:
1575
+ if hasattr(store, "close"):
1576
+ try:
1577
+ await store.close()
1578
+ except Exception:
1579
+ pass
1580
+ try:
1581
+ await integration_service.close()
1582
+ except Exception:
1583
+ pass
1584
+
1585
+ # Register local/daemon routes (memory viewer, LLM proxies, WebSocket, installer)
1586
+ from clsplusplus.local_routes import create_local_router
1587
+ app.include_router(create_local_router(memory_service, settings, metrics_emitter=_metrics))
1588
+
1589
+ # Serve website static files if the directory exists
1590
+ if _website_dir and FilePath(_website_dir).is_dir():
1591
+ app.mount("/", StaticFiles(directory=_website_dir, html=True), name="website")
1592
+
1593
+ return app
1594
+
1595
+
1596
+ app = create_app()