diff --git a/agent/memory_provider.py b/agent/memory_provider.py index 2ee45431b1c3..5f2837f21fa0 100644 --- a/agent/memory_provider.py +++ b/agent/memory_provider.py @@ -142,6 +142,24 @@ class MemoryProvider(ABC): messages is the list that will be summarized/discarded. """ + def get_config_schema(self) -> List[Dict[str, Any]]: + """Return config fields this provider needs for setup. + + Used by 'hermes memory setup' to walk the user through configuration. + Each field is a dict with: + key: config key name (e.g. 'api_key', 'mode') + description: human-readable description + secret: True if this should go to .env (default: False) + required: True if required (default: False) + default: default value (optional) + choices: list of valid values (optional) + url: URL where user can get this credential (optional) + env_var: explicit env var name for secrets (default: auto-generated) + + Return empty list if no config needed (e.g. local-only providers). + """ + return [] + def on_memory_write(self, action: str, target: str, content: str) -> None: """Called when the built-in memory tool writes an entry. diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4a00f1ef322c..342ae87f3542 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4147,6 +4147,30 @@ For more help on a command: plugins_parser.set_defaults(func=cmd_plugins) + # ========================================================================= + # memory command + # ========================================================================= + memory_parser = subparsers.add_parser( + "memory", + help="Manage memory provider plugins", + description=( + "Configure which memory provider plugin is active.\n\n" + "Memory providers give the agent persistent recall across sessions.\n" + "Built-in memory (MEMORY.md / USER.md) is always active.\n" + "One external provider can be active at a time." + ), + formatter_class=__import__("argparse").RawDescriptionHelpFormatter, + ) + memory_subparsers = memory_parser.add_subparsers(dest="memory_command") + memory_subparsers.add_parser("setup", help="Interactive setup wizard") + memory_subparsers.add_parser("status", help="Show current provider and config") + + def cmd_memory(args): + from hermes_cli.memory_setup import memory_command + memory_command(args) + + memory_parser.set_defaults(func=cmd_memory) + # ========================================================================= # honcho command # ========================================================================= diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py new file mode 100644 index 000000000000..fd0bad1fa053 --- /dev/null +++ b/hermes_cli/memory_setup.py @@ -0,0 +1,357 @@ +"""hermes memory setup|status — configure memory provider plugins. + +Auto-detects installed memory providers via the plugin system. +Interactive curses-based UI for provider selection, then walks through +the provider's config schema. Writes config to config.yaml + .env. +""" + +from __future__ import annotations + +import getpass +import os +import sys +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Curses-based interactive picker (same pattern as hermes tools) +# --------------------------------------------------------------------------- + +def _curses_select(title: str, items: list[tuple[str, str]], default: int = 0) -> int: + """Interactive single-select with arrow keys. + + items: list of (label, description) tuples. + Returns selected index, or default on escape/quit. + """ + try: + import curses + result = [default] + + def _menu(stdscr): + curses.curs_set(0) + if curses.has_colors(): + curses.start_color() + curses.use_default_colors() + curses.init_pair(1, curses.COLOR_GREEN, -1) + curses.init_pair(2, curses.COLOR_YELLOW, -1) + curses.init_pair(3, curses.COLOR_CYAN, -1) + cursor = default + + while True: + stdscr.clear() + max_y, max_x = stdscr.getmaxyx() + + # Title + try: + stdscr.addnstr(0, 0, title, max_x - 1, + curses.A_BOLD | (curses.color_pair(2) if curses.has_colors() else 0)) + stdscr.addnstr(1, 0, " ↑↓ navigate ⏎ select q quit", max_x - 1, + curses.color_pair(3) if curses.has_colors() else curses.A_DIM) + except curses.error: + pass + + for i, (label, desc) in enumerate(items): + y = i + 3 + if y >= max_y - 1: + break + arrow = "→" if i == cursor else " " + line = f" {arrow} {label}" + if desc: + line += f" {desc}" + + attr = curses.A_NORMAL + if i == cursor: + attr = curses.A_BOLD + if curses.has_colors(): + attr |= curses.color_pair(1) + try: + stdscr.addnstr(y, 0, line[:max_x - 1], max_x - 1, attr) + except curses.error: + pass + + stdscr.refresh() + key = stdscr.getch() + + if key in (curses.KEY_UP, ord('k')): + cursor = (cursor - 1) % len(items) + elif key in (curses.KEY_DOWN, ord('j')): + cursor = (cursor + 1) % len(items) + elif key in (curses.KEY_ENTER, 10, 13): + result[0] = cursor + return + elif key in (27, ord('q')): + return + + curses.wrapper(_menu) + return result[0] + + except Exception: + # Fallback: numbered input + print(f"\n {title}\n") + for i, (label, desc) in enumerate(items): + marker = "→" if i == default else " " + d = f" {desc}" if desc else "" + print(f" {marker} {i + 1}. {label}{d}") + while True: + try: + val = input(f"\n Select [1-{len(items)}] ({default + 1}): ") + if not val: + return default + idx = int(val) - 1 + if 0 <= idx < len(items): + return idx + except (ValueError, EOFError): + return default + + +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: + sys.stdout.write(f" {label}{suffix}: ") + sys.stdout.flush() + if sys.stdin.isatty(): + val = getpass.getpass(prompt="") + else: + val = sys.stdin.readline().strip() + else: + sys.stdout.write(f" {label}{suffix}: ") + sys.stdout.flush() + val = sys.stdin.readline().strip() + return val or (default or "") + + +# --------------------------------------------------------------------------- +# Provider discovery +# --------------------------------------------------------------------------- + +def _get_available_providers() -> list: + """Discover memory providers from installed plugins. + + Returns list of (name, description, provider_instance) tuples. + """ + try: + from hermes_cli.plugins import get_plugin_memory_providers + providers = get_plugin_memory_providers() + except Exception: + providers = [] + + results = [] + for p in providers: + name = getattr(p, "name", "unknown") + schema = p.get_config_schema() if hasattr(p, "get_config_schema") else [] + has_secrets = any(f.get("secret") for f in schema) + if has_secrets: + desc = "requires API key" + elif not schema: + desc = "no setup needed" + else: + desc = "local" + results.append((name, desc, p)) + return results + + +# --------------------------------------------------------------------------- +# Setup wizard +# --------------------------------------------------------------------------- + +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 + + # Build picker items + items = [] + for name, desc, _ in providers: + items.append((name, f"— {desc}")) + 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) + + config = load_config() + if not isinstance(config.get("memory"), dict): + config["memory"] = {} + + # Built-in only + if selected >= len(providers) or selected < 0: + config["memory"]["provider"] = "" + save_config(config) + print("\n ✓ Memory provider: built-in only") + print(" Saved to config.yaml\n") + return + + name, _, provider = providers[selected] + schema = provider.get_config_schema() if hasattr(provider, "get_config_schema") else [] + + # Provider config section + provider_config = config["memory"].get(name, {}) + if not isinstance(provider_config, dict): + provider_config = {} + + env_path = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))) / ".env" + env_writes = {} + + if schema: + print(f"\n Configuring {name}:\n") + + for field in schema: + key = field["key"] + desc = field.get("description", key) + default = field.get("default") + is_secret = field.get("secret", False) + choices = field.get("choices") + env_var = field.get("env_var") + url = field.get("url") + + if choices and not is_secret: + # Use curses picker for choice fields + choice_items = [(c, "") for c in choices] + current = provider_config.get(key, default) + current_idx = 0 + if current and current in choices: + current_idx = choices.index(current) + sel = _curses_select(f" {desc}", choice_items, default=current_idx) + provider_config[key] = choices[sel] + elif is_secret: + # Prompt for 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: + hint = f" Get yours at {url}" if url else "" + if hint: + print(hint) + val = _prompt(desc, secret=True) + if val and env_var: + env_writes[env_var] = val + else: + # Regular text prompt + current = provider_config.get(key) + effective_default = current or default + val = _prompt(desc, default=str(effective_default) if effective_default else None) + if val: + provider_config[key] = val + + # Write config + config["memory"]["provider"] = name + config["memory"][name] = provider_config + save_config(config) + + # Write secrets to .env + if env_writes: + _write_env_vars(env_path, env_writes) + + print(f"\n ✓ Memory provider: {name}") + print(f" ✓ Config saved to config.yaml") + if env_writes: + print(f" ✓ API keys saved to .env") + print(f"\n Start a new session to activate.\n") + + +def _write_env_vars(env_path: Path, env_writes: dict) -> None: + """Append or update env vars in .env file.""" + env_path.parent.mkdir(parents=True, exist_ok=True) + + existing_lines = [] + if env_path.exists(): + existing_lines = env_path.read_text().splitlines() + + updated_keys = set() + new_lines = [] + for line in existing_lines: + key_match = line.split("=", 1)[0].strip() if "=" in line else "" + if key_match in env_writes: + new_lines.append(f"{key_match}={env_writes[key_match]}") + updated_keys.add(key_match) + else: + new_lines.append(line) + + for key, val in env_writes.items(): + if key not in updated_keys: + new_lines.append(f"{key}={val}") + + env_path.write_text("\n".join(new_lines) + "\n") + + +# --------------------------------------------------------------------------- +# Status +# --------------------------------------------------------------------------- + +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", "") + + print(f"\nMemory status\n" + "─" * 40) + print(f" Built-in: always active") + print(f" Provider: {provider_name or '(none — built-in only)'}") + + if provider_name: + provider_config = mem_config.get(provider_name, {}) + if provider_config: + print(f"\n {provider_name} config:") + for key, val in provider_config.items(): + print(f" {key}: {val}") + + providers = _get_available_providers() + found = any(name == provider_name for name, _, _ in providers) + if found: + print(f"\n Plugin: installed ✓") + for pname, _, p in providers: + if pname == provider_name: + if p.is_available(): + print(f" Status: available ✓") + else: + print(f" Status: not available ✗") + schema = p.get_config_schema() if hasattr(p, "get_config_schema") else [] + secrets = [f for f in schema if f.get("secret")] + if secrets: + print(f" Missing:") + for s in secrets: + env_var = s.get("env_var", "") + url = s.get("url", "") + is_set = bool(os.environ.get(env_var)) + mark = "✓" if is_set else "✗" + line = f" {mark} {env_var}" + if url and not is_set: + line += f" → {url}" + print(line) + break + else: + print(f"\n Plugin: NOT installed ✗") + print(f" Install the '{provider_name}' memory plugin to ~/.hermes/plugins/") + + providers = _get_available_providers() + if providers: + print(f"\n Installed plugins:") + for pname, desc, _ in providers: + active = " ← active" if pname == provider_name else "" + print(f" • {pname} ({desc}){active}") + + print() + + +# --------------------------------------------------------------------------- +# Router +# --------------------------------------------------------------------------- + +def memory_command(args) -> None: + """Route memory subcommands.""" + sub = getattr(args, "memory_command", None) + if sub == "setup": + cmd_setup(args) + elif sub == "status": + cmd_status(args) + else: + cmd_status(args) diff --git a/plugins/cognitive-memory/__init__.py b/plugins/cognitive-memory/__init__.py index 28f6bc398930..e88e92908ab9 100644 --- a/plugins/cognitive-memory/__init__.py +++ b/plugins/cognitive-memory/__init__.py @@ -143,6 +143,12 @@ class CognitiveMemoryProvider(MemoryProvider): def name(self) -> str: return "cognitive" + def get_config_schema(self): + return [ + {"key": "embedding_model", "description": "Embedding model (litellm format)", "default": "text-embedding-3-small"}, + {"key": "decay_half_life", "description": "Importance decay half-life in days (0=disabled)", "default": "30"}, + ] + def is_available(self) -> bool: try: import litellm # noqa: F401 diff --git a/plugins/hermes-memory-store/__init__.py b/plugins/hermes-memory-store/__init__.py index 86435f7a9c0a..5d4913303444 100644 --- a/plugins/hermes-memory-store/__init__.py +++ b/plugins/hermes-memory-store/__init__.py @@ -126,6 +126,14 @@ class HolographicMemoryProvider(MemoryProvider): def is_available(self) -> bool: return True # SQLite is always available, numpy is optional + def get_config_schema(self): + return [ + {"key": "db_path", "description": "SQLite database path", "default": "~/.hermes/memory_store.db"}, + {"key": "auto_extract", "description": "Auto-extract facts at session end", "default": "false", "choices": ["true", "false"]}, + {"key": "default_trust", "description": "Default trust score for new facts", "default": "0.5"}, + {"key": "hrr_dim", "description": "HRR vector dimensions", "default": "1024"}, + ] + def initialize(self, session_id: str, **kwargs) -> None: db_path = self._config.get("db_path", "~/.hermes/memory_store.db") default_trust = float(self._config.get("default_trust", 0.5)) diff --git a/plugins/hindsight-memory/__init__.py b/plugins/hindsight-memory/__init__.py index 2d5a058c1fc0..09d90c64d00f 100644 --- a/plugins/hindsight-memory/__init__.py +++ b/plugins/hindsight-memory/__init__.py @@ -167,6 +167,17 @@ class HindsightMemoryProvider(MemoryProvider): except Exception: return False + def get_config_schema(self): + return [ + {"key": "mode", "description": "Cloud API or local embedded mode", "default": "cloud", "choices": ["cloud", "local"]}, + {"key": "api_key", "description": "Hindsight Cloud API key", "secret": True, "env_var": "HINDSIGHT_API_KEY", "url": "https://app.hindsight.vectorize.io"}, + {"key": "bank_id", "description": "Memory bank identifier", "default": "hermes"}, + {"key": "budget", "description": "Recall thoroughness", "default": "mid", "choices": ["low", "mid", "high"]}, + {"key": "llm_provider", "description": "LLM provider for local mode", "default": "anthropic", "choices": ["anthropic", "openai", "groq", "ollama"]}, + {"key": "llm_api_key", "description": "LLM API key for local mode", "secret": True, "env_var": "HINDSIGHT_LLM_API_KEY"}, + {"key": "llm_model", "description": "LLM model for local mode", "default": "claude-haiku-4-5-20251001"}, + ] + def _make_client(self): """Create a fresh Hindsight client (thread-safe).""" if self._mode == "local": diff --git a/plugins/mem0-memory/__init__.py b/plugins/mem0-memory/__init__.py index c21a24e20a4d..6bf186d1fe95 100644 --- a/plugins/mem0-memory/__init__.py +++ b/plugins/mem0-memory/__init__.py @@ -138,6 +138,14 @@ class Mem0MemoryProvider(MemoryProvider): cfg = _load_config() return bool(cfg.get("api_key")) + def get_config_schema(self): + return [ + {"key": "api_key", "description": "Mem0 Platform API key", "secret": True, "required": True, "env_var": "MEM0_API_KEY", "url": "https://app.mem0.ai"}, + {"key": "user_id", "description": "User identifier", "default": "hermes-user"}, + {"key": "agent_id", "description": "Agent identifier", "default": "hermes"}, + {"key": "rerank", "description": "Enable reranking for recall", "default": "true", "choices": ["true", "false"]}, + ] + def _get_client(self): if self._client is not None: return self._client diff --git a/plugins/openviking-memory/__init__.py b/plugins/openviking-memory/__init__.py index b1e6f7120840..b94d59d0dcc1 100644 --- a/plugins/openviking-memory/__init__.py +++ b/plugins/openviking-memory/__init__.py @@ -102,6 +102,12 @@ class OpenVikingMemoryProvider(MemoryProvider): def name(self) -> str: return "openviking" + def get_config_schema(self): + return [ + {"key": "endpoint", "description": "OpenViking server URL", "required": True, "default": "http://127.0.0.1:1933"}, + {"key": "api_key", "description": "OpenViking API key (if server requires auth)", "secret": True, "env_var": "OPENVIKING_API_KEY"}, + ] + def is_available(self) -> bool: endpoint = os.environ.get("OPENVIKING_ENDPOINT", "") if not endpoint: diff --git a/plugins/retaindb-memory/__init__.py b/plugins/retaindb-memory/__init__.py index 1e591ff54b29..990d71efd9f7 100644 --- a/plugins/retaindb-memory/__init__.py +++ b/plugins/retaindb-memory/__init__.py @@ -121,6 +121,13 @@ class RetainDBMemoryProvider(MemoryProvider): def is_available(self) -> bool: return bool(os.environ.get("RETAINDB_API_KEY")) + def get_config_schema(self): + return [ + {"key": "api_key", "description": "RetainDB API key", "secret": True, "required": True, "env_var": "RETAINDB_API_KEY", "url": "https://retaindb.com"}, + {"key": "base_url", "description": "API endpoint", "default": "https://api.retaindb.com"}, + {"key": "project", "description": "Project identifier", "default": "hermes"}, + ] + def _headers(self) -> dict: return { "Authorization": f"Bearer {self._api_key}",