diff --git a/CHANGELOG.md b/CHANGELOG.md index ba852c380..a9fd8dcfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [SemVer](https://semver.org/) starting from v3.1.2. ### Added +- **A Hermes plugin-catalog directory, `integrations/hermes-catalog/`.** The catalog installs a directory, not a pip package, and a `plugin.yaml` with nothing loadable beside it installs and does nothing (hermes-agent#113851). The new directory is a thin wrapper: `plugin.yaml` (`name: mnemosyne`, `kind: exclusive`, the tool list), a `pyproject.toml` whose dependencies (`mnemosyne-hermes`, `mnemosyne-memory[embeddings]`) Hermes installs into its venv and re-applies after every update, and an `__init__.py` that re-exports the package's registration hooks. The PyPI project in `integrations/hermes/` is untouched, per #859. Validated with `hermes plugins validate` at hermes-agent a08dee94. - **The MCP tool surface now declares its per-call `bank` parameter.** `_resolve_bank()` has always read `arguments["bank"]` before falling back to `MNEMOSYNE_MCP_BANK`, so 24 of 29 handlers already routed each call to its own `Mnemosyne(bank=...)` instance rather than the process-global default. Only three schemas said so, which left the capability undiscoverable: a conforming MCP client cannot use a parameter that is not advertised, and a client validating arguments against the published schema may strip it. Every MCP-served tool that routes on it, 25 of the 29 the dispatcher handles, now declares `bank`, so a single MCP server can serve more than one tenant through its documented interface. Nothing about the runtime changed and calls that omit `bank` behave exactly as before. The four `mnemosyne_shared_*` tools are deliberately excluded: they operate on the shared surface database, which is one global store, and advertising a tenant bank there would promise an isolation that does not exist. `mnemosyne_validate` keeps its own `bank` parameter, which selects `private` or `surface` rather than a tenant partition; that collision predates this change and is left alone rather than repurposed under a shipped name. The persona, sync and `mnemosyne_triple_end` schemas are Hermes-provider-only and are not served over MCP, so they do not declare a bank either. diff --git a/integrations/hermes-catalog/README.md b/integrations/hermes-catalog/README.md new file mode 100644 index 000000000..4e8746bf9 --- /dev/null +++ b/integrations/hermes-catalog/README.md @@ -0,0 +1,24 @@ +# Mnemosyne for the Hermes plugin catalog + +This directory is the Hermes **directory plugin** that the catalog installs +(`hermes plugins install mnemosyne-memory`). It is a thin wrapper: + +- `plugin.yaml` names the plugin `mnemosyne`, marks it `kind: exclusive` (a + memory provider, loaded only when `memory.provider: mnemosyne` is set), and + requires a Hermes release that installs plugin dependencies. +- `pyproject.toml` declares the real implementation, the `mnemosyne-hermes` + package on PyPI, plus `mnemosyne-memory[embeddings]`. Hermes installs both + into its own venv on install and re-applies them after every `hermes update`. +- `__init__.py` re-exports `register` and `register_memory_provider` from + the installed package so the directory is loadable. + +The package itself lives in `../hermes/` and is released to PyPI from there. +Nothing in this directory is imported by the package or its tests. + +## Wrapper installs + +Existing wrapper-mode installs (`mnemosyne-hermes install --mode wrapper`) +also live at `$HERMES_HOME/plugins/mnemosyne`. The two are the same plugin +name on purpose: a machine has one or the other, and a catalog install onto +an existing wrapper is refused by Hermes with "already exists" rather than +silently replacing it. Remove the wrapper first if you want to switch. diff --git a/integrations/hermes-catalog/__init__.py b/integrations/hermes-catalog/__init__.py new file mode 100644 index 000000000..6f194ffb4 --- /dev/null +++ b/integrations/hermes-catalog/__init__.py @@ -0,0 +1,17 @@ +"""Hermes plugin-catalog entry point for Mnemosyne. + +This directory is what the Hermes catalog installs. It carries no +implementation of its own: ``pyproject.toml`` declares the +``mnemosyne-hermes`` package, which Hermes installs into its venv and +re-applies after every ``hermes update`` (hermes-agent#113851). This module +only re-exports that package's registration hooks so the directory is +loadable and ``plugins/memory`` discovery can find the provider. + +``kind: exclusive`` in ``plugin.yaml`` keeps Hermes from importing this +module eagerly in every process; the memory subsystem imports it only when +``memory.provider`` names ``mnemosyne``. +""" + +from mnemosyne_hermes import register, register_memory_provider # noqa: F401 + +__all__ = ["register", "register_memory_provider"] diff --git a/integrations/hermes-catalog/plugin.yaml b/integrations/hermes-catalog/plugin.yaml new file mode 100644 index 000000000..51013301b --- /dev/null +++ b/integrations/hermes-catalog/plugin.yaml @@ -0,0 +1,47 @@ +name: mnemosyne +kind: exclusive +version: 0.7.0 +description: "Native local memory for Hermes - SQLite with vector search, FTS5 hybrid ranking, episodic consolidation, and temporal triples. Zero cloud. Installed from the Hermes plugin catalog; the implementation is the mnemosyne-hermes package declared in pyproject.toml." +author: Abdias J +requires_hermes: ">=0.22" +provides_tools: + - mnemosyne_diagnose + - mnemosyne_export + - mnemosyne_forget + - mnemosyne_get + - mnemosyne_graph_link + - mnemosyne_graph_query + - mnemosyne_import + - mnemosyne_invalidate + - mnemosyne_model_card + - mnemosyne_model_refresh + - mnemosyne_persona_demote + - mnemosyne_persona_list + - mnemosyne_persona_promote + - mnemosyne_persona_reinforce + - mnemosyne_recall + - mnemosyne_recall_canonical + - mnemosyne_recall_diagnostics + - mnemosyne_remember + - mnemosyne_remember_canonical + - mnemosyne_scratchpad_clear + - mnemosyne_scratchpad_read + - mnemosyne_scratchpad_write + - mnemosyne_shared_forget + - mnemosyne_shared_recall + - mnemosyne_shared_remember + - mnemosyne_shared_stats + - mnemosyne_sleep + - mnemosyne_stats + - mnemosyne_sync_pull + - mnemosyne_sync_push + - mnemosyne_sync_status + - mnemosyne_task_progress + - mnemosyne_triple_add + - mnemosyne_triple_end + - mnemosyne_triple_query + - mnemosyne_update + - mnemosyne_validate +provides_hooks: [] +provides_middleware: [] +requires_env: [] diff --git a/integrations/hermes-catalog/pyproject.toml b/integrations/hermes-catalog/pyproject.toml new file mode 100644 index 000000000..a1e2c018e --- /dev/null +++ b/integrations/hermes-catalog/pyproject.toml @@ -0,0 +1,12 @@ +# Hermes catalog wrapper. Not a buildable distribution: the package that +# ships on PyPI is integrations/hermes (mnemosyne-hermes). Hermes reads +# [project].dependencies from this file and installs them into its venv. +[project] +name = "mnemosyne-plugin" +version = "0.7.0" +description = "Mnemosyne memory provider for Hermes Agent, catalog wrapper" +requires-python = ">=3.11" +dependencies = [ + "mnemosyne-hermes>=0.7.0,<0.8", + "mnemosyne-memory[embeddings]>=3.11.1", +] diff --git a/tests/test_hermes_catalog_plugin.py b/tests/test_hermes_catalog_plugin.py new file mode 100644 index 000000000..76afc04c3 --- /dev/null +++ b/tests/test_hermes_catalog_plugin.py @@ -0,0 +1,93 @@ +"""The Hermes plugin-catalog directory (integrations/hermes-catalog) stays loadable and honest. + +It is a thin wrapper: no implementation, only a manifest, a dependency declaration and a +shim that re-exports the package's registration hooks. These tests pin the contract from +hermes-agent#113851 and dplush's #859 decision: the catalog root is separate from the +PyPI project in integrations/hermes, and what the manifest declares matches the package. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent +CATALOG = REPO / "integrations" / "hermes-catalog" +PACKAGE_SRC = REPO / "integrations" / "hermes" / "src" + + +def _manifest() -> dict: + yaml = pytest.importorskip("yaml") + return yaml.safe_load((CATALOG / "plugin.yaml").read_text()) + + +def test_catalog_dir_has_every_loadable_piece(): + for name in ("plugin.yaml", "__init__.py", "pyproject.toml", "README.md"): + assert (CATALOG / name).is_file(), name + + +def test_manifest_is_an_exclusive_memory_provider_named_like_the_wrapper(): + m = _manifest() + assert m["name"] == "mnemosyne", "catalog install dir must match the wrapper's plugin name" + assert m["kind"] == "exclusive", "memory providers must not be imported by the general loader" + assert m["version"] == "0.7.0" + assert m["provides_hooks"] == [] and m["provides_middleware"] == [] and m["requires_env"] == [] + + +def _toml_loads(text: str) -> dict: + try: + import tomllib + except ImportError: # Python 3.10 + tomllib = pytest.importorskip("tomli") + return tomllib.loads(text) + + +def test_wrapper_pyproject_declares_the_package_and_is_not_a_distribution(): + data = _toml_loads((CATALOG / "pyproject.toml").read_text()) + deps = data["project"]["dependencies"] + assert any(d.startswith("mnemosyne-hermes>=0.7.0") for d in deps), deps + assert any(d.startswith("mnemosyne-memory[embeddings]") for d in deps), deps + assert "build-system" not in data, "the catalog wrapper must never build as a package" + assert data["project"]["version"] == _manifest()["version"] + + +def test_declared_tools_are_real_package_tools(): + sys.path.insert(0, str(PACKAGE_SRC)) + try: + from mnemosyne_hermes import tools + finally: + sys.path.pop(0) + real = {s["name"] for s in tools.ALL_TOOL_SCHEMAS} + declared = _manifest()["provides_tools"] + assert len(declared) == len(set(declared)), "duplicate tool declarations" + assert set(declared) <= real, sorted(set(declared) - real) + assert "mnemosyne_recall" in declared and "mnemosyne_remember" in declared + + +def test_shim_loads_in_a_fresh_process_and_exports_both_hooks(): + """What a catalog install does: import the directory with the package on the path.""" + code = ( + "import importlib.util, sys\n" + f"sys.path.insert(0, {str(PACKAGE_SRC)!r})\n" + f"spec = importlib.util.spec_from_file_location('mnemosyne_catalog', {str(CATALOG / '__init__.py')!r})\n" + "mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)\n" + "assert callable(mod.register) and callable(mod.register_memory_provider)\n" + "print('shim-ok')\n" + ) + out = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True, timeout=120, + env={"MNEMOSYNE_NO_EMBEDDINGS": "1", "PATH": "/usr/bin:/bin"}, + ) + assert out.returncode == 0, out.stderr + assert "shim-ok" in out.stdout + + +def test_shim_source_is_discoverable_as_a_memory_provider_without_import(): + """plugins/memory discovery greps __init__.py for the provider contract before importing.""" + src = (CATALOG / "__init__.py").read_text() + assert "register_memory_provider" in src[:8192] + assert re.search(r"^from mnemosyne_hermes import", src, re.M) diff --git a/tests/test_plugin_manifest_versions.py b/tests/test_plugin_manifest_versions.py index ea75a430a..ca96f73b6 100644 --- a/tests/test_plugin_manifest_versions.py +++ b/tests/test_plugin_manifest_versions.py @@ -143,6 +143,8 @@ def test_all_plugin_manifests_have_an_explicit_version_contract(): ROOT / "hermes_memory_provider" / "plugin.yaml": core_version, hermes_root / "plugin.yaml": hermes_version, hermes_root / "src" / "mnemosyne_hermes" / "plugin.yaml": hermes_version, + # Hermes plugin-catalog wrapper: same plugin, same version as the package it pins. + ROOT / "integrations" / "hermes-catalog" / "plugin.yaml": hermes_version, } assert _source_manifest_paths() == set(expected_versions)