Files
hermes-agent/hermes_cli/memory_setup.py
ethernet 5e4a2a3d24 refactor(pm): remove legacy dependency and launch managers
Competing installers and checkout-local venv assumptions bypassed PM
selection, install consent, and generation lifetimes. Route consumers
through PM and installation-bound launchers. Refresh source launchers
before obsolete Python entries can be collected.

Remove Node, browser, and CUA acquisition engines, obsolete venv-holder
handling, detached sync, and unused PM APIs. Keep historical updater
exports inert and preserve external tool ownership and native integration.

Share product freshness and prepared inputs across builders. Align plugin
admission, Docker provisioning, setup instructions, and behavioral tests.

Verified targeted Python and JavaScript tests, desktop and web typechecks,
scoped lint, real product builds, and the Docker frontend smoke test.
The missed post-setup test cleanup is included and verified.

Native Windows/macOS execution, full Rust compilation, and the complete
repository suite remain unverified. Historical compatibility requirements
were preserved and extended, not fully rescanned.
2026-09-12 14:57:38 -04:00

444 lines
17 KiB
Python

"""hermes memory setup|status — configure memory provider plugins."""
from __future__ import annotations
import os
import sys
import shlex
from pathlib import Path
from hermes_constants import get_hermes_home
from hermes_cli.secret_prompt import masked_secret_prompt
_CANCELLED = -1
def _curses_select(
title: str, items: list[tuple[str, str]], default: int = 0, *, cancel_returns: int | None = None
) -> int:
"""Interactive single-select with arrow keys."""
from hermes_cli.curses_ui import curses_radiolist
if cancel_returns is None:
cancel_returns = default
display_items = [f"{label} - {desc}" if desc else label for label, desc in items]
result = curses_radiolist(title, display_items, selected=default, cancel_returns=cancel_returns)
_clear_interactive_transition()
return result
def _print_cancelled_setup() -> None:
print("\n Cancelled. No changes saved.\n")
def _clear_interactive_transition() -> None:
"""Clear stale curses content before entering a follow-up setup screen."""
if not sys.stdout.isatty():
return
sys.stdout.write("\033[2J\033[H")
sys.stdout.flush()
def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
"""Prompt for a value with optional default and secret masking."""
suffix = f" [{default}]" if default else ""
if secret:
val = masked_secret_prompt(f" {label}{suffix}: ")
else:
sys.stdout.write(f" {label}{suffix}: ")
sys.stdout.flush()
val = sys.stdin.readline().strip()
return val or (default or "")
def memory_provider_dependency_inputs(provider_name: str) -> tuple[dict, dict]:
"""Read one candidate declaration for preparation and passive readiness."""
from hermes_cli.plugins_admission import candidate_member_dirs
from hermes_cli.plugins_cmd import PluginOperationError, _read_manifest_for_install
from pm.package import InstallError
from pm.workspace import _is_member_candidate
from plugins.memory import find_provider_dir
plugin_dir = find_provider_dir(provider_name)
if not plugin_dir:
return {}, {}
try:
meta = _read_manifest_for_install(plugin_dir)
member = _is_member_candidate(plugin_dir)
except (PluginOperationError, OSError, ValueError) as exc:
raise InstallError(provider_name, str(exc)) from exc
extra = meta.get("extra")
extras = [extra] if isinstance(extra, str) and extra else []
if not extras and not member:
return meta, {}
# Without a proposed home, selection retains every configured member.
inputs = {"plugin_dirs": lambda: candidate_member_dirs((), extra_dirs=[plugin_dir])} if member else {}
return meta, {"extras": extras, **inputs}
def prepare_memory_provider_dependencies(provider_name: str) -> tuple[dict, str | None]:
"""Prepare the candidate union; PM owns constraint and currency checks."""
import pm
meta, inputs = memory_provider_dependency_inputs(provider_name)
if not inputs:
return meta, None
pm.sync_venv(explicit=True, **inputs)
from hermes_cli.runtime_paths import selected_venv, site_packages
from pm.paths import repo_root
selected = site_packages(selected_venv(repo_root())).resolve()
active = {Path(entry).resolve() for entry in sys.path}
return meta, "installed" if selected in active else "restart_required"
def _install_dependencies(provider_name: str) -> None:
"""Render CLI preparation and external-sidecar guidance."""
import subprocess
meta, status = prepare_memory_provider_dependencies(provider_name)
if status:
print(f" ✓ Dependencies prepared for {provider_name}")
if status == "restart_required":
print(" Restart Hermes to use the prepared dependencies.")
# Also show external (non-pip) dependencies that are missing.
for dep in meta.get("external_dependencies", []):
check_cmd = dep.get("check", "")
install_cmd = dep.get("install", "")
if check_cmd:
try:
subprocess.run(shlex.split(check_cmd), check=True, capture_output=True, timeout=5)
except Exception:
if install_cmd:
print(f"\n ⚠ '{dep.get('name', '')}' not found. Install with:")
print(f" {install_cmd}")
def _schema_of(provider) -> list:
return provider.get_config_schema() if hasattr(provider, "get_config_schema") else []
def _get_available_providers() -> list:
"""Discover memory providers from plugins/memory/ as ``(name, setup_hint, provider)`` tuples."""
try:
from plugins.memory import discover_memory_providers, load_memory_provider
raw = discover_memory_providers()
except Exception:
raw = []
results = []
for name, desc, available in raw:
try:
provider = load_memory_provider(name)
if not provider:
continue
except Exception:
continue
schema = _schema_of(provider)
has_secrets = any(f.get("secret") for f in schema)
has_non_secrets = any(not f.get("secret") for f in schema)
if has_secrets and has_non_secrets:
setup_hint = "API key / local"
elif has_secrets:
setup_hint = "requires API key"
elif not schema:
setup_hint = "no setup needed"
else:
setup_hint = "local"
results.append((name, setup_hint, provider))
return results
def _find_provider(providers: list, provider_name: str):
return next((p for p in providers if p[0] == provider_name), None)
def _post_setup_hook(provider, config: dict) -> bool:
"""Normalize the ``memory`` block; True when the provider's ``post_setup`` took over (it owns
config, connection test and activation), so the caller must stop."""
if not isinstance(config.get("memory"), dict):
config["memory"] = {}
if hasattr(provider, "post_setup"):
provider.post_setup(str(get_hermes_home()), config)
return True
return False
def cmd_setup_provider(provider_name: str) -> None:
"""Run memory setup for a specific provider, skipping the picker."""
from hermes_cli.config import load_config, save_config
match = _find_provider(_get_available_providers(), provider_name)
if not match:
print(f"\n Memory provider '{provider_name}' not found.")
print(" Run 'hermes memory setup' to see available providers.\n")
return
name, _, provider = match
_clear_interactive_transition()
_install_dependencies(name)
config = load_config()
if _post_setup_hook(provider, config):
return
# Fallback: generic schema-based setup (same as cmd_setup)
config["memory"]["provider"] = name
save_config(config)
print(f"\n Memory provider: {name}")
print(" Activation saved to config.yaml\n")
def _prompt_schema_fields(name: str, schema: list, provider_config: dict, env_writes: dict) -> bool:
"""Walk a provider's config schema, prompting per field. False when the user cancelled."""
print(f"\n Configuring {name}:\n")
for field in schema:
key = field["key"]
desc = field.get("description", key)
default = field.get("default")
# Dynamic default: look up default from another field's value
default_from = field.get("default_from")
if default_from and isinstance(default_from, dict):
ref_value = provider_config.get(default_from.get("field", ""), "")
ref_map = default_from.get("map", {})
if ref_value and ref_value in ref_map:
default = ref_map[ref_value]
is_secret = field.get("secret", False)
choices = field.get("choices")
env_var = field.get("env_var")
url = field.get("url")
when = field.get("when")
if when and isinstance(when, dict) and not all(provider_config.get(k) == v for k, v in when.items()):
continue
if choices and not is_secret:
current = provider_config.get(key, default)
current_idx = choices.index(current) if current and current in choices else 0
sel = _curses_select(
f" {desc}", [(c, "") for c in choices], default=current_idx, cancel_returns=_CANCELLED
)
if sel == _CANCELLED:
_print_cancelled_setup()
return False
provider_config[key] = choices[sel]
elif is_secret:
existing = os.environ.get(env_var, "") if env_var else ""
if existing:
masked = f"...{existing[-4:]}" if len(existing) > 4 else "set"
val = _prompt(f"{desc} (current: {masked}, blank to keep)", secret=True)
else:
if url:
print(f" Get yours at {url}")
val = _prompt(desc, secret=True)
if val and env_var:
env_writes[env_var] = val
else:
effective_default = provider_config.get(key) or default
val = _prompt(desc, default=str(effective_default) if effective_default else None)
if val:
provider_config[key] = val
if env_var and env_var not in env_writes:
env_writes[env_var] = val
return True
def cmd_setup(args) -> None:
"""Interactive memory provider setup wizard."""
from hermes_cli.config import load_config, save_config
providers = _get_available_providers()
if not providers:
print("\n No memory provider plugins detected.")
print(" Install a plugin to ~/.hermes/plugins/ and try again.\n")
return
items = [(name, f"— {desc}") for name, desc, _ in providers]
items.append(("Built-in only", "— MEMORY.md / USER.md (default)"))
builtin_idx = len(items) - 1
selected = _curses_select("Memory provider setup", items, default=builtin_idx, cancel_returns=_CANCELLED)
if selected == _CANCELLED:
_print_cancelled_setup()
return
config = load_config()
if not isinstance(config.get("memory"), dict):
config["memory"] = {}
if selected >= len(providers):
config["memory"]["provider"] = ""
save_config(config)
print("\n ✓ Memory provider: built-in only")
print(" Saved to config.yaml\n")
return
name, _, provider = providers[selected]
_clear_interactive_transition()
_install_dependencies(name)
if _post_setup_hook(provider, config):
return
provider_config = config["memory"].get(name, {})
if not isinstance(provider_config, dict):
provider_config = {}
env_writes: dict = {}
schema = _schema_of(provider)
if schema and not _prompt_schema_fields(name, schema, provider_config, env_writes):
return
# Write activation key to config.yaml
config["memory"]["provider"] = name
save_config(config)
if provider_config and hasattr(provider, "save_config"):
try:
provider.save_config(provider_config, str(get_hermes_home()))
except Exception as e:
print(f" Failed to write provider config: {e}")
if env_writes:
_write_env_vars(env_writes)
print(f"\n Memory provider: {name}")
print(" Activation saved to config.yaml")
if provider_config:
print(" Provider config saved")
if env_writes:
print(" API keys saved to .env")
print("\n Start a new session to activate.\n")
def _write_env_vars(
env_writes: dict, hermes_home: str | os.PathLike[str] | None = None) -> None:
"""Persist memory-provider env vars through the canonical ``.env`` writer.
Delegates to ``hermes_cli.config.save_env_value`` so every key flows
through the same input-validation gate as every other ``.env`` writer:
the ``_ENV_VAR_NAME_RE`` regex (no malformed identifiers), the
``_ENV_VAR_NAME_DENYLIST`` (no ``LD_PRELOAD`` / ``PYTHONPATH`` /
``HERMES_HOME`` / etc.), CR/LF stripping on the value, and the atomic
0o600-from-creation write (no TOCTOU permission window).
Validation failures (``ValueError`` from ``save_env_value`` — a
denylisted name or an identifier rejected by ``_ENV_VAR_NAME_RE``) are
surfaced and skipped rather than aborting the wizard, so a single bad
key from one schema field doesn't take down the rest of the batch.
Non-validation errors (filesystem failures, permission errors) are
intentionally NOT caught — those indicate the wizard cannot safely
persist any subsequent key either and should propagate.
``hermes_home`` may be supplied by plugin ``post_setup`` hooks that
already received an explicit home directory (e.g. a non-default
profile). It is applied through the context-local Hermes home override
so ``save_env_value`` still owns the validation, sanitization, and
atomic-write path without mutating global ``os.environ``.
"""
from hermes_cli.config import save_env_value
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
token = set_hermes_home_override(hermes_home) if hermes_home is not None else None
try:
for key, val in env_writes.items():
try:
save_env_value(key, val)
except ValueError as exc:
print(f" Skipping {key}: {exc}")
finally:
if token is not None:
reset_hermes_home_override(token)
def _mark(enabled) -> str:
return "enabled ✓" if enabled else "disabled ✗"
def cmd_status(args) -> None:
"""Show current memory provider config."""
from hermes_cli.config import load_config
config = load_config()
mem_config = config.get("memory", {})
provider_name = mem_config.get("provider", "")
# Memory tool enablement for the CLI platform via the canonical resolver, respecting the
# check_fn gate when both stores are disabled.
from hermes_cli.tools_config import _get_platform_tools
from tools.memory_tool import check_memory_requirements
cli_tools = _get_platform_tools(config, "cli", include_default_mcp_servers=False)
memory_tool_enabled = ("memory" in cli_tools) and check_memory_requirements()
print("\nMemory status\n" + "─" * 40)
print(" Built-in (MEMORY.md / USER.md):")
print(f" Memory injection: {_mark(mem_config.get('memory_enabled', True))}")
print(f" User profile: {_mark(mem_config.get('user_profile_enabled', True))}")
print(f" Memory tool: {_mark(memory_tool_enabled)}")
print(f" Provider: {provider_name or '(none — built-in only)'}")
providers = _get_available_providers()
match = _find_provider(providers, provider_name)
provider = match[2] if match else None
if provider_name:
provider_config = mem_config.get(provider_name, {})
display_config = provider_config
if provider and hasattr(provider, "get_status_config"):
try:
display_config = provider.get_status_config(provider_config)
except Exception as e:
display_config = dict(provider_config) if isinstance(provider_config, dict) else provider_config
if isinstance(display_config, dict):
display_config["status_config_error"] = str(e)
if display_config:
print(f"\n {provider_name} config:")
for key, val in display_config.items():
print(f" {key}: {val}")
if provider:
print("\n Plugin: installed ✓")
if provider.is_available():
print(" Status: available ✓")
else:
print(" Status: not available ✗")
# All fields with env_var (secret and non-secret)
required_fields = [f for f in _schema_of(provider) if f.get("env_var")]
if required_fields:
print(" Missing:")
for f in required_fields:
env_var = f.get("env_var", "")
url = f.get("url", "")
is_set = bool(os.environ.get(env_var))
line = f" {'✓' if is_set else '✗'} {env_var}"
if url and not is_set:
line += f" → {url}"
print(line)
print(" Note: systemd/gateway services do not inherit ~/.hermes/.env —")
print(" set any variables above in the service environment.")
else:
print("\n Plugin: NOT installed ✗")
print(f" Install the '{provider_name}' memory plugin to ~/.hermes/plugins/")
if providers:
print("\n Installed plugins:")
for pname, desc, _ in providers:
active = " ← active" if pname == provider_name else ""
print(f" • {pname} ({desc}){active}")
print()
def memory_command(args) -> None:
"""Route memory subcommands."""
if getattr(args, "memory_command", None) == "setup":
from pm.package import InstallError
provider = getattr(args, "provider", None)
try:
if provider:
cmd_setup_provider(provider)
else:
cmd_setup(args)
except InstallError as exc:
print(f"Memory setup failed: {exc}", file=sys.stderr)
print("Correct the dependency error and retry setup.", file=sys.stderr)
raise SystemExit(1) from exc
else:
cmd_status(args)