Skip to content
Closed
15 changes: 8 additions & 7 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3555,13 +3555,14 @@ def cmd_profile(args):
else:
print(f"Cloned config, .env, SOUL.md from {source_label}.")

# Auto-clone Honcho config for the new profile
try:
from honcho_integration.cli import clone_honcho_for_profile
if clone_honcho_for_profile(name):
print(f"Honcho config cloned (host: hermes.{name})")
except Exception:
pass # Honcho not installed or not configured
# Auto-clone Honcho config for the new profile (only with --clone/--clone-all)
if clone or clone_all:
try:
from honcho_integration.cli import clone_honcho_for_profile
if clone_honcho_for_profile(name):
print(f"Honcho config cloned (peer: {name})")
except Exception:
pass # Honcho not installed or not configured

# Seed bundled skills (skip if --clone-all already copied them)
if not clone_all:
Expand Down
13 changes: 8 additions & 5 deletions honcho_integration/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ def clone_honcho_for_profile(profile_name: str) -> bool:

# AI peer is profile-specific; workspace is shared so all profiles
# see the same user context, sessions, and project history.
new_block["aiPeer"] = new_host
# Use the bare profile name as the peer identity (not the host key).
new_block["aiPeer"] = profile_name
new_block["workspace"] = default_block.get("workspace") or cfg.get("workspace") or HOST
new_block["enabled"] = default_block.get("enabled", True)

Expand Down Expand Up @@ -112,7 +113,9 @@ def cmd_enable(args) -> None:
peer_name = default_block.get("peerName") or cfg.get("peerName")
if peer_name and "peerName" not in block:
block["peerName"] = peer_name
block.setdefault("aiPeer", host)
# Use bare profile name as AI peer, not the host key
ai_peer = host.split(".", 1)[1] if "." in host else host
block.setdefault("aiPeer", ai_peer)
block.setdefault("workspace", default_block.get("workspace") or cfg.get("workspace") or HOST)

_write_config(cfg)
Expand Down Expand Up @@ -422,7 +425,7 @@ def cmd_setup(args) -> None:
try:
from honcho_integration.client import HonchoClientConfig, get_honcho_client, reset_honcho_client
reset_honcho_client()
hcfg = HonchoClientConfig.from_global_config()
hcfg = HonchoClientConfig.from_global_config(host=_host_key())
get_honcho_client(hcfg)
print("OK")
except Exception as e:
Expand Down Expand Up @@ -517,7 +520,7 @@ def cmd_status(args) -> None:

try:
from honcho_integration.client import HonchoClientConfig, get_honcho_client
hcfg = HonchoClientConfig.from_global_config()
hcfg = HonchoClientConfig.from_global_config(host=_host_key())
except Exception as e:
print(f" Config error: {e}\n")
return
Expand Down Expand Up @@ -836,7 +839,7 @@ def cmd_identity(args) -> None:
try:
from honcho_integration.client import HonchoClientConfig, get_honcho_client
from honcho_integration.session import HonchoSessionManager
hcfg = HonchoClientConfig.from_global_config()
hcfg = HonchoClientConfig.from_global_config(host=_host_key())
client = get_honcho_client(hcfg)
mgr = HonchoSessionManager(honcho=client, config=hcfg)
session_key = hcfg.resolve_session_name()
Expand Down
15 changes: 12 additions & 3 deletions honcho_integration/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,22 @@ def resolve_active_host() -> str:
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).
Resolution order:
1. $HERMES_HOME/honcho.json (profile-local, if it exists)
2. ~/.hermes/honcho.json (default profile — shared host blocks live here)
3. ~/.honcho/config.json (global, cross-app interop)

Returns the global path if none exist (for first-time setup writes).
"""
local_path = get_hermes_home() / "honcho.json"
if local_path.exists():
return local_path

# Default profile's config — host blocks accumulate here via setup/clone
default_path = Path.home() / ".hermes" / "honcho.json"
if default_path != local_path and default_path.exists():
return default_path

return GLOBAL_CONFIG_PATH


Expand Down
18 changes: 13 additions & 5 deletions honcho_integration/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,16 +157,24 @@ def _get_or_create_honcho_session(
logger.debug("Honcho session '%s' retrieved from cache", session_id)
return self._sessions_cache[session_id], []

# honcho.session() makes a get-or-create API call on the server.
# This ensures the session exists before add_peers / context calls.
session = self.honcho.session(session_id)

# Configure peer observation settings.
# observe_me=True for AI peer so Honcho watches what the agent says
# and builds its representation over time — enabling identity formation.
from honcho.session import SessionPeerConfig
user_config = SessionPeerConfig(observe_me=True, observe_others=True)
ai_config = SessionPeerConfig(observe_me=True, observe_others=True)
try:
from honcho.session import SessionPeerConfig
user_config = SessionPeerConfig(observe_me=True, observe_others=True)
ai_config = SessionPeerConfig(observe_me=True, observe_others=True)

session.add_peers([(user_peer, user_config), (assistant_peer, ai_config)])
session.add_peers([(user_peer, user_config), (assistant_peer, ai_config)])
except Exception as e:
logger.warning(
"Honcho session '%s' add_peers failed (non-fatal): %s",
session_id, e,
)

# Load existing messages via context() - single call for messages + metadata
existing_messages = []
Expand Down Expand Up @@ -231,7 +239,7 @@ def get_or_create(self, key: str) -> HonchoSession:
chat_id = parts[1] if len(parts) > 1 else key
user_peer_id = self._sanitize_id(f"user-{channel}-{chat_id}")

assistant_peer_id = (
assistant_peer_id = self._sanitize_id(
self._config.ai_peer if self._config else "hermes-assistant"
)

Expand Down
4 changes: 2 additions & 2 deletions tests/honcho_integration/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def test_clones_default_settings_to_new_profile(self, tmp_path):
assert new_block["memoryMode"] == "honcho"
assert new_block["recallMode"] == "tools"
assert new_block["writeFrequency"] == "turn"
assert new_block["aiPeer"] == "hermes.coder"
assert new_block["aiPeer"] == "coder"
assert new_block["workspace"] == "hermes" # shared, not profile-derived
assert new_block["enabled"] is True

Expand Down Expand Up @@ -117,7 +117,7 @@ def test_works_with_api_key_only_no_host_block(self, tmp_path):

assert result is True
cfg = json.loads(config_file.read_text())
assert cfg["hosts"]["hermes.coder"]["aiPeer"] == "hermes.coder"
assert cfg["hosts"]["hermes.coder"]["aiPeer"] == "coder"
assert cfg["hosts"]["hermes.coder"]["workspace"] == "hermes" # shared


Expand Down
15 changes: 9 additions & 6 deletions tests/honcho_integration/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,15 +346,18 @@ def test_prefers_hermes_home_when_exists(self, tmp_path):
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)}):
# No honcho.json in HERMES_HOME — and no ~/.hermes/honcho.json either
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), \
patch.object(Path, "home", staticmethod(lambda: tmp_path)):
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)
def test_falls_back_to_global_without_hermes_home_env(self, tmp_path):
# Point HERMES_HOME to a temp dir that has NO honcho.json
empty_home = tmp_path / ".hermes"
empty_home.mkdir()
with patch.dict(os.environ, {"HERMES_HOME": str(empty_home)}, clear=False), \
patch.object(Path, "home", staticmethod(lambda: tmp_path)):
result = resolve_config_path()
assert result == GLOBAL_CONFIG_PATH

Expand Down
65 changes: 64 additions & 1 deletion tests/honcho_integration/test_config_isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def test_explicit_path_override_still_works(self, isolated_home):


class TestReadConfigFallback:
"""_read_config falls back to global when no local file exists."""
"""_read_config falls back through the config chain."""

def test_reads_local_when_exists(self, isolated_home):
isolated_home["local_config"].write_text(
Expand Down Expand Up @@ -121,6 +121,69 @@ def test_local_takes_priority_over_global(self, isolated_home):
assert cfg["source"] == "local"


class TestDefaultProfileFallback:
"""Non-default profiles fall back to ~/.hermes/honcho.json (default profile config)."""

def test_profile_reads_default_profile_config(self, tmp_path, monkeypatch):
"""A non-default profile with no local honcho.json reads ~/.hermes/honcho.json."""
home = tmp_path / "home"
default_hermes = home / ".hermes"
default_hermes.mkdir(parents=True)
profile_hermes = home / ".hermes" / "profiles" / "coder"
profile_hermes.mkdir(parents=True)
global_dir = home / ".honcho"
global_dir.mkdir(parents=True)

monkeypatch.setattr(Path, "home", staticmethod(lambda: home))

# Default profile has honcho.json with host blocks
default_config = default_hermes / "honcho.json"
default_config.write_text(json.dumps({
"apiKey": "key",
"hosts": {
"hermes": {"peerName": "alice"},
"hermes.coder": {"peerName": "alice", "aiPeer": "hermes.coder"},
},
}))

# Global config has different data
(global_dir / "config.json").write_text(json.dumps({"source": "global"}))

import honcho_integration.client as _client_mod
import honcho_integration.cli as _cli_mod
monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_dir / "config.json")
monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_dir / "config.json")

# Profile's HERMES_HOME points to its own dir (no honcho.json)
monkeypatch.setenv("HERMES_HOME", str(profile_hermes))
assert not (profile_hermes / "honcho.json").exists()

# Should find ~/.hermes/honcho.json, not fall through to global
cfg = _read_config()
assert cfg.get("apiKey") == "key"
assert "hermes.coder" in cfg.get("hosts", {})

def test_default_profile_skips_self(self, tmp_path, monkeypatch):
"""Default profile doesn't double-read its own config."""
home = tmp_path / "home"
default_hermes = home / ".hermes"
default_hermes.mkdir(parents=True)

monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
monkeypatch.setenv("HERMES_HOME", str(default_hermes))

# No honcho.json anywhere
import honcho_integration.client as _client_mod
import honcho_integration.cli as _cli_mod
global_cfg = home / ".honcho" / "config.json"
global_cfg.parent.mkdir(parents=True)
monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_cfg)
monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_cfg)

cfg = _read_config()
assert cfg == {}


class TestMultiProfileIsolation:
"""Two profiles writing config don't interfere with each other."""

Expand Down
Loading
Loading