"""Client for the external EverMemOS consolidation service.""" from __future__ import annotations from typing import Any import httpx from .config import get_config from .schemas import AccessContext, EpisodeRecord, MemoryRecord class EverMemOSError(RuntimeError): """Raised when the external EverMemOS service cannot consolidate.""" class EverMemOSClient: """Small HTTP client with a tolerant response normalizer. The deployed EverMemOS API may evolve independently from Memory Gateway. Gateway sends a stable payload and accepts several common response shapes: `result`, `data`, or the raw top-level object with `candidates/promoted`. """ def __init__( self, base_url: str | None = None, api_key: str | None = None, timeout: int | None = None, health_path: str | None = None, consolidate_path: str | None = None, ) -> None: config = get_config().evermemos self.base_url = (base_url or config.url).rstrip("/") self.api_key = api_key if api_key is not None else config.api_key self.timeout = timeout or config.timeout self.health_path = health_path or config.health_path self.consolidate_path = consolidate_path or config.consolidate_path def _headers(self) -> dict[str, str]: headers = {"Content-Type": "application/json"} if self.api_key: headers["X-API-Key"] = self.api_key headers["Authorization"] = f"Bearer {self.api_key}" return headers def health(self) -> dict[str, Any]: url = self.base_url + self.health_path try: with httpx.Client(timeout=self.timeout, headers=self._headers()) as client: response = client.get(url) response.raise_for_status() return {"status": "ok", "url": self.base_url, "response": response.json()} except Exception as exc: # noqa: BLE001 return {"status": "error", "url": self.base_url, "error": str(exc)} def consolidate_session( self, session_id: str, ctx: AccessContext, episodes: list[EpisodeRecord], existing_memories: list[MemoryRecord], min_importance: float, target_namespace: str | None, ) -> dict[str, Any]: payload = { "schema_version": "memory-gateway.evermemos.consolidate.v1", "session_id": session_id, "context": ctx.model_dump(mode="json"), "min_importance": min_importance, "target_namespace": target_namespace, "episodes": [episode.model_dump(mode="json") for episode in episodes], "existing_memories": [memory.model_dump(mode="json") for memory in existing_memories], } paths = [ self.consolidate_path, "/v1/sessions/consolidate", "/v1/memory/consolidate", "/api/v1/sessions/consolidate", "/api/consolidate", "/consolidate", ] errors: list[str] = [] for path in dict.fromkeys(paths): try: with httpx.Client(timeout=self.timeout, headers=self._headers()) as client: response = client.post(self.base_url + path, json=payload) if response.status_code == 404: errors.append(f"{path}: 404") continue response.raise_for_status() return self._normalize_response(response.json(), path) except Exception as exc: # noqa: BLE001 errors.append(f"{path}: {exc}") if "Connection refused" in str(exc) or "timed out" in str(exc): break raise EverMemOSError("; ".join(errors) or "EverMemOS consolidation failed") def _normalize_response(self, payload: dict[str, Any], path: str) -> dict[str, Any]: data = payload.get("result") or payload.get("data") or payload return { "backend": "external", "service_url": self.base_url, "endpoint": path, "raw": payload, "session_id": data.get("session_id"), "episodes": data.get("episodes"), "candidates": data.get("candidates") or data.get("candidate_memories") or [], "promoted": data.get("promoted") or data.get("promoted_memories") or data.get("memories") or [], "duplicates": data.get("duplicates") or [], "conflicts": data.get("conflicts") or [], "review_drafts": data.get("review_drafts") or [], }