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.
Files changed (50) hide show
  1. contextos/__init__.py +3 -0
  2. contextos/api.py +487 -0
  3. contextos/auth.py +257 -0
  4. contextos/cache_layer.py +135 -0
  5. contextos/chunker.py +153 -0
  6. contextos/cli.py +2302 -0
  7. contextos/compressor.py +91 -0
  8. contextos/config.py +145 -0
  9. contextos/connectors/__init__.py +28 -0
  10. contextos/connectors/base.py +119 -0
  11. contextos/connectors/github.py +220 -0
  12. contextos/connectors/json_source.py +224 -0
  13. contextos/connectors/openapi.py +275 -0
  14. contextos/dashboard.py +402 -0
  15. contextos/embedder.py +153 -0
  16. contextos/evaluator.py +221 -0
  17. contextos/graph.py +196 -0
  18. contextos/ingestors/__init__.py +64 -0
  19. contextos/ingestors/docx.py +128 -0
  20. contextos/ingestors/pdf.py +72 -0
  21. contextos/ingestors/pptx.py +128 -0
  22. contextos/logger.py +244 -0
  23. contextos/mcp_server.py +478 -0
  24. contextos/memory.py +234 -0
  25. contextos/plugins.py +190 -0
  26. contextos/py.typed +0 -0
  27. contextos/retrieval.py +275 -0
  28. contextos/scaffolder.py +166 -0
  29. contextos/schema.py +189 -0
  30. contextos/session.py +299 -0
  31. contextos/store.py +448 -0
  32. contextos/symbols.py +223 -0
  33. contextos/templates/__init__.py +0 -0
  34. contextos/templates/api-first/architecture/api.md +47 -0
  35. contextos/templates/default/architecture/overview.md +33 -0
  36. contextos/templates/default/context/current.md +35 -0
  37. contextos/templates/default/decisions/ADR-001-example.md +36 -0
  38. contextos/templates/default/domain/entity.md +32 -0
  39. contextos/templates/default/product/vision.md +32 -0
  40. contextos/templates/default/workflows/example-flow.md +35 -0
  41. contextos/templates/microservice/architecture/service.md +44 -0
  42. contextos/ui.py +155 -0
  43. contextos/vault.py +314 -0
  44. contextos/watcher.py +222 -0
  45. contextos_vault-1.5.0.dist-info/METADATA +1031 -0
  46. contextos_vault-1.5.0.dist-info/RECORD +50 -0
  47. contextos_vault-1.5.0.dist-info/WHEEL +5 -0
  48. contextos_vault-1.5.0.dist-info/entry_points.txt +2 -0
  49. contextos_vault-1.5.0.dist-info/licenses/LICENSE +21 -0
  50. contextos_vault-1.5.0.dist-info/top_level.txt +1 -0
contextos/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ContextOS — Local-first knowledge OS for AI coding agents."""
2
+
3
+ __version__ = "1.5.0"
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
+ )