From 0455b3a5358c721e93c6d5fea90004f4f55aa091 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 14 May 2026 18:22:59 -0700 Subject: [PATCH] Sync integrations snapshot 14e7509 --- .claude-plugin/marketplace.json | 2 +- .fallowrc.json | 1 + packages/mcp-server/README.md | 1 + packages/mcp-server/package.json | 4 + packages/mcp-server/src/config.test.ts | 14 ++ packages/mcp-server/src/config.ts | 3 + packages/mcp-server/src/embedded-client.ts | 33 +++ packages/mcp-server/src/server.ts | 1 + .../claude-code/.claude-plugin/plugin.json | 5 +- plugins/claude-code/README.md | 1 + plugins/claude-code/package.json | 2 +- plugins/codex/.codex-mcp.json | 2 + plugins/codex/.codex-plugin/plugin.json | 2 +- plugins/codex/package.json | 2 +- plugins/codex/skills/atomicmemory/SKILL.md | 2 +- plugins/cursor/package.json | 2 +- plugins/hermes/CHANGELOG.md | 17 ++ plugins/hermes/README.md | 25 +- plugins/hermes/install.mjs | 103 ++++++++ plugins/hermes/package.json | 8 +- plugins/hermes/plugin.yaml | 2 +- plugins/hermes/pyproject.toml | 29 +++ plugins/hermes/python_sdk.py | 10 +- .../hermes/tests/test_install_path_imports.py | 57 +++++ plugins/hermes/tests/test_python_sdk.py | 16 ++ plugins/openclaw/README.md | 10 +- plugins/openclaw/openclaw.plugin.json | 8 +- plugins/openclaw/package.json | 6 + .../openclaw/skills/atomicmemory/skill.yaml | 2 +- plugins/openclaw/src/index.test.ts | 81 ++++++ plugins/openclaw/src/index.ts | 233 ++++++++++++++---- scripts/bump-plugin-versions.mjs | 6 + 32 files changed, 611 insertions(+), 79 deletions(-) create mode 100644 packages/mcp-server/src/embedded-client.ts create mode 100644 plugins/hermes/CHANGELOG.md create mode 100644 plugins/hermes/install.mjs create mode 100644 plugins/hermes/pyproject.toml create mode 100644 plugins/openclaw/src/index.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8a081ca..0c64aba 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "claude-code", "source": "./plugins/claude-code", "description": "Persistent semantic memory for Claude Code — user preferences, project context, prior decisions, and codebase facts that survive across sessions.", - "version": "0.1.10", + "version": "0.1.11", "category": "productivity", "homepage": "https://docs.atomicmemory.ai/integrations/coding-agents/claude-code", "license": "Apache-2.0" diff --git a/.fallowrc.json b/.fallowrc.json index c93759a..f0465df 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -11,6 +11,7 @@ "packages/mcp-server/src/spawn.ts", "packages/*/src/**/*.test.ts", "plugins/openclaw/src/index.ts", + "plugins/*/src/**/*.test.ts", "adapters/vercel-ai-sdk/src/index.ts", "adapters/*/src/**/*.test.ts" ], diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 9d77bab..f2b7847 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -37,6 +37,7 @@ The binary loads config from environment variables: | Variable | Required | Purpose | |---|---|---| | `ATOMICMEMORY_API_URL` | no** | Provider base URL. Defaults to the local AtomicMemory core (`http://127.0.0.1:3050`) when `ATOMICMEMORY_PROVIDER=atomicmemory`; required for `mem0`. | +| `ATOMICMEMORY_API_KEY` | no | Optional bearer credential forwarded to providers that require HTTP authorization. | | `ATOMICMEMORY_PROVIDER` | no | Provider name — one of `atomicmemory` or `mem0`. Defaults to `atomicmemory`. | | `ATOMICMEMORY_SCOPE_USER` | no | Default `user` scope. Defaults to the local machine user when omitted. | | `ATOMICMEMORY_SCOPE_AGENT` | no* | Default `agent` scope | diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 53e8092..f6cfab2 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -13,6 +13,10 @@ "./spawn": { "types": "./dist/spawn.d.ts", "import": "./dist/spawn.js" + }, + "./embedded-client": { + "types": "./dist/embedded-client.d.ts", + "import": "./dist/embedded-client.js" } }, "bin": { diff --git a/packages/mcp-server/src/config.test.ts b/packages/mcp-server/src/config.test.ts index a43a0bf..94c7778 100644 --- a/packages/mcp-server/src/config.test.ts +++ b/packages/mcp-server/src/config.test.ts @@ -19,12 +19,16 @@ test('loadConfigFromEnv defaults URL, provider, and user scope', () => { test('loadConfigFromEnv keeps explicit scope overrides', () => { const config = loadConfigFromEnv({ USER: 'machine-user', + ATOMICMEMORY_API_URL: 'https://memory.example.com/', + ATOMICMEMORY_API_KEY: 'am-test-key', ATOMICMEMORY_SCOPE_USER: 'configured-user', ATOMICMEMORY_SCOPE_AGENT: 'codex', ATOMICMEMORY_SCOPE_NAMESPACE: 'repo', ATOMICMEMORY_SCOPE_THREAD: 'thread-1', } as NodeJS.ProcessEnv); + assert.equal(config.apiUrl, 'https://memory.example.com'); + assert.equal(config.apiKey, 'am-test-key'); assert.deepEqual(config.scope, { user: 'configured-user', agent: 'codex', @@ -41,6 +45,16 @@ test('validateConfig accepts plugin config without URL, key, or scope', () => { assert.ok(config.scope?.user); }); +test('validateConfig accepts explicit plugin api key', () => { + const config = validateConfig({ + apiUrl: 'https://memory.example.com', + apiKey: 'am-plugin-key', + }); + + assert.equal(config.apiUrl, 'https://memory.example.com'); + assert.equal(config.apiKey, 'am-plugin-key'); +}); + test('validateConfig requires explicit apiUrl for mem0', () => { assert.throws( () => validateConfig({ provider: 'mem0' }), diff --git a/packages/mcp-server/src/config.ts b/packages/mcp-server/src/config.ts index a30b363..dcaa325 100644 --- a/packages/mcp-server/src/config.ts +++ b/packages/mcp-server/src/config.ts @@ -24,6 +24,7 @@ const ScopeSchema = z const ConfigSchema = z .object({ apiUrl: z.string().url().optional(), + apiKey: z.string().optional(), provider: z.enum(['atomicmemory', 'mem0']).default(DEFAULT_PROVIDER), scope: ScopeSchema.optional(), }) @@ -39,6 +40,8 @@ export type Scope = z.infer; */ export function loadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): ServerConfig { const raw = { + apiUrl: cleanOptional(env.ATOMICMEMORY_API_URL), + apiKey: cleanOptional(env.ATOMICMEMORY_API_KEY), provider: cleanOptional(env.ATOMICMEMORY_PROVIDER), scope: parseScope(env), }; diff --git a/packages/mcp-server/src/embedded-client.ts b/packages/mcp-server/src/embedded-client.ts new file mode 100644 index 0000000..882fc61 --- /dev/null +++ b/packages/mcp-server/src/embedded-client.ts @@ -0,0 +1,33 @@ +/** + * @file Embedded MCP client helper for host plugins that cannot speak + * MCP over stdio directly. The shared MCP server still owns tool + * semantics; this helper just wires an in-memory MCP client to it. + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { spawnAtomicMemoryMcp } from './spawn.js'; + +export interface EmbeddedMcpToolCaller { + callTool(input: { name: string; arguments?: Record }): Promise<{ content: unknown }>; + close(): Promise; +} + +export async function createEmbeddedMcpToolCaller( + config: unknown, + clientInfo: { name: string; version: string }, +): Promise { + const { server } = await spawnAtomicMemoryMcp(config); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client(clientInfo); + await server.connect(serverTransport); + await client.connect(clientTransport); + return { + callTool(input) { + return client.callTool(input) as Promise<{ content: unknown }>; + }, + close() { + return client.close(); + }, + }; +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index b06e1f9..b826243 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -184,6 +184,7 @@ export async function buildServer(config: ServerConfig): Promise { async function initClient(config: ServerConfig): Promise { const providerConfig = { apiUrl: config.apiUrl, + ...(config.apiKey ? { apiKey: config.apiKey } : {}), }; const providers: MemoryClientConfig['providers'] = config.provider === 'mem0' diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json index 6f2453d..81eaab5 100644 --- a/plugins/claude-code/.claude-plugin/plugin.json +++ b/plugins/claude-code/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "atomicmemory", - "version": "0.1.10", + "version": "0.1.11", "description": "Persistent semantic memory for Claude Code — user preferences, project context, prior decisions, and codebase facts that survive across sessions.", "author": { "name": "AtomicMemory", @@ -14,7 +14,10 @@ "args": ["-c", "exec node \"$ATOMICMEMORY_MCP_SERVER_BIN\""], "env": { "ATOMICMEMORY_MCP_SERVER_BIN": "${ATOMICMEMORY_MCP_SERVER_BIN}", + "ATOMICMEMORY_API_URL": "${ATOMICMEMORY_API_URL}", + "ATOMICMEMORY_API_KEY": "${ATOMICMEMORY_API_KEY}", "ATOMICMEMORY_PROVIDER": "${ATOMICMEMORY_PROVIDER:-atomicmemory}", + "ATOMICMEMORY_SCOPE_USER": "${ATOMICMEMORY_SCOPE_USER}", "ATOMICMEMORY_SCOPE_AGENT": "${ATOMICMEMORY_SCOPE_AGENT:-}", "ATOMICMEMORY_SCOPE_NAMESPACE": "${ATOMICMEMORY_SCOPE_NAMESPACE:-}", "ATOMICMEMORY_SCOPE_THREAD": "${ATOMICMEMORY_SCOPE_THREAD:-}" diff --git a/plugins/claude-code/README.md b/plugins/claude-code/README.md index 183e8ec..d4adb88 100644 --- a/plugins/claude-code/README.md +++ b/plugins/claude-code/README.md @@ -56,6 +56,7 @@ export ATOMICMEMORY_CAPTURE_LEVEL="balanced" # minimal|balanced|full ``` `ATOMICMEMORY_MCP_SERVER_BIN`, `ATOMICMEMORY_API_URL`, `ATOMICMEMORY_API_KEY`, `ATOMICMEMORY_PROVIDER`, `ATOMICMEMORY_SCOPE_USER`, and `ATOMICMEMORY_CAPTURE_LEVEL` are required for the Claude Code plugin and hooks. Optional scope vars narrow retrieval and lifecycle record metadata. +If `ATOMICMEMORY_SCOPE_USER` is empty, the MCP server derives a local user from the host OS; set it explicitly when multiple operators share a machine or when you need a stable cross-machine identity. #### Local extraction with Claude Code auth diff --git a/plugins/claude-code/package.json b/plugins/claude-code/package.json index ea6189e..1f79f45 100644 --- a/plugins/claude-code/package.json +++ b/plugins/claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@atomicmemory/claude-code-plugin", - "version": "0.1.10", + "version": "0.1.11", "description": "AtomicMemory plugin for Claude Code — persistent semantic memory across sessions.", "private": false, "license": "Apache-2.0", diff --git a/plugins/codex/.codex-mcp.json b/plugins/codex/.codex-mcp.json index 6cda00c..1d3ae64 100644 --- a/plugins/codex/.codex-mcp.json +++ b/plugins/codex/.codex-mcp.json @@ -6,7 +6,9 @@ "env_vars": [ "ATOMICMEMORY_MCP_SERVER_BIN", "ATOMICMEMORY_API_URL", + "ATOMICMEMORY_API_KEY", "ATOMICMEMORY_PROVIDER", + "ATOMICMEMORY_SCOPE_USER", "ATOMICMEMORY_SCOPE_AGENT", "ATOMICMEMORY_SCOPE_NAMESPACE", "ATOMICMEMORY_SCOPE_THREAD" diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json index 3c4635f..916b8c6 100644 --- a/plugins/codex/.codex-plugin/plugin.json +++ b/plugins/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "atomicmemory", - "version": "0.1.10", + "version": "0.1.11", "description": "AtomicMemory memory layer for Codex. Pluggable semantic memory — swap backends through the SDK's MemoryProvider model by config, not code change.", "author": { "name": "AtomicMemory", diff --git a/plugins/codex/package.json b/plugins/codex/package.json index 9464fe9..11da0f6 100644 --- a/plugins/codex/package.json +++ b/plugins/codex/package.json @@ -1,6 +1,6 @@ { "name": "@atomicmemory/codex-plugin", - "version": "0.1.10", + "version": "0.1.11", "description": "AtomicMemory plugin for OpenAI Codex — plugin manifest, MCP server config, and memory protocol skill.", "private": true, "license": "Apache-2.0", diff --git a/plugins/codex/skills/atomicmemory/SKILL.md b/plugins/codex/skills/atomicmemory/SKILL.md index 2719fd9..a5d8ed1 100644 --- a/plugins/codex/skills/atomicmemory/SKILL.md +++ b/plugins/codex/skills/atomicmemory/SKILL.md @@ -10,7 +10,7 @@ description: > license: Apache-2.0 metadata: author: AtomicMemory - version: "0.1.10" + version: "0.1.11" category: ai-memory tags: "memory, semantic-search, codex, pluggable" --- diff --git a/plugins/cursor/package.json b/plugins/cursor/package.json index 3e7fba1..a9d9cbb 100644 --- a/plugins/cursor/package.json +++ b/plugins/cursor/package.json @@ -1,6 +1,6 @@ { "name": "@atomicmemory/cursor-plugin", - "version": "0.1.10", + "version": "0.1.11", "description": "AtomicMemory integration for Cursor - MCP configuration and project rules for persistent semantic memory.", "private": true, "license": "Apache-2.0", diff --git a/plugins/hermes/CHANGELOG.md b/plugins/hermes/CHANGELOG.md new file mode 100644 index 0000000..032a199 --- /dev/null +++ b/plugins/hermes/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## 0.1.11 - 2026-05-14 + +### Added + +- Added the packaged Hermes provider installer exposed through the `atomicmemory-hermes` npm binary. + +### Fixed + +- Preserved the Python SDK import path when Hermes is installed from the packaged npm artifact. + +## 0.1.10 - 2026-05-14 + +### Added + +- Initial public npm package for the AtomicMemory Hermes plugin. diff --git a/plugins/hermes/README.md b/plugins/hermes/README.md index f5c488c..8be8a57 100644 --- a/plugins/hermes/README.md +++ b/plugins/hermes/README.md @@ -24,19 +24,18 @@ hooks, tool schemas. Memory semantics flow through the published Python SDK. ## Prerequisites -- Hermes Agent installed and `HERMES_HOME` set +- Hermes Agent installed - AtomicMemory core URL exported as `ATOMICMEMORY_API_URL` -## Install (dev) +## Install -The simplest dev install symlinks the plugin into Hermes' memory directory. -Hermes installs the published `atomicmemory` SDK from `plugin.yaml`. +Install the provider from the published npm package. The installer copies the +Python provider files into Hermes' memory-provider directory; no repository +clone is required. ```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" +npx -y @atomicmemory/hermes-plugin install +export ATOMICMEMORY_API_URL="http://127.0.0.1:3050" ``` Then select and verify the provider: @@ -48,6 +47,14 @@ hermes memory status # confirm "atomicmemory" is active ``` +For source development, symlink the checkout instead: + +```bash +cd /path/to/atomicmemory-integrations +mkdir -p "$HERMES_HOME/plugins/memory" +ln -s "$(pwd)/plugins/hermes" "$HERMES_HOME/plugins/memory/atomicmemory" +``` + ## Config Hermes' setup wizard prompts for a minimal pair (`scope_user`, `memory_scope`). @@ -144,7 +151,7 @@ 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//`. | +| Provider does not appear in `hermes memory setup` | Wrong install path. User-installed memory providers must live under `$HERMES_HOME/plugins/memory//`. | | `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`. | diff --git a/plugins/hermes/install.mjs b/plugins/hermes/install.mjs new file mode 100644 index 0000000..b6d7cc3 --- /dev/null +++ b/plugins/hermes/install.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** + * Install the AtomicMemory Hermes provider from the published npm package. + * + * Hermes memory providers are filesystem plugins under + * `$HERMES_HOME/plugins/memory/`. The npm package already contains the + * Python provider files, so this installer copies only that managed provider + * surface into the active Hermes profile without requiring a Git checkout. + */ + +import { copyFileSync, mkdirSync, readFileSync } from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const packageDir = dirname(fileURLToPath(import.meta.url)); + +function main(argv) { + const options = parseArgs(argv); + if (options.help) { + printHelp(); + return; + } + if (options.command !== 'install') { + throw new Error(`Unknown command '${options.command}'. Expected 'install'.`); + } + const target = options.target ?? defaultTarget(); + installProvider(target); + printNextSteps(target); +} + +function parseArgs(argv) { + const options = { command: 'install', target: undefined, help: false }; + const args = [...argv]; + if (args[0] && !args[0].startsWith('-')) { + options.command = args.shift(); + } + while (args.length > 0) { + const arg = args.shift(); + if (arg === '--help' || arg === '-h') { + options.help = true; + continue; + } + if (arg === '--target') { + const value = args.shift(); + if (!value) throw new Error('--target requires a path'); + options.target = resolve(value); + continue; + } + throw new Error(`Unknown option '${arg}'`); + } + return options; +} + +function defaultTarget() { + const hermesHome = process.env.HERMES_HOME || defaultHermesHome(); + return join(hermesHome, 'plugins', 'memory', 'atomicmemory'); +} + +function defaultHermesHome() { + const home = process.env.HOME; + if (!home) { + throw new Error('Set HERMES_HOME or HOME before installing the Hermes provider.'); + } + return join(home, '.hermes'); +} + +function installProvider(target) { + mkdirSync(target, { recursive: true }); + for (const file of providerFiles()) { + copyFileSync(join(packageDir, file), join(target, basename(file))); + } +} + +function providerFiles() { + const pkg = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')); + return pkg.files.filter((file) => file.endsWith('.py') || file === 'plugin.yaml' || file === 'README.md'); +} + +function printNextSteps(target) { + console.log(`Installed AtomicMemory Hermes provider to ${target}`); + console.log(''); + console.log('Next:'); + console.log(' export ATOMICMEMORY_API_URL="http://127.0.0.1:3050"'); + console.log(' hermes memory setup'); + console.log(' hermes memory status'); +} + +function printHelp() { + console.log(`Usage: atomicmemory-hermes [install] [--target ] + +Installs the AtomicMemory Hermes memory provider into: + $HERMES_HOME/plugins/memory/atomicmemory + +When HERMES_HOME is unset, defaults to: + $HOME/.hermes/plugins/memory/atomicmemory`); +} + +try { + main(process.argv.slice(2)); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/plugins/hermes/package.json b/plugins/hermes/package.json index 31d8dc0..6dd355f 100644 --- a/plugins/hermes/package.json +++ b/plugins/hermes/package.json @@ -1,6 +1,6 @@ { "name": "@atomicmemory/hermes-plugin", - "version": "0.1.10", + "version": "0.1.11", "description": "AtomicMemory native Hermes memory provider — Python SDK-backed, cross-tool memory by default.", "publishConfig": { "access": "public", @@ -12,7 +12,11 @@ "url": "git+https://github.com/atomicstrata/atomicmemory-integrations.git", "directory": "plugins/hermes" }, + "bin": { + "atomicmemory-hermes": "./install.mjs" + }, "files": [ + "CHANGELOG.md", "__init__.py", "client.py", "config.py", @@ -20,6 +24,8 @@ "tools.py", "breaker.py", "worker.py", + "install.mjs", + "pyproject.toml", "plugin.yaml", "README.md" ], diff --git a/plugins/hermes/plugin.yaml b/plugins/hermes/plugin.yaml index dcdc1c1..882fde9 100644 --- a/plugins/hermes/plugin.yaml +++ b/plugins/hermes/plugin.yaml @@ -1,5 +1,5 @@ name: atomicmemory -version: 0.1.10 +version: 0.1.11 description: "AtomicMemory native Hermes memory provider — Python SDK-backed, cross-tool memory by default." pip_dependencies: - "atomicmemory>=1.0.1,<2.0.0" diff --git a/plugins/hermes/pyproject.toml b/plugins/hermes/pyproject.toml new file mode 100644 index 0000000..38bfe31 --- /dev/null +++ b/plugins/hermes/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "atomicmemory-hermes" +version = "0.1.11" +description = "AtomicMemory native Hermes memory provider." +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" +authors = [{ name = "Atomic Strata" }] +dependencies = ["atomicmemory>=1.0.1,<2.0.0"] + +[project.urls] +Repository = "https://github.com/atomicstrata/atomicmemory-integrations" +Documentation = "https://docs.atomicstrata.ai/integrations/coding-agents/hermes/local" + +[project.entry-points."hermes_agent.plugins"] +atomicmemory = "atomicmemory_hermes:register" + +[tool.setuptools] +packages = ["atomicmemory_hermes"] + +[tool.setuptools.package-dir] +atomicmemory_hermes = "." + +[tool.setuptools.package-data] +atomicmemory_hermes = ["plugin.yaml", "README.md"] diff --git a/plugins/hermes/python_sdk.py b/plugins/hermes/python_sdk.py index d2ef099..5192b4f 100644 --- a/plugins/hermes/python_sdk.py +++ b/plugins/hermes/python_sdk.py @@ -252,15 +252,7 @@ def _is_atomicmemory_module(name: str) -> bool: 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 + return {Path(__file__).resolve().parent} def _remove_plugin_import_roots(plugin_roots: set[Path]) -> list[tuple[int, str]]: diff --git a/plugins/hermes/tests/test_install_path_imports.py b/plugins/hermes/tests/test_install_path_imports.py index 2bc0ab7..beb04e0 100644 --- a/plugins/hermes/tests/test_install_path_imports.py +++ b/plugins/hermes/tests/test_install_path_imports.py @@ -16,6 +16,7 @@ import importlib.util import json import shutil +import subprocess import sys import tempfile import unittest @@ -32,7 +33,37 @@ def _shipped_files() -> list[str]: return [f for f in files if f.endswith(".py")] +def _provider_install_files() -> list[str]: + """Return package files the npm installer should copy into Hermes.""" + pkg = json.loads((PLUGIN_ROOT / "package.json").read_text(encoding="utf-8")) + files = pkg.get("files") or [] + return [f for f in files if f.endswith(".py") or f in {"plugin.yaml", "README.md"}] + + class InstallPathImportsResolve(unittest.TestCase): + def test_install_mjs_copies_provider_files(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "atomicmemory" + result = subprocess.run( + ["node", str(PLUGIN_ROOT / "install.mjs"), "install", "--target", str(target)], + capture_output=True, + check=True, + text=True, + ) + + self.assertIn(f"Installed AtomicMemory Hermes provider to {target}", result.stdout) + for name in _provider_install_files(): + self.assertTrue((target / name).exists(), f"{name} was not installed") + self.assertFalse((target / "install.mjs").exists()) + self.assertFalse((target / "pyproject.toml").exists()) + + def test_pyproject_declares_hermes_entry_point(self) -> None: + pyproject = (PLUGIN_ROOT / "pyproject.toml").read_text(encoding="utf-8") + + self.assertRegex(pyproject, r'(?m)^name\s*=\s*"atomicmemory-hermes"$') + self.assertRegex(pyproject, r'(?m)^atomicmemory\s*=\s*"atomicmemory_hermes:register"$') + self.assertRegex(pyproject, r'(?m)^atomicmemory_hermes\s*=\s*"\."$') + def test_package_loads_when_directory_is_renamed_to_atomicmemory(self) -> None: shipped = _shipped_files() self.assertIn("__init__.py", shipped, "package.json must ship __init__.py") @@ -68,6 +99,32 @@ def test_package_loads_when_directory_is_renamed_to_atomicmemory(self) -> None: if mod == "atomicmemory" or mod.startswith("atomicmemory."): del sys.modules[mod] + def test_package_loads_via_python_distribution_name(self) -> None: + shipped = _shipped_files() + + with tempfile.TemporaryDirectory() as tmp: + install_root = Path(tmp) / "site-packages" + pkg_dir = install_root / "atomicmemory_hermes" + pkg_dir.mkdir(parents=True) + for name in shipped: + shutil.copy(PLUGIN_ROOT / name, pkg_dir / name) + + sys.path.insert(0, str(install_root)) + for mod in list(sys.modules): + if mod == "atomicmemory_hermes" or mod.startswith("atomicmemory_hermes."): + del sys.modules[mod] + try: + installed = importlib.import_module("atomicmemory_hermes") + self.assertTrue(hasattr(installed, "AtomicMemoryMemoryProvider")) + self.assertTrue(hasattr(installed, "register")) + tools_mod = importlib.import_module("atomicmemory_hermes.tools") + self.assertTrue(hasattr(tools_mod, "TOOL_HANDLERS")) + finally: + sys.path.remove(str(install_root)) + for mod in list(sys.modules): + if mod == "atomicmemory_hermes" or mod.startswith("atomicmemory_hermes."): + del sys.modules[mod] + if __name__ == "__main__": unittest.main() diff --git a/plugins/hermes/tests/test_python_sdk.py b/plugins/hermes/tests/test_python_sdk.py index e25f640..5c9b3a9 100644 --- a/plugins/hermes/tests/test_python_sdk.py +++ b/plugins/hermes/tests/test_python_sdk.py @@ -115,6 +115,22 @@ def test_loader_survives_plugin_named_atomicmemory(self) -> None: self.assertIs(sys.modules.get("atomicmemory"), prior.get("atomicmemory")) self.assertEqual(sys.path, prior_path) + def test_loader_survives_cached_published_sdk_modules(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + sdk_root = _write_fake_sdk(Path(tmp) / "site-packages") + prior = _stash_atomicmemory_modules() + prior_path = list(sys.path) + sys.path.insert(0, str(sdk_root)) + try: + first = _load_sdk_types() + second = _load_sdk_types() + finally: + _restore_atomicmemory_modules(prior) + sys.path[:] = prior_path + + self.assertEqual(first.MemoryClient.__name__, "MemoryClient") + self.assertEqual(second.MemoryClient.__name__, "MemoryClient") + def _client_with_fakes(*, has_atomic: bool = True) -> tuple[PythonSdkAtomicMemoryClient, "FakeMemoryClient"]: FakeMemoryClient.next_has_atomic = has_atomic diff --git a/plugins/openclaw/README.md b/plugins/openclaw/README.md index 75202e5..521fb80 100644 --- a/plugins/openclaw/README.md +++ b/plugins/openclaw/README.md @@ -2,7 +2,7 @@ Persistent semantic memory for OpenClaw agents. Installed from a local clone of this repo — not distributed through ClawHub or any other marketplace. -The plugin embeds the shared [`@atomicmemory/mcp-server`](../../packages/mcp-server) in-process and registers it as `atomicmemory.memory`. The agent-facing skill uses the same four tools as the other integrations: `memory_search`, `memory_ingest`, `memory_package`, and `memory_list`. +The plugin embeds the shared [`@atomicmemory/mcp-server`](../../packages/mcp-server) in-process and registers the same four tools as the other integrations: `memory_search`, `memory_ingest`, `memory_package`, and `memory_list`. ## Install @@ -10,7 +10,7 @@ The plugin embeds the shared [`@atomicmemory/mcp-server`](../../packages/mcp-ser git clone https://github.com/atomicstrata/atomicmemory-integrations.git cd atomicmemory-integrations/plugins/openclaw -claw plugin install . +openclaw plugins install . ``` See the [full documentation](https://docs.atomicmemory.ai/integrations/coding-agents/openclaw) for config details. @@ -44,10 +44,10 @@ plugins/openclaw/ │ ├── skill.yaml # skill permissions + entrypoint │ └── instructions.md # agent-facing prompt └── src/ - └── index.ts # plugin onLoad — spawns the MCP server + └── index.ts # plugin register entrypoint — exposes MCP tools ``` -The plugin embeds [`@atomicmemory/mcp-server`](../../packages/mcp-server) in-process via its `/spawn` export. No subprocess, no extra dependency for the host. All memory semantics live in the shared server. +The plugin embeds [`@atomicmemory/mcp-server`](../../packages/mcp-server) in-process through its embedded client helper. No subprocess, no separate host dependency. All memory semantics live in the shared server. ## Memory behavior @@ -77,7 +77,7 @@ Then rebuild and reinstall: ```bash pnpm --filter @atomicmemory/openclaw-plugin build -claw plugin install . +openclaw plugins install . ``` Restart the OpenClaw host if it keeps plugin modules loaded. diff --git a/plugins/openclaw/openclaw.plugin.json b/plugins/openclaw/openclaw.plugin.json index 44ab6e0..235d27c 100644 --- a/plugins/openclaw/openclaw.plugin.json +++ b/plugins/openclaw/openclaw.plugin.json @@ -1,10 +1,9 @@ { "id": "atomicmemory", "name": "AtomicMemory", - "version": "0.1.10", + "version": "0.1.11", "description": "Persistent semantic memory for OpenClaw agents — cross-channel user memory and deterministic session snapshots via the AtomicMemory SDK's pluggable MemoryProvider model.", "kind": "memory", - "providers": ["atomicmemory.memory"], "skills": ["./skills/atomicmemory"], "configSchema": { "type": "object", @@ -12,9 +11,12 @@ "properties": { "apiUrl": { "type": "string", - "format": "uri", "description": "Optional provider base URL. Defaults to the local AtomicMemory core for provider=atomicmemory. Required for provider=mem0." }, + "apiKey": { + "type": "string", + "description": "Optional bearer credential forwarded to AtomicMemory core as Authorization: Bearer ." + }, "provider": { "type": "string", "enum": ["atomicmemory", "mem0"], diff --git a/plugins/openclaw/package.json b/plugins/openclaw/package.json index cdf8a99..3350ad0 100644 --- a/plugins/openclaw/package.json +++ b/plugins/openclaw/package.json @@ -5,6 +5,11 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "openclaw": { + "extensions": [ + "./dist/index.js" + ] + }, "license": "Apache-2.0", "repository": { "type": "git", @@ -20,6 +25,7 @@ "scripts": { "build": "pnpm --dir ../../packages/mcp-server build && tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "pnpm build && node --test 'dist/**/*.test.js'", "lint": "tsc -p tsconfig.json --noEmit", "prepack": "pnpm build" }, diff --git a/plugins/openclaw/skills/atomicmemory/skill.yaml b/plugins/openclaw/skills/atomicmemory/skill.yaml index cf0f269..430eaee 100644 --- a/plugins/openclaw/skills/atomicmemory/skill.yaml +++ b/plugins/openclaw/skills/atomicmemory/skill.yaml @@ -1,5 +1,5 @@ name: atomicmemory -version: 0.1.10 +version: 0.1.11 author: name: AtomicMemory url: https://atomicmem.ai diff --git a/plugins/openclaw/src/index.test.ts b/plugins/openclaw/src/index.test.ts new file mode 100644 index 0000000..cb97a26 --- /dev/null +++ b/plugins/openclaw/src/index.test.ts @@ -0,0 +1,81 @@ +/** + * @file Regression tests for OpenClaw plugin registration behavior. + * OpenClaw loads plugins for inventory commands such as + * `openclaw plugins list`; registration must therefore stay + * synchronous and must not start the embedded MCP server until a + * memory tool is actually executed. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import plugin, { createOpenClawPlugin } from './index.js'; + +test('register exposes memory tools without requiring provider config', () => { + const tools: Array<{ name: string }> = []; + + plugin.register({ + registerTool(tool) { + tools.push({ name: tool.name }); + }, + }); + + assert.deepEqual( + tools.map((tool) => tool.name).sort(), + ['memory_ingest', 'memory_list', 'memory_package', 'memory_search'], + ); +}); + +test('execute lazily creates one MCP caller and parses result details', async () => { + const createdConfigs: unknown[] = []; + const toolCalls: Array<{ name: string; arguments?: Record }> = []; + const testPlugin = createOpenClawPlugin(async (config) => { + createdConfigs.push(config); + return { + async callTool(input) { + toolCalls.push(input); + return { content: [{ type: 'text', text: JSON.stringify({ ok: true, call: toolCalls.length }) }] }; + }, + }; + }); + const tools = registerWithConfig(testPlugin); + const list = tools.find((tool) => tool.name === 'memory_list'); + assert.ok(list); + assert.equal(createdConfigs.length, 0); + + const first = await list.execute('call-1', { limit: 1 }); + const second = await list.execute('call-2', { limit: 2 }); + + assert.deepEqual(createdConfigs, [normalizedConfig()]); + assert.deepEqual(toolCalls, [ + { name: 'memory_list', arguments: { limit: 1 } }, + { name: 'memory_list', arguments: { limit: 2 } }, + ]); + assert.deepEqual(first.details, { ok: true, call: 1 }); + assert.deepEqual(second.details, { ok: true, call: 2 }); + assert.deepEqual(first.content, [{ type: 'text', text: '{"ok":true,"call":1}' }]); +}); + +function registerWithConfig(testPlugin: typeof plugin) { + const tools: Array[0]['registerTool']>[0]> = []; + testPlugin.register({ + pluginConfig: { + apiUrl: 'http://127.0.0.1:3050///', + apiKey: ' local-dev-key ', + provider: 'atomicmemory', + scope: { user: 'pip', namespace: 'repo' }, + }, + registerTool(tool) { + tools.push(tool); + }, + }); + return tools; +} + +function normalizedConfig() { + return { + apiUrl: 'http://127.0.0.1:3050', + apiKey: 'local-dev-key', + provider: 'atomicmemory', + scope: { user: 'pip', namespace: 'repo' }, + }; +} diff --git a/plugins/openclaw/src/index.ts b/plugins/openclaw/src/index.ts index 649f9e8..deb3b93 100644 --- a/plugins/openclaw/src/index.ts +++ b/plugins/openclaw/src/index.ts @@ -1,59 +1,217 @@ /** - * @file OpenClaw plugin entry — spawns the shared AtomicMemory MCP - * server in-process and registers it as a memory provider with - * the OpenClaw runtime. All memory semantics live in - * `@atomicmemory/mcp-server`; this file is a thin adapter. - * - * OpenClaw's plugin SDK is not yet on npm, so the host-facing - * interface is declared locally. The OpenClaw runtime discovers - * plugins by inspecting the default export's shape (id + - * onLoad), so matching the shape is sufficient — no import of a - * `definePlugin` factory is required for compatibility. + * @file OpenClaw plugin entry. OpenClaw plugins register tools directly, + * while the other AtomicMemory agent integrations speak MCP. This + * adapter embeds the shared MCP server in-process, then exposes its + * four memory tools through OpenClaw's `registerTool` contract so + * memory semantics stay owned by `@atomicmemory/mcp-server`. */ -import { spawnAtomicMemoryMcp } from '@atomicmemory/mcp-server/spawn'; +import { createEmbeddedMcpToolCaller } from '@atomicmemory/mcp-server/embedded-client'; import { hostname, userInfo } from 'node:os'; interface AtomicMemoryConfig { apiUrl?: string; + apiKey?: string; /** Provider name dispatched through the SDK's MemoryProvider model. */ provider?: 'atomicmemory' | 'mem0'; scope?: { user?: string; agent?: string; namespace?: string; thread?: string }; } -interface PluginContext { - config: AtomicMemoryConfig; - registerProvider(id: string, provider: unknown): void; +interface PluginApi { + pluginConfig?: AtomicMemoryConfig; + registerTool(tool: AgentTool, options?: { name?: string; names?: string[] }): void; } -export interface Plugin { +interface Plugin { id: string; - onLoad(ctx: PluginContext): Promise; + name: string; + description: string; + kind: 'memory'; + register(api: PluginApi): void; } -const PROVIDER_ID = 'atomicmemory.memory'; +interface AgentTool { + name: string; + label: string; + description: string; + parameters: Record; + execute(toolCallId: string, params: Record): Promise; +} + +interface ToolResult { + content: Array<{ type: 'text'; text: string }>; + details?: unknown; +} + +interface McpToolCaller { + callTool(input: { name: string; arguments?: Record }): Promise<{ content: unknown }>; +} + +type CreateMcpToolCaller = ( + config: unknown, + clientInfo: { name: string; version: string }, +) => Promise; + +type McpClientFactory = () => Promise; + +interface McpTextContent { + type: 'text'; + text: string; +} + +const TOOL_NAMES = ['memory_search', 'memory_ingest', 'memory_package', 'memory_list'] as const; + +export function createOpenClawPlugin(createCaller: CreateMcpToolCaller = createEmbeddedMcpToolCaller): Plugin { + return { + id: 'atomicmemory', + name: 'AtomicMemory', + description: 'Persistent semantic memory for OpenClaw agents.', + kind: 'memory', + register(api: PluginApi): void { + const client = lazyMcpClient(api.pluginConfig ?? {}, createCaller); + for (const name of TOOL_NAMES) { + api.registerTool(createTool(name, client)); + } + }, + }; +} + +function lazyMcpClient(config: AtomicMemoryConfig, createCaller: CreateMcpToolCaller): McpClientFactory { + let client: Promise | undefined; + return () => { + client ??= createMcpClient(config, createCaller); + return client; + }; +} + +async function createMcpClient(config: AtomicMemoryConfig, createCaller: CreateMcpToolCaller) { + return createCaller(normalizeConfig(config), { + name: 'atomicmemory-openclaw', + version: '0.1.0', + }); +} + +function createTool(name: (typeof TOOL_NAMES)[number], client: McpClientFactory): AgentTool { + return { + name, + label: labelFor(name), + description: descriptionFor(name), + parameters: schemaFor(name), + async execute(_toolCallId, params) { + const caller = await client(); + const result = await caller.callTool({ name, arguments: params }); + return openClawResult(result.content); + }, + }; +} + +function openClawResult(content: unknown): ToolResult { + const text = textContent(content); + return { + content: [{ type: 'text', text }], + details: parseDetails(text), + }; +} + +function textContent(content: unknown): string { + if (!Array.isArray(content)) return JSON.stringify(content); + const first = content.find((item): item is McpTextContent => isTextContent(item)); + return first?.text ?? JSON.stringify(content); +} -const plugin: Plugin = { - id: 'atomicmemory', - async onLoad(ctx) { - const { server } = await spawnAtomicMemoryMcp(normalizeConfig(ctx.config)); - ctx.registerProvider(PROVIDER_ID, server); - }, -}; +function isTextContent(value: unknown): value is McpTextContent { + return ( + typeof value === 'object' && + value !== null && + (value as { type?: unknown }).type === 'text' && + typeof (value as { text?: unknown }).text === 'string' + ); +} + +function parseDetails(text: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +function schemaFor(name: (typeof TOOL_NAMES)[number]): Record { + switch (name) { + case 'memory_search': + return objectSchema({ query: stringSchema(), limit: optionalNumberSchema(), scope: scopeSchema() }, ['query']); + case 'memory_ingest': + return objectSchema({ + mode: enumSchema(['text', 'messages', 'verbatim']), + content: stringSchema(), + messages: { type: 'array' }, + scope: scopeSchema(), + metadata: { type: 'object', additionalProperties: true }, + provenance: { type: 'object', additionalProperties: true }, + }, ['mode']); + case 'memory_package': + return objectSchema({ query: stringSchema(), tokenBudget: optionalNumberSchema(), scope: scopeSchema() }, ['query']); + case 'memory_list': + return objectSchema({ limit: optionalNumberSchema(), scope: scopeSchema() }, []); + } +} + +function objectSchema(properties: Record, required: string[]): Record { + return { type: 'object', additionalProperties: false, properties, required }; +} + +function scopeSchema(): Record { + return objectSchema({ + user: stringSchema(), + agent: stringSchema(), + namespace: stringSchema(), + thread: stringSchema(), + }, []); +} + +function stringSchema(): Record { + return { type: 'string' }; +} + +function optionalNumberSchema(): Record { + return { type: 'number' }; +} + +function enumSchema(values: string[]): Record { + return { type: 'string', enum: values }; +} + +function labelFor(name: (typeof TOOL_NAMES)[number]): string { + return { + memory_search: 'Memory Search', + memory_ingest: 'Memory Ingest', + memory_package: 'Memory Package', + memory_list: 'Memory List', + }[name]; +} + +function descriptionFor(name: (typeof TOOL_NAMES)[number]): string { + return { + memory_search: 'Search AtomicMemory by meaning.', + memory_ingest: 'Store durable memory through AtomicMemory.', + memory_package: 'Assemble a token-budgeted AtomicMemory context package.', + memory_list: 'List recent memories for the configured scope.', + }[name]; +} function normalizeConfig(config: AtomicMemoryConfig): { apiUrl: string; + apiKey?: string; provider: 'atomicmemory' | 'mem0'; scope: { user: string; agent?: string; namespace?: string; thread?: string }; } { const provider = config.provider ?? 'atomicmemory'; const scope = normalizeScope(config.scope); + const apiKey = cleanOptional(config.apiKey); + const result = { apiUrl: resolveApiUrl(config.apiUrl, provider), provider, scope }; - return { - apiUrl: resolveApiUrl(config.apiUrl, provider), - provider, - scope, - }; + if (apiKey) return { ...result, apiKey }; + return result; } function normalizeScope(scope: AtomicMemoryConfig['scope']): { @@ -63,14 +221,7 @@ function normalizeScope(scope: AtomicMemoryConfig['scope']): { thread?: string; } { const user = cleanOptional(scope?.user) ?? defaultScopeUser(); - - const result: { - user: string; - agent?: string; - namespace?: string; - thread?: string; - } = { user }; - + const result: { user: string; agent?: string; namespace?: string; thread?: string } = { user }; const agent = cleanOptional(scope?.agent); const namespace = cleanOptional(scope?.namespace); const thread = cleanOptional(scope?.thread); @@ -78,7 +229,6 @@ function normalizeScope(scope: AtomicMemoryConfig['scope']): { if (agent) result.agent = agent; if (namespace) result.namespace = namespace; if (thread) result.thread = thread; - return result; } @@ -97,10 +247,7 @@ function defaultScopeUser(): string { ); } -function resolveApiUrl( - apiUrl: string | undefined, - provider: 'atomicmemory' | 'mem0', -): string { +function resolveApiUrl(apiUrl: string | undefined, provider: 'atomicmemory' | 'mem0'): string { const normalized = cleanOptional(apiUrl); if (normalized) return normalized.replace(/\/+$/, ''); if (provider === 'atomicmemory') return 'http://127.0.0.1:3050'; @@ -115,4 +262,4 @@ function readOsUsername(): string | undefined { } } -export default plugin; +export default createOpenClawPlugin(); diff --git a/scripts/bump-plugin-versions.mjs b/scripts/bump-plugin-versions.mjs index b184afb..6fbf0a6 100755 --- a/scripts/bump-plugin-versions.mjs +++ b/scripts/bump-plugin-versions.mjs @@ -55,6 +55,12 @@ const targets = [ ), jsonPathTarget('plugins/hermes/package.json', ['version']), + regexTarget( + 'plugins/hermes/pyproject.toml', + 'project.version', + /^version\s*=\s*"([^"]+)"$/m, + (version) => `version = "${version}"`, + ), regexTarget( 'plugins/hermes/plugin.yaml', 'version',