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:
@@ -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:
|
||||
|
||||
115
hermes_cli/memory_provider_migration.py
Normal file
115
hermes_cli/memory_provider_migration.py
Normal 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
|
||||
@@ -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}'. "
|
||||
|
||||
@@ -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:
|
||||
|
||||
42
tests/hermes_cli/test_memory_provider_migration.py
Normal file
42
tests/hermes_cli/test_memory_provider_migration.py
Normal 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]
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user