feat(memory): a configured provider that left core is installed from the catalog automatically

Built-in memory providers are moving to their maintainers' repos and the plugin catalog. Their
name, memory.<name> config section, data directory and tool names stay the same, so the only
thing a user on the built-in form loses is the code path. Two hooks now fetch it:

- hermes update: every profile home sharing the venv whose memory.provider resolves nowhere gets
  the catalog plugin installed at its reviewed pin (kill list, dependency constraints, enable).
- agent init: when the provider resolves nowhere, one attempt per process (Desktop users never
  run `hermes update` by hand); honours security.allow_lazy_installs.

Offline / not in the catalog: a warning with the exact one-liner instead of DEBUG-only silence.
Also accepts `owner/repo#subdir` for plugin installs (the catalog's spelling; honcho ships its
plugin in a subdirectory of its main repo).

Live: bundled honcho removed, memory.provider=honcho, catalog entry staged → AIAgent() installs
plugins/honcho from plastic-labs/honcho#hermes-plugin-honcho with deps and loads it; config kept.
This commit is contained in:
teknium1
2026-09-17 18:49:34 -07:00
committed by Teknium
parent e83b1d51f1
commit e21ccdd7dc
6 changed files with 183 additions and 2 deletions

View File

@@ -1297,6 +1297,11 @@ def _init_memory(agent, _agent_cfg, skip_memory, platform):
from plugins.memory import load_memory_provider as _load_mem
agent._memory_manager = _MemoryManager()
_mp = _load_mem(_mem_provider_name)
if _mp is None:
# The provider left core for the catalog (or was never installed): fetch it once.
from hermes_cli.memory_provider_migration import recover_at_startup
if recover_at_startup(_mem_provider_name):
_mp = _load_mem(_mem_provider_name)
if _mp and _mp.is_available():
agent._memory_manager.add_provider(_mp)
elif _mp is not None and _mem_provider_name not in _warned_unavailable_providers:

View File

@@ -0,0 +1,115 @@
"""Move a user from a memory provider that left core onto its catalog plugin.
A bundled ``plugins/memory/<name>`` that becomes a standalone catalog plugin keeps the same provider
name, config section (``memory.<name>``), data directory and tool names, so the migration is only
"the code now lives under ``HERMES_HOME/plugins/<name>``". Two hooks call :func:`migrate_home`:
* ``hermes update`` — for every profile home that shares the venv (primary; runs where the venv was
just rebuilt anyway).
* agent init — when the configured provider cannot be found at all, once per process (Desktop
users update through the app and never run ``hermes update`` by hand).
Both install the catalog entry at its reviewed pin through the normal plugin install path (kill
list, dependency constraints, enable), never a custom source. Offline or absent from the catalog:
the user gets the exact one-liner instead of silently running without memory.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Callable, Optional
logger = logging.getLogger(__name__)
_attempted: set[str] = set()
def configured_provider(home: Path) -> str:
"""``memory.provider`` of *home*'s effective config, or ``""``."""
from hermes_cli.plugin_python_deps import _read_home_config
memory = _read_home_config(home).get("memory") or {}
return str(memory.get("provider") or "").strip()
def provider_present(name: str) -> bool:
"""True when the provider resolves anywhere Hermes looks (bundled, user dir, entry point)."""
from plugins.memory import find_provider_dir
return find_provider_dir(name) is not None
def catalog_source(name: str) -> Optional[str]:
"""The catalog entry that ships provider *name*, or None when the catalog has no such plugin."""
from hermes_cli.plugin_catalog import get_live_catalog_entry
entry = get_live_catalog_entry(name)
return entry.name if entry is not None else None
def migrate_home(home: Path, *, install: Callable[[str], dict], say: Callable[[str], None] = print) -> Optional[str]:
"""Install the configured provider's catalog plugin into *home* when the provider is gone.
Returns the installed plugin name, or None when nothing needed doing or the install could not
happen (already reported through *say*). Never raises: memory being down must not take the
update or the agent down with it.
"""
name = configured_provider(home)
if not name or provider_present(name):
return None
if catalog_source(name) is None:
say(f" ⚠ Memory provider '{name}' is configured but not installed and not in the plugin catalog. "
f"Install it with `hermes plugins install <source>` or change memory.provider.")
return None
try:
result = install(name)
except Exception as exc: # network, uv, kill list — report, do not raise
result = {"ok": False, "error": str(exc)}
if result.get("ok"):
say(f" ✓ Memory provider '{name}' moved out of core — installed its plugin from the catalog "
f"(your memory.{name} settings and data are unchanged).")
return name
say(f" ⚠ Memory provider '{name}' moved out of core and could not be installed automatically: "
f"{result.get('error') or 'unknown error'}. Run `hermes plugins install {name}`.")
return None
def _install_into(home: Path) -> Callable[[str], dict]:
def _install(name: str) -> dict:
from hermes_cli.plugins_cmd import dashboard_install_plugin
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
token = set_hermes_home_override(home)
try:
return dashboard_install_plugin("", force=False, enable=True, catalog_name=name)
finally:
reset_hermes_home_override(token)
return _install
def migrate_all_homes(*, say: Callable[[str], None] = print) -> list[str]:
"""``hermes update`` hook: every profile home sharing this venv. Returns installed plugin names."""
from hermes_cli.plugin_python_deps import dependency_homes
installed: list[str] = []
for home in dependency_homes():
try:
name = migrate_home(home, install=_install_into(home), say=say)
except Exception as exc:
logger.debug("memory provider migration skipped for %s: %s", home, exc)
continue
if name:
installed.append(name)
return installed
def recover_at_startup(name: str) -> bool:
"""Agent-init hook for a configured provider that resolved nowhere. One attempt per process per
name; honours ``security.allow_lazy_installs`` because it installs code. True when installed."""
if name in _attempted:
return False
_attempted.add(name)
from tools.lazy_deps import _allow_lazy_installs
if not _allow_lazy_installs():
logger.warning("Memory provider '%s' is not installed; security.allow_lazy_installs is off — "
"run `hermes plugins install %s`.", name, name)
return False
from hermes_constants import get_hermes_home
home = Path(get_hermes_home())
return migrate_home(home, install=_install_into(home), say=logger.warning) == name

View File

@@ -235,10 +235,11 @@ def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]:
return git_url + ".git", (subdir.strip("/") or None)
return identifier, None
# owner/repo[/subdir...] shorthand
# owner/repo[/subdir...] or owner/repo#subdir shorthand (the catalog spells subdirs with ``#``).
identifier, _, fragment = identifier.partition("#")
parts = [p for p in identifier.strip("/").split("/") if p]
if len(parts) >= 2:
subdir = "/".join(parts[2:]).strip("/")
subdir = "/".join([*parts[2:], *fragment.split("/")]).strip("/")
return f"https://github.com/{parts[0]}/{parts[1]}.git", (subdir or None)
raise ValueError(
f"Invalid plugin identifier: '{identifier}'. "

View File

@@ -421,6 +421,17 @@ def _reapply_plugin_python_dependencies() -> None:
f"then `hermes plugins enable {name}`.")
if report.failed:
print(f" ⚠ Plugin Python dependencies not re-applied: {report.failed}")
_migrate_removed_memory_providers()
def _migrate_removed_memory_providers() -> None:
"""A configured memory provider that no longer ships in core is installed from the catalog, for
every profile home sharing this venv (its config section, data and tool names are unchanged)."""
try:
from hermes_cli.memory_provider_migration import migrate_all_homes
migrate_all_homes()
except Exception as exc: # the update must finish even if the migration step blows up
print(f" ⚠ Memory provider migration skipped: {exc}")
def _is_android_python() -> bool:

View File

@@ -0,0 +1,42 @@
"""A memory provider that left core is installed from the catalog, config untouched; a provider the
catalog does not know is reported with the one-liner instead of silently dropping memory."""
from pathlib import Path
import pytest
from hermes_cli import memory_provider_migration as mig
@pytest.fixture
def home(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text("memory:\n provider: honcho\n honcho:\n workspace: keep-me\n")
monkeypatch.setattr(mig, "provider_present", lambda name: (tmp_path / "plugins" / name).is_dir())
return tmp_path
def test_missing_provider_installs_its_catalog_plugin_and_keeps_config(home, monkeypatch):
monkeypatch.setattr(mig, "catalog_source", lambda name: name)
calls: list[str] = []
said: list[str] = []
def fake_install(name: str) -> dict:
calls.append(name)
(home / "plugins" / name).mkdir(parents=True)
return {"ok": True}
assert mig.migrate_home(home, install=fake_install, say=said.append) == "honcho"
assert calls == ["honcho"]
assert "settings and data are unchanged" in said[0]
assert "workspace: keep-me" in (home / "config.yaml").read_text()
# present now → nothing to do, nothing said
assert mig.migrate_home(home, install=fake_install, say=said.append) is None
assert calls == ["honcho"]
def test_provider_unknown_to_catalog_is_reported_not_installed(home, monkeypatch):
monkeypatch.setattr(mig, "catalog_source", lambda name: None)
said: list[str] = []
assert mig.migrate_home(home, install=lambda n: pytest.fail("must not install"), say=said.append) is None
assert "not in the plugin catalog" in said[0] and "memory.provider" in said[0]

View File

@@ -97,3 +97,10 @@ def test_kill_list_blocks_cli_dashboard_and_tui_paths(world, monkeypatch):
pc.cmd_install("cat-plugin", enable=False, allow_removed=True)
assert (world["plugins_dir"] / "cat-plugin" / cat.CATALOG_SIDECAR).exists()
assert cat.removed_annotation("cat-plugin", world["plugins_dir"] / "cat-plugin") == "malware"
def test_owner_repo_hash_subdir_shorthand_resolves_like_the_catalog_spelling():
from hermes_cli.plugins_cmd import _resolve_git_url
assert _resolve_git_url("plastic-labs/honcho#hermes-plugin-honcho") == (
"https://github.com/plastic-labs/honcho.git", "hermes-plugin-honcho")
assert _resolve_git_url("owner/repo") == ("https://github.com/owner/repo.git", None)