runtime-memory 3.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 (54) hide show
  1. runtime_memory/__init__.py +28 -0
  2. runtime_memory/claude_code/__init__.py +48 -0
  3. runtime_memory/claude_code/commands.py +698 -0
  4. runtime_memory/claude_code/daemon.py +852 -0
  5. runtime_memory/claude_code/hooks.py +722 -0
  6. runtime_memory/cli/__init__.py +8 -0
  7. runtime_memory/cli/main.py +1936 -0
  8. runtime_memory/core/__init__.py +216 -0
  9. runtime_memory/core/config.py +473 -0
  10. runtime_memory/core/embeddings.py +908 -0
  11. runtime_memory/core/engine.py +1007 -0
  12. runtime_memory/core/exceptions.py +547 -0
  13. runtime_memory/core/legacy_env.py +39 -0
  14. runtime_memory/core/logging.py +160 -0
  15. runtime_memory/core/models.py +1051 -0
  16. runtime_memory/core/observability.py +725 -0
  17. runtime_memory/core/paths.py +30 -0
  18. runtime_memory/core/resilience.py +511 -0
  19. runtime_memory/core/retrieval.py +819 -0
  20. runtime_memory/core/storage.py +1105 -0
  21. runtime_memory/extraction/__init__.py +36 -0
  22. runtime_memory/extraction/extractor.py +1143 -0
  23. runtime_memory/hermes/__init__.py +39 -0
  24. runtime_memory/hermes/_base.py +154 -0
  25. runtime_memory/hermes/bridge.py +119 -0
  26. runtime_memory/hermes/plugin.yaml +13 -0
  27. runtime_memory/hermes/provider.py +536 -0
  28. runtime_memory/hermes/tools.py +230 -0
  29. runtime_memory/hermes/trace.py +177 -0
  30. runtime_memory/plugin/__init__.py +646 -0
  31. runtime_memory/sdk/__init__.py +97 -0
  32. runtime_memory/sdk/client.py +1577 -0
  33. runtime_memory/server/__init__.py +75 -0
  34. runtime_memory/server/api.py +1665 -0
  35. runtime_memory/server/mcp.py +1574 -0
  36. runtime_memory/server/static/css/styles.css +1110 -0
  37. runtime_memory/server/static/index.html +264 -0
  38. runtime_memory/server/static/js/api.js +294 -0
  39. runtime_memory/server/static/js/app.js +771 -0
  40. runtime_memory/tasks/__init__.py +114 -0
  41. runtime_memory/tasks/adapter.py +501 -0
  42. runtime_memory/tasks/claude_code_adapter.py +495 -0
  43. runtime_memory/tasks/claude_code_parser.py +339 -0
  44. runtime_memory/tasks/cli_bridge.py +415 -0
  45. runtime_memory/tasks/linking.py +397 -0
  46. runtime_memory/tasks/models.py +520 -0
  47. runtime_memory/tasks/outcomes.py +320 -0
  48. runtime_memory/tasks/parser.py +305 -0
  49. runtime_memory/tasks/unified_adapter.py +661 -0
  50. runtime_memory-3.0.0.dist-info/METADATA +497 -0
  51. runtime_memory-3.0.0.dist-info/RECORD +54 -0
  52. runtime_memory-3.0.0.dist-info/WHEEL +4 -0
  53. runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
  54. runtime_memory-3.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1665 @@
1
+ """REST API Server for Runtime Memory.
2
+
3
+ This module implements a FastAPI-based REST API that exposes memory operations
4
+ for HTTP-based access.
5
+
6
+ Usage:
7
+ mem serve --rest --port 8080
8
+
9
+ Or programmatically:
10
+ from runtime_memory.server.api import create_app
11
+ app = create_app()
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import os
18
+ import secrets
19
+ import time
20
+ from collections import defaultdict
21
+ from contextlib import asynccontextmanager
22
+ from datetime import datetime, timezone
23
+ from functools import wraps
24
+ from typing import Any, Callable, Optional
25
+
26
+ from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
27
+ from fastapi.middleware.cors import CORSMiddleware
28
+ from fastapi.responses import JSONResponse
29
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
30
+
31
+ from runtime_memory import __version__
32
+ from runtime_memory.core.engine import MemoryEngine
33
+ from runtime_memory.core.logging import get_logger
34
+ from runtime_memory.core.models import (
35
+ Memory,
36
+ MemoryCategory,
37
+ MemoryScope,
38
+ MemorySource,
39
+ Outcome,
40
+ )
41
+ from runtime_memory.core.paths import default_db_path
42
+
43
+ logger = get_logger(__name__)
44
+
45
+
46
+ # =============================================================================
47
+ # Configuration
48
+ # =============================================================================
49
+
50
+
51
+ class APIConfig:
52
+ """API configuration."""
53
+
54
+ def __init__(
55
+ self,
56
+ api_key: Optional[str] = None,
57
+ rate_limit: int = 100,
58
+ rate_window: float = 60.0,
59
+ max_request_size: int = 1_000_000, # 1MB
60
+ cors_origins: list[str] | None = None,
61
+ ):
62
+ self.api_key = api_key or os.environ.get("RUNTIME_MEMORY_API_KEY")
63
+ self.rate_limit = rate_limit
64
+ self.rate_window = rate_window
65
+ self.max_request_size = max_request_size
66
+ self.cors_origins = cors_origins or ["*"]
67
+
68
+
69
+ # =============================================================================
70
+ # Request/Response Models
71
+ # =============================================================================
72
+
73
+
74
+ class MemoryCreateRequest(BaseModel):
75
+ """Request model for creating a memory."""
76
+
77
+ model_config = ConfigDict(str_strip_whitespace=True)
78
+
79
+ content: str = Field(..., min_length=1, max_length=100000)
80
+ category: MemoryCategory = MemoryCategory.GENERAL
81
+ project: Optional[str] = Field(default=None, max_length=255)
82
+ tags: list[str] = Field(default_factory=list, max_length=20)
83
+ importance: float = Field(default=0.5, ge=0.0, le=1.0)
84
+ entities: list[str] = Field(default_factory=list, max_length=50)
85
+
86
+ @field_validator("content")
87
+ @classmethod
88
+ def content_not_empty(cls, v: str) -> str:
89
+ if not v.strip():
90
+ raise ValueError("Content cannot be empty")
91
+ return v.strip()
92
+
93
+
94
+ class MemoryUpdateRequest(BaseModel):
95
+ """Request model for updating a memory."""
96
+
97
+ model_config = ConfigDict(str_strip_whitespace=True)
98
+
99
+ content: Optional[str] = Field(default=None, min_length=1, max_length=100000)
100
+ category: Optional[MemoryCategory] = None
101
+ tags: Optional[list[str]] = Field(default=None, max_length=20)
102
+ importance: Optional[float] = Field(default=None, ge=0.0, le=1.0)
103
+
104
+
105
+ class SearchRequest(BaseModel):
106
+ """Request model for searching memories."""
107
+
108
+ query: str = Field(..., min_length=1, max_length=1000)
109
+ limit: int = Field(default=10, ge=1, le=100)
110
+ categories: Optional[list[MemoryCategory]] = None
111
+ project: Optional[str] = None
112
+ min_score: float = Field(default=-1.0, ge=-1.0, le=1.0)
113
+ search_type: str = Field(default="semantic", pattern="^(semantic|keyword)$")
114
+
115
+
116
+ class OutcomeRequest(BaseModel):
117
+ """Request model for recording outcome."""
118
+
119
+ memory_ids: list[str] = Field(..., min_length=1, max_length=50)
120
+ outcome: Outcome
121
+
122
+
123
+ class IngestRequest(BaseModel):
124
+ """Request model for ingesting transcript."""
125
+
126
+ transcript: str = Field(..., min_length=1, max_length=100000)
127
+ project: Optional[str] = None
128
+ session_id: Optional[str] = None
129
+
130
+
131
+ class MemoryResponse(BaseModel):
132
+ """Response model for a single memory."""
133
+
134
+ model_config = ConfigDict(from_attributes=True)
135
+
136
+ id: str
137
+ content: str
138
+ category: MemoryCategory
139
+ outcome_score: float
140
+ confidence: float
141
+ importance: float
142
+ use_count: int
143
+ project: Optional[str]
144
+ scope: MemoryScope
145
+ source: MemorySource
146
+ tags: list[str]
147
+ entities: list[str]
148
+ supersedes: Optional[str]
149
+ archived: bool
150
+ created_at: datetime
151
+ updated_at: datetime
152
+
153
+ @classmethod
154
+ def from_memory(cls, memory: Memory) -> "MemoryResponse":
155
+ return cls(
156
+ id=memory.id,
157
+ content=memory.content,
158
+ category=memory.category,
159
+ outcome_score=memory.outcome_score,
160
+ confidence=memory.confidence,
161
+ importance=memory.importance,
162
+ use_count=memory.use_count,
163
+ project=memory.project,
164
+ scope=memory.scope,
165
+ source=memory.source,
166
+ tags=memory.tags,
167
+ entities=memory.entities,
168
+ supersedes=memory.supersedes,
169
+ archived=memory.archived,
170
+ created_at=memory.created_at,
171
+ updated_at=memory.updated_at,
172
+ )
173
+
174
+
175
+ class SearchResultResponse(BaseModel):
176
+ """Response model for a search result."""
177
+
178
+ memory: MemoryResponse
179
+ score: float
180
+ semantic_score: float = 0.0
181
+ recency_score: float = 0.0
182
+ frequency_score: float = 0.0
183
+
184
+
185
+ class SearchResponse(BaseModel):
186
+ """Response model for search results."""
187
+
188
+ count: int
189
+ results: list[SearchResultResponse]
190
+
191
+
192
+ class MemoryListResponse(BaseModel):
193
+ """Response model for memory list."""
194
+
195
+ count: int
196
+ memories: list[MemoryResponse]
197
+
198
+
199
+ class ContextResponse(BaseModel):
200
+ """Response model for context."""
201
+
202
+ project: Optional[str]
203
+ total_count: int
204
+ included_count: int
205
+ formatted: str
206
+ memories: list[MemoryResponse]
207
+
208
+
209
+ class StatsResponse(BaseModel):
210
+ """Response model for statistics."""
211
+
212
+ total_memories: int
213
+ active_memories: int
214
+ archived_memories: int
215
+ by_category: dict[str, int]
216
+ by_scope: dict[str, int]
217
+ by_source: dict[str, int]
218
+ avg_outcome_score: float
219
+ total_uses: int
220
+
221
+
222
+ class ComponentHealth(BaseModel):
223
+ """Health status of a single component."""
224
+
225
+ name: str
226
+ status: str
227
+ message: str = ""
228
+ duration_ms: float = 0.0
229
+
230
+
231
+ class HealthResponse(BaseModel):
232
+ """Response model for health check."""
233
+
234
+ status: str
235
+ version: str
236
+ timestamp: datetime
237
+ checks: list[ComponentHealth] = Field(default_factory=list)
238
+
239
+
240
+ class ReadinessResponse(BaseModel):
241
+ """Response model for readiness check."""
242
+
243
+ ready: bool
244
+ message: str = ""
245
+ checks: dict[str, bool] = Field(default_factory=dict)
246
+
247
+
248
+ class ErrorResponse(BaseModel):
249
+ """Response model for errors."""
250
+
251
+ error: str
252
+ detail: Optional[str] = None
253
+ status_code: int
254
+
255
+
256
+ class OutcomeResponse(BaseModel):
257
+ """Response model for outcome recording."""
258
+
259
+ success: bool
260
+ memory_ids: list[str]
261
+ outcome: str
262
+ adjustment: str
263
+
264
+
265
+ # =============================================================================
266
+ # Rate Limiter
267
+ # =============================================================================
268
+
269
+
270
+ class RateLimiter:
271
+ """Rate limiter for API requests."""
272
+
273
+ def __init__(self, max_requests: int = 100, window_seconds: float = 60.0):
274
+ self.max_requests = max_requests
275
+ self.window_seconds = window_seconds
276
+ self._requests: dict[str, list[float]] = defaultdict(list)
277
+
278
+ def is_allowed(self, client_id: str) -> bool:
279
+ """Check if request is allowed."""
280
+ now = time.time()
281
+ window_start = now - self.window_seconds
282
+
283
+ # Clean old requests
284
+ self._requests[client_id] = [
285
+ t for t in self._requests[client_id] if t > window_start
286
+ ]
287
+
288
+ if len(self._requests[client_id]) >= self.max_requests:
289
+ return False
290
+
291
+ self._requests[client_id].append(now)
292
+ return True
293
+
294
+ def get_remaining(self, client_id: str) -> int:
295
+ """Get remaining requests for client."""
296
+ now = time.time()
297
+ window_start = now - self.window_seconds
298
+ current = len([t for t in self._requests[client_id] if t > window_start])
299
+ return max(0, self.max_requests - current)
300
+
301
+
302
+ # =============================================================================
303
+ # Application State
304
+ # =============================================================================
305
+
306
+
307
+ class AppState:
308
+ """Application state container."""
309
+
310
+ def __init__(self):
311
+ self.engine: Optional[MemoryEngine] = None
312
+ self.config: Optional[APIConfig] = None
313
+ self.rate_limiter: Optional[RateLimiter] = None
314
+
315
+ def get_engine(self) -> MemoryEngine:
316
+ """Get or create engine."""
317
+ if self.engine is None:
318
+ from pathlib import Path
319
+
320
+ db_path = os.environ.get(
321
+ "RUNTIME_MEMORY_DB",
322
+ str(default_db_path()),
323
+ )
324
+ Path(db_path).parent.mkdir(parents=True, exist_ok=True)
325
+ self.engine = MemoryEngine(db_path=db_path)
326
+ return self.engine
327
+
328
+
329
+ # Global state
330
+ _app_state = AppState()
331
+
332
+
333
+ def get_engine() -> MemoryEngine:
334
+ """Dependency for getting the engine."""
335
+ return _app_state.get_engine()
336
+
337
+
338
+ def get_config() -> APIConfig:
339
+ """Dependency for getting the config."""
340
+ if _app_state.config is None:
341
+ _app_state.config = APIConfig()
342
+ return _app_state.config
343
+
344
+
345
+ def get_rate_limiter() -> RateLimiter:
346
+ """Dependency for getting the rate limiter."""
347
+ if _app_state.rate_limiter is None:
348
+ config = get_config()
349
+ _app_state.rate_limiter = RateLimiter(
350
+ max_requests=config.rate_limit,
351
+ window_seconds=config.rate_window,
352
+ )
353
+ return _app_state.rate_limiter
354
+
355
+
356
+ # =============================================================================
357
+ # Authentication
358
+ # =============================================================================
359
+
360
+
361
+ async def verify_api_key(
362
+ x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
363
+ config: APIConfig = Depends(get_config),
364
+ ) -> Optional[str]:
365
+ """Verify API key if configured."""
366
+ if config.api_key is None:
367
+ return None # No auth required
368
+
369
+ if x_api_key is None:
370
+ raise HTTPException(
371
+ status_code=status.HTTP_401_UNAUTHORIZED,
372
+ detail="API key required",
373
+ )
374
+
375
+ # Constant-time comparison
376
+ if not secrets.compare_digest(x_api_key, config.api_key):
377
+ raise HTTPException(
378
+ status_code=status.HTTP_401_UNAUTHORIZED,
379
+ detail="Invalid API key",
380
+ )
381
+
382
+ return x_api_key
383
+
384
+
385
+ # =============================================================================
386
+ # Middleware
387
+ # =============================================================================
388
+
389
+
390
+ async def rate_limit_middleware(request: Request, call_next):
391
+ """Rate limiting middleware."""
392
+ rate_limiter = get_rate_limiter()
393
+ config = get_config()
394
+
395
+ # Get client identifier
396
+ client_id = request.client.host if request.client else "unknown"
397
+
398
+ if not rate_limiter.is_allowed(client_id):
399
+ return JSONResponse(
400
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
401
+ content={
402
+ "error": "Rate limit exceeded",
403
+ "detail": f"Maximum {config.rate_limit} requests per {config.rate_window} seconds",
404
+ "status_code": 429,
405
+ },
406
+ )
407
+
408
+ response = await call_next(request)
409
+
410
+ # Add rate limit headers
411
+ remaining = rate_limiter.get_remaining(client_id)
412
+ response.headers["X-RateLimit-Limit"] = str(config.rate_limit)
413
+ response.headers["X-RateLimit-Remaining"] = str(remaining)
414
+
415
+ return response
416
+
417
+
418
+ async def request_logging_middleware(request: Request, call_next):
419
+ """Request logging middleware."""
420
+ start_time = time.time()
421
+
422
+ response = await call_next(request)
423
+
424
+ duration = time.time() - start_time
425
+ logger.info(
426
+ f"{request.method} {request.url.path} - {response.status_code} ({duration:.3f}s)"
427
+ )
428
+
429
+ return response
430
+
431
+
432
+ # =============================================================================
433
+ # Exception Handlers
434
+ # =============================================================================
435
+
436
+
437
+ async def validation_exception_handler(request: Request, exc: Exception):
438
+ """Handle validation errors."""
439
+ return JSONResponse(
440
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
441
+ content={
442
+ "error": "Validation error",
443
+ "detail": str(exc),
444
+ "status_code": 422,
445
+ },
446
+ )
447
+
448
+
449
+ async def general_exception_handler(request: Request, exc: Exception):
450
+ """Handle general errors (not HTTPException)."""
451
+ # Let HTTPException be handled by FastAPI's default handler
452
+ if isinstance(exc, HTTPException):
453
+ raise exc
454
+
455
+ logger.error(f"Unhandled error: {exc}")
456
+ return JSONResponse(
457
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
458
+ content={
459
+ "error": "Internal server error",
460
+ "detail": str(exc) if os.environ.get("DEBUG") else None,
461
+ "status_code": 500,
462
+ },
463
+ )
464
+
465
+
466
+ # =============================================================================
467
+ # Application Factory
468
+ # =============================================================================
469
+
470
+
471
+ def create_app(
472
+ config: Optional[APIConfig] = None,
473
+ engine: Optional[MemoryEngine] = None,
474
+ ) -> FastAPI:
475
+ """Create the FastAPI application.
476
+
477
+ Args:
478
+ config: Optional API configuration
479
+ engine: Optional MemoryEngine instance
480
+
481
+ Returns:
482
+ Configured FastAPI application
483
+ """
484
+ from pathlib import Path
485
+
486
+ from fastapi.responses import FileResponse
487
+ from fastapi.staticfiles import StaticFiles
488
+
489
+ # Set up state
490
+ if config:
491
+ _app_state.config = config
492
+ if engine:
493
+ _app_state.engine = engine
494
+
495
+ config = config or get_config()
496
+
497
+ # Create app
498
+ app = FastAPI(
499
+ title="Runtime Memory API",
500
+ description="REST API for Runtime Memory - Persistent memory for AI coding agents",
501
+ version=__version__,
502
+ docs_url="/docs",
503
+ redoc_url="/redoc",
504
+ )
505
+
506
+ # Add CORS middleware
507
+ app.add_middleware(
508
+ CORSMiddleware,
509
+ allow_origins=config.cors_origins,
510
+ allow_credentials=True,
511
+ allow_methods=["*"],
512
+ allow_headers=["*"],
513
+ )
514
+
515
+ # Add custom middleware
516
+ app.middleware("http")(rate_limit_middleware)
517
+ app.middleware("http")(request_logging_middleware)
518
+
519
+ # Add exception handlers
520
+ app.add_exception_handler(ValueError, validation_exception_handler)
521
+ # Note: Don't add general Exception handler as it catches HTTPException too
522
+
523
+ # Register routes
524
+ app.include_router(router)
525
+
526
+ # Static files for Web UI
527
+ static_dir = Path(__file__).parent / "static"
528
+ if static_dir.exists():
529
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
530
+
531
+ @app.get("/", include_in_schema=False)
532
+ async def serve_index():
533
+ """Serve the Web UI index page."""
534
+ return FileResponse(static_dir / "index.html")
535
+
536
+ return app
537
+
538
+
539
+ # =============================================================================
540
+ # Routes
541
+ # =============================================================================
542
+
543
+ from fastapi import APIRouter
544
+
545
+ router = APIRouter()
546
+
547
+
548
+ @router.get("/health", response_model=HealthResponse, tags=["System"])
549
+ async def health_check(
550
+ engine: MemoryEngine = Depends(get_engine),
551
+ ):
552
+ """Comprehensive health check endpoint.
553
+
554
+ Returns overall health status and individual component checks.
555
+ """
556
+ checks = []
557
+ overall_healthy = True
558
+
559
+ # Check database connectivity
560
+ try:
561
+ start = time.time()
562
+ stats = await engine.stats()
563
+ duration_ms = (time.time() - start) * 1000
564
+ checks.append(
565
+ ComponentHealth(
566
+ name="database",
567
+ status="healthy",
568
+ message=f"{stats.storage_stats.total_memories} memories",
569
+ duration_ms=round(duration_ms, 2),
570
+ )
571
+ )
572
+ except Exception as e:
573
+ checks.append(
574
+ ComponentHealth(
575
+ name="database",
576
+ status="unhealthy",
577
+ message=str(e),
578
+ )
579
+ )
580
+ overall_healthy = False
581
+
582
+ # Check embedding model (if available)
583
+ try:
584
+ if engine._embedding_provider:
585
+ start = time.time()
586
+ # Quick embedding test
587
+ await engine._embedding_provider.embed("test")
588
+ duration_ms = (time.time() - start) * 1000
589
+ checks.append(
590
+ ComponentHealth(
591
+ name="embedding",
592
+ status="healthy",
593
+ message="Model loaded",
594
+ duration_ms=round(duration_ms, 2),
595
+ )
596
+ )
597
+ except Exception as e:
598
+ checks.append(
599
+ ComponentHealth(
600
+ name="embedding",
601
+ status="degraded",
602
+ message=str(e),
603
+ )
604
+ )
605
+
606
+ return HealthResponse(
607
+ status="healthy" if overall_healthy else "unhealthy",
608
+ version=__version__,
609
+ timestamp=datetime.now(timezone.utc),
610
+ checks=checks,
611
+ )
612
+
613
+
614
+ @router.get("/health/live", tags=["System"])
615
+ async def liveness_check():
616
+ """Kubernetes liveness probe.
617
+
618
+ Returns 200 if the service is running.
619
+ """
620
+ return {"alive": True}
621
+
622
+
623
+ @router.get("/health/ready", response_model=ReadinessResponse, tags=["System"])
624
+ async def readiness_check(
625
+ engine: MemoryEngine = Depends(get_engine),
626
+ ):
627
+ """Kubernetes readiness probe.
628
+
629
+ Returns 200 if the service is ready to handle requests.
630
+ """
631
+ checks = {}
632
+
633
+ # Check database
634
+ try:
635
+ await engine.stats()
636
+ checks["database"] = True
637
+ except Exception:
638
+ checks["database"] = False
639
+
640
+ ready = all(checks.values())
641
+
642
+ if not ready:
643
+ raise HTTPException(
644
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
645
+ detail="Service not ready",
646
+ )
647
+
648
+ return ReadinessResponse(
649
+ ready=ready,
650
+ message="All systems operational" if ready else "Some systems unavailable",
651
+ checks=checks,
652
+ )
653
+
654
+
655
+ @router.get("/metrics", tags=["System"])
656
+ async def get_metrics():
657
+ """Prometheus metrics endpoint.
658
+
659
+ Returns metrics in Prometheus text format.
660
+ """
661
+ from runtime_memory.core.observability import get_metrics_collector
662
+
663
+ collector = get_metrics_collector()
664
+ metrics_text = collector.to_prometheus()
665
+
666
+ from fastapi.responses import PlainTextResponse
667
+
668
+ return PlainTextResponse(
669
+ content=metrics_text,
670
+ media_type="text/plain; charset=utf-8",
671
+ )
672
+
673
+
674
+ @router.get("/stats", response_model=StatsResponse, tags=["System"])
675
+ async def get_stats(
676
+ project: Optional[str] = Query(None),
677
+ engine: MemoryEngine = Depends(get_engine),
678
+ _: Optional[str] = Depends(verify_api_key),
679
+ ):
680
+ """Get memory statistics."""
681
+ stats = await engine.stats(project=project)
682
+ # StatsResponse fields come from storage_stats
683
+ storage = stats.storage_stats
684
+ return StatsResponse(
685
+ total_memories=storage.total_memories,
686
+ active_memories=storage.active_memories,
687
+ archived_memories=storage.archived_memories,
688
+ by_category=storage.by_category,
689
+ by_scope=storage.by_scope,
690
+ by_source=storage.by_source,
691
+ avg_outcome_score=storage.avg_outcome_score,
692
+ total_uses=storage.total_uses,
693
+ )
694
+
695
+
696
+ # -----------------------------------------------------------------------------
697
+ # Memory CRUD
698
+ # -----------------------------------------------------------------------------
699
+
700
+
701
+ @router.post(
702
+ "/memories",
703
+ response_model=MemoryResponse,
704
+ status_code=status.HTTP_201_CREATED,
705
+ tags=["Memories"],
706
+ )
707
+ async def create_memory(
708
+ request: MemoryCreateRequest,
709
+ engine: MemoryEngine = Depends(get_engine),
710
+ _: Optional[str] = Depends(verify_api_key),
711
+ ):
712
+ """Create a new memory."""
713
+ memory = await engine.add(
714
+ content=request.content,
715
+ category=request.category,
716
+ project=request.project,
717
+ tags=request.tags,
718
+ importance=request.importance,
719
+ entities=request.entities,
720
+ source=MemorySource.EXPLICIT,
721
+ )
722
+ return MemoryResponse.from_memory(memory)
723
+
724
+
725
+ @router.get("/memories/{memory_id}", response_model=MemoryResponse, tags=["Memories"])
726
+ async def get_memory(
727
+ memory_id: str,
728
+ engine: MemoryEngine = Depends(get_engine),
729
+ _: Optional[str] = Depends(verify_api_key),
730
+ ):
731
+ """Get a memory by ID."""
732
+ from runtime_memory.core.engine import MemoryNotFoundError
733
+
734
+ try:
735
+ memory = await engine.get(memory_id)
736
+ except MemoryNotFoundError:
737
+ raise HTTPException(
738
+ status_code=status.HTTP_404_NOT_FOUND,
739
+ detail=f"Memory not found: {memory_id}",
740
+ )
741
+ return MemoryResponse.from_memory(memory)
742
+
743
+
744
+ @router.patch("/memories/{memory_id}", response_model=MemoryResponse, tags=["Memories"])
745
+ async def update_memory(
746
+ memory_id: str,
747
+ request: MemoryUpdateRequest,
748
+ engine: MemoryEngine = Depends(get_engine),
749
+ _: Optional[str] = Depends(verify_api_key),
750
+ ):
751
+ """Update a memory."""
752
+ from runtime_memory.core.engine import MemoryNotFoundError
753
+
754
+ try:
755
+ # Update memory with provided fields
756
+ updated = await engine.update(
757
+ memory_id=memory_id,
758
+ content=request.content,
759
+ category=request.category,
760
+ tags=request.tags,
761
+ importance=request.importance,
762
+ )
763
+ except MemoryNotFoundError:
764
+ raise HTTPException(
765
+ status_code=status.HTTP_404_NOT_FOUND,
766
+ detail=f"Memory not found: {memory_id}",
767
+ )
768
+ return MemoryResponse.from_memory(updated)
769
+
770
+
771
+ @router.delete(
772
+ "/memories/{memory_id}",
773
+ status_code=status.HTTP_204_NO_CONTENT,
774
+ tags=["Memories"],
775
+ )
776
+ async def delete_memory(
777
+ memory_id: str,
778
+ engine: MemoryEngine = Depends(get_engine),
779
+ _: Optional[str] = Depends(verify_api_key),
780
+ ):
781
+ """Archive (soft delete) a memory."""
782
+ from runtime_memory.core.engine import MemoryNotFoundError
783
+
784
+ try:
785
+ await engine.delete(memory_id)
786
+ except MemoryNotFoundError:
787
+ raise HTTPException(
788
+ status_code=status.HTTP_404_NOT_FOUND,
789
+ detail=f"Memory not found: {memory_id}",
790
+ )
791
+ return None
792
+
793
+
794
+ @router.get("/memories", response_model=MemoryListResponse, tags=["Memories"])
795
+ async def list_memories(
796
+ category: Optional[MemoryCategory] = Query(None),
797
+ project: Optional[str] = Query(None),
798
+ limit: int = Query(20, ge=1, le=100),
799
+ include_archived: bool = Query(False),
800
+ engine: MemoryEngine = Depends(get_engine),
801
+ _: Optional[str] = Depends(verify_api_key),
802
+ ):
803
+ """List memories with optional filters."""
804
+ memories = await engine.list(
805
+ category=category,
806
+ project=project,
807
+ limit=limit,
808
+ include_archived=include_archived,
809
+ )
810
+ return MemoryListResponse(
811
+ count=len(memories),
812
+ memories=[MemoryResponse.from_memory(m) for m in memories],
813
+ )
814
+
815
+
816
+ # -----------------------------------------------------------------------------
817
+ # Search
818
+ # -----------------------------------------------------------------------------
819
+
820
+
821
+ @router.post("/memories/search", response_model=SearchResponse, tags=["Search"])
822
+ async def search_memories(
823
+ request: SearchRequest,
824
+ engine: MemoryEngine = Depends(get_engine),
825
+ _: Optional[str] = Depends(verify_api_key),
826
+ ):
827
+ """Search memories by query.
828
+
829
+ Supports two search types:
830
+ - semantic: Vector similarity search (finds conceptually related memories)
831
+ - keyword: Full-text search (exact keyword matching)
832
+ """
833
+ if request.search_type == "keyword":
834
+ # Use FTS for keyword search
835
+ memories = await engine._storage.search_fts(
836
+ query=request.query,
837
+ project=request.project,
838
+ limit=request.limit,
839
+ )
840
+ # Filter by category if specified
841
+ if request.categories:
842
+ memories = [m for m in memories if m.category in request.categories]
843
+
844
+ return SearchResponse(
845
+ count=len(memories),
846
+ results=[
847
+ SearchResultResponse(
848
+ memory=MemoryResponse.from_memory(m),
849
+ score=1.0, # FTS doesn't provide similarity scores
850
+ semantic_score=0.0,
851
+ recency_score=0.0,
852
+ frequency_score=0.0,
853
+ )
854
+ for m in memories
855
+ ],
856
+ )
857
+
858
+ # Semantic search (default)
859
+ # Engine only supports single category, use first if list provided
860
+ category = request.categories[0] if request.categories else None
861
+ results = await engine.search(
862
+ query=request.query,
863
+ limit=request.limit,
864
+ category=category,
865
+ project=request.project,
866
+ min_score=request.min_score,
867
+ )
868
+ return SearchResponse(
869
+ count=len(results),
870
+ results=[
871
+ SearchResultResponse(
872
+ memory=MemoryResponse.from_memory(r.memory),
873
+ score=r.score,
874
+ semantic_score=r.semantic_score,
875
+ recency_score=r.recency_score,
876
+ frequency_score=r.frequency_score,
877
+ )
878
+ for r in results
879
+ ],
880
+ )
881
+
882
+
883
+ # -----------------------------------------------------------------------------
884
+ # Outcome
885
+ # -----------------------------------------------------------------------------
886
+
887
+
888
+ @router.post("/memories/outcome", response_model=OutcomeResponse, tags=["Feedback"])
889
+ async def record_outcome(
890
+ request: OutcomeRequest,
891
+ engine: MemoryEngine = Depends(get_engine),
892
+ _: Optional[str] = Depends(verify_api_key),
893
+ ):
894
+ """Record outcome feedback for memories."""
895
+ success = await engine.record_outcome(
896
+ memory_ids=request.memory_ids,
897
+ outcome=request.outcome,
898
+ )
899
+
900
+ if not success:
901
+ raise HTTPException(
902
+ status_code=status.HTTP_404_NOT_FOUND,
903
+ detail="One or more memories not found",
904
+ )
905
+
906
+ adjustment = {
907
+ Outcome.WORKED: "+0.2",
908
+ Outcome.FAILED: "-0.3",
909
+ Outcome.PARTIAL: "+0.05",
910
+ }[request.outcome]
911
+
912
+ return OutcomeResponse(
913
+ success=True,
914
+ memory_ids=request.memory_ids,
915
+ outcome=request.outcome.value,
916
+ adjustment=adjustment,
917
+ )
918
+
919
+
920
+ # -----------------------------------------------------------------------------
921
+ # Context
922
+ # -----------------------------------------------------------------------------
923
+
924
+
925
+ @router.get("/context", response_model=ContextResponse, tags=["Context"])
926
+ async def get_context(
927
+ project: Optional[str] = Query(None),
928
+ limit: int = Query(10, ge=1, le=50),
929
+ format: str = Query("markdown"),
930
+ engine: MemoryEngine = Depends(get_engine),
931
+ _: Optional[str] = Depends(verify_api_key),
932
+ ):
933
+ """Get project context."""
934
+ context = await engine.get_context(project=project, max_memories=limit)
935
+
936
+ from runtime_memory.plugin import ContextFormatter
937
+
938
+ formatted = ContextFormatter.format_for_injection(
939
+ context.memories,
940
+ style=format if format in ["brief", "detailed", "markdown"] else "markdown",
941
+ )
942
+
943
+ return ContextResponse(
944
+ project=project,
945
+ total_count=context.total_count,
946
+ included_count=context.included_count,
947
+ formatted=formatted,
948
+ memories=[MemoryResponse.from_memory(m) for m in context.memories],
949
+ )
950
+
951
+
952
+ # -----------------------------------------------------------------------------
953
+ # Sessions
954
+ # -----------------------------------------------------------------------------
955
+
956
+
957
+ @router.post("/sessions/ingest", tags=["Sessions"])
958
+ async def ingest_transcript(
959
+ request: IngestRequest,
960
+ engine: MemoryEngine = Depends(get_engine),
961
+ _: Optional[str] = Depends(verify_api_key),
962
+ ):
963
+ """Ingest a conversation transcript to extract memories."""
964
+ # TODO: Implement full extraction when extractor integration is ready
965
+ return {
966
+ "status": "accepted",
967
+ "message": "Transcript received for processing",
968
+ "transcript_length": len(request.transcript),
969
+ "project": request.project,
970
+ "session_id": request.session_id,
971
+ }
972
+
973
+
974
+ # -----------------------------------------------------------------------------
975
+ # Beads Integration
976
+ # -----------------------------------------------------------------------------
977
+
978
+
979
+ class BeadsSyncRequest(BaseModel):
980
+ """Request model for Beads sync."""
981
+
982
+ task_id: Optional[str] = Field(default=None, description="Sync specific task only")
983
+
984
+
985
+ class BeadsSyncResponse(BaseModel):
986
+ """Response model for Beads sync."""
987
+
988
+ success: bool
989
+ tasks_found: int = 0
990
+ tasks_synced: int = 0
991
+ outcomes_recorded: int = 0
992
+ errors: list[str] = Field(default_factory=list)
993
+ warnings: list[str] = Field(default_factory=list)
994
+
995
+
996
+ class BeadsContextResponse(BaseModel):
997
+ """Response model for Beads context."""
998
+
999
+ success: bool
1000
+ task_id: Optional[str] = None
1001
+ task_title: Optional[str] = None
1002
+ task_status: Optional[str] = None
1003
+ task_description: Optional[str] = None
1004
+ memories_count: int = 0
1005
+ formatted: str = ""
1006
+ error: Optional[str] = None
1007
+
1008
+
1009
+ class BeadsLinkRequest(BaseModel):
1010
+ """Request model for linking memory to task."""
1011
+
1012
+ memory_id: str = Field(..., min_length=1)
1013
+ task_id: Optional[str] = Field(default=None, description="Task to link to (uses current if not provided)")
1014
+ context: Optional[str] = Field(default=None, description="Context about how memory is used")
1015
+
1016
+
1017
+ class BeadsLinkResponse(BaseModel):
1018
+ """Response model for link operation."""
1019
+
1020
+ success: bool
1021
+ memory_id: Optional[str] = None
1022
+ task_id: Optional[str] = None
1023
+ error: Optional[str] = None
1024
+
1025
+
1026
+ class BeadsTaskResponse(BaseModel):
1027
+ """Response model for a single task."""
1028
+
1029
+ id: str
1030
+ title: str
1031
+ status: str
1032
+ description: str = ""
1033
+ is_ready: bool = False
1034
+ is_completed: bool = False
1035
+
1036
+
1037
+ class BeadsTasksResponse(BaseModel):
1038
+ """Response model for task list."""
1039
+
1040
+ success: bool
1041
+ count: int = 0
1042
+ tasks: list[BeadsTaskResponse] = Field(default_factory=list)
1043
+ error: Optional[str] = None
1044
+
1045
+
1046
+ class BeadsStatsResponse(BaseModel):
1047
+ """Response model for Beads stats."""
1048
+
1049
+ beads_available: bool
1050
+ beads_dir: Optional[str] = None
1051
+ tasks: dict = Field(default_factory=dict)
1052
+ links: dict = Field(default_factory=dict)
1053
+ auto_outcome_enabled: bool = True
1054
+ outcome_on_cancel: bool = False
1055
+
1056
+
1057
+ # Beads adapter singleton
1058
+ _beads_adapter = None
1059
+
1060
+
1061
+ async def get_beads_adapter(engine: MemoryEngine = Depends(get_engine)):
1062
+ """Get or create the Beads adapter."""
1063
+ global _beads_adapter
1064
+ if _beads_adapter is None:
1065
+ from runtime_memory.tasks import BeadsAdapter
1066
+ _beads_adapter = BeadsAdapter(engine)
1067
+ if not _beads_adapter._initialized:
1068
+ await _beads_adapter.initialize()
1069
+ return _beads_adapter
1070
+
1071
+
1072
+ @router.post("/beads/sync", response_model=BeadsSyncResponse, tags=["Beads"])
1073
+ async def beads_sync(
1074
+ request: BeadsSyncRequest = None,
1075
+ adapter = Depends(get_beads_adapter),
1076
+ _: Optional[str] = Depends(verify_api_key),
1077
+ ):
1078
+ """Sync outcomes for completed Beads tasks.
1079
+
1080
+ When a task completes, memories that helped solve it get their outcome scores boosted.
1081
+ """
1082
+ if not adapter.is_available:
1083
+ return BeadsSyncResponse(
1084
+ success=False,
1085
+ errors=["Beads not available (no .beads/ directory found)"],
1086
+ )
1087
+
1088
+ if request and request.task_id:
1089
+ # Sync specific task
1090
+ from runtime_memory.tasks import BeadsTaskStatus
1091
+ task = adapter.get_task(request.task_id)
1092
+ if not task:
1093
+ return BeadsSyncResponse(success=False, errors=[f"Task {request.task_id} not found"])
1094
+
1095
+ if task.status == BeadsTaskStatus.DONE:
1096
+ count = await adapter.on_task_done(request.task_id)
1097
+ elif task.status == BeadsTaskStatus.CANCELLED:
1098
+ count = await adapter.on_task_cancelled(request.task_id)
1099
+ elif task.status == BeadsTaskStatus.BLOCKED:
1100
+ count = await adapter.on_task_blocked(request.task_id)
1101
+ else:
1102
+ count = 0
1103
+
1104
+ return BeadsSyncResponse(
1105
+ success=True,
1106
+ tasks_found=1,
1107
+ tasks_synced=1 if count > 0 else 0,
1108
+ outcomes_recorded=count,
1109
+ )
1110
+ else:
1111
+ # Sync all
1112
+ result = await adapter.sync()
1113
+ return BeadsSyncResponse(
1114
+ success=result.success,
1115
+ tasks_found=result.tasks_found,
1116
+ tasks_synced=result.tasks_synced,
1117
+ outcomes_recorded=result.outcomes_recorded,
1118
+ errors=result.errors,
1119
+ warnings=result.warnings,
1120
+ )
1121
+
1122
+
1123
+ @router.get("/beads/context", response_model=BeadsContextResponse, tags=["Beads"])
1124
+ async def beads_context(
1125
+ task_id: Optional[str] = Query(None, description="Task ID (uses current if not provided)"),
1126
+ limit: int = Query(10, ge=1, le=50, description="Max memories to include"),
1127
+ adapter = Depends(get_beads_adapter),
1128
+ _: Optional[str] = Depends(verify_api_key),
1129
+ ):
1130
+ """Get unified context combining task info and relevant memories."""
1131
+ if not adapter.is_available:
1132
+ return BeadsContextResponse(
1133
+ success=False,
1134
+ error="Beads not available (no .beads/ directory found)",
1135
+ )
1136
+
1137
+ context = await adapter.get_unified_context(task_id, limit)
1138
+
1139
+ if not context:
1140
+ return BeadsContextResponse(
1141
+ success=False,
1142
+ error="No task found",
1143
+ )
1144
+
1145
+ return BeadsContextResponse(
1146
+ success=True,
1147
+ task_id=context.task.id,
1148
+ task_title=context.task.title,
1149
+ task_status=context.task.status.value,
1150
+ task_description=context.task.description,
1151
+ memories_count=len(context.memories),
1152
+ formatted=context.formatted,
1153
+ )
1154
+
1155
+
1156
+ @router.post("/beads/link", response_model=BeadsLinkResponse, tags=["Beads"])
1157
+ async def beads_link(
1158
+ request: BeadsLinkRequest,
1159
+ adapter = Depends(get_beads_adapter),
1160
+ engine: MemoryEngine = Depends(get_engine),
1161
+ _: Optional[str] = Depends(verify_api_key),
1162
+ ):
1163
+ """Link a memory to a Beads task for outcome tracking."""
1164
+ if not adapter.is_available:
1165
+ return BeadsLinkResponse(
1166
+ success=False,
1167
+ error="Beads not available (no .beads/ directory found)",
1168
+ )
1169
+
1170
+ # Get task ID
1171
+ task_id = request.task_id
1172
+ if task_id:
1173
+ task = adapter.get_task(task_id)
1174
+ if not task:
1175
+ return BeadsLinkResponse(success=False, error=f"Task {task_id} not found")
1176
+ else:
1177
+ task = adapter.get_current_task()
1178
+ if not task:
1179
+ return BeadsLinkResponse(success=False, error="No current task found")
1180
+ task_id = task.id
1181
+
1182
+ # Verify memory exists
1183
+ try:
1184
+ await engine.get(request.memory_id)
1185
+ except Exception:
1186
+ return BeadsLinkResponse(success=False, error=f"Memory {request.memory_id} not found")
1187
+
1188
+ # Create link
1189
+ await adapter.link_memory_to_task(task_id, request.memory_id, request.context)
1190
+
1191
+ return BeadsLinkResponse(
1192
+ success=True,
1193
+ memory_id=request.memory_id,
1194
+ task_id=task_id,
1195
+ )
1196
+
1197
+
1198
+ @router.get("/beads/tasks", response_model=BeadsTasksResponse, tags=["Beads"])
1199
+ async def beads_tasks(
1200
+ status: Optional[str] = Query(None, description="Filter by status"),
1201
+ limit: int = Query(20, ge=1, le=100, description="Max tasks to return"),
1202
+ adapter = Depends(get_beads_adapter),
1203
+ _: Optional[str] = Depends(verify_api_key),
1204
+ ):
1205
+ """List Beads tasks with optional status filter."""
1206
+ if not adapter.is_available:
1207
+ return BeadsTasksResponse(
1208
+ success=False,
1209
+ error="Beads not available (no .beads/ directory found)",
1210
+ )
1211
+
1212
+ # Parse status filter
1213
+ status_enum = None
1214
+ if status:
1215
+ from runtime_memory.tasks import BeadsTaskStatus
1216
+ try:
1217
+ status_enum = BeadsTaskStatus(status)
1218
+ except ValueError:
1219
+ return BeadsTasksResponse(success=False, error=f"Invalid status: {status}")
1220
+
1221
+ tasks = adapter.list_tasks(status=status_enum)[:limit]
1222
+
1223
+ return BeadsTasksResponse(
1224
+ success=True,
1225
+ count=len(tasks),
1226
+ tasks=[
1227
+ BeadsTaskResponse(
1228
+ id=t.id,
1229
+ title=t.title,
1230
+ status=t.status.value,
1231
+ description=t.description[:100] if t.description else "",
1232
+ is_ready=t.is_ready,
1233
+ is_completed=t.is_completed,
1234
+ )
1235
+ for t in tasks
1236
+ ],
1237
+ )
1238
+
1239
+
1240
+ @router.get("/beads/tasks/{task_id}", response_model=BeadsTaskResponse, tags=["Beads"])
1241
+ async def beads_task_detail(
1242
+ task_id: str,
1243
+ adapter = Depends(get_beads_adapter),
1244
+ _: Optional[str] = Depends(verify_api_key),
1245
+ ):
1246
+ """Get details for a specific task."""
1247
+ if not adapter.is_available:
1248
+ raise HTTPException(
1249
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
1250
+ detail="Beads not available",
1251
+ )
1252
+
1253
+ task = adapter.get_task(task_id)
1254
+ if not task:
1255
+ raise HTTPException(
1256
+ status_code=status.HTTP_404_NOT_FOUND,
1257
+ detail=f"Task {task_id} not found",
1258
+ )
1259
+
1260
+ return BeadsTaskResponse(
1261
+ id=task.id,
1262
+ title=task.title,
1263
+ status=task.status.value,
1264
+ description=task.description,
1265
+ is_ready=task.is_ready,
1266
+ is_completed=task.is_completed,
1267
+ )
1268
+
1269
+
1270
+ @router.get("/beads/tasks/{task_id}/memories", tags=["Beads"])
1271
+ async def beads_task_memories(
1272
+ task_id: str,
1273
+ adapter = Depends(get_beads_adapter),
1274
+ _: Optional[str] = Depends(verify_api_key),
1275
+ ):
1276
+ """Get memories linked to a task."""
1277
+ if not adapter.is_available:
1278
+ raise HTTPException(
1279
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
1280
+ detail="Beads not available",
1281
+ )
1282
+
1283
+ task = adapter.get_task(task_id)
1284
+ if not task:
1285
+ raise HTTPException(
1286
+ status_code=status.HTTP_404_NOT_FOUND,
1287
+ detail=f"Task {task_id} not found",
1288
+ )
1289
+
1290
+ memories = await adapter.get_task_memories(task_id)
1291
+
1292
+ return {
1293
+ "task_id": task_id,
1294
+ "count": len(memories),
1295
+ "memories": [
1296
+ {
1297
+ "id": m.id,
1298
+ "content": m.content[:200] + "..." if len(m.content) > 200 else m.content,
1299
+ "category": m.category.value,
1300
+ "outcome_score": m.outcome_score,
1301
+ }
1302
+ for m in memories
1303
+ ],
1304
+ }
1305
+
1306
+
1307
+ @router.get("/beads/stats", response_model=BeadsStatsResponse, tags=["Beads"])
1308
+ async def beads_stats(
1309
+ adapter = Depends(get_beads_adapter),
1310
+ _: Optional[str] = Depends(verify_api_key),
1311
+ ):
1312
+ """Get Beads integration statistics."""
1313
+ stats = await adapter.get_stats()
1314
+ return BeadsStatsResponse(**stats)
1315
+
1316
+
1317
+ # -----------------------------------------------------------------------------
1318
+ # Unified Tasks Integration (Phase 7 - Claude Code Tasks Adapter)
1319
+ # -----------------------------------------------------------------------------
1320
+
1321
+
1322
+ class TaskResponse(BaseModel):
1323
+ """Response model for a unified task."""
1324
+
1325
+ id: str
1326
+ title: str
1327
+ status: str
1328
+ description: str = ""
1329
+ source: str # "beads" or "claude_code"
1330
+ is_ready: bool = False
1331
+ is_completed: bool = False
1332
+
1333
+
1334
+ class TasksListResponse(BaseModel):
1335
+ """Response model for unified task list."""
1336
+
1337
+ success: bool
1338
+ count: int = 0
1339
+ tasks: list[TaskResponse] = Field(default_factory=list)
1340
+ sources: list[str] = Field(default_factory=list)
1341
+ error: Optional[str] = None
1342
+
1343
+
1344
+ class TasksSyncRequest(BaseModel):
1345
+ """Request model for unified tasks sync."""
1346
+
1347
+ source: Optional[str] = Field(
1348
+ default=None,
1349
+ description="Source to sync: 'beads', 'claude_code', or None for all",
1350
+ )
1351
+ task_id: Optional[str] = Field(default=None, description="Sync specific task only")
1352
+
1353
+
1354
+ class TasksSyncResponse(BaseModel):
1355
+ """Response model for unified tasks sync."""
1356
+
1357
+ success: bool
1358
+ total_tasks_found: int = 0
1359
+ total_tasks_synced: int = 0
1360
+ total_outcomes_recorded: int = 0
1361
+ results: dict = Field(default_factory=dict)
1362
+ errors: list[str] = Field(default_factory=list)
1363
+
1364
+
1365
+ class TasksContextResponse(BaseModel):
1366
+ """Response model for unified tasks context."""
1367
+
1368
+ success: bool
1369
+ task_id: Optional[str] = None
1370
+ task_title: Optional[str] = None
1371
+ task_status: Optional[str] = None
1372
+ task_description: Optional[str] = None
1373
+ source: Optional[str] = None
1374
+ memories_count: int = 0
1375
+ formatted: str = ""
1376
+ error: Optional[str] = None
1377
+
1378
+
1379
+ class TasksStatsResponse(BaseModel):
1380
+ """Response model for unified tasks stats."""
1381
+
1382
+ available_sources: list[str] = Field(default_factory=list)
1383
+ beads: dict = Field(default_factory=dict)
1384
+ claude_code: dict = Field(default_factory=dict)
1385
+
1386
+
1387
+ # Unified adapter singleton
1388
+ _unified_adapter = None
1389
+
1390
+
1391
+ async def get_unified_adapter(engine: MemoryEngine = Depends(get_engine)):
1392
+ """Get or create the unified task adapter."""
1393
+ global _unified_adapter
1394
+ if _unified_adapter is None:
1395
+ from runtime_memory.tasks import UnifiedTaskAdapter
1396
+ _unified_adapter = UnifiedTaskAdapter(engine)
1397
+ if not _unified_adapter._initialized:
1398
+ await _unified_adapter.initialize()
1399
+ return _unified_adapter
1400
+
1401
+
1402
+ @router.get("/tasks", response_model=TasksListResponse, tags=["Tasks"])
1403
+ async def list_tasks(
1404
+ source: Optional[str] = Query(None, description="Filter by source: beads, claude_code"),
1405
+ task_status: Optional[str] = Query(None, description="Filter by status: pending, in_progress, done"),
1406
+ limit: int = Query(50, ge=1, le=200, description="Max tasks to return"),
1407
+ adapter = Depends(get_unified_adapter),
1408
+ _: Optional[str] = Depends(verify_api_key),
1409
+ ):
1410
+ """List tasks from all available sources.
1411
+
1412
+ Combines tasks from Beads (.beads/) and Claude Code (~/.claude/todos/).
1413
+ """
1414
+ from runtime_memory.tasks import TaskSource
1415
+
1416
+ # Parse source filter
1417
+ source_filter = None
1418
+ if source:
1419
+ try:
1420
+ source_filter = TaskSource(source)
1421
+ except ValueError:
1422
+ return TasksListResponse(
1423
+ success=False,
1424
+ error=f"Invalid source: {source}. Use 'beads' or 'claude_code'",
1425
+ )
1426
+
1427
+ tasks = adapter.list_tasks(source=source_filter, status=task_status)[:limit]
1428
+
1429
+ return TasksListResponse(
1430
+ success=True,
1431
+ count=len(tasks),
1432
+ tasks=[
1433
+ TaskResponse(
1434
+ id=t.id,
1435
+ title=t.title,
1436
+ status=t.status,
1437
+ description=t.description[:100] if t.description else "",
1438
+ source=t.source.value,
1439
+ is_ready=t.is_ready,
1440
+ is_completed=t.is_completed,
1441
+ )
1442
+ for t in tasks
1443
+ ],
1444
+ sources=[s.value for s in adapter.available_sources],
1445
+ )
1446
+
1447
+
1448
+ @router.get("/tasks/stats", response_model=TasksStatsResponse, tags=["Tasks"])
1449
+ async def get_tasks_stats(
1450
+ adapter = Depends(get_unified_adapter),
1451
+ _: Optional[str] = Depends(verify_api_key),
1452
+ ):
1453
+ """Get statistics for all task integrations."""
1454
+ stats = await adapter.get_stats()
1455
+ return TasksStatsResponse(
1456
+ available_sources=stats.get("available_sources", []),
1457
+ beads=stats.get("beads", {}),
1458
+ claude_code=stats.get("claude_code", {}),
1459
+ )
1460
+
1461
+
1462
+ @router.get("/tasks/context", response_model=TasksContextResponse, tags=["Tasks"])
1463
+ async def get_tasks_context(
1464
+ task_id: Optional[str] = Query(None, description="Task ID (uses current if not provided)"),
1465
+ source: Optional[str] = Query(None, description="Source: beads, claude_code"),
1466
+ limit: int = Query(10, ge=1, le=50, description="Max memories to include"),
1467
+ adapter = Depends(get_unified_adapter),
1468
+ _: Optional[str] = Depends(verify_api_key),
1469
+ ):
1470
+ """Get unified context combining task info and relevant memories."""
1471
+ from runtime_memory.tasks import TaskSource
1472
+
1473
+ # Parse source filter
1474
+ source_filter = None
1475
+ if source:
1476
+ try:
1477
+ source_filter = TaskSource(source)
1478
+ except ValueError:
1479
+ return TasksContextResponse(
1480
+ success=False,
1481
+ error=f"Invalid source: {source}",
1482
+ )
1483
+
1484
+ context = await adapter.get_unified_context(task_id, source_filter, limit)
1485
+
1486
+ if not context:
1487
+ return TasksContextResponse(
1488
+ success=False,
1489
+ error="No task found",
1490
+ )
1491
+
1492
+ return TasksContextResponse(
1493
+ success=True,
1494
+ task_id=context.task.id,
1495
+ task_title=context.task.title,
1496
+ task_status=context.task.status.value,
1497
+ task_description=context.task.description if hasattr(context.task, 'description') else context.task.content,
1498
+ source=context.source.value,
1499
+ memories_count=len(context.memories),
1500
+ formatted=context.formatted,
1501
+ )
1502
+
1503
+
1504
+ @router.get("/tasks/{task_id}", response_model=TaskResponse, tags=["Tasks"])
1505
+ async def get_task(
1506
+ task_id: str,
1507
+ adapter = Depends(get_unified_adapter),
1508
+ _: Optional[str] = Depends(verify_api_key),
1509
+ ):
1510
+ """Get a specific task by ID from any source."""
1511
+ task = adapter.get_task(task_id)
1512
+ if not task:
1513
+ raise HTTPException(
1514
+ status_code=status.HTTP_404_NOT_FOUND,
1515
+ detail=f"Task {task_id} not found",
1516
+ )
1517
+
1518
+ return TaskResponse(
1519
+ id=task.id,
1520
+ title=task.title,
1521
+ status=task.status,
1522
+ description=task.description,
1523
+ source=task.source.value,
1524
+ is_ready=task.is_ready,
1525
+ is_completed=task.is_completed,
1526
+ )
1527
+
1528
+
1529
+ @router.post("/tasks/sync", response_model=TasksSyncResponse, tags=["Tasks"])
1530
+ async def sync_tasks(
1531
+ request: TasksSyncRequest = None,
1532
+ adapter = Depends(get_unified_adapter),
1533
+ _: Optional[str] = Depends(verify_api_key),
1534
+ ):
1535
+ """Sync outcomes for completed tasks from all sources.
1536
+
1537
+ When a task completes, memories that helped solve it get their outcome scores boosted.
1538
+ """
1539
+ from runtime_memory.tasks import TaskSource
1540
+
1541
+ if request and request.task_id:
1542
+ # Sync specific task
1543
+ count = await adapter.on_task_completed(request.task_id)
1544
+ return TasksSyncResponse(
1545
+ success=True,
1546
+ total_tasks_found=1,
1547
+ total_tasks_synced=1 if count > 0 else 0,
1548
+ total_outcomes_recorded=count,
1549
+ )
1550
+
1551
+ # Parse source filter
1552
+ source_filter = None
1553
+ if request and request.source:
1554
+ try:
1555
+ source_filter = TaskSource(request.source)
1556
+ except ValueError:
1557
+ return TasksSyncResponse(
1558
+ success=False,
1559
+ errors=[f"Invalid source: {request.source}"],
1560
+ )
1561
+
1562
+ # Sync all or specific source
1563
+ result = await adapter.sync(source=source_filter)
1564
+
1565
+ if hasattr(result, 'results'):
1566
+ # UnifiedSyncResult
1567
+ return TasksSyncResponse(
1568
+ success=result.success,
1569
+ total_tasks_found=result.total_tasks_found,
1570
+ total_tasks_synced=result.total_tasks_synced,
1571
+ total_outcomes_recorded=result.total_outcomes_recorded,
1572
+ results={k.value: v.to_dict() for k, v in result.results.items()},
1573
+ errors=result.errors,
1574
+ )
1575
+ else:
1576
+ # Single TaskSyncResult
1577
+ return TasksSyncResponse(
1578
+ success=result.success,
1579
+ total_tasks_found=result.tasks_found,
1580
+ total_tasks_synced=result.tasks_synced,
1581
+ total_outcomes_recorded=result.outcomes_recorded,
1582
+ results={result.source.value: result.to_dict()},
1583
+ errors=result.errors,
1584
+ )
1585
+
1586
+
1587
+ @router.get("/tasks/{task_id}/memories", tags=["Tasks"])
1588
+ async def get_task_memories(
1589
+ task_id: str,
1590
+ adapter = Depends(get_unified_adapter),
1591
+ _: Optional[str] = Depends(verify_api_key),
1592
+ ):
1593
+ """Get memories linked to a task."""
1594
+ task = adapter.get_task(task_id)
1595
+ if not task:
1596
+ raise HTTPException(
1597
+ status_code=status.HTTP_404_NOT_FOUND,
1598
+ detail=f"Task {task_id} not found",
1599
+ )
1600
+
1601
+ memories = await adapter.get_task_memories(task_id, source=task.source)
1602
+
1603
+ return {
1604
+ "success": True,
1605
+ "task_id": task_id,
1606
+ "source": task.source.value,
1607
+ "count": len(memories),
1608
+ "memories": [
1609
+ {
1610
+ "id": m.id,
1611
+ "content": m.content[:200] + "..." if len(m.content) > 200 else m.content,
1612
+ "category": m.category.value,
1613
+ "outcome_score": m.outcome_score,
1614
+ }
1615
+ for m in memories
1616
+ ],
1617
+ }
1618
+
1619
+
1620
+ # =============================================================================
1621
+ # Server Runner
1622
+ # =============================================================================
1623
+
1624
+
1625
+ def run_server(
1626
+ host: str = "127.0.0.1",
1627
+ port: int = 8080,
1628
+ config: Optional[APIConfig] = None,
1629
+ engine: Optional[MemoryEngine] = None,
1630
+ ):
1631
+ """Run the REST API server.
1632
+
1633
+ Args:
1634
+ host: Host to bind to
1635
+ port: Port to listen on
1636
+ config: Optional API configuration
1637
+ engine: Optional MemoryEngine instance
1638
+ """
1639
+ import signal
1640
+
1641
+ import uvicorn
1642
+
1643
+ app = create_app(config=config, engine=engine)
1644
+
1645
+ # Configure for graceful shutdown on single Ctrl+C
1646
+ uvicorn_config = uvicorn.Config(
1647
+ app,
1648
+ host=host,
1649
+ port=port,
1650
+ log_level="warning", # Reduce noise
1651
+ timeout_graceful_shutdown=5, # Clean shutdown timeout
1652
+ )
1653
+ server = uvicorn.Server(uvicorn_config)
1654
+
1655
+ # Handle SIGINT (Ctrl+C) gracefully
1656
+ def handle_sigint(sig, frame):
1657
+ server.should_exit = True
1658
+
1659
+ signal.signal(signal.SIGINT, handle_sigint)
1660
+
1661
+ server.run()
1662
+
1663
+
1664
+ if __name__ == "__main__":
1665
+ run_server()