diff --git a/README.md b/README.md index 2302ad7..895dca9 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Memory semantics live in [`@atomicmemory/sdk`](https://github.com/atomicstrata/a The `@atomicmemory/cli` package is separate from the MCP stdio server. It uses the same SDK directly, but is designed for normal terminal and agent-script workflows: an Ink/React interactive UI, Honcho-style help, `init`, `doctor`, grouped `memory` / `lifecycle` / `audit` / `lessons` / `agents` / `runtime` / `config` commands, and stable `--agent` JSON output. -**Hermes is the exception** to the MCP-only wrapper shape. Hermes' native memory-provider API gives the integration first-class access to `prefetch`, `queue_prefetch`, and `sync_turn` lifecycle hooks, which MCP cannot supply. The Hermes plugin uses the local `atomicmemory-python` SDK, so the Python lifecycle code never reaches into core HTTP directly. +**Hermes is the exception** to the MCP-only wrapper shape. Hermes' native memory-provider API gives the integration first-class access to `prefetch`, `queue_prefetch`, and `sync_turn` lifecycle hooks, which MCP cannot supply. The Hermes plugin uses the published `atomicmemory` Python SDK, so the Python lifecycle code never reaches into core HTTP directly. ## Develop @@ -263,8 +263,8 @@ Restart the OpenClaw host if it keeps plugin modules loaded. Verify the plugin r ### 5. Hermes -The Hermes integration is a Python memory provider backed by the local -`atomicmemory-python` SDK. After changing the provider or SDK adapter: +The Hermes integration is a Python memory provider backed by the published +`atomicmemory` SDK. After changing the provider or SDK adapter: ```bash python3 -m unittest discover plugins/hermes/tests @@ -278,8 +278,8 @@ helper keeps these fields in sync: - `plugins/hermes/plugin.yaml` at `/version` - `plugins/hermes/package.json` at `/version` -For dev installs, symlink the plugin into Hermes' memory directory. The SDK -defaults to `../../../atomicmemory-python` relative to `plugins/hermes`: +For dev installs, symlink the plugin into Hermes' memory directory. Hermes +installs the published Python SDK from `plugins/hermes/plugin.yaml`: ```bash mkdir -p "$HERMES_HOME/plugins/memory" @@ -294,7 +294,6 @@ Required baseline env before launching Hermes: export ATOMICMEMORY_API_URL="https://memory.yourco.com" # Optional: # export ATOMICMEMORY_API_KEY="..." -# export ATOMICMEMORY_PYTHON_SDK_PATH="/path/to/atomicmemory-python" # export ATOMICMEMORY_MEMORY_SCOPE="shared" # or siloed # export ATOMICMEMORY_MEMORY_MODE="hybrid" # hybrid | context | tools ``` diff --git a/plugins/hermes/README.md b/plugins/hermes/README.md index 65db1e6..f5c488c 100644 --- a/plugins/hermes/README.md +++ b/plugins/hermes/README.md @@ -16,32 +16,27 @@ Hermes Agent (Python) → plugins/memory/atomicmemory/__init__.py → AtomicMemoryClient (Python protocol) → PythonSdkAtomicMemoryClient - → local atomicmemory-python MemoryClient + → published atomicmemory Python SDK MemoryClient ``` The Python provider owns Hermes lifecycle compatibility only — registration, -hooks, tool schemas. Memory semantics flow through the Python SDK. Until the -SDK is published, the plugin imports it from a local checkout. +hooks, tool schemas. Memory semantics flow through the published Python SDK. ## Prerequisites -- Local `atomicmemory-python` checkout. Default relative path from this plugin: - `../../../atomicmemory-python`. - Hermes Agent installed and `HERMES_HOME` set - AtomicMemory core URL exported as `ATOMICMEMORY_API_URL` ## Install (dev) The simplest dev install symlinks the plugin into Hermes' memory directory. -The plugin then resolves the unpublished SDK from the sibling local checkout: +Hermes installs the published `atomicmemory` SDK from `plugin.yaml`. ```bash cd /path/to/atomicmemory-integrations mkdir -p "$HERMES_HOME/plugins/memory" ln -s "$(pwd)/plugins/hermes" "$HERMES_HOME/plugins/memory/atomicmemory" export ATOMICMEMORY_API_URL="http://localhost:3050" -# Optional if the sibling checkout is somewhere else: -# export ATOMICMEMORY_PYTHON_SDK_PATH="/path/to/atomicmemory-python" ``` Then select and verify the provider: @@ -68,7 +63,6 @@ have a default API URL and fails to start if `ATOMICMEMORY_API_URL` is unset. |---|---| | `ATOMICMEMORY_API_URL` | AtomicMemory core URL. Required. | | `ATOMICMEMORY_API_KEY` | Bearer credential for AtomicMemory core. Optional. | -| `ATOMICMEMORY_PYTHON_SDK_PATH` | Local `atomicmemory-python` checkout. Defaults to `../../../atomicmemory-python` relative to the plugin. | | `ATOMICMEMORY_PROVIDER` | SDK provider name. Defaults to `atomicmemory`. | | `ATOMICMEMORY_SCOPE_USER` | Hermes user identity. Defaults to `$USER`. | | `ATOMICMEMORY_MEMORY_SCOPE` | `shared` (default) or `siloed`. | @@ -92,7 +86,6 @@ have a default API URL and fails to start if `ATOMICMEMORY_API_URL` is unset. | `prefetch_method` | `context` / `fast`. | | `search_limit` | int. | | `token_budget` | int. | -| `python_sdk_path` | Local `atomicmemory-python` checkout path. | Secrets are never persisted here — `api_key` and `api_url` are deliberately not in the allowed key set. @@ -152,9 +145,8 @@ run while AtomicMemory is temporarily unavailable. | Symptom | Likely cause | |---|---| | Provider does not appear in `hermes memory setup` | Wrong install path. Memory providers must live under `$HERMES_HOME/plugins/memory//`, not `$HERMES_HOME/plugins//`. | -| `is_available()` returns False | `ATOMICMEMORY_API_URL` unset, or `ATOMICMEMORY_PYTHON_SDK_PATH` does not point at a local SDK checkout. | -| Import fails at startup | The Hermes Python environment is missing the SDK dependencies from `plugin.yaml`, or `atomicmemory-python` is not at the configured path. | -| Default SDK path works in dev but not after install | The relative default only fits the integrations checkout. Production installs must set `ATOMICMEMORY_PYTHON_SDK_PATH` or `python_sdk_path` until the Python SDK is published. | +| `is_available()` returns False | `ATOMICMEMORY_API_URL` unset, or the Hermes Python environment did not install the `atomicmemory` dependency from `plugin.yaml`. | +| Import fails at startup | The Hermes Python environment is missing the SDK dependency from `plugin.yaml`. | | Calls fail with `PROVIDER_UNSUPPORTED` while `memory_scope=siloed` | The configured SDK provider is not the AtomicMemory core (e.g. it's `mem0`). Either switch `ATOMICMEMORY_PROVIDER=atomicmemory` or move to `memory_scope=shared`. | ## Tests diff --git a/plugins/hermes/__init__.py b/plugins/hermes/__init__.py index ba02954..100fa42 100644 --- a/plugins/hermes/__init__.py +++ b/plugins/hermes/__init__.py @@ -37,7 +37,7 @@ from .python_sdk import ( PythonSdkAtomicMemoryClient, PythonSdkConfig, - resolve_python_sdk_path, + sdk_is_available, ) from .tools import ( CONCLUDE_SCHEMA, @@ -100,11 +100,7 @@ def name(self) -> str: def is_available(self) -> bool: if not os.environ.get("ATOMICMEMORY_API_URL"): return False - try: - resolve_python_sdk_path(load_config().python_sdk_path) - except FileNotFoundError: - return False - return True + return sdk_is_available() def get_config_schema(self) -> list[dict[str, Any]]: return get_config_schema() @@ -319,7 +315,6 @@ def _on_ingest_failure(self, exc: BaseException) -> None: def _default_client_factory(config: ProviderConfig) -> AtomicMemoryClient: return PythonSdkAtomicMemoryClient( config=PythonSdkConfig( - sdk_path=config.python_sdk_path, provider=os.environ.get("ATOMICMEMORY_PROVIDER", "atomicmemory"), api_url=os.environ.get("ATOMICMEMORY_API_URL"), api_key=os.environ.get("ATOMICMEMORY_API_KEY"), diff --git a/plugins/hermes/config.py b/plugins/hermes/config.py index 78fb9b5..b3c578a 100644 --- a/plugins/hermes/config.py +++ b/plugins/hermes/config.py @@ -28,7 +28,6 @@ DEFAULT_MEMORY_MODE = "hybrid" DEFAULT_MEMORY_SCOPE = "shared" DEFAULT_PREFETCH_METHOD = "context" -DEFAULT_PYTHON_SDK_PATH = "../../../atomicmemory-python" VALID_MEMORY_MODES = {"hybrid", "context", "tools"} VALID_MEMORY_SCOPES = {"shared", "siloed"} @@ -43,7 +42,6 @@ "memory_mode", "memory_scope", "prefetch_method", - "python_sdk_path", } """Keys allowed in $HERMES_HOME/atomicmemory.json. @@ -62,7 +60,6 @@ class ProviderConfig: memory_mode: str = DEFAULT_MEMORY_MODE memory_scope: str = DEFAULT_MEMORY_SCOPE prefetch_method: str = DEFAULT_PREFETCH_METHOD - python_sdk_path: str = DEFAULT_PYTHON_SDK_PATH def load_config( @@ -87,7 +84,6 @@ def load_config( prefetch_method=_normalized( env.get("ATOMICMEMORY_PREFETCH_METHOD"), DEFAULT_PREFETCH_METHOD, VALID_PREFETCH_METHODS, ), - python_sdk_path=_clean(env.get("ATOMICMEMORY_PYTHON_SDK_PATH")) or DEFAULT_PYTHON_SDK_PATH, ) file_overrides = _read_config_file(hermes_home) return _apply_file_overrides(cfg, file_overrides) @@ -218,8 +214,6 @@ def _apply_file_overrides(cfg: ProviderConfig, file_overrides: dict[str, Any]) - cfg.prefetch_method = _normalized( file_overrides["prefetch_method"], cfg.prefetch_method, VALID_PREFETCH_METHODS, ) - if "python_sdk_path" in file_overrides: - cfg.python_sdk_path = _clean(str(file_overrides["python_sdk_path"])) or cfg.python_sdk_path cfg.search_limit = max(1, min(cfg.search_limit, 50)) cfg.token_budget = max(100, cfg.token_budget) return cfg diff --git a/plugins/hermes/package.json b/plugins/hermes/package.json index 7b88245..31d8dc0 100644 --- a/plugins/hermes/package.json +++ b/plugins/hermes/package.json @@ -2,7 +2,10 @@ "name": "@atomicmemory/hermes-plugin", "version": "0.1.10", "description": "AtomicMemory native Hermes memory provider — Python SDK-backed, cross-tool memory by default.", - "private": true, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "license": "Apache-2.0", "repository": { "type": "git", diff --git a/plugins/hermes/plugin.yaml b/plugins/hermes/plugin.yaml index 3a5364e..dcdc1c1 100644 --- a/plugins/hermes/plugin.yaml +++ b/plugins/hermes/plugin.yaml @@ -2,9 +2,7 @@ name: atomicmemory version: 0.1.10 description: "AtomicMemory native Hermes memory provider — Python SDK-backed, cross-tool memory by default." pip_dependencies: - - "httpx>=0.27" - - "pydantic>=2.7" - - "numpy>=1.26" + - "atomicmemory>=1.0.1,<2.0.0" hooks: - system_prompt_block - prefetch diff --git a/plugins/hermes/python_sdk.py b/plugins/hermes/python_sdk.py index 276117e..d2ef099 100644 --- a/plugins/hermes/python_sdk.py +++ b/plugins/hermes/python_sdk.py @@ -1,10 +1,10 @@ """Python SDK client adapter for the Hermes AtomicMemory provider. This module is the only production implementation of the Hermes -``AtomicMemoryClient`` protocol. It imports the unpublished -``atomicmemory-python`` SDK from a local path, then routes shared reads -through the generic SDK surface and siloed reads through the AtomicMemory -namespace where ``source_site`` is supported. +``AtomicMemoryClient`` protocol. It imports the published ``atomicmemory`` +Python SDK, then routes shared reads through the generic SDK surface and +siloed reads through the AtomicMemory namespace where ``source_site`` is +supported. """ from __future__ import annotations @@ -27,14 +27,12 @@ Scope, SearchPage, ) -from .config import DEFAULT_PYTHON_SDK_PATH @dataclass(frozen=True) class PythonSdkConfig: """Runtime config needed to construct the Python SDK MemoryClient.""" - sdk_path: str = DEFAULT_PYTHON_SDK_PATH provider: str = "atomicmemory" api_url: str | None = None api_key: str | None = None @@ -54,23 +52,17 @@ class PythonSdkTypes: AtomicMemoryListOptions: Any -def resolve_python_sdk_path(sdk_path: str | Path | None = None) -> Path: - """Resolve the local unpublished ``atomicmemory-python`` source path.""" - raw = str(sdk_path or DEFAULT_PYTHON_SDK_PATH).strip() - path = Path(raw).expanduser() - if not path.is_absolute(): - path = Path(__file__).resolve().parent / path - resolved = path.resolve() - if not (resolved / "atomicmemory").is_dir(): - raise FileNotFoundError( - f"atomicmemory-python SDK not found at {resolved}. " - "Set ATOMICMEMORY_PYTHON_SDK_PATH to the local SDK checkout." - ) - return resolved +def sdk_is_available() -> bool: + """Return whether the published AtomicMemory Python SDK can be imported.""" + try: + _load_sdk_types() + except ImportError: + return False + return True class PythonSdkAtomicMemoryClient: - """AtomicMemoryClient implementation backed by ``atomicmemory-python``.""" + """AtomicMemoryClient implementation backed by the ``atomicmemory`` SDK.""" def __init__( self, @@ -87,7 +79,7 @@ def initialize(self) -> None: return if not self._config.api_url: raise BridgeError("ATOMICMEMORY_API_URL is required", code="CONFIG_REQUIRED") - types = self._types or _load_sdk_types(self._config.sdk_path) + types = self._types or _load_sdk_types() providers = {self._config.provider: _provider_config(self._config)} self._client = types.MemoryClient(providers=providers, default_provider=self._config.provider) self._client.initialize() @@ -215,12 +207,11 @@ def _user_scope(self, scope: Scope) -> Any: return self._types.UserScope(user_id=user) -def _load_sdk_types(sdk_path: str | Path) -> PythonSdkTypes: - resolved = resolve_python_sdk_path(sdk_path) - resolved_str = str(resolved) - prior_path_index = _move_sdk_path_to_front(resolved_str) +def _load_sdk_types() -> PythonSdkTypes: + plugin_roots = _plugin_roots() + removed_path_entries = _remove_plugin_import_roots(plugin_roots) try: - saved_modules = _stash_non_sdk_atomicmemory_modules(resolved) + saved_modules = _stash_plugin_atomicmemory_modules(plugin_roots) try: from atomicmemory import MemoryClient # type: ignore[import-not-found] from atomicmemory.providers.atomicmemory.handle import ( # type: ignore[import-not-found] @@ -231,7 +222,7 @@ def _load_sdk_types(sdk_path: str | Path) -> PythonSdkTypes: finally: _restore_modules(saved_modules) finally: - _restore_sdk_path(resolved_str, prior_path_index) + _restore_path_entries(removed_path_entries) return PythonSdkTypes( MemoryClient=MemoryClient, @@ -241,31 +232,13 @@ def _load_sdk_types(sdk_path: str | Path) -> PythonSdkTypes: ) -def _move_sdk_path_to_front(resolved_str: str) -> int | None: - if resolved_str not in sys.path: - sys.path.insert(0, resolved_str) - return None - prior_index = sys.path.index(resolved_str) - sys.path.pop(prior_index) - sys.path.insert(0, resolved_str) - return prior_index - - -def _restore_sdk_path(resolved_str: str, prior_index: int | None) -> None: - if resolved_str in sys.path: - sys.path.remove(resolved_str) - if prior_index is not None: - sys.path.insert(min(prior_index, len(sys.path)), resolved_str) - - -def _stash_non_sdk_atomicmemory_modules(sdk_root: Path) -> dict[str, Any]: +def _stash_plugin_atomicmemory_modules(plugin_roots: set[Path]) -> dict[str, Any]: saved: dict[str, Any] = {} for name, module in list(sys.modules.items()): if not _is_atomicmemory_module(name): continue - if _module_is_under(module, sdk_root): - continue - saved[name] = sys.modules.pop(name) + if _should_stash_atomicmemory_module(name, module, plugin_roots): + saved[name] = sys.modules.pop(name) return saved @@ -278,6 +251,46 @@ def _is_atomicmemory_module(name: str) -> bool: return name == "atomicmemory" or name.startswith("atomicmemory.") +def _plugin_roots() -> set[Path]: + roots = {Path(__file__).resolve().parent} + module = sys.modules.get("atomicmemory") + file_name = getattr(module, "__file__", None) + if file_name: + try: + roots.add(Path(file_name).resolve().parent) + except OSError: + pass + return roots + + +def _remove_plugin_import_roots(plugin_roots: set[Path]) -> list[tuple[int, str]]: + removed: list[tuple[int, str]] = [] + for index, path_entry in reversed(list(enumerate(sys.path))): + if _path_entry_points_to_plugin(path_entry, plugin_roots): + removed.append((index, sys.path.pop(index))) + return list(reversed(removed)) + + +def _restore_path_entries(removed_entries: list[tuple[int, str]]) -> None: + for index, path_entry in removed_entries: + sys.path.insert(min(index, len(sys.path)), path_entry) + + +def _path_entry_points_to_plugin(path_entry: str, plugin_roots: set[Path]) -> bool: + raw_path = Path(path_entry or ".").expanduser() + try: + candidate = (raw_path / "atomicmemory").resolve() + except OSError: + return False + return any(candidate == root for root in plugin_roots) + + +def _should_stash_atomicmemory_module(name: str, module: Any, plugin_roots: set[Path]) -> bool: + if any(_module_is_under(module, root) for root in plugin_roots): + return True + return name == "atomicmemory" and not hasattr(module, "MemoryClient") + + def _module_is_under(module: Any, root: Path) -> bool: file_name = getattr(module, "__file__", None) if not file_name: diff --git a/plugins/hermes/tests/test_config.py b/plugins/hermes/tests/test_config.py index 2c04f2d..64f24c0 100644 --- a/plugins/hermes/tests/test_config.py +++ b/plugins/hermes/tests/test_config.py @@ -28,7 +28,6 @@ def test_env_seeds_then_file_overrides(self) -> None: "ATOMICMEMORY_SCOPE_AGENT": "env-agent", "ATOMICMEMORY_MEMORY_SCOPE": "shared", "ATOMICMEMORY_SEARCH_LIMIT": "9", - "ATOMICMEMORY_PYTHON_SDK_PATH": "../atomicmemory-python", } with tempfile.TemporaryDirectory() as tmp: Path(tmp, "atomicmemory.json").write_text( @@ -37,7 +36,6 @@ def test_env_seeds_then_file_overrides(self) -> None: "scope_agent": "file-agent", "memory_scope": "siloed", "search_limit": 11, - "python_sdk_path": "../../sdk", }, ), encoding="utf-8", @@ -49,7 +47,6 @@ def test_env_seeds_then_file_overrides(self) -> None: self.assertEqual(cfg.scope_agent, "file-agent") self.assertEqual(cfg.memory_scope, "siloed") self.assertEqual(cfg.search_limit, 11) - self.assertEqual(cfg.python_sdk_path, "../../sdk") class LoadConfigDefaults(unittest.TestCase): diff --git a/plugins/hermes/tests/test_python_sdk.py b/plugins/hermes/tests/test_python_sdk.py index b459dc5..e25f640 100644 --- a/plugins/hermes/tests/test_python_sdk.py +++ b/plugins/hermes/tests/test_python_sdk.py @@ -16,7 +16,6 @@ PythonSdkConfig, PythonSdkTypes, _load_sdk_types, - resolve_python_sdk_path, ) @@ -93,27 +92,24 @@ def test_source_site_requires_atomicmemory_namespace(self) -> None: class PythonSdkPathResolution(unittest.TestCase): - def test_relative_sdk_path_resolves_against_plugin_dir(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) / "sdk" - (root / "atomicmemory").mkdir(parents=True) - - resolved = resolve_python_sdk_path(root) - - self.assertEqual(resolved, root.resolve()) - def test_loader_survives_plugin_named_atomicmemory(self) -> None: with tempfile.TemporaryDirectory() as tmp: - sdk_root = _write_fake_sdk(Path(tmp) / "sdk") - plugin_module = SimpleNamespace(__file__="/tmp/plugin/atomicmemory/__init__.py") + plugin_parent = Path(tmp) / "plugins" + plugin_root = plugin_parent / "atomicmemory" + plugin_root.mkdir(parents=True) + sdk_root = _write_fake_sdk(Path(tmp) / "site-packages") + plugin_module = SimpleNamespace(__file__=str(plugin_root / "__init__.py")) prior = _stash_atomicmemory_modules() sys.modules["atomicmemory"] = plugin_module prior_path = list(sys.path) + sys.path.insert(0, str(sdk_root)) + sys.path.insert(0, str(plugin_parent)) try: - types = _load_sdk_types(sdk_root) + types = _load_sdk_types() finally: sys.modules.pop("atomicmemory", None) _restore_atomicmemory_modules(prior) + sys.path[:] = prior_path self.assertEqual(types.MemoryClient.__name__, "MemoryClient") self.assertIs(sys.modules.get("atomicmemory"), prior.get("atomicmemory"))