Prepare dependency generations before selecting them. Keep shipped tool bytes separate from writable additions, and store facts beside their entries. Validate proposed plugin sets before config publication. Restore the previous config if the facts write fails. Consolidate duplicate updater, backup, setup, and voice helpers. Repair launcher selection, dependency consumers, download ownership, update feeds, and native Windows process and file handling. Verification: 206 changed/prior-failing Python files reported 4630 passed, one failed, and 330 skipped. Fix the remaining Hindsight fixture boundary. The final targeted rerun reported 234 passed and two skipped. The store review regression batch reported 83 passed and one skipped. Desktop TypeScript checks, 56 selected Electron tests, 24 release tests, and the removed-import/compatibility guards passed. This is an integration checkpoint, not full audit acceptance. The complete Python suite has not run on this fixed tree. Crash-atomic plugin publication, generation cleanup, receipt correlation, and packaged lifecycle acceptance remain open in docs/pm-audit-status.md.
442 lines
17 KiB
Python
442 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
|
|
|
|
# pip name → import name mapping for packages where they differ
|
|
_IMPORT_NAMES = {
|
|
"honcho-ai": "honcho",
|
|
"mem0ai": "mem0",
|
|
"hindsight-client": "hindsight_client",
|
|
"hindsight-all": "hindsight"}
|
|
|
|
|
|
def _provider_extras(provider_name: str, manifest: dict, plugin_dir=None) -> list[str]:
|
|
"""The declared core extra; manifest requirements join the staged union."""
|
|
declared = manifest.get("extra")
|
|
return [declared] if isinstance(declared, str) and declared else []
|
|
|
|
|
|
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 _install_dependencies(provider_name: str, *, force: bool = False) -> None:
|
|
"""Install pip dependencies declared in ``plugin.yaml``.
|
|
|
|
With ``force`` every declared dependency goes to the installer even if it imports (the resolver
|
|
no-ops when nothing drifted) — how ``hermes update`` heals a provider after a venv rebuild.
|
|
|
|
When ``force`` is true, every declared dependency is handed to the installer even if its import
|
|
currently succeeds — the resolver then reinstalls anything missing or version-drifted and no-ops on
|
|
satisfied ranges. This is how ``hermes update`` heals the active memory provider after a venv
|
|
rebuild/sync removed or downgraded its bridge packages (#53272, #70636).
|
|
"""
|
|
import subprocess
|
|
from plugins.memory import find_provider_dir
|
|
|
|
plugin_dir = find_provider_dir(provider_name)
|
|
if not plugin_dir:
|
|
return
|
|
yaml_path = plugin_dir / "plugin.yaml"
|
|
if not yaml_path.exists():
|
|
return
|
|
try:
|
|
import yaml
|
|
with open(yaml_path, encoding="utf-8-sig") as f:
|
|
meta = yaml.safe_load(f) or {}
|
|
except Exception:
|
|
return
|
|
|
|
extras = _provider_extras(provider_name, meta, plugin_dir=plugin_dir)
|
|
if not extras:
|
|
return
|
|
|
|
import pm
|
|
|
|
missing = [e for e in extras if force or not pm.available(e)]
|
|
if not missing:
|
|
return
|
|
|
|
print(f"\n Installing dependencies: {', '.join(missing)}")
|
|
try:
|
|
pm.sync_venv(missing, explicit=True)
|
|
print(f" ✓ Installed {', '.join(missing)}")
|
|
except Exception as e:
|
|
print(f" ⚠ Install failed: {e}")
|
|
print(" Run manually: hermes pm install")
|
|
|
|
# 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":
|
|
provider = getattr(args, "provider", None)
|
|
if provider:
|
|
cmd_setup_provider(provider)
|
|
else:
|
|
cmd_setup(args)
|
|
else:
|
|
cmd_status(args)
|