Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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
```
Expand Down
18 changes: 5 additions & 13 deletions plugins/hermes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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`. |
Expand All @@ -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.
Expand Down Expand Up @@ -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/<name>/`, not `$HERMES_HOME/plugins/<name>/`. |
| `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
Expand Down
9 changes: 2 additions & 7 deletions plugins/hermes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from .python_sdk import (
PythonSdkAtomicMemoryClient,
PythonSdkConfig,
resolve_python_sdk_path,
sdk_is_available,
)
from .tools import (
CONCLUDE_SCHEMA,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"),
Expand Down
6 changes: 0 additions & 6 deletions plugins/hermes/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -43,7 +42,6 @@
"memory_mode",
"memory_scope",
"prefetch_method",
"python_sdk_path",
}
"""Keys allowed in $HERMES_HOME/atomicmemory.json.

Expand All @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion plugins/hermes/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 1 addition & 3 deletions plugins/hermes/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 61 additions & 48 deletions plugins/hermes/python_sdk.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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]
Expand All @@ -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,
Expand All @@ -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


Expand All @@ -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:
Expand Down
3 changes: 0 additions & 3 deletions plugins/hermes/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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",
Expand All @@ -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):
Expand Down
Loading
Loading