contextos-vault 1.5.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.
- contextos/__init__.py +3 -0
- contextos/api.py +487 -0
- contextos/auth.py +257 -0
- contextos/cache_layer.py +135 -0
- contextos/chunker.py +153 -0
- contextos/cli.py +2302 -0
- contextos/compressor.py +91 -0
- contextos/config.py +145 -0
- contextos/connectors/__init__.py +28 -0
- contextos/connectors/base.py +119 -0
- contextos/connectors/github.py +220 -0
- contextos/connectors/json_source.py +224 -0
- contextos/connectors/openapi.py +275 -0
- contextos/dashboard.py +402 -0
- contextos/embedder.py +153 -0
- contextos/evaluator.py +221 -0
- contextos/graph.py +196 -0
- contextos/ingestors/__init__.py +64 -0
- contextos/ingestors/docx.py +128 -0
- contextos/ingestors/pdf.py +72 -0
- contextos/ingestors/pptx.py +128 -0
- contextos/logger.py +244 -0
- contextos/mcp_server.py +478 -0
- contextos/memory.py +234 -0
- contextos/plugins.py +190 -0
- contextos/py.typed +0 -0
- contextos/retrieval.py +275 -0
- contextos/scaffolder.py +166 -0
- contextos/schema.py +189 -0
- contextos/session.py +299 -0
- contextos/store.py +448 -0
- contextos/symbols.py +223 -0
- contextos/templates/__init__.py +0 -0
- contextos/templates/api-first/architecture/api.md +47 -0
- contextos/templates/default/architecture/overview.md +33 -0
- contextos/templates/default/context/current.md +35 -0
- contextos/templates/default/decisions/ADR-001-example.md +36 -0
- contextos/templates/default/domain/entity.md +32 -0
- contextos/templates/default/product/vision.md +32 -0
- contextos/templates/default/workflows/example-flow.md +35 -0
- contextos/templates/microservice/architecture/service.md +44 -0
- contextos/ui.py +155 -0
- contextos/vault.py +314 -0
- contextos/watcher.py +222 -0
- contextos_vault-1.5.0.dist-info/METADATA +1031 -0
- contextos_vault-1.5.0.dist-info/RECORD +50 -0
- contextos_vault-1.5.0.dist-info/WHEEL +5 -0
- contextos_vault-1.5.0.dist-info/entry_points.txt +2 -0
- contextos_vault-1.5.0.dist-info/licenses/LICENSE +21 -0
- contextos_vault-1.5.0.dist-info/top_level.txt +1 -0
contextos/__init__.py
ADDED
contextos/api.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ContextOS api.py — FastAPI server.
|
|
3
|
+
Binds EXCLUSIVELY to 127.0.0.1 — never 0.0.0.0.
|
|
4
|
+
All endpoints (except /health) require Authorization: Bearer ctx_<token>
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from fastapi import FastAPI, HTTPException, Depends, Query, Request, Response
|
|
17
|
+
from fastapi.responses import JSONResponse
|
|
18
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
19
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
20
|
+
|
|
21
|
+
from contextos.schema import (
|
|
22
|
+
SearchRequest, SearchResponse, ContextRequest, ContextResponse,
|
|
23
|
+
HealthResponse, DocumentType, TokenScope,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# App factory — lazy-init heavy objects on first request
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
_embedder = None
|
|
33
|
+
_store = None
|
|
34
|
+
_graph_builder = None
|
|
35
|
+
_config = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_config():
|
|
39
|
+
global _config
|
|
40
|
+
if _config is None:
|
|
41
|
+
from contextos.config import load_config
|
|
42
|
+
_config = load_config()
|
|
43
|
+
return _config
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_embedder():
|
|
47
|
+
global _embedder
|
|
48
|
+
if _embedder is None:
|
|
49
|
+
from contextos.embedder import Embedder
|
|
50
|
+
cfg = get_config()
|
|
51
|
+
_embedder = Embedder(cfg.embeddings_dir)
|
|
52
|
+
return _embedder
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_store():
|
|
56
|
+
global _store
|
|
57
|
+
if _store is None:
|
|
58
|
+
from contextos.store import VectorStore
|
|
59
|
+
cfg = get_config()
|
|
60
|
+
_store = VectorStore(cfg.lancedb_dir)
|
|
61
|
+
return _store
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_graph():
|
|
65
|
+
global _graph_builder
|
|
66
|
+
if _graph_builder is None:
|
|
67
|
+
from contextos.graph import GraphBuilder
|
|
68
|
+
cfg = get_config()
|
|
69
|
+
_graph_builder = GraphBuilder()
|
|
70
|
+
_graph_builder.load(cfg.graph_dir)
|
|
71
|
+
return _graph_builder
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# FastAPI app
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
app = FastAPI(
|
|
79
|
+
title="ContextOS",
|
|
80
|
+
description="Local-first knowledge OS for AI coding agents",
|
|
81
|
+
version="1.3.0-rc1",
|
|
82
|
+
docs_url="/docs",
|
|
83
|
+
redoc_url=None,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Only allow localhost origins
|
|
87
|
+
app.add_middleware(
|
|
88
|
+
CORSMiddleware,
|
|
89
|
+
allow_origins=["http://127.0.0.1", "http://localhost"],
|
|
90
|
+
allow_methods=["*"],
|
|
91
|
+
allow_headers=["*"],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
security = HTTPBearer(auto_error=False)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Request ID + logging middleware
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
@app.middleware("http")
|
|
102
|
+
async def request_middleware(request: Request, call_next):
|
|
103
|
+
from contextos.logger import get_logger, new_request_id
|
|
104
|
+
request_id = new_request_id()
|
|
105
|
+
request.state.request_id = request_id
|
|
106
|
+
request.state.start_time = time.time()
|
|
107
|
+
|
|
108
|
+
response = await call_next(request)
|
|
109
|
+
|
|
110
|
+
latency_ms = int((time.time() - request.state.start_time) * 1000)
|
|
111
|
+
response.headers["X-Request-ID"] = request_id
|
|
112
|
+
response.headers["X-Latency-MS"] = str(latency_ms)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
cfg = get_config()
|
|
116
|
+
from contextos.logger import get_logger
|
|
117
|
+
logger = get_logger(cfg.logs_dir)
|
|
118
|
+
logger.log_request(
|
|
119
|
+
request_id = request_id,
|
|
120
|
+
endpoint = request.url.path,
|
|
121
|
+
method = request.method,
|
|
122
|
+
latency_ms = latency_ms,
|
|
123
|
+
token_id = None,
|
|
124
|
+
status_code = response.status_code,
|
|
125
|
+
)
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
import logging as _logging
|
|
128
|
+
_logging.getLogger(__name__).debug("Request logging failed: %s", exc)
|
|
129
|
+
|
|
130
|
+
return response
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def require_token(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)):
|
|
134
|
+
"""Validate Bearer token. Raises 401 if missing/invalid, 403 if expired, 429 if rate limited."""
|
|
135
|
+
if credentials is None:
|
|
136
|
+
raise HTTPException(status_code=401, detail="Authorization header required")
|
|
137
|
+
|
|
138
|
+
from contextos.auth import validate_token, check_rate_limit
|
|
139
|
+
cfg = get_config()
|
|
140
|
+
token = validate_token(credentials.credentials, cfg.tokens_dir)
|
|
141
|
+
|
|
142
|
+
if token is None:
|
|
143
|
+
raise HTTPException(status_code=401, detail="Invalid or revoked token")
|
|
144
|
+
|
|
145
|
+
if token.is_expired():
|
|
146
|
+
raise HTTPException(status_code=403, detail="Token has expired")
|
|
147
|
+
|
|
148
|
+
if not check_rate_limit(token, cfg.tokens_dir):
|
|
149
|
+
raise HTTPException(
|
|
150
|
+
status_code=429,
|
|
151
|
+
detail="Rate limit exceeded — 1000 req/min",
|
|
152
|
+
headers={"Retry-After": "60"},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return token
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def require_scope(required: TokenScope):
|
|
159
|
+
"""Dependency factory: enforce a minimum token scope."""
|
|
160
|
+
def _check(token=Depends(require_token)):
|
|
161
|
+
if not token.has_scope(required):
|
|
162
|
+
raise HTTPException(
|
|
163
|
+
status_code=403,
|
|
164
|
+
detail=f"Insufficient scope. Required: {required.value}, "
|
|
165
|
+
f"token has: {token.scope.value if token.scope else 'none'}"
|
|
166
|
+
)
|
|
167
|
+
return token
|
|
168
|
+
return _check
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# Endpoints
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
@app.get("/health", response_model=HealthResponse)
|
|
176
|
+
def health(deep: bool = Query(False, description="Run a live search to verify end-to-end")):
|
|
177
|
+
"""Health check. ?deep=true runs a sample search to verify retrieval works."""
|
|
178
|
+
store = get_store()
|
|
179
|
+
doc_count = store.count_documents()
|
|
180
|
+
|
|
181
|
+
if deep:
|
|
182
|
+
# End-to-end verification
|
|
183
|
+
try:
|
|
184
|
+
embedder = get_embedder()
|
|
185
|
+
qv = embedder.embed_query("health check")
|
|
186
|
+
results = store.search(qv, limit=1)
|
|
187
|
+
retrieval_ok = True
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
logger.warning("Deep health check failed: %s", exc)
|
|
190
|
+
retrieval_ok = False
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
"status": "ok" if retrieval_ok else "degraded",
|
|
194
|
+
"indexed": doc_count,
|
|
195
|
+
"version": "1.3.0-rc1",
|
|
196
|
+
"retrieval_ok": retrieval_ok,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return HealthResponse(status="ok", indexed=doc_count, version="1.3.0-rc1")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.get("/metrics")
|
|
203
|
+
def metrics(_token=Depends(require_scope(TokenScope.read))):
|
|
204
|
+
"""Return request metrics: total_requests, avg_latency_ms, cache stats."""
|
|
205
|
+
from contextos.logger import get_logger
|
|
206
|
+
from contextos.cache_layer import get_cache
|
|
207
|
+
cfg = get_config()
|
|
208
|
+
log_metrics = get_logger(cfg.logs_dir).get_metrics()
|
|
209
|
+
cache_stats = get_cache().stats()
|
|
210
|
+
return {**log_metrics, "cache": cache_stats}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@app.get("/audit")
|
|
214
|
+
def audit(
|
|
215
|
+
limit: int = Query(50, le=500),
|
|
216
|
+
_token=Depends(require_scope(TokenScope.admin)),
|
|
217
|
+
):
|
|
218
|
+
"""Return recent audit log entries. Requires admin scope."""
|
|
219
|
+
from contextos.logger import get_logger
|
|
220
|
+
cfg = get_config()
|
|
221
|
+
return {"entries": get_logger(cfg.logs_dir).read_audit(limit=limit)}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@app.post("/search", response_model=SearchResponse)
|
|
225
|
+
def search(
|
|
226
|
+
request: SearchRequest,
|
|
227
|
+
session_id: Optional[str] = None,
|
|
228
|
+
_token=Depends(require_scope(TokenScope.read)),
|
|
229
|
+
):
|
|
230
|
+
"""Primary retrieval endpoint. Agents call this to find relevant document chunks."""
|
|
231
|
+
from contextos.retrieval import search as do_search
|
|
232
|
+
|
|
233
|
+
embedder = get_embedder()
|
|
234
|
+
store = get_store()
|
|
235
|
+
graph = get_graph() if request.include_graph else None
|
|
236
|
+
|
|
237
|
+
result = do_search(
|
|
238
|
+
query=request.query,
|
|
239
|
+
embedder=embedder,
|
|
240
|
+
store=store,
|
|
241
|
+
graph_builder=graph,
|
|
242
|
+
project=request.project or None,
|
|
243
|
+
type_filter=request.type.value if request.type else None,
|
|
244
|
+
domain_filter=request.domain,
|
|
245
|
+
limit=request.limit,
|
|
246
|
+
include_graph=request.include_graph,
|
|
247
|
+
use_hybrid=request.use_hybrid,
|
|
248
|
+
hybrid_alpha=request.hybrid_alpha,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Log to session if provided
|
|
252
|
+
if session_id:
|
|
253
|
+
try:
|
|
254
|
+
from contextos.session import log_search
|
|
255
|
+
cfg = get_config()
|
|
256
|
+
log_search(cfg.contextos_dir / "sessions", session_id,
|
|
257
|
+
request.query, len(result.results))
|
|
258
|
+
except Exception:
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@app.post("/context", response_model=ContextResponse)
|
|
265
|
+
def context(
|
|
266
|
+
request: ContextRequest,
|
|
267
|
+
session_id: Optional[str] = None,
|
|
268
|
+
_token=Depends(require_scope(TokenScope.read)),
|
|
269
|
+
):
|
|
270
|
+
"""Assemble a ready-to-paste context block. Cached for 5 minutes per query."""
|
|
271
|
+
from contextos.retrieval import assemble_context
|
|
272
|
+
from contextos.cache_layer import get_cache
|
|
273
|
+
|
|
274
|
+
cache = get_cache()
|
|
275
|
+
cache_key = cache.make_key(request.query, request.project, request.max_tokens)
|
|
276
|
+
|
|
277
|
+
# Try cache first
|
|
278
|
+
cached = cache.get(cache_key)
|
|
279
|
+
if cached is not None:
|
|
280
|
+
if session_id:
|
|
281
|
+
try:
|
|
282
|
+
from contextos.session import log_context
|
|
283
|
+
cfg = get_config()
|
|
284
|
+
log_context(cfg.contextos_dir / "sessions", session_id,
|
|
285
|
+
request.query, cached.token_estimate)
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
return cached
|
|
289
|
+
|
|
290
|
+
embedder = get_embedder()
|
|
291
|
+
store = get_store()
|
|
292
|
+
graph = get_graph()
|
|
293
|
+
|
|
294
|
+
result = assemble_context(
|
|
295
|
+
query=request.query,
|
|
296
|
+
embedder=embedder,
|
|
297
|
+
store=store,
|
|
298
|
+
graph_builder=graph,
|
|
299
|
+
project=request.project or None,
|
|
300
|
+
max_tokens=request.max_tokens,
|
|
301
|
+
priority_order=request.priority_order,
|
|
302
|
+
use_hybrid=getattr(request, 'use_hybrid', True),
|
|
303
|
+
hybrid_alpha=getattr(request, 'hybrid_alpha', 0.7),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Store in cache
|
|
307
|
+
cache.set(cache_key, result)
|
|
308
|
+
|
|
309
|
+
if session_id:
|
|
310
|
+
try:
|
|
311
|
+
from contextos.session import log_context
|
|
312
|
+
cfg = get_config()
|
|
313
|
+
log_context(cfg.contextos_dir / "sessions", session_id,
|
|
314
|
+
request.query, result.token_estimate)
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
return result
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@app.get("/graph")
|
|
322
|
+
def graph_endpoint(_token=Depends(require_scope(TokenScope.read))):
|
|
323
|
+
"""Return the full knowledge graph as nodes and edges."""
|
|
324
|
+
graph_builder = get_graph()
|
|
325
|
+
cfg = get_config()
|
|
326
|
+
|
|
327
|
+
graph_path = cfg.graph_dir / "graph.json"
|
|
328
|
+
if not graph_path.exists():
|
|
329
|
+
return {"nodes": [], "edges": [], "summary": {"nodes": 0, "edges": 0}}
|
|
330
|
+
|
|
331
|
+
with open(graph_path, "r", encoding="utf-8") as f:
|
|
332
|
+
data = json.load(f)
|
|
333
|
+
|
|
334
|
+
summary = graph_builder.get_summary()
|
|
335
|
+
data["summary"] = summary
|
|
336
|
+
return data
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@app.get("/documents")
|
|
340
|
+
def list_documents(
|
|
341
|
+
project: Optional[str] = Query(None),
|
|
342
|
+
type: Optional[str] = Query(None),
|
|
343
|
+
domain: Optional[str] = Query(None),
|
|
344
|
+
status: Optional[str] = Query(None),
|
|
345
|
+
_token=Depends(require_scope(TokenScope.read)),
|
|
346
|
+
):
|
|
347
|
+
"""List all indexed documents with optional filters."""
|
|
348
|
+
store = get_store()
|
|
349
|
+
docs = store.list_documents(
|
|
350
|
+
project=project,
|
|
351
|
+
type_filter=type,
|
|
352
|
+
domain_filter=domain,
|
|
353
|
+
status_filter=status,
|
|
354
|
+
)
|
|
355
|
+
return {"documents": docs, "count": len(docs)}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
# Watcher status
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
@app.get("/watcher")
|
|
363
|
+
def watcher_status_endpoint(_token=Depends(require_scope(TokenScope.read))):
|
|
364
|
+
"""Return live watch mode status."""
|
|
365
|
+
try:
|
|
366
|
+
from contextos.watcher import watcher_status
|
|
367
|
+
return watcher_status()
|
|
368
|
+
except Exception:
|
|
369
|
+
return {"active": False}
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
# Session endpoints
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
@app.post("/session/start")
|
|
377
|
+
def session_start_ep(
|
|
378
|
+
name: Optional[str] = None,
|
|
379
|
+
_token=Depends(require_scope(TokenScope.read)),
|
|
380
|
+
):
|
|
381
|
+
"""Start a new agent session."""
|
|
382
|
+
from contextos.session import create_session
|
|
383
|
+
cfg = get_config()
|
|
384
|
+
session = create_session(cfg.contextos_dir / "sessions", name)
|
|
385
|
+
return {"session_id": session["id"], "name": session["name"], "started_at": session["started_at"]}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@app.post("/session/{session_id}/event")
|
|
389
|
+
def session_event_ep(
|
|
390
|
+
session_id: str,
|
|
391
|
+
event_type: str,
|
|
392
|
+
payload: dict,
|
|
393
|
+
_token=Depends(require_scope(TokenScope.read)),
|
|
394
|
+
):
|
|
395
|
+
"""Log an event to an active session."""
|
|
396
|
+
from contextos.session import add_event
|
|
397
|
+
cfg = get_config()
|
|
398
|
+
success = add_event(cfg.contextos_dir / "sessions", session_id, event_type, payload)
|
|
399
|
+
if not success:
|
|
400
|
+
raise HTTPException(status_code=404, detail=f"Session {session_id} not found or ended")
|
|
401
|
+
return {"ok": True}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@app.post("/session/{session_id}/end")
|
|
405
|
+
def session_end_ep(session_id: str, _token=Depends(require_scope(TokenScope.read))):
|
|
406
|
+
"""End a session and generate summary."""
|
|
407
|
+
from contextos.session import end_session
|
|
408
|
+
cfg = get_config()
|
|
409
|
+
try:
|
|
410
|
+
session = end_session(cfg.contextos_dir / "sessions", session_id)
|
|
411
|
+
return {"session_id": session_id, "summary": session.get("summary", {})}
|
|
412
|
+
except ValueError as exc:
|
|
413
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@app.get("/session/last")
|
|
417
|
+
def session_last_ep(_token=Depends(require_scope(TokenScope.read))):
|
|
418
|
+
"""Return the most recent completed session summary."""
|
|
419
|
+
from contextos.session import get_last_session
|
|
420
|
+
cfg = get_config()
|
|
421
|
+
session = get_last_session(cfg.contextos_dir / "sessions")
|
|
422
|
+
return {"session": session}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@app.get("/session/active")
|
|
426
|
+
def session_active_ep(_token=Depends(require_scope(TokenScope.read))):
|
|
427
|
+
"""Return the currently active session, if any."""
|
|
428
|
+
from contextos.session import get_active_session
|
|
429
|
+
cfg = get_config()
|
|
430
|
+
return {"session": get_active_session(cfg.contextos_dir / "sessions")}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# ---------------------------------------------------------------------------
|
|
434
|
+
# Pull endpoint
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
@app.post("/pull")
|
|
438
|
+
def pull_ep(
|
|
439
|
+
connector: str,
|
|
440
|
+
source: Optional[str] = None,
|
|
441
|
+
project: Optional[str] = None,
|
|
442
|
+
pull_type: Optional[str] = None,
|
|
443
|
+
force: bool = False,
|
|
444
|
+
_token=Depends(require_scope(TokenScope.read)),
|
|
445
|
+
):
|
|
446
|
+
"""Pull external data from a connector into the output directory."""
|
|
447
|
+
from contextos.connectors import CONNECTORS
|
|
448
|
+
cfg = get_config()
|
|
449
|
+
conn_cls = CONNECTORS.get(connector.lower())
|
|
450
|
+
if not conn_cls:
|
|
451
|
+
raise HTTPException(status_code=400, detail=f"Unknown connector: {connector}")
|
|
452
|
+
proj = project or cfg.project_name
|
|
453
|
+
conn_config: dict = {}
|
|
454
|
+
if source: conn_config["source"] = source; conn_config["repo"] = source
|
|
455
|
+
if pull_type: conn_config["type"] = pull_type
|
|
456
|
+
conn = conn_cls(project=proj, config=conn_config)
|
|
457
|
+
out_dir = cfg.contextos_dir / "pulled" / connector / proj
|
|
458
|
+
try:
|
|
459
|
+
return conn.pull(out_dir, force=force)
|
|
460
|
+
except Exception as exc:
|
|
461
|
+
raise HTTPException(status_code=500, detail=str(exc))
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# ---------------------------------------------------------------------------
|
|
465
|
+
# Server startup
|
|
466
|
+
# ---------------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
def create_app() -> FastAPI:
|
|
469
|
+
"""Return the FastAPI app instance."""
|
|
470
|
+
return app
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def run_server(port: int = 8080):
|
|
474
|
+
"""
|
|
475
|
+
Start uvicorn server. ALWAYS binds to 127.0.0.1.
|
|
476
|
+
Never binds to 0.0.0.0 — this is enforced here and not configurable.
|
|
477
|
+
"""
|
|
478
|
+
import uvicorn
|
|
479
|
+
|
|
480
|
+
logger.info("Starting ContextOS API on http://127.0.0.1:%d", port)
|
|
481
|
+
uvicorn.run(
|
|
482
|
+
"contextos.api:app",
|
|
483
|
+
host="127.0.0.1", # HARDCODED — never 0.0.0.0
|
|
484
|
+
port=port,
|
|
485
|
+
log_level="warning",
|
|
486
|
+
reload=False,
|
|
487
|
+
)
|