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.
- runtime_memory/__init__.py +28 -0
- runtime_memory/claude_code/__init__.py +48 -0
- runtime_memory/claude_code/commands.py +698 -0
- runtime_memory/claude_code/daemon.py +852 -0
- runtime_memory/claude_code/hooks.py +722 -0
- runtime_memory/cli/__init__.py +8 -0
- runtime_memory/cli/main.py +1936 -0
- runtime_memory/core/__init__.py +216 -0
- runtime_memory/core/config.py +473 -0
- runtime_memory/core/embeddings.py +908 -0
- runtime_memory/core/engine.py +1007 -0
- runtime_memory/core/exceptions.py +547 -0
- runtime_memory/core/legacy_env.py +39 -0
- runtime_memory/core/logging.py +160 -0
- runtime_memory/core/models.py +1051 -0
- runtime_memory/core/observability.py +725 -0
- runtime_memory/core/paths.py +30 -0
- runtime_memory/core/resilience.py +511 -0
- runtime_memory/core/retrieval.py +819 -0
- runtime_memory/core/storage.py +1105 -0
- runtime_memory/extraction/__init__.py +36 -0
- runtime_memory/extraction/extractor.py +1143 -0
- runtime_memory/hermes/__init__.py +39 -0
- runtime_memory/hermes/_base.py +154 -0
- runtime_memory/hermes/bridge.py +119 -0
- runtime_memory/hermes/plugin.yaml +13 -0
- runtime_memory/hermes/provider.py +536 -0
- runtime_memory/hermes/tools.py +230 -0
- runtime_memory/hermes/trace.py +177 -0
- runtime_memory/plugin/__init__.py +646 -0
- runtime_memory/sdk/__init__.py +97 -0
- runtime_memory/sdk/client.py +1577 -0
- runtime_memory/server/__init__.py +75 -0
- runtime_memory/server/api.py +1665 -0
- runtime_memory/server/mcp.py +1574 -0
- runtime_memory/server/static/css/styles.css +1110 -0
- runtime_memory/server/static/index.html +264 -0
- runtime_memory/server/static/js/api.js +294 -0
- runtime_memory/server/static/js/app.js +771 -0
- runtime_memory/tasks/__init__.py +114 -0
- runtime_memory/tasks/adapter.py +501 -0
- runtime_memory/tasks/claude_code_adapter.py +495 -0
- runtime_memory/tasks/claude_code_parser.py +339 -0
- runtime_memory/tasks/cli_bridge.py +415 -0
- runtime_memory/tasks/linking.py +397 -0
- runtime_memory/tasks/models.py +520 -0
- runtime_memory/tasks/outcomes.py +320 -0
- runtime_memory/tasks/parser.py +305 -0
- runtime_memory/tasks/unified_adapter.py +661 -0
- runtime_memory-3.0.0.dist-info/METADATA +497 -0
- runtime_memory-3.0.0.dist-info/RECORD +54 -0
- runtime_memory-3.0.0.dist-info/WHEEL +4 -0
- runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
- 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()
|