feat(honcho): instance-local config via HERMES_HOME, default session strategy to per-directory
- Add resolve_config_path(): checks $HERMES_HOME/honcho.json first, falls back to ~/.honcho/config.json. Enables isolated Hermes instances with independent Honcho credentials and settings. - Update CLI and doctor to use resolved path instead of hardcoded global. - Change default session_strategy from per-session to per-directory. Part 1 of #1962 by @erosika.
This commit is contained in:
@@ -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'")
|
||||
|
||||
@@ -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 <session-name>")
|
||||
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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user