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:
parent
f4a74d3ac7
commit
e183744cb5
5 changed files with 108 additions and 30 deletions
|
|
@ -717,13 +717,14 @@ def run_doctor(args):
|
||||||
print(color("◆ Honcho Memory", Colors.CYAN, Colors.BOLD))
|
print(color("◆ Honcho Memory", Colors.CYAN, Colors.BOLD))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from honcho_integration.client import HonchoClientConfig, GLOBAL_CONFIG_PATH
|
from honcho_integration.client import HonchoClientConfig, resolve_config_path
|
||||||
hcfg = HonchoClientConfig.from_global_config()
|
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")
|
check_warn("Honcho config not found", f"run: hermes honcho setup")
|
||||||
elif not hcfg.enabled:
|
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:
|
elif not hcfg.api_key:
|
||||||
check_fail("Honcho API key not set", "run: hermes honcho setup")
|
check_fail("Honcho API key not set", "run: hermes honcho setup")
|
||||||
issues.append("No Honcho API key — run 'hermes honcho setup'")
|
issues.append("No Honcho API key — run 'hermes honcho setup'")
|
||||||
|
|
|
||||||
|
|
@ -10,22 +10,30 @@ import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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"
|
HOST = "hermes"
|
||||||
|
|
||||||
|
|
||||||
|
def _config_path() -> Path:
|
||||||
|
"""Return the active Honcho config path (instance-local or global)."""
|
||||||
|
return resolve_config_path()
|
||||||
|
|
||||||
|
|
||||||
def _read_config() -> dict:
|
def _read_config() -> dict:
|
||||||
if GLOBAL_CONFIG_PATH.exists():
|
path = _config_path()
|
||||||
|
if path.exists():
|
||||||
try:
|
try:
|
||||||
return json.loads(GLOBAL_CONFIG_PATH.read_text(encoding="utf-8"))
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _write_config(cfg: dict) -> None:
|
def _write_config(cfg: dict, path: Path | None = None) -> None:
|
||||||
GLOBAL_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
path = path or _config_path()
|
||||||
GLOBAL_CONFIG_PATH.write_text(
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(
|
||||||
json.dumps(cfg, indent=2, ensure_ascii=False) + "\n",
|
json.dumps(cfg, indent=2, ensure_ascii=False) + "\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
@ -87,9 +95,14 @@ def cmd_setup(args) -> None:
|
||||||
"""Interactive Honcho setup wizard."""
|
"""Interactive Honcho setup wizard."""
|
||||||
cfg = _read_config()
|
cfg = _read_config()
|
||||||
|
|
||||||
|
active_path = _config_path()
|
||||||
print("\nHoncho memory setup\n" + "─" * 40)
|
print("\nHoncho memory setup\n" + "─" * 40)
|
||||||
print(" Honcho gives Hermes persistent cross-session memory.")
|
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():
|
if not _ensure_sdk_installed():
|
||||||
return
|
return
|
||||||
|
|
@ -162,10 +175,10 @@ def cmd_setup(args) -> None:
|
||||||
hermes_host["recallMode"] = new_recall
|
hermes_host["recallMode"] = new_recall
|
||||||
|
|
||||||
# Session strategy
|
# 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(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 (default)")
|
||||||
print(" per-directory — one session per working directory")
|
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(" per-repo — one session per git repository (uses repo root name)")
|
||||||
print(" global — single session across all directories")
|
print(" global — single session across all directories")
|
||||||
new_strat = _prompt("Session strategy", default=current_strat)
|
new_strat = _prompt("Session strategy", default=current_strat)
|
||||||
|
|
@ -176,7 +189,7 @@ def cmd_setup(args) -> None:
|
||||||
hermes_host.setdefault("saveMessages", True)
|
hermes_host.setdefault("saveMessages", True)
|
||||||
|
|
||||||
_write_config(cfg)
|
_write_config(cfg)
|
||||||
print(f"\n Config written to {GLOBAL_CONFIG_PATH}")
|
print(f"\n Config written to {active_path}")
|
||||||
|
|
||||||
# Test connection
|
# Test connection
|
||||||
print(" Testing connection... ", end="", flush=True)
|
print(" Testing connection... ", end="", flush=True)
|
||||||
|
|
@ -223,8 +236,10 @@ def cmd_status(args) -> None:
|
||||||
|
|
||||||
cfg = _read_config()
|
cfg = _read_config()
|
||||||
|
|
||||||
|
active_path = _config_path()
|
||||||
|
|
||||||
if not cfg:
|
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")
|
print(" Run 'hermes honcho setup' to configure.\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -243,7 +258,7 @@ def cmd_status(args) -> None:
|
||||||
print(f" API key: {masked}")
|
print(f" API key: {masked}")
|
||||||
print(f" Workspace: {hcfg.workspace_id}")
|
print(f" Workspace: {hcfg.workspace_id}")
|
||||||
print(f" Host: {hcfg.host}")
|
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" AI peer: {hcfg.ai_peer}")
|
||||||
print(f" User peer: {hcfg.peer_name or 'not set'}")
|
print(f" User peer: {hcfg.peer_name or 'not set'}")
|
||||||
print(f" Session key: {hcfg.resolve_session_name()}")
|
print(f" Session key: {hcfg.resolve_session_name()}")
|
||||||
|
|
@ -275,7 +290,7 @@ def cmd_sessions(args) -> None:
|
||||||
if not sessions:
|
if not sessions:
|
||||||
print(" No session mappings configured.\n")
|
print(" No session mappings configured.\n")
|
||||||
print(" Add one with: hermes honcho map <session-name>")
|
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
|
return
|
||||||
|
|
||||||
cwd = os.getcwd()
|
cwd = os.getcwd()
|
||||||
|
|
@ -361,7 +376,7 @@ def cmd_peer(args) -> None:
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
_write_config(cfg)
|
_write_config(cfg)
|
||||||
print(f" Saved to {GLOBAL_CONFIG_PATH}\n")
|
print(f" Saved to {_config_path()}\n")
|
||||||
|
|
||||||
|
|
||||||
def cmd_mode(args) -> None:
|
def cmd_mode(args) -> None:
|
||||||
|
|
@ -434,7 +449,7 @@ def cmd_tokens(args) -> None:
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
_write_config(cfg)
|
_write_config(cfg)
|
||||||
print(f" Saved to {GLOBAL_CONFIG_PATH}\n")
|
print(f" Saved to {_config_path()}\n")
|
||||||
|
|
||||||
|
|
||||||
def cmd_identity(args) -> None:
|
def cmd_identity(args) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
"""Honcho client initialization and configuration.
|
"""Honcho client initialization and configuration.
|
||||||
|
|
||||||
Reads the global ~/.honcho/config.json when available, falling back
|
Resolution order for config file:
|
||||||
to environment variables.
|
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:
|
Resolution order for host-specific settings:
|
||||||
1. Explicit host block fields (always win)
|
1. Explicit host block fields (always win)
|
||||||
|
|
@ -27,6 +29,24 @@ GLOBAL_CONFIG_PATH = Path.home() / ".honcho" / "config.json"
|
||||||
HOST = "hermes"
|
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"}
|
_RECALL_MODE_ALIASES = {"auto": "hybrid"}
|
||||||
_VALID_RECALL_MODES = {"hybrid", "context", "tools"}
|
_VALID_RECALL_MODES = {"hybrid", "context", "tools"}
|
||||||
|
|
||||||
|
|
@ -107,7 +127,7 @@ class HonchoClientConfig:
|
||||||
# "tools" — Honcho tools only, no auto-injected context
|
# "tools" — Honcho tools only, no auto-injected context
|
||||||
recall_mode: str = "hybrid"
|
recall_mode: str = "hybrid"
|
||||||
# Session resolution
|
# Session resolution
|
||||||
session_strategy: str = "per-session"
|
session_strategy: str = "per-directory"
|
||||||
session_peer_prefix: bool = False
|
session_peer_prefix: bool = False
|
||||||
sessions: dict[str, str] = field(default_factory=dict)
|
sessions: dict[str, str] = field(default_factory=dict)
|
||||||
# Raw global config for anything else consumers need
|
# Raw global config for anything else consumers need
|
||||||
|
|
@ -136,11 +156,11 @@ class HonchoClientConfig:
|
||||||
host: str = HOST,
|
host: str = HOST,
|
||||||
config_path: Path | None = None,
|
config_path: Path | None = None,
|
||||||
) -> HonchoClientConfig:
|
) -> 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():
|
if not path.exists():
|
||||||
logger.debug("No global Honcho config at %s, falling back to env", path)
|
logger.debug("No global Honcho config at %s, falling back to env", path)
|
||||||
return cls.from_env()
|
return cls.from_env()
|
||||||
|
|
@ -216,7 +236,7 @@ class HonchoClientConfig:
|
||||||
# sessionStrategy / sessionPeerPrefix: host first, root fallback
|
# sessionStrategy / sessionPeerPrefix: host first, root fallback
|
||||||
session_strategy = (
|
session_strategy = (
|
||||||
host_block.get("sessionStrategy")
|
host_block.get("sessionStrategy")
|
||||||
or raw.get("sessionStrategy", "per-session")
|
or raw.get("sessionStrategy", "per-directory")
|
||||||
)
|
)
|
||||||
host_prefix = host_block.get("sessionPeerPrefix")
|
host_prefix = host_block.get("sessionPeerPrefix")
|
||||||
session_peer_prefix = (
|
session_peer_prefix = (
|
||||||
|
|
@ -326,7 +346,7 @@ class HonchoClientConfig:
|
||||||
return f"{self.peer_name}-{base}"
|
return f"{self.peer_name}-{base}"
|
||||||
return 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"):
|
if self.session_strategy in ("per-directory", "per-session"):
|
||||||
base = Path(cwd).name
|
base = Path(cwd).name
|
||||||
if self.session_peer_prefix and self.peer_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
|
pass # Memory is optional -- don't break agent init
|
||||||
|
|
||||||
# Honcho AI-native memory (cross-session user modeling)
|
# 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 = None # HonchoSessionManager | None
|
||||||
self._honcho_session_key = honcho_session_key
|
self._honcho_session_key = honcho_session_key
|
||||||
self._honcho_config = None # HonchoClientConfig | None
|
self._honcho_config = None # HonchoClientConfig | None
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from honcho_integration.client import (
|
||||||
HonchoClientConfig,
|
HonchoClientConfig,
|
||||||
get_honcho_client,
|
get_honcho_client,
|
||||||
reset_honcho_client,
|
reset_honcho_client,
|
||||||
|
resolve_config_path,
|
||||||
GLOBAL_CONFIG_PATH,
|
GLOBAL_CONFIG_PATH,
|
||||||
HOST,
|
HOST,
|
||||||
)
|
)
|
||||||
|
|
@ -25,7 +26,7 @@ class TestHonchoClientConfigDefaults:
|
||||||
assert config.environment == "production"
|
assert config.environment == "production"
|
||||||
assert config.enabled is False
|
assert config.enabled is False
|
||||||
assert config.save_messages is True
|
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.recall_mode == "hybrid"
|
||||||
assert config.session_peer_prefix is False
|
assert config.session_peer_prefix is False
|
||||||
assert config.linked_hosts == []
|
assert config.linked_hosts == []
|
||||||
|
|
@ -157,7 +158,7 @@ class TestFromGlobalConfig:
|
||||||
config_file = tmp_path / "config.json"
|
config_file = tmp_path / "config.json"
|
||||||
config_file.write_text(json.dumps({"apiKey": "key"}))
|
config_file.write_text(json.dumps({"apiKey": "key"}))
|
||||||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
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):
|
def test_context_tokens_host_block_wins(self, tmp_path):
|
||||||
"""Host block contextTokens should override root."""
|
"""Host block contextTokens should override root."""
|
||||||
|
|
@ -330,6 +331,47 @@ class TestGetLinkedWorkspaces:
|
||||||
assert "cursor" in workspaces
|
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:
|
class TestResetHonchoClient:
|
||||||
def test_reset_clears_singleton(self):
|
def test_reset_clears_singleton(self):
|
||||||
import honcho_integration.client as mod
|
import honcho_integration.client as mod
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue