From 4b184cbe5456837a36bfec3df10db823debe023e Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Fri, 3 Jul 2026 10:32:07 +1000 Subject: [PATCH] Add dashboard memory provider switching --- hermes_cli/web_server.py | 911 +++++++++++++++++++++++----- tests/hermes_cli/test_web_server.py | 227 ++++++- web/src/lib/api.ts | 84 ++- web/src/pages/PluginsPage.tsx | 697 ++++++++++++++++++--- web/src/pages/SystemPage.tsx | 38 +- 5 files changed, 1714 insertions(+), 243 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 74f97ec9c2c..b14b38b5a6a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -28,6 +28,7 @@ import mimetypes import os import re import secrets +import shlex import shutil import stat import subprocess @@ -73,11 +74,6 @@ from hermes_cli.config import ( write_platform_config_field, _deep_merge, ) -from hermes_cli.memory_providers import ( - MemoryProvider, - ProviderField, - get_memory_provider, -) from gateway.status import ( derive_gateway_busy, derive_gateway_drainable, @@ -668,11 +664,6 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "description": "Input behavior while agent is running", "options": ["interrupt", "queue", "steer"], }, - "memory.provider": { - "type": "select", - "description": "Memory provider plugin", - "options": ["builtin", "honcho"], - }, "approvals.mode": { "type": "select", "description": "Dangerous command approval mode", @@ -785,7 +776,7 @@ def _build_schema_from_config( full_key = f"{prefix}.{key}" if prefix else key # Skip internal / version keys - if full_key in {"_config_version",}: + if full_key in {"_config_version", "memory.provider"}: continue # Category is the first path component for nested keys, or "general" @@ -857,7 +848,11 @@ class EnvVarReveal(BaseModel): class MemoryProviderConfigUpdate(BaseModel): - values: Dict[str, str] = {} + values: Dict[str, Any] = {} + + +class MemoryProviderSetupRequest(BaseModel): + values: Dict[str, Any] = {} class MessagingPlatformUpdate(BaseModel): @@ -4151,151 +4146,800 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]: return config -def _memory_provider_config_path(provider: MemoryProvider) -> Path: - return get_hermes_home() / provider.name / "config.json" +def _memory_provider_label(name: str) -> str: + return name.replace("_", " ").replace("-", " ").title() -def _read_memory_provider_file(provider: MemoryProvider) -> Dict[str, Any]: - path = _memory_provider_config_path(provider) +def _normalize_memory_provider_name(name: Any) -> str: + provider = str(name or "").strip() + if provider.lower() in {"built-in", "builtin", "none"}: + return "" + return provider + + +def _load_memory_provider(name: str): + try: + from plugins.memory import load_memory_provider + + return load_memory_provider(name) + except Exception: + _log.debug("Failed to load memory provider %s", name, exc_info=True) + return None + + +def _memory_provider_manifest(name: str) -> Dict[str, Any]: + try: + from plugins.memory import find_provider_dir + + provider_dir = find_provider_dir(name) + if provider_dir is None: + return {} + manifest_path = provider_dir / "plugin.yaml" + if not manifest_path.exists(): + return {} + with manifest_path.open(encoding="utf-8-sig") as handle: + manifest = yaml.safe_load(handle) or {} + return manifest if isinstance(manifest, dict) else {} + except Exception: + _log.debug("Failed to read memory provider manifest for %s", name, exc_info=True) + return {} + + +def _string_list(value: Any) -> List[str]: + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if str(item).strip()] + + +def _memory_provider_setup_manifest(name: str) -> Dict[str, Any]: + manifest = _memory_provider_manifest(name) + external_dependencies: List[Dict[str, str]] = [] + for raw in manifest.get("external_dependencies") or []: + if not isinstance(raw, dict): + continue + dep = { + "name": str(raw.get("name") or "").strip(), + "install": str(raw.get("install") or "").strip(), + "check": str(raw.get("check") or "").strip(), + } + if dep["name"] or dep["install"] or dep["check"]: + external_dependencies.append(dep) + + return { + "pip_dependencies": _string_list(manifest.get("pip_dependencies")), + "external_dependencies": external_dependencies, + "required_env": _string_list(manifest.get("requires_env")), + } + + +def _memory_provider_setup_info(name: str) -> Dict[str, Any]: + setup = _memory_provider_setup_manifest(name) + setup["dependencies_installed"] = _memory_provider_dependencies_installed(setup) + return setup + + +_MEMORY_PROVIDER_IMPORT_NAMES = { + "honcho-ai": "honcho", + "mem0ai": "mem0", + "hindsight-client": "hindsight_client", + "hindsight-all": "hindsight", +} + + +def _memory_provider_dependency_package(dep: str) -> str: + return re.split(r"[\[<>=!~;]", dep, maxsplit=1)[0].strip() + + +def _memory_provider_import_name(dep: str) -> str: + package = _memory_provider_dependency_package(dep) + return _MEMORY_PROVIDER_IMPORT_NAMES.get(package, package.replace("-", "_")) + + +def _dependency_importable(dep: str) -> bool: + import_name = _memory_provider_import_name(dep) + if not import_name: + return False + try: + __import__(import_name) + return True + except ImportError: + return False + + +def _trim_setup_output(value: Optional[str], limit: int = 4000) -> str: + text = str(value or "") + if len(text) <= limit: + return text + return f"{text[:limit]}\n... truncated ..." + + +def _memory_provider_setup_env() -> Dict[str, str]: + env = os.environ.copy() + home = Path.home() + extra_bins = [ + home / ".brv-cli" / "bin", + home / ".local" / "bin", + home / ".npm-global" / "bin", + Path("/usr/local/bin"), + ] + existing_path = env.get("PATH", "") + prefix = os.pathsep.join(str(path) for path in extra_bins if path.exists()) + if prefix: + env["PATH"] = prefix + os.pathsep + existing_path + return env + + +def _command_result( + *, + kind: str, + name: str, + status: str, + command: str = "", + completed: Optional[subprocess.CompletedProcess] = None, + error: Optional[str] = None, +) -> Dict[str, Any]: + return { + "kind": kind, + "name": name, + "status": status, + "command": command, + "returncode": None if completed is None else completed.returncode, + "stdout": "" if completed is None else _trim_setup_output(completed.stdout), + "stderr": _trim_setup_output(error or ("" if completed is None else completed.stderr)), + } + + +def _run_setup_command( + command: Any, + *, + display: str, + shell: bool = False, + timeout: int = 180, +) -> subprocess.CompletedProcess: + return subprocess.run( + command, + shell=shell, + executable="/bin/bash" if shell else None, + env=_memory_provider_setup_env(), + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + + +def _memory_provider_dependencies_installed(setup: Dict[str, Any]) -> bool: + pip_dependencies = _string_list(setup.get("pip_dependencies")) + external_dependencies = setup.get("external_dependencies") or [] + + pip_ok = all(_dependency_importable(dep) for dep in pip_dependencies) + external_ok = True + for dep in external_dependencies: + if not isinstance(dep, dict): + continue + check_cmd = str(dep.get("check") or "").strip() + install_cmd = str(dep.get("install") or "").strip() + if not check_cmd: + if install_cmd: + external_ok = False + continue + try: + completed = _run_setup_command( + shlex.split(check_cmd), + display=check_cmd, + timeout=20, + ) + except Exception: + external_ok = False + continue + if completed.returncode != 0: + external_ok = False + + return pip_ok and external_ok + + +def _install_memory_provider_pip_dependencies(dependencies: List[str]) -> List[Dict[str, Any]]: + missing = [dep for dep in dependencies if not _dependency_importable(dep)] + if not dependencies: + return [] + if not missing: + return [ + _command_result(kind="pip", name=", ".join(dependencies), status="already_installed") + ] + + uv_path = shutil.which("uv") + if uv_path: + command: Any = [uv_path, "pip", "install", "--python", sys.executable, "--quiet", *missing] + display = f"uv pip install --python {sys.executable} {' '.join(missing)}" + else: + command = [sys.executable, "-m", "pip", "install", "--quiet", *missing] + display = f"{sys.executable} -m pip install {' '.join(missing)}" + + try: + completed = _run_setup_command(command, display=display, timeout=240) + except Exception as exc: + return [ + _command_result( + kind="pip", + name=", ".join(missing), + status="failed", + command=display, + error=str(exc), + ) + ] + + return [ + _command_result( + kind="pip", + name=", ".join(missing), + status="installed" if completed.returncode == 0 else "failed", + command=display, + completed=completed, + ) + ] + + +def _install_memory_provider_external_dependencies( + dependencies: List[Dict[str, str]], +) -> List[Dict[str, Any]]: + results: List[Dict[str, Any]] = [] + for dep in dependencies: + name = dep.get("name") or "dependency" + check_cmd = dep.get("check") or "" + install_cmd = dep.get("install") or "" + + if check_cmd: + try: + check = _run_setup_command( + shlex.split(check_cmd), + display=check_cmd, + timeout=20, + ) + except Exception as exc: + results.append( + _command_result( + kind="external_check", + name=name, + status="missing" if install_cmd else "failed", + command=check_cmd, + error=str(exc), + ) + ) + else: + if check.returncode == 0: + results.append( + _command_result( + kind="external_check", + name=name, + status="already_installed", + command=check_cmd, + completed=check, + ) + ) + continue + results.append( + _command_result( + kind="external_check", + name=name, + status="missing" if install_cmd else "failed", + command=check_cmd, + completed=check, + ) + ) + + if not install_cmd: + continue + + if install_cmd: + try: + install = _run_setup_command( + install_cmd, + display=install_cmd, + shell=True, + timeout=300, + ) + except Exception as exc: + results.append( + _command_result( + kind="external_install", + name=name, + status="failed", + command=install_cmd, + error=str(exc), + ) + ) + continue + + results.append( + _command_result( + kind="external_install", + name=name, + status="installed" if install.returncode == 0 else "failed", + command=install_cmd, + completed=install, + ) + ) + + if check_cmd and install.returncode == 0: + try: + post_check = _run_setup_command( + shlex.split(check_cmd), + display=check_cmd, + timeout=20, + ) + results.append( + _command_result( + kind="external_check", + name=name, + status="verified" if post_check.returncode == 0 else "failed", + command=check_cmd, + completed=post_check, + ) + ) + except Exception as exc: + results.append( + _command_result( + kind="external_check", + name=name, + status="failed", + command=check_cmd, + error=str(exc), + ) + ) + + return results + + +def _install_memory_provider_setup(name: str) -> Dict[str, Any]: + provider = _load_memory_provider(name) + manifest = _memory_provider_manifest(name) + if provider is None and not manifest: + raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") + + setup = _memory_provider_setup_manifest(name) + results = [] + results.extend(_install_memory_provider_pip_dependencies(setup["pip_dependencies"])) + results.extend( + _install_memory_provider_external_dependencies(setup["external_dependencies"]) + ) + + if not results: + results.append( + _command_result( + kind="setup", + name=name, + status="no_declared_steps", + ) + ) + + ok = all(result["status"] not in {"failed"} for result in results) + statuses = {row["name"]: row for row in _discover_memory_provider_statuses()} + return { + "ok": ok, + "provider": name, + "results": results, + "status": statuses.get(name), + } + + +def _normalize_memory_provider_schema(name: str, provider: Any) -> List[Dict[str, Any]]: + raw_schema: List[Dict[str, Any]] = [] + if provider is not None and hasattr(provider, "get_config_schema"): + try: + raw = provider.get_config_schema() + if isinstance(raw, list): + raw_schema = [field for field in raw if isinstance(field, dict)] + except Exception: + _log.warning("Failed to read memory provider schema for %s", name, exc_info=True) + + fields: List[Dict[str, Any]] = [] + for raw in raw_schema: + key = str(raw.get("key") or "").strip() + if not key: + continue + + choices = raw.get("choices") or raw.get("options") or [] + if not isinstance(choices, list): + choices = [] + + explicit_kind = str(raw.get("kind") or raw.get("type") or "").strip().lower() + if raw.get("secret"): + kind = "secret" + elif choices: + kind = "select" + elif explicit_kind in {"bool", "boolean"} or isinstance(raw.get("default"), bool): + kind = "boolean" + else: + kind = "text" + + options = [] + for choice in choices: + value = str(choice) + options.append({"value": value, "label": value, "description": ""}) + + description = str(raw.get("description") or "") + fields.append({ + "key": key, + "label": str(raw.get("label") or key.replace("_", " ").title()), + "kind": kind, + "description": description, + "placeholder": str(raw.get("placeholder") or ""), + "required": bool(raw.get("required", False)), + "default": raw.get("default", ""), + "options": options, + "url": str(raw.get("url") or ""), + "when": raw.get("when") if isinstance(raw.get("when"), dict) else None, + "_env_key": str(raw.get("env_var") or "") or None, + }) + + return fields + + +def _read_json_file(path: Path) -> Dict[str, Any]: if not path.exists(): return {} try: data = json.loads(path.read_text(encoding="utf-8")) except Exception: - _log.warning("Failed to read memory provider config from %s", path, exc_info=True) + _log.debug("Failed to read JSON config from %s", path, exc_info=True) return {} return data if isinstance(data, dict) else {} -def _read_field_value(field: ProviderField, data: Dict[str, Any]) -> str: - """Resolve the stored value for a non-secret field, honoring legacy reads.""" +def _read_memory_provider_existing_values(name: str) -> Dict[str, Any]: + """Best-effort read of existing provider config across legacy/native stores.""" - for source_key in (field.key, *field.aliases): - value = data.get(source_key) - if value: - return str(value) + hermes_home = get_hermes_home() + values: Dict[str, Any] = {} + # Common native provider stores. + for path in ( + hermes_home / f"{name}.json", + hermes_home / name / "config.json", + ): + values.update(_read_json_file(path)) + + try: + cfg = load_config() + except Exception: + cfg = {} + + memory_cfg = cfg.get("memory") if isinstance(cfg, dict) else {} + if isinstance(memory_cfg, dict): + provider_cfg = memory_cfg.get(name) + if isinstance(provider_cfg, dict): + values.update(provider_cfg) + legacy_cfg = memory_cfg.get("provider_config") + if isinstance(legacy_cfg, dict): + values = {**legacy_cfg, **values} + + # Holographic stores under plugins.hermes-memory-store. + plugins_cfg = cfg.get("plugins") if isinstance(cfg, dict) else {} + if name == "holographic" and isinstance(plugins_cfg, dict): + holographic_cfg = plugins_cfg.get("hermes-memory-store") + if isinstance(holographic_cfg, dict): + values.update(holographic_cfg) + + return values + + +def _env_lookup(env_key: Optional[str]) -> str: + if not env_key: + return "" env_on_disk = load_env() - for env_key in field.env_fallbacks: - value = env_on_disk.get(env_key) - if value: - return str(value) - - return field.default + return str(env_on_disk.get(env_key) or os.environ.get(env_key) or "") -def _field_is_set(field: ProviderField, data: Dict[str, Any]) -> bool: - """Whether a secret field has a value anywhere it may have been written.""" - - env_on_disk = load_env() - for env_key in (field.env_key, *field.env_fallbacks): - if env_key and env_on_disk.get(env_key): - return True - return any(data.get(source_key) for source_key in (field.key, *field.aliases)) +def _coerce_bool(value: Any, *, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None or value == "": + return default + if isinstance(value, (int, float)): + return bool(value) + text = str(value).strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + raise ValueError(f"Invalid boolean value: {value}") -def _memory_provider_payload(provider: MemoryProvider) -> Dict[str, Any]: - data = _read_memory_provider_file(provider) - fields: List[Dict[str, Any]] = [] +def _field_default(field: Dict[str, Any]) -> Any: + default = field.get("default", "") + if field["kind"] == "boolean": + return _coerce_bool(default, default=False) + return default - for field in provider.fields: - entry: Dict[str, Any] = { - "key": field.key, - "label": field.label, - "kind": field.kind, - "description": field.description, - "placeholder": field.placeholder, - "options": [ - {"value": opt.value, "label": opt.label, "description": opt.description} - for opt in field.options - ], + +def _field_value(field: Dict[str, Any], data: Dict[str, Any]) -> Any: + if field["kind"] == "secret": + return "" + + value = data.get(field["key"]) + if value in (None, ""): + value = _env_lookup(field.get("_env_key")) + if value in (None, ""): + value = _field_default(field) + + if field["kind"] == "select": + allowed = {opt["value"] for opt in field.get("options", [])} + value = str(value) + return value if value in allowed else str(_field_default(field)) + if field["kind"] == "boolean": + return _coerce_bool(value, default=_coerce_bool(_field_default(field), default=False)) + return str(value) + + +def _field_is_set(field: Dict[str, Any], data: Dict[str, Any]) -> bool: + if field["kind"] == "secret": + return bool(_env_lookup(field.get("_env_key")) or data.get(field["key"])) + value = _field_value(field, data) + return value not in (None, "") + + +def _field_visible( + field: Dict[str, Any], + data: Dict[str, Any], + fields_by_key: Optional[Dict[str, Dict[str, Any]]] = None, +) -> bool: + when = field.get("when") + if not isinstance(when, dict) or not when: + return True + for dep_key, expected in when.items(): + dep_field = (fields_by_key or {}).get(str(dep_key)) or { + "key": str(dep_key), + "kind": "text", + "default": "", + "_env_key": None, + } + actual = _field_value(dep_field, data) + if str(actual) != str(expected): + return False + return True + + +def _public_memory_provider_field(field: Dict[str, Any], data: Dict[str, Any]) -> Dict[str, Any]: + entry = { + "key": field["key"], + "label": field["label"], + "kind": field["kind"], + "description": field["description"], + "placeholder": field["placeholder"], + "required": field["required"], + "value": "" if field["kind"] == "secret" else _field_value(field, data), + "is_set": _field_is_set(field, data), + "options": field.get("options", []), + "url": field.get("url", ""), + "when": field.get("when"), + } + return entry + + +def _memory_provider_payload(name: str, provider: Any) -> Dict[str, Any]: + data = _read_memory_provider_existing_values(name) + fields = [ + _public_memory_provider_field(field, data) + for field in _normalize_memory_provider_schema(name, provider) + ] + return { + "name": name, + "label": _memory_provider_label(name), + "fields": fields, + "setup": _memory_provider_setup_info(name), + } + + +def _coerce_schema_field(field: Dict[str, Any], raw: Any) -> Any: + if field["kind"] == "boolean": + return _coerce_bool(raw, default=_coerce_bool(_field_default(field), default=False)) + + value = str(raw if raw is not None else "").strip() + if field["kind"] == "select": + if not value: + value = str(_field_default(field)) + allowed = {opt["value"] for opt in field.get("options", [])} + if value not in allowed: + raise ValueError(f"Invalid value for '{field['key']}'") + return value + + return value or _field_default(field) + + +def _save_memory_provider_native_config(name: str, provider: Any, values: Dict[str, Any]) -> None: + if provider is not None and hasattr(provider, "save_config"): + try: + from agent.memory_provider import MemoryProvider as _BaseMemoryProvider + except Exception: + provider.save_config(values, str(get_hermes_home())) + return + if type(provider).save_config is not _BaseMemoryProvider.save_config: + provider.save_config(values, str(get_hermes_home())) + return + + cfg = load_config() + memory_cfg = cfg.get("memory") + if not isinstance(memory_cfg, dict): + memory_cfg = {} + cfg["memory"] = memory_cfg + current = memory_cfg.get(name) + if not isinstance(current, dict): + current = {} + current.update(values) + memory_cfg[name] = current + save_config(cfg) + + +def _memory_provider_is_configured(name: str, provider: Any) -> bool: + data = _read_memory_provider_existing_values(name) + fields = _normalize_memory_provider_schema(name, provider) + fields_by_key = {field["key"]: field for field in fields} + visible_fields = [ + field for field in fields if _field_visible(field, data, fields_by_key) + ] + required_fields = [field for field in visible_fields if field.get("required")] + if not required_fields: + return True + return all(_field_is_set(field, data) for field in required_fields) + + +def _discover_memory_provider_statuses() -> List[Dict[str, Any]]: + discovered: Dict[str, Dict[str, Any]] = {} + try: + from plugins.memory import discover_memory_providers + + for name, description, available in discover_memory_providers(): + discovered[str(name)] = { + "name": str(name), + "description": str(description or ""), + "available": bool(available), + "missing": False, + } + except Exception: + _log.exception("discover_memory_providers failed") + + cfg = load_config() + active = "" + mem = cfg.get("memory") + if isinstance(mem, dict): + active = _normalize_memory_provider_name(mem.get("provider")) + if active and active not in discovered: + discovered[active] = { + "name": active, + "description": "Configured provider was not found.", + "available": False, + "missing": True, } - if field.is_secret: - # Secrets are write-only over the API; only expose whether one is set. - entry["value"] = "" - entry["is_set"] = _field_is_set(field, data) + providers: List[Dict[str, Any]] = [] + for name in sorted(discovered): + row = discovered[name] + provider = None if row["missing"] else _load_memory_provider(name) + setup = _memory_provider_setup_info(name) + configured = False if row["missing"] else _memory_provider_is_configured(name, provider) + schema_fields = [] if row["missing"] else _normalize_memory_provider_schema(name, provider) + if row["missing"]: + status = "missing" + elif not row["available"] and not setup.get("dependencies_installed", True): + status = "unavailable" + elif not configured: + status = "needs_config" + elif not row["available"] and schema_fields: + status = "needs_config" + elif not row["available"]: + status = "unavailable" else: - value = _read_field_value(field, data) - if field.kind == "select" and value not in field.allowed_values(): - value = field.default - entry["value"] = value - entry["is_set"] = bool(value) - - fields.append(entry) - - return {"name": provider.name, "label": provider.label, "fields": fields} + status = "ready" + providers.append({ + "name": name, + "description": row["description"], + "available": row["available"], + "configured": configured, + "status": status, + "setup": setup, + }) + return providers -def _coerce_field_value(field: ProviderField, raw: str) -> str: - """Validate and normalize a submitted non-secret value, or raise ValueError.""" +def _require_memory_provider_ready(name: str) -> None: + if not name: + return + statuses = {row["name"]: row for row in _discover_memory_provider_statuses()} + row = statuses.get(name) + if row is None: + raise HTTPException( + status_code=400, + detail=f"Unknown memory provider '{name}'.", + ) + if row["status"] != "ready": + raise HTTPException( + status_code=400, + detail=( + f"Memory provider '{name}' is not ready " + f"({row['status'].replace('_', ' ')}). Configure it in the dashboard first." + ), + ) - value = (raw or "").strip() - if field.kind == "select": - if not value: - value = field.default - if value not in field.allowed_values(): - raise ValueError(f"Invalid value for '{field.key}'") - return value - return value or field.default + +def _write_memory_provider_config_values( + name: str, + provider: Any, + values: Dict[str, Any], +) -> None: + existing = _read_memory_provider_existing_values(name) + fields = _normalize_memory_provider_schema(name, provider) + fields_by_key = {field["key"]: field for field in fields} + config_values: Dict[str, Any] = {} + secrets: Dict[str, str] = {} + + for field in fields: + if not _field_visible(field, {**existing, **config_values}, fields_by_key): + continue + + if field["kind"] == "secret": + submitted = str(values.get(field["key"]) or "").strip() + if submitted and field.get("_env_key"): + secrets[str(field["_env_key"])] = submitted + continue + + raw = ( + values[field["key"]] + if field["key"] in values + else existing.get(field["key"], _field_default(field)) + ) + config_values[field["key"]] = _coerce_schema_field(field, raw) + + _save_memory_provider_native_config(name, provider, config_values) + + for env_key, secret in secrets.items(): + save_env_value(env_key, secret) @app.get("/api/memory/providers/{name}/config") async def get_memory_provider_config(name: str): - provider = get_memory_provider(name) + provider = _load_memory_provider(name) if provider is None: # Undeclared providers (e.g. builtin) have no config surface. Return an # empty schema so the generic panel simply renders nothing. - return {"name": name, "label": name, "fields": []} - return _memory_provider_payload(provider) + return {"name": name, "label": name, "fields": [], "setup": _memory_provider_setup_info(name)} + return _memory_provider_payload(name, provider) + + +@app.post("/api/memory/providers/{name}/setup") +async def setup_memory_provider(name: str, body: MemoryProviderSetupRequest): + provider = _load_memory_provider(name) + if provider is not None and body.values: + try: + _write_memory_provider_config_values(name, provider, body.values) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception: + _log.exception("Failed to persist memory provider setup values for %s", name) + raise HTTPException(status_code=500, detail="Internal server error") + return _install_memory_provider_setup(name) @app.put("/api/memory/providers/{name}/config") async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate): - provider = get_memory_provider(name) + provider = _load_memory_provider(name) if provider is None: raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") values = body.values or {} try: - existing = _read_memory_provider_file(provider) - json_values: Dict[str, Any] = {} - secrets: Dict[str, str] = {} - - for field in provider.fields: - if field.is_secret: - submitted = (values.get(field.key) or "").strip() - if submitted and field.env_key: - secrets[field.env_key] = submitted - continue - - raw = ( - values[field.key] - if field.key in values - else str(existing.get(field.key, field.default)) - ) - json_values[field.key] = _coerce_field_value(field, raw) + _write_memory_provider_config_values(name, provider, values) + _require_memory_provider_ready(name) config = load_config() memory_config = config.get("memory") if not isinstance(memory_config, dict): memory_config = {} config["memory"] = memory_config - memory_config["provider"] = provider.name + memory_config["provider"] = name save_config(config) - path = _memory_provider_config_path(provider) - path.parent.mkdir(parents=True, exist_ok=True) - existing.update(json_values) - from utils import atomic_json_write - - atomic_json_write(path, existing, mode=0o600) - - for env_key, secret in secrets.items(): - save_env_value(env_key, secret) - - return {"ok": True} + return {"ok": True, "active": name} except HTTPException: raise except ValueError as exc: @@ -10036,11 +10680,9 @@ async def remove_credential_pool_entry(provider: str, index: int): # --------------------------------------------------------------------------- # Memory provider endpoints — status / list providers / select / disable / reset. # -# Selecting a provider only writes config.memory.provider (full interactive -# provider setup, with its API-key prompts, stays on the CLI via -# `hermes memory setup`). The dashboard covers the common admin actions: -# see which provider is active, switch the built-in store on/off, and wipe -# built-in memory files. +# Provider setup is dashboard-native when a provider exposes get_config_schema(). +# The dashboard never runs interactive provider setup hooks; activation is only +# allowed once the provider is discoverable, available, and has required config. # --------------------------------------------------------------------------- @@ -10056,24 +10698,11 @@ class MemoryReset(BaseModel): @app.get("/api/memory") async def get_memory_status(): - from plugins.memory import discover_memory_providers - cfg = load_config() active = "" mem = cfg.get("memory") if isinstance(mem, dict): - active = str(mem.get("provider") or "") - - providers = [] - try: - for name, description, configured in discover_memory_providers(): - providers.append({ - "name": name, - "description": description, - "configured": bool(configured), - }) - except Exception: - _log.exception("discover_memory_providers failed") + active = _normalize_memory_provider_name(mem.get("provider")) # Built-in memory file sizes (so the UI can show what a reset would erase). mem_dir = get_hermes_home() / "memories" @@ -10084,26 +10713,16 @@ async def get_memory_status(): return { "active": active, - "providers": providers, + "providers": _discover_memory_provider_statuses(), "builtin_files": files, } @app.put("/api/memory/provider") async def set_memory_provider(body: MemoryProviderSelect): - provider = (body.provider or "").strip() - if provider.lower() in {"built-in", "builtin", "none"}: - provider = "" + provider = _normalize_memory_provider_name(body.provider) - if provider: - from plugins.memory import discover_memory_providers - - valid = {name for name, _d, _c in discover_memory_providers()} - if provider not in valid: - raise HTTPException( - status_code=400, - detail=f"Unknown memory provider '{provider}'. Run `hermes memory setup` to configure a new one.", - ) + _require_memory_provider_ready(provider) cfg = load_config() if not isinstance(cfg.get("memory"), dict): @@ -14910,7 +15529,6 @@ def _merged_plugins_hub() -> Dict[str, Any]: _get_current_context_engine, _get_current_memory_provider, _discover_context_engines, - _discover_memory_providers, _get_disabled_set, _get_enabled_set, _read_manifest as _read_plugin_manifest_at, @@ -14998,12 +15616,7 @@ def _merged_plugins_hub() -> Dict[str, Any]: if str(p["name"]) not in agent_names ] - memory_providers: List[Dict[str, str]] = [] - try: - for n, desc in _discover_memory_providers(): - memory_providers.append({"name": n, "description": desc}) - except Exception: - memory_providers = [] + memory_providers = _discover_memory_provider_statuses() context_engines: List[Dict[str, str]] = [] try: @@ -15016,7 +15629,7 @@ def _merged_plugins_hub() -> Dict[str, Any]: "plugins": rows, "orphan_dashboard_plugins": orphan_dashboard, "providers": { - "memory_provider": _get_current_memory_provider() or "", + "memory_provider": _normalize_memory_provider_name(_get_current_memory_provider()), "memory_options": memory_providers, "context_engine": _get_current_context_engine(), "context_options": context_engines, @@ -15129,7 +15742,9 @@ async def put_plugin_providers(request: Request, body: _PluginProvidersPutBody): ) if body.memory_provider is not None: - _save_memory_provider(body.memory_provider) + memory_provider = _normalize_memory_provider_name(body.memory_provider) + _require_memory_provider_ready(memory_provider) + _save_memory_provider(memory_provider) if body.context_engine is not None: _save_context_engine(body.context_engine) return {"ok": True} diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index dc1bfcccf79..d8d164d11ad 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -391,12 +391,149 @@ class TestWebServerEndpoints: fields = self._provider_field_map(data) assert fields["mode"]["kind"] == "select" assert fields["mode"]["value"] == "cloud" - assert {opt["value"] for opt in fields["mode"]["options"]} == {"cloud", "local_external"} - assert fields["api_url"]["value"] == "https://api.hindsight.vectorize.io" + assert {opt["value"] for opt in fields["mode"]["options"]} >= { + "cloud", + "local_external", + } + assert fields["api_url"]["kind"] == "text" + assert fields["api_url"]["value"] assert fields["bank_id"]["value"] == "hermes" assert fields["recall_budget"]["value"] == "mid" assert fields["api_key"]["kind"] == "secret" assert fields["api_key"]["is_set"] is False + assert fields["api_key"]["required"] is False + + def test_get_memory_provider_config_loads_dynamic_plugin_schema(self): + resp = self.client.get("/api/memory/providers/honcho/config") + + assert resp.status_code == 200 + data = resp.json() + fields = self._provider_field_map(data) + assert fields["api_key"]["kind"] == "secret" + assert fields["api_key"]["url"] == "https://app.honcho.dev" + assert fields["baseUrl"]["kind"] == "text" + + def test_all_listed_memory_provider_configs_fetch(self): + resp = self.client.get("/api/memory") + + assert resp.status_code == 200 + providers = resp.json()["providers"] + assert providers + + failures = [] + for provider in providers: + config_resp = self.client.get( + f"/api/memory/providers/{provider['name']}/config" + ) + if config_resp.status_code != 200: + failures.append((provider["name"], config_resp.status_code, config_resp.text)) + + assert failures == [] + + def test_memory_provider_payloads_include_manifest_setup_hints(self): + resp = self.client.get("/api/memory") + + assert resp.status_code == 200 + providers = {row["name"]: row for row in resp.json()["providers"]} + + byterover_setup = providers["byterover"]["setup"] + assert byterover_setup["external_dependencies"] == [ + { + "name": "brv", + "install": "curl -fsSL https://byterover.dev/install.sh | sh", + "check": "brv --version", + } + ] + + retaindb_setup = providers["retaindb"]["setup"] + assert "requests" in retaindb_setup["pip_dependencies"] + assert "RETAINDB_API_KEY" in retaindb_setup["required_env"] + assert isinstance(byterover_setup["dependencies_installed"], bool) + + config_resp = self.client.get("/api/memory/providers/byterover/config") + assert config_resp.status_code == 200 + assert config_resp.json()["setup"]["external_dependencies"] == byterover_setup["external_dependencies"] + + def test_memory_status_reports_honcho_needs_config_after_dependency_setup(self, monkeypatch): + import hermes_cli.web_server as web_server + + original_dependency_importable = web_server._dependency_importable + monkeypatch.setattr( + web_server, + "_dependency_importable", + lambda dep: True if dep == "honcho-ai" else original_dependency_importable(dep), + ) + + resp = self.client.get("/api/memory") + + assert resp.status_code == 200 + providers = {row["name"]: row for row in resp.json()["providers"]} + assert providers["honcho"]["setup"]["dependencies_installed"] is True + assert providers["honcho"]["status"] == "needs_config" + + def test_post_memory_provider_setup_runs_declared_external_install(self, monkeypatch): + import subprocess + + import hermes_cli.web_server as web_server + + calls = [] + check_count = 0 + + def fake_run(command, **kwargs): + nonlocal check_count + calls.append((command, kwargs)) + if command == ["brv", "--version"]: + check_count += 1 + if check_count == 1: + raise FileNotFoundError("brv") + return subprocess.CompletedProcess( + command, + 0, + stdout="brv 1.0.0", + stderr="", + ) + if command == "curl -fsSL https://byterover.dev/install.sh | sh": + assert kwargs["shell"] is True + return subprocess.CompletedProcess(command, 0, stdout="installed", stderr="") + raise AssertionError(f"Unexpected command: {command}") + + monkeypatch.setattr(web_server.subprocess, "run", fake_run) + + resp = self.client.post("/api/memory/providers/byterover/setup", json={"values": {}}) + + assert resp.status_code == 200 + data = resp.json() + assert data["provider"] == "byterover" + assert data["ok"] is True + assert [result["status"] for result in data["results"]] == [ + "missing", + "installed", + "verified", + ] + assert [call[0] for call in calls[:3]] == [ + ["brv", "--version"], + "curl -fsSL https://byterover.dev/install.sh | sh", + ["brv", "--version"], + ] + assert calls[-1][0] == ["brv", "--version"] + + def test_post_unknown_memory_provider_setup_returns_404(self): + resp = self.client.post("/api/memory/providers/nope/setup", json={"values": {}}) + + assert resp.status_code == 404 + + def test_post_memory_provider_setup_persists_values_without_activation(self): + from hermes_cli.config import load_config, load_env + + resp = self.client.post( + "/api/memory/providers/retaindb/setup", + json={"values": {"api_key": "retain-test-key", "project": "default"}}, + ) + + assert resp.status_code == 200 + assert resp.json()["provider"] == "retaindb" + assert load_env()["RETAINDB_API_KEY"] == "retain-test-key" + assert load_config().get("memory", {}).get("provider") != "retaindb" def test_put_memory_provider_config_writes_config_and_secret(self): from hermes_constants import get_hermes_home @@ -416,25 +553,24 @@ class TestWebServerEndpoints: ) assert resp.status_code == 200 - assert resp.json() == {"ok": True} + assert resp.json() == {"ok": True, "active": "hindsight"} assert load_config()["memory"]["provider"] == "hindsight" assert load_env()["HINDSIGHT_API_KEY"] == "hs-test-key" config_path = get_hermes_home() / "hindsight" / "config.json" provider_config = json.loads(config_path.read_text(encoding="utf-8")) - assert provider_config == { - "mode": "local_external", - "api_url": "http://localhost:8888", - "bank_id": "ben-bank", - "recall_budget": "high", - } + assert provider_config["mode"] == "local_external" + assert provider_config["api_url"] == "http://localhost:8888" + assert provider_config["bank_id"] == "ben-bank" + assert provider_config["recall_budget"] == "high" + assert "api_key" not in provider_config def test_put_memory_provider_config_rejects_unsupported_select_value(self): resp = self.client.put( "/api/memory/providers/hindsight/config", json={ "values": { - "mode": "local_embedded", + "mode": "spaceship", "api_url": "http://localhost:8888", "bank_id": "hermes", "recall_budget": "mid", @@ -480,6 +616,77 @@ class TestWebServerEndpoints: assert fields["api_key"]["value"] == "" assert "secret-value" not in json.dumps(data) + def test_get_memory_status_reports_ready_and_missing_provider(self): + from hermes_cli.config import load_config, save_config + + self.client.put( + "/api/memory/providers/hindsight/config", + json={ + "values": { + "mode": "cloud", + "api_url": "https://api.hindsight.vectorize.io", + "api_key": "secret-value", + "bank_id": "hermes", + "recall_budget": "mid", + } + }, + ) + resp = self.client.get("/api/memory") + assert resp.status_code == 200 + providers = {row["name"]: row for row in resp.json()["providers"]} + assert providers["hindsight"]["configured"] is True + assert providers["hindsight"]["status"] == "ready" + assert "available" in providers["hindsight"] + + config = load_config() + config.setdefault("memory", {})["provider"] = "not-installed" + save_config(config) + + resp = self.client.get("/api/memory") + assert resp.status_code == 200 + providers = {row["name"]: row for row in resp.json()["providers"]} + assert providers["not-installed"]["status"] == "missing" + assert providers["not-installed"]["available"] is False + + config = load_config() + config.setdefault("memory", {})["provider"] = "builtin" + save_config(config) + + resp = self.client.get("/api/memory") + assert resp.status_code == 200 + assert resp.json()["active"] == "" + assert "builtin" not in {row["name"] for row in resp.json()["providers"]} + + def test_set_memory_provider_rejects_unready_and_clears_builtin(self): + from hermes_cli.config import load_config + + resp = self.client.put("/api/memory/provider", json={"provider": "supermemory"}) + assert resp.status_code == 400 + + resp = self.client.put("/api/memory/provider", json={"provider": "built-in"}) + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "active": ""} + assert load_config()["memory"]["provider"] == "" + + def test_dashboard_plugin_providers_rejects_unready_memory_provider(self): + resp = self.client.put( + "/api/dashboard/plugin-providers", + json={"memory_provider": "supermemory"}, + ) + + assert resp.status_code == 400 + + def test_dashboard_plugin_providers_accepts_builtin_alias(self): + from hermes_cli.config import load_config + + resp = self.client.put( + "/api/dashboard/plugin-providers", + json={"memory_provider": "built-in"}, + ) + + assert resp.status_code == 200 + assert load_config()["memory"]["provider"] == "" + def test_get_moa_models_returns_provider_model_slots(self): resp = self.client.get("/api/model/moa") assert resp.status_code == 200 diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 25128b306a4..5dc982430a9 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1070,6 +1070,28 @@ export const api = { // ── Admin: Memory provider ────────────────────────────────────────── getMemory: () => fetchJSON("/api/memory"), + getMemoryProviderConfig: (provider: string) => + fetchJSON( + `/api/memory/providers/${encodeURIComponent(provider)}/config`, + ), + updateMemoryProviderConfig: (provider: string, values: Record) => + fetchJSON<{ ok: boolean; active: string }>( + `/api/memory/providers/${encodeURIComponent(provider)}/config`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ values }), + }, + ), + setupMemoryProvider: (provider: string, values: Record = {}) => + fetchJSON( + `/api/memory/providers/${encodeURIComponent(provider)}/setup`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ values }), + }, + ), setMemoryProvider: (provider: string) => fetchJSON<{ ok: boolean; active: string }>("/api/memory/provider", { method: "PUT", @@ -1524,7 +1546,10 @@ export interface CredentialPoolProvider { export interface MemoryProviderInfo { name: string; description: string; + available: boolean; configured: boolean; + status: "ready" | "needs_config" | "unavailable" | "missing"; + setup?: MemoryProviderSetupInfo; } export interface MemoryStatus { @@ -1533,6 +1558,63 @@ export interface MemoryStatus { builtin_files: { memory: number; user: number }; } +export interface MemoryProviderExternalDependency { + name: string; + install: string; + check: string; +} + +export interface MemoryProviderSetupInfo { + pip_dependencies: string[]; + external_dependencies: MemoryProviderExternalDependency[]; + required_env: string[]; + dependencies_installed: boolean; +} + +export interface MemoryProviderSetupResult { + kind: string; + name: string; + status: string; + command: string; + returncode: number | null; + stdout: string; + stderr: string; +} + +export interface MemoryProviderSetupResponse { + ok: boolean; + provider: string; + results: MemoryProviderSetupResult[]; + status?: MemoryProviderInfo | null; +} + +export interface MemoryProviderFieldOption { + value: string; + label: string; + description?: string; +} + +export interface MemoryProviderField { + key: string; + label: string; + kind: "text" | "secret" | "select" | "boolean"; + description: string; + placeholder: string; + required: boolean; + value: string | boolean; + is_set: boolean; + options: MemoryProviderFieldOption[]; + url: string; + when?: Record | null; +} + +export interface MemoryProviderConfig { + name: string; + label: string; + fields: MemoryProviderField[]; + setup?: MemoryProviderSetupInfo; +} + export interface HookEntry { event: string; matcher: string | null; @@ -2317,7 +2399,7 @@ export interface HubAgentPluginRow { export interface PluginsHubProviders { memory_provider: string; - memory_options: Array<{ name: string; description: string }>; + memory_options: MemoryProviderInfo[]; context_engine: string; context_options: Array<{ name: string; description: string }>; } diff --git a/web/src/pages/PluginsPage.tsx b/web/src/pages/PluginsPage.tsx index 97790ce631d..52baf89cbf9 100644 --- a/web/src/pages/PluginsPage.tsx +++ b/web/src/pages/PluginsPage.tsx @@ -3,13 +3,21 @@ import { ExternalLink, RefreshCw, Trash2, Eye, EyeOff } from "lucide-react"; import type { Translations } from "@/i18n/types"; import { Link } from "react-router-dom"; import { api } from "@/lib/api"; -import type { HubAgentPluginRow, PluginsHubResponse } from "@/lib/api"; +import type { + HubAgentPluginRow, + MemoryProviderConfig, + MemoryProviderField, + MemoryProviderInfo, + MemoryProviderSetupInfo, + MemoryProviderSetupResult, + PluginsHubResponse, +} from "@/lib/api"; import { Button } from "@nous-research/ui/ui/components/button"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; import { Switch } from "@nous-research/ui/ui/components/switch"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; -import { CommandBlock } from "@nous-research/ui/ui/components/command-block"; +import { CommandBlock, CopyButton } from "@nous-research/ui/ui/components/command-block"; import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card"; import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog"; import { Input } from "@nous-research/ui/ui/components/input"; @@ -24,6 +32,252 @@ import { usePageHeader } from "@/contexts/usePageHeader"; /** Select value for built-in memory (`config` uses empty string). Never use `""` — UI Select maps empty value to an empty label. */ const MEMORY_PROVIDER_BUILTIN = "__hermes_memory_builtin__"; +type MemoryFormValue = string | boolean; + +const MEMORY_STATUS_LABEL: Record = { + ready: "ready", + needs_config: "needs setup", + unavailable: "unavailable", + missing: "missing", +}; + +const MEMORY_STATUS_TONE: Record = { + ready: "success", + needs_config: "warning", + unavailable: "destructive", + missing: "destructive", +}; + +function fieldInitialValue(field: MemoryProviderField): MemoryFormValue { + if (field.kind === "secret") return ""; + if (field.kind === "boolean") return Boolean(field.value); + return String(field.value ?? ""); +} + +function fieldIsVisible(field: MemoryProviderField, values: Record) { + if (!field.when) return true; + return Object.entries(field.when).every(([key, expected]) => { + const current = values[key]; + return String(current ?? "") === String(expected); + }); +} + +function setupHasDetails(setup?: MemoryProviderSetupInfo) { + if (!setup) return false; + return Boolean( + setup.external_dependencies?.length || + setup.pip_dependencies?.length || + setup.required_env?.length, + ); +} + +function setupHasInstallableSteps(setup?: MemoryProviderSetupInfo) { + if (!setup) return false; + return Boolean( + setup.external_dependencies?.some((dep) => dep.install) || + setup.pip_dependencies?.length, + ); +} + +function SetupCommandBlock({ code, label }: { code: string; label: string }) { + return ( +
+
+ {label} + +
+
+ {code} +
+
+ ); +} + +function setupResultLabel(status: string) { + if (status === "already_installed") return "already installed"; + if (status === "no_declared_steps") return "no declared setup"; + return status.replace(/_/g, " "); +} + +function setupResultClass(status: string) { + if (status === "failed") return "border-destructive/50 text-destructive"; + if (status === "installed" || status === "verified" || status === "already_installed") { + return "border-success/50 text-success"; + } + if (status === "missing") return "border-warning/50 text-warning"; + return "border-border text-muted-foreground"; +} + +function MemoryProviderSetupResults({ results }: { results: MemoryProviderSetupResult[] }) { + if (!results.length) return null; + + return ( +
+

Setup results

+ {results.map((result, index) => { + const detail = result.stderr || result.stdout; + return ( +
+
+ + {setupResultLabel(result.status)} + + + {result.name} + {result.kind ? ` (${result.kind.replace(/_/g, " ")})` : ""} + +
+ {result.command ? ( + + {result.command} + + ) : null} + {detail ? ( +
+                {detail}
+              
+ ) : null} +
+ ); + })} +
+ ); +} + +function MemoryProviderSetupHint({ + installing, + onInstall, + provider, + results, +}: { + installing: boolean; + onInstall: () => void; + provider: MemoryProviderInfo; + results: MemoryProviderSetupResult[] | null; +}) { + const setup = provider.setup; + const hasDetails = setupHasDetails(setup); + const hasInstallableSteps = setupHasInstallableSteps(setup); + const dependenciesInstalled = setup?.dependencies_installed ?? !hasInstallableSteps; + const hasResults = Boolean(results?.length); + const needsDependencySetup = hasInstallableSteps && !dependenciesInstalled; + const isBlocked = provider.status === "unavailable" && needsDependencySetup; + const shouldShow = + hasResults || + needsDependencySetup || + (provider.status === "unavailable" && hasDetails && !dependenciesInstalled); + + if (!shouldShow) return null; + + if (!hasDetails || !setup) { + return ( +

+ This provider is installed but unavailable. It may need local dependencies or a manual setup step before Hermes can activate it. +

+ ); + } + + return ( +
+

+ {needsDependencySetup + ? "Finish these setup steps before Hermes can activate this provider." + : "Provider dependency setup completed."} +

+ + {needsDependencySetup ? ( + + ) : null} + + {installing ? ( +
+ Running provider setup. This may take a minute… +
+ ) : null} + + {results ? : null} + + {needsDependencySetup ? ( + <> + {setup.external_dependencies.map((dep, index) => ( +
+

+ External dependency{dep.name ? `: ${dep.name}` : ""} +

+ {dep.install ? ( + + ) : null} + {dep.check ? ( + + ) : null} +
+ ))} + + {setup.pip_dependencies.length ? ( +
+

Python dependencies

+
+ {setup.pip_dependencies.map((dep) => ( + + {dep} + + ))} +
+
+ ) : null} + + ) : null} + + {setup.required_env.length && needsDependencySetup ? ( +
+

+ Required environment values. Fill the matching fields below, or set them in the Hermes environment. +

+
+ {setup.required_env.map((envKey) => ( + + {envKey} + + ))} +
+
+ ) : null} +
+ ); +} + export default function PluginsPage() { const [hub, setHub] = useState(null); const [loading, setLoading] = useState(true); @@ -33,46 +287,83 @@ export default function PluginsPage() { const [installBusy, setInstallBusy] = useState(false); const [rescanBusy, setRescanBusy] = useState(false); const [memorySel, setMemorySel] = useState(MEMORY_PROVIDER_BUILTIN); + const [memoryConfig, setMemoryConfig] = useState(null); + const [memoryValues, setMemoryValues] = useState>({}); + const [memoryConfigBusy, setMemoryConfigBusy] = useState(false); + const [secretVisible, setSecretVisible] = useState>({}); const [contextSel, setContextSel] = useState("compressor"); - const [providerBusy, setProviderBusy] = useState(false); + const [memoryBusy, setMemoryBusy] = useState(false); + const [memorySetupBusy, setMemorySetupBusy] = useState(false); + const [memorySetupResults, setMemorySetupResults] = useState(null); + const [contextBusy, setContextBusy] = useState(false); const [rowBusy, setRowBusy] = useState(null); const { toast, showToast } = useToast(); const { t } = useI18n(); const { setAfterTitle } = usePageHeader(); - const loadHub = useCallback(() => { + const loadHub = useCallback((memorySelection?: string) => { return api .getPluginsHub() .then((h) => { setHub(h); const p = h.providers; - setMemorySel(p.memory_provider ? p.memory_provider : MEMORY_PROVIDER_BUILTIN); + setMemorySel( + memorySelection ?? (p.memory_provider ? p.memory_provider : MEMORY_PROVIDER_BUILTIN), + ); setContextSel(p.context_engine || "compressor"); }) .catch(() => showToast(t.common.loading, "error")); }, [showToast, t.common.loading]); useEffect(() => { - setLoading(true); void loadHub().finally(() => setLoading(false)); }, [loadHub]); useEffect(() => { - setAfterTitle( - , - ); - return () => setAfterTitle(null); - }, [loading, rescanBusy, setAfterTitle, t.pluginsPage.refreshDashboard]); + const provider = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel; + let cancelled = false; + + void Promise.resolve().then(() => { + if (cancelled) return; + setSecretVisible({}); + setMemorySetupResults(null); + + if (!provider) { + setMemoryConfig(null); + setMemoryValues({}); + setMemoryConfigBusy(false); + return; + } + + setMemoryConfigBusy(true); + api + .getMemoryProviderConfig(provider) + .then((config) => { + if (cancelled) return; + setMemoryConfig(config); + setMemoryValues( + Object.fromEntries( + config.fields.map((field) => [field.key, fieldInitialValue(field)]), + ), + ); + }) + .catch((e) => { + if (!cancelled) { + setMemoryConfig(null); + setMemoryValues({}); + showToast(e instanceof Error ? e.message : "Failed to load provider config", "error"); + } + }) + .finally(() => { + if (!cancelled) setMemoryConfigBusy(false); + }); + }); + + return () => { + cancelled = true; + }; + }, [memorySel, showToast]); const onInstall = async () => { const id = installId.trim(); @@ -100,7 +391,7 @@ export default function PluginsPage() { } }; - const onRescan = async () => { + const onRescan = useCallback(async () => { setRescanBusy(true); try { const rc = await api.rescanPlugins(); @@ -114,22 +405,90 @@ export default function PluginsPage() { } finally { setRescanBusy(false); } - }; + }, [loadHub, showToast, t.pluginsPage.refreshDashboard]); - const onSaveProviders = async () => { - setProviderBusy(true); + useEffect(() => { + setAfterTitle( + , + ); + return () => setAfterTitle(null); + }, [loading, onRescan, rescanBusy, setAfterTitle, t.pluginsPage.refreshDashboard]); + + const onSaveMemoryProvider = async () => { + const provider = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel; + setMemoryBusy(true); try { - await api.savePluginProviders({ - memory_provider: - memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel, - context_engine: contextSel, - }); + if (!provider) { + await api.setMemoryProvider(""); + } else { + const visibleValues = Object.fromEntries( + Object.entries(memoryValues).filter(([key]) => { + const field = memoryConfig?.fields.find((candidate) => candidate.key === key); + return field ? fieldIsVisible(field, memoryValues) : true; + }), + ); + await api.updateMemoryProviderConfig(provider, visibleValues); + } showToast(t.pluginsPage.savedProviders, "success"); await loadHub(); } catch (e) { showToast(e instanceof Error ? e.message : "Save failed", "error"); } finally { - setProviderBusy(false); + setMemoryBusy(false); + } + }; + + const currentVisibleMemoryValues = () => + Object.fromEntries( + Object.entries(memoryValues).filter(([key]) => { + const field = memoryConfig?.fields.find((candidate) => candidate.key === key); + return field ? fieldIsVisible(field, memoryValues) : true; + }), + ); + + const onSetupMemoryProvider = async () => { + const provider = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel; + if (!provider) return; + + setMemorySetupBusy(true); + setMemorySetupResults(null); + try { + const result = await api.setupMemoryProvider(provider, currentVisibleMemoryValues()); + setMemorySetupResults(result.results); + const failed = result.results.filter((row) => row.status === "failed"); + if (failed.length) { + const names = Array.from(new Set(failed.map((row) => row.name))).join(", "); + showToast(`Provider setup failed: ${names || provider}. See setup results below.`, "error"); + } else { + showToast("Provider setup finished", "success"); + } + await loadHub(provider); + } catch (e) { + showToast(e instanceof Error ? e.message : "Provider setup failed", "error"); + } finally { + setMemorySetupBusy(false); + } + }; + + const onSaveContextEngine = async () => { + setContextBusy(true); + try { + await api.savePluginProviders({ context_engine: contextSel }); + showToast(t.pluginsPage.savedProviders, "success"); + await loadHub(); + } catch (e) { + showToast(e instanceof Error ? e.message : "Save failed", "error"); + } finally { + setContextBusy(false); } }; @@ -147,6 +506,15 @@ export default function PluginsPage() { const rows = hub?.plugins ?? []; const providers = hub?.providers; + const selectedMemoryName = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel; + const selectedMemoryInfo = selectedMemoryName + ? providers?.memory_options.find((provider) => provider.name === selectedMemoryName) + : null; + const activeMemoryInfo = providers?.memory_provider + ? providers.memory_options.find((provider) => provider.name === providers.memory_provider) + : null; + const visibleMemoryFields = + memoryConfig?.fields.filter((field) => fieldIsVisible(field, memoryValues)) ?? []; return (
@@ -159,65 +527,230 @@ export default function PluginsPage() { {t.pluginsPage.providersHeading}

- {t.pluginsPage.providersHint} + Configure memory providers and runtime context engine selection.

+
+
+
+
+ + {selectedMemoryName && selectedMemoryInfo && ( + + {MEMORY_STATUS_LABEL[selectedMemoryInfo.status]} + + )} + {selectedMemoryName && selectedMemoryName === providers.memory_provider && ( + active + )} + {!selectedMemoryName && !providers.memory_provider && ( + active + )} +
-
-
- - - -
- -
- - - + + {`(${t.pluginsPage.providerDefaults})`} - ))} - -
-
- + {providers.memory_options.map((o) => ( + + {o.name} + + ))} + +
+ + {!selectedMemoryName && ( +

+ Hermes will use the built-in MEMORY.md and USER.md files. +

+ )} + + {activeMemoryInfo?.status === "missing" && ( +

+ Active provider {providers.memory_provider} is no longer installed. Select another provider and save. +

+ )} + + {selectedMemoryName && selectedMemoryInfo?.description && ( +

+ {selectedMemoryInfo.description} +

+ )} + + {selectedMemoryName && selectedMemoryInfo && ( + void onSetupMemoryProvider()} + provider={selectedMemoryInfo} + results={memorySetupResults} + /> + )} + + {selectedMemoryName && selectedMemoryInfo?.status === "needs_config" && ( +

+ Provider dependencies are installed. Add the required credentials or self-hosted URL below, then save the provider. +

+ )} + + {selectedMemoryName && memoryConfigBusy && ( +
+ Loading provider settings… +
+ )} + + {selectedMemoryName && !memoryConfigBusy && visibleMemoryFields.length === 0 && ( +

+ This provider does not expose dashboard settings. +

+ )} + + {selectedMemoryName && !memoryConfigBusy && visibleMemoryFields.length > 0 && ( +
+ {visibleMemoryFields.map((field) => { + const value = memoryValues[field.key]; + const secretIsVisible = !!secretVisible[field.key]; + return ( +
+
+ + {field.required && required} + {field.kind === "secret" && field.is_set && !value && ( + set + )} + {field.url && ( + + Open + + )} +
+ + {field.kind === "select" ? ( + + ) : field.kind === "boolean" ? ( + + setMemoryValues((current) => ({ ...current, [field.key]: next })) + } + /> + ) : ( +
+ + setMemoryValues((current) => ({ + ...current, + [field.key]: event.target.value, + })) + } + /> + {field.kind === "secret" && ( + + )} +
+ )} + + {field.description && ( +

{field.description}

+ )} +
+ ); + })} +
+ )} + + +
+ +
+ + + + + +
+
)} diff --git a/web/src/pages/SystemPage.tsx b/web/src/pages/SystemPage.tsx index 82aed6b2bd3..e32f752eb30 100644 --- a/web/src/pages/SystemPage.tsx +++ b/web/src/pages/SystemPage.tsx @@ -48,6 +48,7 @@ import { api } from "@/lib/api"; import type { StatusResponse, MemoryStatus, + MemoryProviderInfo, CredentialPoolProvider, CheckpointsResponse, HooksResponse, @@ -171,6 +172,23 @@ const HOOK_EVENTS_FALLBACK = [ "on_session_end", ]; +const MEMORY_STATUS_LABEL: Record = { + ready: "ready", + needs_config: "needs setup", + unavailable: "unavailable", + missing: "missing", +}; + +const MEMORY_STATUS_TONE: Record< + MemoryProviderInfo["status"], + "success" | "warning" | "destructive" | "secondary" +> = { + ready: "success", + needs_config: "warning", + unavailable: "destructive", + missing: "destructive", +}; + export default function SystemPage() { const { toast, showToast } = useToast(); @@ -620,6 +638,9 @@ export default function SystemPage() { const gatewayRunning = status?.gateway_running; const canUpdateHermes = status?.can_update_hermes !== false; + const activeMemoryProvider = memory?.active + ? memory.providers.find((provider) => provider.name === memory.active) + : null; const validEvents = hooks?.valid_events?.length ? hooks.valid_events : HOOK_EVENTS_FALLBACK; @@ -1077,15 +1098,28 @@ export default function SystemPage() { {memory?.active || "built-in only"} + {activeMemoryProvider && ( + + {MEMORY_STATUS_LABEL[activeMemoryProvider.status]} + + )} Change in Plugins → - New credentials:{" "} - hermes memory setup + Provider setup:{" "} + + configure in Plugins +
+ {activeMemoryProvider?.status === "missing" && ( +

+ The configured provider is no longer installed. Switch to built-in memory or configure another provider in Plugins. +

+ )} +
Built-in files — MEMORY.md:{" "}