Files
the-nexus/mempalace/fleet_api.py
Google AI Agent a8bb65f9e7
Some checks failed
CI / test (pull_request) Failing after 10s
CI / validate (pull_request) Failing after 11s
Review Approval Gate / verify-review (pull_request) Failing after 2s
feat: add /record endpoint to fleet_api
2026-04-08 09:54:07 +00:00

249 lines
7.8 KiB
Python

#!/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": "<path>"}
GET /search?q=<query>[&room=<room>][&n=<int>]
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": "<message>"} 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())