diff --git a/plugins/memory/supermemory/__init__.py b/plugins/memory/supermemory/__init__.py index 1d086f47df84..cdd256420268 100644 --- a/plugins/memory/supermemory/__init__.py +++ b/plugins/memory/supermemory/__init__.py @@ -31,7 +31,7 @@ _VALID_SEARCH_MODES = ("hybrid", "memories", "documents") _DEFAULT_API_TIMEOUT = 5.0 _MIN_CAPTURE_LENGTH = 10 _MAX_ENTITY_CONTEXT_LENGTH = 1500 -_CONVERSATIONS_URL = "https://api.supermemory.ai/v4/conversations" +_DEFAULT_BASE_URL = "https://api.supermemory.ai" _API_KEY_URL = "http://app.supermemory.ai/integrations?connect=hermes" _TRIVIAL_RE = re.compile( r"^(ok|okay|thanks|thank you|got it|sure|yes|no|yep|nope|k|ty|thx|np)\.?$", @@ -65,6 +65,7 @@ def _default_config() -> dict: "search_mode": _DEFAULT_SEARCH_MODE, "entity_context": _DEFAULT_ENTITY_CONTEXT, "api_timeout": _DEFAULT_API_TIMEOUT, + "base_url": "", "enable_custom_container_tags": False, "custom_containers": [], "custom_container_instructions": "", @@ -77,6 +78,15 @@ def _sanitize_tag(raw: str) -> str: return tag.strip("_") or _DEFAULT_CONTAINER_TAG +def _resolve_base_url(config_value: Any = "") -> str: + """Resolve the API base URL: config > SUPERMEMORY_BASE_URL env var > default. + + Supports self-hosted Supermemory servers (e.g. http://localhost:6767). + """ + raw = str(config_value or "").strip() or os.environ.get("SUPERMEMORY_BASE_URL", "").strip() + return (raw or _DEFAULT_BASE_URL).rstrip("/") or _DEFAULT_BASE_URL + + def _clamp_entity_context(text: str) -> str: if not text: return _DEFAULT_ENTITY_CONTEXT @@ -129,6 +139,7 @@ def _load_supermemory_config(hermes_home: str) -> dict: config["api_timeout"] = max(0.5, min(15.0, float(config.get("api_timeout", _DEFAULT_API_TIMEOUT)))) except Exception: config["api_timeout"] = _DEFAULT_API_TIMEOUT + config["base_url"] = str(config.get("base_url", "") or "").strip() # Multi-container support config["enable_custom_container_tags"] = _as_bool(config.get("enable_custom_container_tags"), False) @@ -263,7 +274,8 @@ def _is_trivial_message(text: str) -> bool: class _SupermemoryClient: - def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid"): + def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid", + base_url: str = ""): # Lazy-install the supermemory SDK on demand. ensure() honors # security.allow_lazy_installs (default true) and, on a sealed Docker # venv, redirects the install to the durable target. On failure we @@ -276,15 +288,16 @@ class _SupermemoryClient: pass except Exception: pass - from supermemory import Supermemory self._api_key = api_key self._container_tag = container_tag self._search_mode = search_mode if search_mode in _VALID_SEARCH_MODES else _DEFAULT_SEARCH_MODE self._timeout = timeout + self._base_url = (base_url or _DEFAULT_BASE_URL).rstrip("/") or _DEFAULT_BASE_URL self._client = Supermemory( api_key=api_key, + base_url=self._base_url, timeout=timeout, max_retries=0, default_headers={"x-sm-source": "hermes"}, @@ -388,7 +401,7 @@ class _SupermemoryClient: payload["metadata"] = self._merge_metadata(metadata) req = urllib.request.Request( - _CONVERSATIONS_URL, + f"{self._base_url}/v4/conversations", data=json.dumps(payload).encode("utf-8"), headers={ "Authorization": f"Bearer {self._api_key}", @@ -531,6 +544,7 @@ class SupermemoryMemoryProvider(MemoryProvider): self._search_mode = _DEFAULT_SEARCH_MODE self._entity_context = _DEFAULT_ENTITY_CONTEXT self._api_timeout = _DEFAULT_API_TIMEOUT + self._base_url = _DEFAULT_BASE_URL self._hermes_home = "" self._write_enabled = True self._active = False @@ -645,6 +659,9 @@ class SupermemoryMemoryProvider(MemoryProvider): self._search_mode = self._config["search_mode"] self._entity_context = self._config["entity_context"] self._api_timeout = self._config["api_timeout"] + # Base URL: config > SUPERMEMORY_BASE_URL env var > api.supermemory.ai. + # Supports self-hosted Supermemory servers. + self._base_url = _resolve_base_url(self._config["base_url"]) self._enable_custom_containers = self._config["enable_custom_container_tags"] self._custom_containers = self._config["custom_containers"] self._custom_container_instructions = self._config["custom_container_instructions"] @@ -663,6 +680,7 @@ class SupermemoryMemoryProvider(MemoryProvider): timeout=self._api_timeout, container_tag=self._container_tag, search_mode=self._search_mode, + base_url=self._base_url, ) except Exception: logger.warning("Supermemory initialization failed", exc_info=True) diff --git a/tests/plugins/memory/test_supermemory_provider.py b/tests/plugins/memory/test_supermemory_provider.py index c0dc890433f5..2eed3741d846 100644 --- a/tests/plugins/memory/test_supermemory_provider.py +++ b/tests/plugins/memory/test_supermemory_provider.py @@ -17,11 +17,13 @@ from plugins.memory.supermemory import ( class FakeClient: - def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid"): + def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid", + base_url: str = ""): self.api_key = api_key self.timeout = timeout self.container_tag = container_tag self.search_mode = search_mode + self.base_url = base_url self.add_calls = [] self.search_results = [] self.profile_response = {"static": [], "dynamic": [], "search_results": []} @@ -373,6 +375,71 @@ def test_invalid_search_mode_falls_back_to_default(monkeypatch, tmp_path): assert p._search_mode == "hybrid" +# -- Base URL tests ------------------------------------------------------------- + + +def test_base_url_defaults_to_cloud(monkeypatch, tmp_path): + """Without config or env override, the client targets api.supermemory.ai.""" + monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key") + monkeypatch.delenv("SUPERMEMORY_BASE_URL", raising=False) + monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient) + p = SupermemoryMemoryProvider() + p.initialize("s1", hermes_home=str(tmp_path), platform="cli") + assert p._base_url == "https://api.supermemory.ai" + assert p._client.base_url == "https://api.supermemory.ai" + + +def test_base_url_env_var_override(monkeypatch, tmp_path): + """SUPERMEMORY_BASE_URL points the provider at a self-hosted server (trailing slash stripped).""" + monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key") + monkeypatch.setenv("SUPERMEMORY_BASE_URL", "http://localhost:6767/") + monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient) + p = SupermemoryMemoryProvider() + p.initialize("s1", hermes_home=str(tmp_path), platform="cli") + assert p._base_url == "http://localhost:6767" + assert p._client.base_url == "http://localhost:6767" + + +def test_base_url_config_overrides_env(monkeypatch, tmp_path): + """base_url in supermemory.json takes precedence over the env var.""" + monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key") + monkeypatch.setenv("SUPERMEMORY_BASE_URL", "http://env-host:6767") + monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient) + _save_supermemory_config({"base_url": "http://config-host:6767/"}, str(tmp_path)) + p = SupermemoryMemoryProvider() + p.initialize("s1", hermes_home=str(tmp_path), platform="cli") + assert p._base_url == "http://config-host:6767" + assert p._client.base_url == "http://config-host:6767" + + +def test_ingest_conversation_uses_custom_base_url(monkeypatch): + """The raw urllib conversations ingest hits the configured base URL, not the cloud.""" + from plugins.memory.supermemory import _SupermemoryClient + + client = _SupermemoryClient.__new__(_SupermemoryClient) + client._api_key = "test-key" + client._container_tag = "hermes" + client._timeout = 1.0 + client._base_url = "http://localhost:6767" + + captured = {} + + class _FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def fake_urlopen(req, timeout=None): + captured["url"] = req.full_url + return _FakeResponse() + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + client.ingest_conversation("s1", [{"role": "user", "content": "hello there"}]) + assert captured["url"] == "http://localhost:6767/v4/conversations" + + # -- Multi-container tests ----------------------------------------------------