"""Minimal v1 REST surface over the engine — stdlib only, no web framework. The package stays dependency-light (numpy-only core), so the server is built on ``http.server`` rather than FastAPI. It exposes the SDK 1:1 as JSON over HTTP. The centerpiece is ``POST /v1/inspect``: it returns the full explainable contract — routing scores, selected vs. unselected experts, kept and dropped items with their score breakdown, and the final prompt-ready pack — which the Context Console UI and the future MCP server both build on. Endpoints (all under ``/v1``): GET /v1/health liveness + store size GET /v1/experts the typed expert taxonomy + seed descriptions GET /v1/items list stored items (?scope=&expert= filters) POST /v1/remember write an item POST /v1/inspect explainable routed pack (the contract) POST /v1/pack prompt-ready context pack POST /v1/forget delete an item by id The dispatch is a pure function ``dispatch(service, method, path, body, query)`` returning ``(status, payload)`` so the contract is testable without sockets. """ from __future__ import annotations import json from functools import lru_cache from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path from typing import Dict, Optional, Tuple from urllib.parse import parse_qs, urlparse from ... import CONTRACT_VERSION, __version__ from ...manager import ContextManager, _item_dict from ...routing.experts import EXPERT_DESCRIPTIONS from ...schema.enums import EXPERTS API_PREFIX = "/v1" _INSPECTOR_HTML = Path(__file__).parent / "inspector.html" _CONSOLE_DIR = Path(__file__).parent / "console" _CONTENT_TYPES = {".html": "text/html; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".json": "application/json", ".png": "image/png", ".ico": "image/x-icon"} @lru_cache(maxsize=1) def inspector_html() -> str: """The bundled single-file Context Inspector UI (served at / and /ui).""" return _INSPECTOR_HTML.read_text(encoding="utf-8") def console_asset(name: str) -> Optional[Tuple[bytes, str]]: """Read a bundled Console asset by name (path-traversal safe). Returns ``(bytes, content_type)`` or ``None`` if it is not a known file. The Console is a zero-dependency, same-origin SPA served at /console. """ name = (name or "").lstrip("/") or "index.html" if "/" in name or "\\" in name or name.startswith("."): return None # no nested paths / traversal in Phase 0 path = _CONSOLE_DIR / name if not path.is_file() or path.parent != _CONSOLE_DIR: return None ctype = _CONTENT_TYPES.get(path.suffix, "application/octet-stream") return path.read_bytes(), ctype class HttpError(Exception): """Raised by handlers to return a specific status + message.""" def __init__(self, status: int, message: str): super().__init__(message) self.status = status self.message = message def _need(body: dict, key: str, typ=str): if not isinstance(body, dict) or key not in body: raise HttpError(400, f"missing required field: {key!r}") val = body[key] if not isinstance(val, typ): raise HttpError(400, f"field {key!r} must be {typ.__name__}") return val class RestService: """Pure request handlers over a ContextManager — no HTTP concerns.""" def __init__(self, manager: ContextManager): self.m = manager # ---- GET -------------------------------------------------------------- def health(self) -> dict: return {"status": "ok", "name": self.m.config.name, "version": __version__, "items": len(self.m.store.all_items())} def experts(self) -> dict: return {"experts": [{"name": e, "description": EXPERT_DESCRIPTIONS.get(e, "")} for e in EXPERTS]} def list_items(self, query: Dict[str, list]) -> dict: scope = (query.get("scope") or [None])[0] expert = (query.get("expert") or [None])[0] items = self.m.items(scope=scope, expert=expert) return {"items": [_item_dict(it) for it in items], "count": len(items)} def version(self) -> dict: return {"contract_version": CONTRACT_VERSION, "implementation": "matrix-context", "implementation_version": __version__, "name": self.m.config.name} def scopes(self) -> dict: """Discover the scope hierarchy present in the store.""" scopes = sorted({it.scope for it in self.m.store.all_items()}) return {"scopes": scopes, "count": len(scopes)} def get_item(self, item_id: str) -> dict: it = self.m.store.get(item_id) if it is None: raise HttpError(404, f"item not found: {item_id}") return {"item": _item_dict(it)} # ---- POST ------------------------------------------------------------- def remember(self, body: dict) -> dict: content = _need(body, "content") it = self.m.remember( content, expert=body.get("expert", "semantic"), scope=body.get("scope", "/"), importance=float(body.get("importance", 0.5)), tags=tuple(body.get("tags", ()) or ()), ttl=body.get("ttl"), ) return {"item": _item_dict(it)} def inspect(self, body: dict) -> dict: return self.m.build_inspection( _need(body, "query"), scope=body.get("scope", "/"), top_experts=int(body.get("top_experts", self.m.DEFAULT_TOP_EXPERTS)), max_tokens=int(body.get("max_tokens", 600)), pin_experts=tuple(body.get("pin_experts", ()) or ()), ) def pack(self, body: dict) -> dict: pk = self.m.build_pack( _need(body, "query"), scope=body.get("scope", "/"), top_experts=int(body.get("top_experts", self.m.DEFAULT_TOP_EXPERTS)), max_tokens=int(body.get("max_tokens", 600)), pin_experts=tuple(body.get("pin_experts", ()) or ()), ) return { "tokens": pk.tokens, "selected_experts": pk.selected_experts, "routing_reason": pk.routing_reason, "citations": pk.citations, "prompt": pk.to_prompt(), "items": [{"id": p.item.id, "expert": p.item.expert, "content": p.item.content} for p in pk.items], } def router_explain(self, body: dict) -> dict: """Routing decision only (the essential inspectability differentiator): selected vs. unselected experts and per-expert scores.""" ins = self.m.build_inspection( _need(body, "query"), scope=body.get("scope", "/"), top_experts=int(body.get("top_experts", self.m.DEFAULT_TOP_EXPERTS)), max_tokens=int(body.get("max_tokens", 600)), pin_experts=tuple(body.get("pin_experts", ()) or ()), ) r = ins["routing"] return { "query": ins["query"], "selected_experts": r["selected_experts"], "unselected_experts": r["unselected_experts"], "scores": [{"expert": e, "score": s} for e, s in sorted(r["scores"].items(), key=lambda x: -x[1])], "widened": r["widened"], "reason": r["reason"], } def forget(self, body: dict) -> dict: item_id = _need(body, "id") return {"id": item_id, "deleted": self.m.forget(item_id)} # Routing tables: path -> handler name. _GET = { f"{API_PREFIX}/health": "health", f"{API_PREFIX}/version": "version", f"{API_PREFIX}/experts": "experts", f"{API_PREFIX}/scopes": "scopes", f"{API_PREFIX}/items": "list_items", } _POST = { f"{API_PREFIX}/remember": "remember", f"{API_PREFIX}/recall": "inspect", # recall is inspect's routed candidates f"{API_PREFIX}/inspect": "inspect", f"{API_PREFIX}/pack": "pack", f"{API_PREFIX}/router/explain": "router_explain", f"{API_PREFIX}/forget": "forget", } _ALL_PATHS = set(_GET) | set(_POST) _ITEM_PREFIX = f"{API_PREFIX}/items/" def dispatch(service: RestService, method: str, path: str, body: Optional[dict] = None, query: Optional[Dict[str, list]] = None) -> Tuple[int, dict]: """Pure router: map a request to a handler and return (status, payload).""" method = method.upper() try: if method == "GET" and path in _GET: name = _GET[path] handler = getattr(service, name) return 200, (handler(query or {}) if name == "list_items" else handler()) # GET /v1/items/{id} if path.startswith(_ITEM_PREFIX) and len(path) > len(_ITEM_PREFIX): if method != "GET": return 405, {"error": f"method {method} not allowed for {path}"} return 200, service.get_item(path[len(_ITEM_PREFIX):]) if method == "POST" and path in _POST: status = 201 if path == f"{API_PREFIX}/remember" else 200 return status, getattr(service, _POST[path])(body or {}) if path in _ALL_PATHS: return 405, {"error": f"method {method} not allowed for {path}"} return 404, {"error": f"not found: {path}"} except HttpError as e: return e.status, {"error": e.message} except (ValueError, TypeError) as e: return 400, {"error": str(e)} def _make_handler(service: RestService): class Handler(BaseHTTPRequestHandler): server_version = "matrix-context/" + __version__ def log_message(self, *a): # quiet by default pass def _send(self, status: int, payload: dict): data = json.dumps(payload).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def _send_html(self, html: str): data = html.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def _send_bytes(self, data: bytes, content_type: str): self.send_response(200) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def _read_body(self) -> dict: length = int(self.headers.get("Content-Length") or 0) if not length: return {} raw = self.rfile.read(length) try: parsed = json.loads(raw or b"{}") except json.JSONDecodeError: raise HttpError(400, "invalid JSON body") if not isinstance(parsed, dict): raise HttpError(400, "JSON body must be an object") return parsed def do_GET(self): parsed = urlparse(self.path) if parsed.path in ("/", "/ui", "/inspector"): self._send_html(inspector_html()) return if parsed.path == "/favicon.ico": self._send(204, {}) return # Context Console (same-origin SPA): /console and /console/ if parsed.path == "/console" or parsed.path == "/console/": asset = console_asset("index.html") if asset: self._send_bytes(*asset) return if parsed.path.startswith("/console/"): asset = console_asset(parsed.path[len("/console/"):]) if asset: self._send_bytes(*asset) else: self._send(404, {"error": f"not found: {parsed.path}"}) return status, payload = dispatch(service, "GET", parsed.path, query=parse_qs(parsed.query)) self._send(status, payload) def do_POST(self): parsed = urlparse(self.path) try: body = self._read_body() except HttpError as e: self._send(e.status, {"error": e.message}) return status, payload = dispatch(service, "POST", parsed.path, body=body) self._send(status, payload) return Handler def create_app(manager: Optional[ContextManager] = None, *, host: str = "127.0.0.1", port: int = 8088, name: str = "rest", path: Optional[str] = None) -> HTTPServer: """Build (but do not start) the HTTP server bound to ``host:port``. A single-threaded server is used so the SQLite connection stays on one thread; it is sufficient for local use, the UI, and CI. Call ``server.serve_forever()`` to run, or use :func:`serve`. """ if manager is None: manager = ContextManager.create(name, path=path or f"{name}.matrix-context.db") service = RestService(manager) return HTTPServer((host, port), _make_handler(service)) def serve(host: str = "127.0.0.1", port: int = 8088, manager: Optional[ContextManager] = None, name: str = "rest", path: Optional[str] = None) -> None: # pragma: no cover server = create_app(manager, host=host, port=port, name=name, path=path) print(f"matrix-context REST listening on http://{host}:{port}{API_PREFIX}") try: server.serve_forever() except KeyboardInterrupt: server.server_close()