From cabc9163de1ebb3f223f455e243f0f7900fd841e Mon Sep 17 00:00:00 2001 From: Cahid Arda Oz Date: Wed, 28 Oct 2026 13:43:44 +0000 Subject: [PATCH 1/2] chore(hermes-memory): prepare for the Hermes plugin catalog (DX-3182) --- .changeset/hermes-catalog-prep.md | 6 ++++++ CLAUDE.md | 3 ++- pyproject.toml | 8 ++++++++ python/hermes-memory/README.md | 7 +++++++ python/hermes-memory/__init__.py | 10 ++++++++++ python/hermes-memory/plugin.yaml | 12 ++++++++++++ python/hermes-memory/pyproject.toml | 3 ++- python/hermes-memory/tests/test_version.py | 6 ++++++ scripts/sync-version.mjs | 10 ++++++++++ uv.lock | 2 +- 10 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 .changeset/hermes-catalog-prep.md create mode 100644 python/hermes-memory/__init__.py create mode 100644 python/hermes-memory/plugin.yaml diff --git a/.changeset/hermes-catalog-prep.md b/.changeset/hermes-catalog-prep.md new file mode 100644 index 0000000..41b8a9d --- /dev/null +++ b/.changeset/hermes-catalog-prep.md @@ -0,0 +1,6 @@ +--- +"upstash-hermes-memory": patch +--- + +Prepare for the Hermes plugin catalog: add `plugin.yaml` and a directory `__init__.py`, cap +`upstash-redis` below 2, and document telemetry and how to turn it off. diff --git a/CLAUDE.md b/CLAUDE.md index 9b4e405..07092f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1424,7 +1424,8 @@ Python lives **beside** the pnpm workspace, not instead of any of it: and writes its `CHANGELOG.md`. `privatePackages: { version: true, tag: false }`: changesets never tags or publishes it. - `scripts/sync-version.mjs` copies that version into `pyproject.toml` and - `src//_version.py` (`VERSION`, for telemetry), discovering packages by folder; `ci:version` + `src//_version.py` (`VERSION`, for telemetry), plus `plugin.yaml` when the package has one (the + Hermes plugin manifest), discovering packages by folder; `ci:version` then runs `uv lock`, so the Version Packages PR carries a consistent `uv.lock`. **Never edit a Python version by hand** — `sync-version.mjs --check` fails CI on drift. - `scripts/publish-python.mjs` (`pnpm ci:publish:python`) is PyPI's `changeset publish`: for every diff --git a/pyproject.toml b/pyproject.toml index a93cc97..bf62581 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,5 +11,13 @@ dev = ["pytest>=8", "ruff>=0.6"] line-length = 100 target-version = "py311" +# A Hermes directory plugin's entry point is `__init__.py` at the plugin root, and the root is a +# hyphenated `python/` folder. +[tool.ruff.lint.per-file-ignores] +"python/*/__init__.py" = ["N999"] + [tool.pytest.ini_options] testpaths = ["python"] +# The plugin-root `__init__.py` makes `python/` look like a package to the default +# `prepend` mode, which then fails to import it; importlib mode imports test files on their own. +addopts = ["--import-mode=importlib"] diff --git a/python/hermes-memory/README.md b/python/hermes-memory/README.md index 3cdfa52..880ea2e 100644 --- a/python/hermes-memory/README.md +++ b/python/hermes-memory/README.md @@ -37,6 +37,13 @@ these): | `UPSTASH_HERMES_USER_ID` | Whose memories these are. Defaults to the gateway user, then the Hermes profile name, then `default`. | | `UPSTASH_HERMES_MEMORY_PREFIX`, `UPSTASH_HERMES_CHAT_PREFIX` | Key prefixes. Default `agentkit:memory` and `agentkit:chat`, the TS SDK's defaults, so the two share memories. | +## Telemetry + +On by default, like the other Upstash SDKs: requests to your Upstash database carry +`upstash-hermes-memory@` in the `Upstash-Telemetry-Sdk` header, next to the tag +`upstash-redis` already sends. Nothing else is collected and nothing goes anywhere but your +database. Set `UPSTASH_DISABLE_TELEMETRY=1` to turn it off. + ## Redis layout | | Key | Index | diff --git a/python/hermes-memory/__init__.py b/python/hermes-memory/__init__.py new file mode 100644 index 0000000..ad9794f --- /dev/null +++ b/python/hermes-memory/__init__.py @@ -0,0 +1,10 @@ +"""Entry point for directory installs (the Hermes plugin catalog). + +The catalog clones this directory and imports its `__init__.py`, so it re-exports `register` from +the package under `src/`. pip installs skip this file and use the `hermes_agent.memory_providers` +entry point in pyproject.toml. +""" + +from .src.upstash_hermes_memory import register + +__all__ = ["register"] diff --git a/python/hermes-memory/plugin.yaml b/python/hermes-memory/plugin.yaml new file mode 100644 index 0000000..2f272da --- /dev/null +++ b/python/hermes-memory/plugin.yaml @@ -0,0 +1,12 @@ +# Manifest Hermes reads when this directory is installed as a plugin (the plugin catalog points +# `subdir` here). pip installs use the entry point in pyproject.toml instead. Catalog rules: +# https://hermes-agent.nousresearch.com/docs/developer-guide/plugins/catalog-submission +name: upstash +# Copied from package.json by scripts/sync-version.mjs, like pyproject.toml. +version: "0.1.0" +description: "Upstash Redis memory provider: long-term memory, searchable chat history and recall on Redis Search, sharing keys with @upstash/agentkit-sdk." +# Also declared in the catalog entry; older Hermes releases only enforce this one (catalog rule 14). +requires_hermes: ">=0.19" +requires_env: + - UPSTASH_REDIS_REST_URL + - UPSTASH_REDIS_REST_TOKEN diff --git a/python/hermes-memory/pyproject.toml b/python/hermes-memory/pyproject.toml index ef27866..4a10634 100644 --- a/python/hermes-memory/pyproject.toml +++ b/python/hermes-memory/pyproject.toml @@ -5,7 +5,8 @@ description = "Upstash Redis memory provider for Hermes Agent: long-term memory, readme = "README.md" license = "MIT" requires-python = ">=3.11" -dependencies = ["upstash-redis>=1.8.0"] +# Upper bound: the Hermes plugin catalog asks for one on every dependency (catalog rule 10). +dependencies = ["upstash-redis>=1.8.0,<2"] # Hermes discovers pip-installed memory providers through this group; the entry-point name is the # value of `memory.provider` in Hermes's config.yaml. diff --git a/python/hermes-memory/tests/test_version.py b/python/hermes-memory/tests/test_version.py index 7443bc6..95345b6 100644 --- a/python/hermes-memory/tests/test_version.py +++ b/python/hermes-memory/tests/test_version.py @@ -21,6 +21,12 @@ def test_version_matches_package_json(): assert VERSION == package_json["version"] +def test_version_matches_plugin_manifest(): + # Catalog installs read plugin.yaml, not pyproject.toml; sync-version.mjs stamps both. + manifest = (PACKAGE_DIR / "plugin.yaml").read_text() + assert re.search(r'^version: "(.*)"$', manifest, re.MULTILINE).group(1) == VERSION + + def test_package_json_names_the_distribution(): # sync-version.mjs derives the module path from this name, and publish-python.mjs queries PyPI # with it, so it has to be the PyPI project name exactly. diff --git a/scripts/sync-version.mjs b/scripts/sync-version.mjs index 41ccddc..4e3ebe6 100644 --- a/scripts/sync-version.mjs +++ b/scripts/sync-version.mjs @@ -71,6 +71,16 @@ for (const dir of pythonDirs) { version, pyproject.replace(/^version = ".*"$/m, `version = "${version}"`), ); + // A Hermes plugin manifest (catalog installs read it instead of pyproject.toml) carries the version too. + if (existsSync(join(root, pkgDir, "plugin.yaml"))) { + const manifest = readFileSync(join(root, pkgDir, "plugin.yaml"), "utf8"); + stamp( + pkgDir, + "plugin.yaml", + version, + manifest.replace(/^version: .*$/m, `version: "${version}"`), + ); + } const { name } = JSON.parse(readFileSync(join(root, pkgDir, "package.json"), "utf8")); stamp( pkgDir, diff --git a/uv.lock b/uv.lock index 5f2cf9d..5b235f8 100644 --- a/uv.lock +++ b/uv.lock @@ -185,7 +185,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "upstash-redis", specifier = ">=1.8.0" }] +requires-dist = [{ name = "upstash-redis", specifier = ">=1.8.0,<2" }] [[package]] name = "upstash-redis" From 0a96610e81e70187e477194f240626c10b2f3620 Mon Sep 17 00:00:00 2001 From: Cahid Arda Oz Date: Mon, 2 Nov 2026 08:32:50 +0000 Subject: [PATCH 2/2] fix(hermes-memory): define register() in the plugin root so Hermes detects the provider Hermes detects directory memory providers by parsing root-level modules for a register_memory_provider() call or a MemoryProvider subclass; a re-export of register from src/ fails that check (Copilot review on #83). --- python/hermes-memory/__init__.py | 18 +++++++++++---- .../hermes-memory/tests/test_plugin_root.py | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 python/hermes-memory/tests/test_plugin_root.py diff --git a/python/hermes-memory/__init__.py b/python/hermes-memory/__init__.py index ad9794f..577fc6f 100644 --- a/python/hermes-memory/__init__.py +++ b/python/hermes-memory/__init__.py @@ -1,10 +1,20 @@ """Entry point for directory installs (the Hermes plugin catalog). -The catalog clones this directory and imports its `__init__.py`, so it re-exports `register` from -the package under `src/`. pip installs skip this file and use the `hermes_agent.memory_providers` -entry point in pyproject.toml. +The catalog clones this directory and imports its `__init__.py`. pip installs skip this file and use +the `hermes_agent.memory_providers` entry point in pyproject.toml. + +`register` is defined here, not re-exported from `src/`: Hermes decides whether a directory plugin is +a memory provider by parsing (never importing) its root-level modules for a +`register_memory_provider(...)` call or a `MemoryProvider` subclass (`_defines_memory_provider` in +hermes-agent's plugins/memory/__init__.py). A re-export alone fails that check, so a catalog install +could not be activated with `memory.provider: upstash`. """ -from .src.upstash_hermes_memory import register +from .src.upstash_hermes_memory import UpstashMemoryProvider __all__ = ["register"] + + +def register(ctx) -> None: + """Hermes plugin entry point for directory installs.""" + ctx.register_memory_provider(UpstashMemoryProvider()) diff --git a/python/hermes-memory/tests/test_plugin_root.py b/python/hermes-memory/tests/test_plugin_root.py new file mode 100644 index 0000000..77e7dca --- /dev/null +++ b/python/hermes-memory/tests/test_plugin_root.py @@ -0,0 +1,22 @@ +"""The root `__init__.py` must pass Hermes's static memory-provider check for directory installs.""" + +import ast +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def _registers_memory_provider(module: Path) -> bool: + # Same rule as `_defines_memory_provider` in hermes-agent's plugins/memory/__init__.py: a + # `register_memory_provider(...)` call in a root-level module, found by parsing, not importing. + tree = ast.parse(module.read_text(encoding="utf-8")) + return any( + isinstance(node, ast.Call) + and "register_memory_provider" + in (getattr(node.func, "attr", None), getattr(node.func, "id", None)) + for node in ast.walk(tree) + ) + + +def test_root_init_registers_the_provider(): + assert _registers_memory_provider(ROOT / "__init__.py")