#!/usr/bin/env python3 """ fleet_api.py — Lightweight HTTP API for the shared fleet palace. Exposes fleet memory search and recording over HTTP so that Alpha servers and other wizard deployments can query the palace without direct filesystem access. Endpoints: GET /health Returns {"status": "ok", "palace": ""} GET /search?q=[&room=][&n=] Returns {"results": [...], "query": "...", "room": "...", "count": N} Each result: {"text": "...", "room": "...", "wing": "...", "score": 0.9} GET /wings Returns {"wings": ["bezalel", ...]} — distinct wizard wings present POST /record Body: {"text": "...", "room": "...", "wing": "...", "source_file": "...", "metadata": {...}} Returns {"success": true, "id": "..."} Error responses use {"error": ""} with appropriate HTTP status codes. Usage: # Default: localhost:7771, fleet palace at /var/lib/mempalace/fleet python mempalace/fleet_api.py # Custom host/port/palace: FLEET_PALACE_PATH=/data/fleet python mempalace/fleet_api.py --host 0.0.0.0 --port 8080 Refs: #1078, #1075, #1085 """ from __future__ import annotations import argparse import json import os import sys from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse # Add repo root to path so we can import nexus.mempalace _HERE = Path(__file__).resolve().parent _REPO_ROOT = _HERE.parent if str(_REPO_ROOT) not in sys.path: sys.path.insert(0, str(_REPO_ROOT)) DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 7771 MAX_RESULTS = 50 def _get_palace_path() -> Path: return Path(os.environ.get("FLEET_PALACE_PATH", "/var/lib/mempalace/fleet")) def _json_response(handler: BaseHTTPRequestHandler, status: int, body: dict) -> None: payload = json.dumps(body).encode() handler.send_response(status) handler.send_header("Content-Type", "application/json") handler.send_header("Content-Length", str(len(payload))) handler.end_headers() handler.wfile.write(payload) def _handle_health(handler: BaseHTTPRequestHandler) -> None: palace = _get_palace_path() _json_response(handler, 200, { "status": "ok", "palace": str(palace), "palace_exists": palace.exists(), }) def _handle_search(handler: BaseHTTPRequestHandler, qs: dict) -> None: query_terms = qs.get("q", [""]) q = query_terms[0].strip() if query_terms else "" if not q: _json_response(handler, 400, {"error": "Missing required parameter: q"}) return room_terms = qs.get("room", []) room = room_terms[0].strip() if room_terms else None n_terms = qs.get("n", []) try: n = max(1, min(int(n_terms[0]), MAX_RESULTS)) if n_terms else 10 except (ValueError, IndexError): _json_response(handler, 400, {"error": "Invalid parameter: n must be an integer"}) return try: from nexus.mempalace.searcher import search_fleet, MemPalaceUnavailable except ImportError as exc: _json_response(handler, 503, {"error": f"MemPalace module not available: {exc}"}) return try: results = search_fleet(q, room=room, n_results=n) except Exception as exc: # noqa: BLE001 _json_response(handler, 503, {"error": str(exc)}) return _json_response(handler, 200, { "query": q, "room": room, "count": len(results), "results": [ { "text": r.text, "room": r.room, "wing": r.wing, "score": round(r.score, 4), } for r in results ], }) def _handle_wings(handler: BaseHTTPRequestHandler) -> None: """Return distinct wizard wing names found in the fleet palace directory.""" palace = _get_palace_path() if not palace.exists(): _json_response(handler, 503, { "error": f"Fleet palace not found: {palace}", }) return wings = sorted({ p.name for p in palace.iterdir() if p.is_dir() }) _json_response(handler, 200, {"wings": wings}) def _handle_record(handler: BaseHTTPRequestHandler) -> None: """Handle POST /record to add a new memory.""" content_length = int(handler.headers.get("Content-Length", 0)) if not content_length: _json_response(handler, 400, {"error": "Missing request body"}) return try: body = json.loads(handler.rfile.read(content_length)) except json.JSONDecodeError: _json_response(handler, 400, {"error": "Invalid JSON body"}) return text = body.get("text", "").strip() if not text: _json_response(handler, 400, {"error": "Missing required field: text"}) return room = body.get("room", "general") wing = body.get("wing") source_file = body.get("source_file", "") metadata = body.get("metadata", {}) try: from nexus.mempalace.searcher import add_memory, MemPalaceUnavailable except ImportError as exc: _json_response(handler, 503, {"error": f"MemPalace module not available: {exc}"}) return try: # Note: add_memory uses MEMPALACE_PATH by default. # For fleet_api, we should probably use FLEET_PALACE_PATH. palace_path = _get_palace_path() doc_id = add_memory( text=text, room=room, wing=wing, palace_path=palace_path, source_file=source_file, extra_metadata=metadata ) _json_response(handler, 201, {"success": True, "id": doc_id}) except Exception as exc: _json_response(handler, 503, {"error": str(exc)}) class FleetAPIHandler(BaseHTTPRequestHandler): """Request handler for the fleet memory API.""" def log_message(self, fmt: str, *args) -> None: # noqa: ANN001 # Prefix with tag for easier log filtering sys.stderr.write(f"[fleet_api] {fmt % args}\n") def do_GET(self) -> None: # noqa: N802 parsed = urlparse(self.path) path = parsed.path.rstrip("/") or "/" qs = parse_qs(parsed.query) if path == "/health": _handle_health(self) elif path == "/search": _handle_search(self, qs) elif path == "/wings": _handle_wings(self) else: _json_response(self, 404, { "error": f"Unknown endpoint: {path}", "endpoints": ["/health", "/search", "/wings"], }) def do_POST(self) -> None: # noqa: N802 parsed = urlparse(self.path) path = parsed.path.rstrip("/") or "/" if path == "/record": _handle_record(self) else: _json_response(self, 404, { "error": f"Unknown endpoint: {path}", "endpoints": ["/record"], }) def make_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> HTTPServer: return HTTPServer((host, port), FleetAPIHandler) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Fleet palace HTTP API server." ) parser.add_argument("--host", default=DEFAULT_HOST, help=f"Bind host (default: {DEFAULT_HOST})") parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Bind port (default: {DEFAULT_PORT})") args = parser.parse_args(argv) palace = _get_palace_path() print(f"[fleet_api] Palace: {palace}") if not palace.exists(): print(f"[fleet_api] WARNING: palace path does not exist yet: {palace}", file=sys.stderr) server = make_server(args.host, args.port) print(f"[fleet_api] Listening on http://{args.host}:{args.port}") try: server.serve_forever() except KeyboardInterrupt: print("\n[fleet_api] Shutting down.") return 0 if __name__ == "__main__": sys.exit(main())