diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index d49e63175..a28433dd1 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -717,13 +717,14 @@ def run_doctor(args): print(color("◆ Honcho Memory", Colors.CYAN, Colors.BOLD)) try: - from honcho_integration.client import HonchoClientConfig, GLOBAL_CONFIG_PATH + from honcho_integration.client import HonchoClientConfig, resolve_config_path hcfg = HonchoClientConfig.from_global_config() + _honcho_cfg_path = resolve_config_path() - if not GLOBAL_CONFIG_PATH.exists(): + if not _honcho_cfg_path.exists(): check_warn("Honcho config not found", f"run: hermes honcho setup") elif not hcfg.enabled: - check_info("Honcho disabled (set enabled: true in ~/.honcho/config.json to activate)") + check_info(f"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)") elif not hcfg.api_key: check_fail("Honcho API key not set", "run: hermes honcho setup") issues.append("No Honcho API key — run 'hermes honcho setup'") diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py index 270c4b36e..e4f3e0bb1 100644 --- a/honcho_integration/cli.py +++ b/honcho_integration/cli.py @@ -10,22 +10,30 @@ import os import sys from pathlib import Path -GLOBAL_CONFIG_PATH = Path.home() / ".honcho" / "config.json" +from honcho_integration.client import resolve_config_path, GLOBAL_CONFIG_PATH + HOST = "hermes" +def _config_path() -> Path: + """Return the active Honcho config path (instance-local or global).""" + return resolve_config_path() + + def _read_config() -> dict: - if GLOBAL_CONFIG_PATH.exists(): + path = _config_path() + if path.exists(): try: - return json.loads(GLOBAL_CONFIG_PATH.read_text(encoding="utf-8")) + return json.loads(path.read_text(encoding="utf-8")) except Exception: pass return {} -def _write_config(cfg: dict) -> None: - GLOBAL_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - GLOBAL_CONFIG_PATH.write_text( +def _write_config(cfg: dict, path: Path | None = None) -> None: + path = path or _config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) @@ -87,9 +95,14 @@ def cmd_setup(args) -> None: """Interactive Honcho setup wizard.""" cfg = _read_config() + active_path = _config_path() print("\nHoncho memory setup\n" + "─" * 40) print(" Honcho gives Hermes persistent cross-session memory.") - print(" Config is shared with other hosts at ~/.honcho/config.json\n") + if active_path != GLOBAL_CONFIG_PATH: + print(f" Instance config: {active_path}") + else: + print(" Config is shared with other hosts at ~/.honcho/config.json") + print() if not _ensure_sdk_installed(): return @@ -162,10 +175,10 @@ def cmd_setup(args) -> None: hermes_host["recallMode"] = new_recall # Session strategy - current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-session") + current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-directory") print(f"\n Session strategy options:") - print(" per-session — new Honcho session each run, named by Hermes session ID (default)") - print(" per-directory — one session per working directory") + print(" per-directory — one session per working directory (default)") + print(" per-session — new Honcho session each run, named by Hermes session ID") print(" per-repo — one session per git repository (uses repo root name)") print(" global — single session across all directories") new_strat = _prompt("Session strategy", default=current_strat) @@ -176,7 +189,7 @@ def cmd_setup(args) -> None: hermes_host.setdefault("saveMessages", True) _write_config(cfg) - print(f"\n Config written to {GLOBAL_CONFIG_PATH}") + print(f"\n Config written to {active_path}") # Test connection print(" Testing connection... ", end="", flush=True) @@ -223,8 +236,10 @@ def cmd_status(args) -> None: cfg = _read_config() + active_path = _config_path() + if not cfg: - print(" No Honcho config found at ~/.honcho/config.json") + print(f" No Honcho config found at {active_path}") print(" Run 'hermes honcho setup' to configure.\n") return @@ -243,7 +258,7 @@ def cmd_status(args) -> None: print(f" API key: {masked}") print(f" Workspace: {hcfg.workspace_id}") print(f" Host: {hcfg.host}") - print(f" Config path: {GLOBAL_CONFIG_PATH}") + print(f" Config path: {active_path}") print(f" AI peer: {hcfg.ai_peer}") print(f" User peer: {hcfg.peer_name or 'not set'}") print(f" Session key: {hcfg.resolve_session_name()}") @@ -275,7 +290,7 @@ def cmd_sessions(args) -> None: if not sessions: print(" No session mappings configured.\n") print(" Add one with: hermes honcho map ") - print(" Or edit ~/.honcho/config.json directly.\n") + print(f" Or edit {_config_path()} directly.\n") return cwd = os.getcwd() @@ -361,7 +376,7 @@ def cmd_peer(args) -> None: if changed: _write_config(cfg) - print(f" Saved to {GLOBAL_CONFIG_PATH}\n") + print(f" Saved to {_config_path()}\n") def cmd_mode(args) -> None: @@ -434,7 +449,7 @@ def cmd_tokens(args) -> None: if changed: _write_config(cfg) - print(f" Saved to {GLOBAL_CONFIG_PATH}\n") + print(f" Saved to {_config_path()}\n") def cmd_identity(args) -> None: diff --git a/honcho_integration/client.py b/honcho_integration/client.py index 9382656b0..12f9a5482 100644 --- a/honcho_integration/client.py +++ b/honcho_integration/client.py @@ -1,7 +1,9 @@ """Honcho client initialization and configuration. -Reads the global ~/.honcho/config.json when available, falling back -to environment variables. +Resolution order for config file: + 1. $HERMES_HOME/honcho.json (instance-local, enables isolated Hermes instances) + 2. ~/.honcho/config.json (global, shared across all Honcho-enabled apps) + 3. Environment variables (HONCHO_API_KEY, HONCHO_ENVIRONMENT) Resolution order for host-specific settings: 1. Explicit host block fields (always win) @@ -27,6 +29,24 @@ GLOBAL_CONFIG_PATH = Path.home() / ".honcho" / "config.json" HOST = "hermes" +def _get_hermes_home() -> Path: + """Get HERMES_HOME without importing hermes_cli (avoids circular deps).""" + return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) + + +def resolve_config_path() -> Path: + """Return the active Honcho config path. + + Checks $HERMES_HOME/honcho.json first (instance-local), then falls back + to ~/.honcho/config.json (global). Returns the global path if neither + exists (for first-time setup writes). + """ + local_path = _get_hermes_home() / "honcho.json" + if local_path.exists(): + return local_path + return GLOBAL_CONFIG_PATH + + _RECALL_MODE_ALIASES = {"auto": "hybrid"} _VALID_RECALL_MODES = {"hybrid", "context", "tools"} @@ -107,7 +127,7 @@ class HonchoClientConfig: # "tools" — Honcho tools only, no auto-injected context recall_mode: str = "hybrid" # Session resolution - session_strategy: str = "per-session" + session_strategy: str = "per-directory" session_peer_prefix: bool = False sessions: dict[str, str] = field(default_factory=dict) # Raw global config for anything else consumers need @@ -136,11 +156,11 @@ class HonchoClientConfig: host: str = HOST, config_path: Path | None = None, ) -> HonchoClientConfig: - """Create config from ~/.honcho/config.json. + """Create config from the resolved Honcho config path. - Falls back to environment variables if the file doesn't exist. + Resolution: $HERMES_HOME/honcho.json -> ~/.honcho/config.json -> env vars. """ - path = config_path or GLOBAL_CONFIG_PATH + path = config_path or resolve_config_path() if not path.exists(): logger.debug("No global Honcho config at %s, falling back to env", path) return cls.from_env() @@ -216,7 +236,7 @@ class HonchoClientConfig: # sessionStrategy / sessionPeerPrefix: host first, root fallback session_strategy = ( host_block.get("sessionStrategy") - or raw.get("sessionStrategy", "per-session") + or raw.get("sessionStrategy", "per-directory") ) host_prefix = host_block.get("sessionPeerPrefix") session_peer_prefix = ( @@ -326,7 +346,7 @@ class HonchoClientConfig: return f"{self.peer_name}-{base}" return base - # per-directory: one Honcho session per working directory + # per-directory: one Honcho session per working directory (default) if self.session_strategy in ("per-directory", "per-session"): base = Path(cwd).name if self.session_peer_prefix and self.peer_name: diff --git a/run_agent.py b/run_agent.py index e45dc061a..7383c67d2 100644 --- a/run_agent.py +++ b/run_agent.py @@ -901,7 +901,7 @@ class AIAgent: pass # Memory is optional -- don't break agent init # Honcho AI-native memory (cross-session user modeling) - # Reads ~/.honcho/config.json as the single source of truth. + # Reads $HERMES_HOME/honcho.json (instance) or ~/.honcho/config.json (global). self._honcho = None # HonchoSessionManager | None self._honcho_session_key = honcho_session_key self._honcho_config = None # HonchoClientConfig | None diff --git a/tests/honcho_integration/test_client.py b/tests/honcho_integration/test_client.py index a9a837e62..d784887c6 100644 --- a/tests/honcho_integration/test_client.py +++ b/tests/honcho_integration/test_client.py @@ -11,6 +11,7 @@ from honcho_integration.client import ( HonchoClientConfig, get_honcho_client, reset_honcho_client, + resolve_config_path, GLOBAL_CONFIG_PATH, HOST, ) @@ -25,7 +26,7 @@ class TestHonchoClientConfigDefaults: assert config.environment == "production" assert config.enabled is False assert config.save_messages is True - assert config.session_strategy == "per-session" + assert config.session_strategy == "per-directory" assert config.recall_mode == "hybrid" assert config.session_peer_prefix is False assert config.linked_hosts == [] @@ -157,7 +158,7 @@ class TestFromGlobalConfig: config_file = tmp_path / "config.json" config_file.write_text(json.dumps({"apiKey": "key"})) config = HonchoClientConfig.from_global_config(config_path=config_file) - assert config.session_strategy == "per-session" + assert config.session_strategy == "per-directory" def test_context_tokens_host_block_wins(self, tmp_path): """Host block contextTokens should override root.""" @@ -330,6 +331,47 @@ class TestGetLinkedWorkspaces: assert "cursor" in workspaces +class TestResolveConfigPath: + def test_prefers_hermes_home_when_exists(self, tmp_path): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + local_cfg = hermes_home / "honcho.json" + local_cfg.write_text('{"apiKey": "local"}') + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + result = resolve_config_path() + assert result == local_cfg + + def test_falls_back_to_global_when_no_local(self, tmp_path): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + # No honcho.json in HERMES_HOME + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + result = resolve_config_path() + assert result == GLOBAL_CONFIG_PATH + + def test_falls_back_to_global_without_hermes_home_env(self): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("HERMES_HOME", None) + result = resolve_config_path() + assert result == GLOBAL_CONFIG_PATH + + def test_from_global_config_uses_local_path(self, tmp_path): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + local_cfg = hermes_home / "honcho.json" + local_cfg.write_text(json.dumps({ + "apiKey": "local-key", + "workspace": "local-ws", + })) + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + config = HonchoClientConfig.from_global_config() + assert config.api_key == "local-key" + assert config.workspace_id == "local-ws" + + class TestResetHonchoClient: def test_reset_clears_singleton(self): import honcho_integration.client as mod