diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index d18e4d95a1..74412ee3d0 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -10,6 +10,7 @@ from pathlib import Path from hermes_constants import get_hermes_home from plugins.memory.honcho.client import _first_parsed, _host_block, profile_host_key, resolve_active_host, resolve_config_path, HOST +from plugins.memory.honcho.session_peers import sanitize_peer_id from hermes_cli.config import cfg_get from utils import read_json_or_empty @@ -837,7 +838,7 @@ def _setup_wizard(args) -> None: _migrate_pin_key(cfg) # canonicalize legacy pinPeerName before detection/writes _migrate_pin_key(hermes_host) # Taken before the prompts populate the block: an existing install must not default to pinning every account. - new_host = not any(k in hermes_host for k in (*_IDENTITY_MAPPING_KEYS, "peerName", "workspace", "enabled")) + new_host = not any(k in hermes_host or k in cfg for k in (*_IDENTITY_MAPPING_KEYS, "peerName", "workspace", "enabled")) # --- 1. Cloud or local? --- print(" Deployment:\n cloud -- Honcho cloud (api.honcho.dev)\n local -- self-hosted Honcho server") @@ -1060,10 +1061,10 @@ def _state_db_path() -> Path: def _seen_gateway_accounts(db_path: Path) -> list[dict]: - """Gateway accounts recorded in state.db, most recent first; bot authors are skipped. + """Gateway accounts recorded in state.db, most recent first. - A session row keeps only its last routing peer, so a shared thread contributes - its most recent author and not every participant. + A session row keeps only its last routing peer, so a shared thread contributes its most recent + author and not every participant. The row's origin does not record whether the author was a bot. """ if not db_path.exists(): return [] @@ -1096,8 +1097,6 @@ def _seen_gateway_accounts(db_path: Path) -> list[dict]: origin = dict(json.loads(origin_json)) if origin_json else {} except Exception: origin = {} - if origin.get("is_bot"): - continue accounts.append({ "platform": source or "?", "user_id": str(user_id), @@ -1109,11 +1108,6 @@ def _seen_gateway_accounts(db_path: Path) -> list[dict]: return accounts -def _sanitize_peer_id(s: str) -> str: - import re - return re.sub(r'[^a-zA-Z0-9_-]', '-', s) - - def _preview_peer_resolution( user_id: str, *, pin: bool, aliases: dict, prefix: str, peer_name: str, user_id_alt: str = "", @@ -1167,14 +1161,13 @@ def _api_workspace_peers(client) -> list[str] | None: return None try: peers: list[str] = [] - page = 1 - while len(peers) < _PEERS_MAP_FETCH_CAP: - batch = list(client.peers(page=page, size=50)) - peers += [str(p.id) for p in batch] - if len(batch) < 50: - break - page += 1 - return peers + page = client.peers(page=1, size=50) + # Iterating a SyncPage walks every following page; .items is the one page asked for. + while True: + peers += [str(p.id) for p in page.items] + if len(peers) >= _PEERS_MAP_FETCH_CAP or not page.has_next_page(): + return peers[:_PEERS_MAP_FETCH_CAP] + page = page.get_next_page() except Exception: return None @@ -1184,7 +1177,7 @@ def _api_workspaces(client) -> list[str] | None: if client is None: return None try: - return [str(w) for w in client.workspaces(size=50)] + return [str(w) for w in client.workspaces(size=50).items] except Exception: return None @@ -1217,12 +1210,12 @@ def _classify_workspace_peers( ai = block.get("aiPeer") or cfg.get("aiPeer") or hostk if pn: labels.setdefault( - _sanitize_peer_id(pn), + sanitize_peer_id(pn), "your peer (peerName)" if hostk == active_host else f"peerName of profile {name}", ) who = "this profile" if hostk == active_host else f"profile {name}" - labels.setdefault(_sanitize_peer_id(ai), f"AI peer · {who}") + labels.setdefault(sanitize_peer_id(ai), f"AI peer · {who}") # Host blocks that are not Hermes profiles: other apps sharing the config. for hostk, block in (cfg.get("hosts") or {}).items(): @@ -1231,16 +1224,16 @@ def _classify_workspace_peers( for key, kind in (("peerName", "peer"), ("aiPeer", "AI peer")): val = block.get(key) if isinstance(val, str) and val.strip(): - labels.setdefault(_sanitize_peer_id(val.strip()), f"{kind} of app '{hostk}'") + labels.setdefault(sanitize_peer_id(val.strip()), f"{kind} of app '{hostk}'") for target in aliases.values(): if isinstance(target, str) and target.strip(): - labels.setdefault(_sanitize_peer_id(target.strip()), "alias target") + labels.setdefault(sanitize_peer_id(target.strip()), "alias target") for acct in accounts: rid = acct["user_id"] for candidate in ([rid, prefix + rid] if prefix else [rid]): - labels.setdefault(_sanitize_peer_id(candidate), f"runtime peer · {acct['platform']} {rid}") + labels.setdefault(sanitize_peer_id(candidate), f"runtime peer · {acct['platform']} {rid}") return { pid: labels.get(pid) or ("fallback peer (pre-identity traffic)" if pid.startswith("user-") else "unrecognized") @@ -1420,7 +1413,7 @@ def cmd_peers_map(args) -> None: labels = show() print("\n Map: account number or a runtime ID · pN inspects a peer ·") print(" w lists workspaces · blank finishes.") - changed = False + changed = repointed = False while True: sel = _prompt("Account (blank to finish)", default="").strip() if not sel: @@ -1432,6 +1425,7 @@ def cmd_peers_map(args) -> None: if switched: workspace, client, ws_peers = switched labels = show() + repointed = True continue if low.startswith("p") and low[1:].isdigit() and ws_peers: @@ -1467,13 +1461,13 @@ def cmd_peers_map(args) -> None: working[rid] = entered changed = True print(f" {rid} → {entered} — future messages resolve to '{entered}'") - if ws_peers is not None and _sanitize_peer_id(entered) not in ws_peers: + if ws_peers is not None and sanitize_peer_id(entered) not in ws_peers: print(f" '{entered}' is a new peer — created on first message.") - if prev_resolved in (ws_peers or ()) and prev_resolved != _sanitize_peer_id(entered): + if prev_resolved in (ws_peers or ()) and prev_resolved != sanitize_peer_id(entered): print(f" peer '{prev_resolved}' keeps its existing history.") if not changed: - print(" Nothing changed.\n") + print(" Aliases unchanged.\n" if repointed else " Nothing changed.\n") return _save_alias_map(cfg, host, working, aliases_from_root) diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index e979e32069..8715667ac0 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -564,8 +564,10 @@ class TestSetupWizardDeploymentShape: ({"apiKey": "***", "hosts": {"hermes": {}}}, True), ({"apiKey": "***", "hosts": {"hermes": {"pinUserPeer": False, "peerName": "eri"}}}, False), ({"apiKey": "***", "hosts": {"hermes": {"enabled": True, "workspace": "hermes", "peerName": "eri"}}}, False), + ({"apiKey": "***", "enabled": True, "workspace": "hermes", "peerName": "eri"}, False), ], ids=["fresh-config-defaults-to-single", "empty-host-block-defaults-to-single", - "configured-multi-keeps-multi", "existing-install-without-mapping-keys-keeps-multi"]) + "configured-multi-keeps-multi", "existing-install-without-mapping-keys-keeps-multi", + "legacy-root-level-install-keeps-multi"]) def test_choice_default_follows_config(self, monkeypatch, tmp_path, initial_cfg, expected_pin): """Enter on a fresh config picks the pinned personal shape. An existing install, with or without mapping keys, keeps its detected shape so Enter never merges every account onto one peer.""" diff --git a/tests/honcho_plugin/test_identity_signature.py b/tests/honcho_plugin/test_identity_signature.py index 5b0d21986a..66c53f0eed 100644 --- a/tests/honcho_plugin/test_identity_signature.py +++ b/tests/honcho_plugin/test_identity_signature.py @@ -33,7 +33,7 @@ def test_signature_uses_neutral_keys(honcho_json): "pin_user_identity": True, "runtime_identity_prefix": "tg_", "user_identity_aliases": [("111", "alice"), ("222", "bob")], - "session_prefixing": [True], + "session_prefixing": [True, False], "a2a_sessions": False, } assert not any(k.startswith("honcho") for k in sig) diff --git a/tests/honcho_plugin/test_peers_map.py b/tests/honcho_plugin/test_peers_map.py index 8943e036c2..f6b68622f3 100644 --- a/tests/honcho_plugin/test_peers_map.py +++ b/tests/honcho_plugin/test_peers_map.py @@ -8,6 +8,7 @@ import pytest import plugins.memory.honcho.cli as honcho_cli from plugins.memory.honcho.cli import _preview_peer_resolution, _seen_gateway_accounts +from plugins.memory.honcho.session_peers import sanitize_peer_id def _make_state_db(path, rows): @@ -24,8 +25,8 @@ def _make_state_db(path, rows): conn.close() -def _origin(user_name=None, is_bot=False, user_id_alt=None): - return json.dumps({"user_name": user_name, "is_bot": is_bot, "user_id_alt": user_id_alt}) +def _origin(user_name=None, user_id_alt=None): + return json.dumps({"user_name": user_name, "user_id_alt": user_id_alt}) def _row(user_id, platform="telegram", name="eri", started=100.0): @@ -43,7 +44,6 @@ class TestSeenGatewayAccounts: ("s1", "telegram", "111", None, "Eri", _origin("eri"), 100.0), # predates session_key ("s2", "telegram", "111", "", "Eri", _origin("eri"), 200.0), ("s3", "discord", "222", "k2", "Tek DM", None, 900.0), - ("s4", "discord", "333", "k3", "Bot", _origin("webhook", is_bot=True), 950.0), ("s5", "cli", None, None, None, None, 999.0), ]) assert _seen_gateway_accounts(db) == [ @@ -91,7 +91,7 @@ def test_prefixed_preview_matches_runtime_hash_suffix(user_id, peer_name): config=HonchoClientConfig(peer_name=peer_name, runtime_peer_prefix="tg_"), runtime_user_peer_name=user_id, ) runtime = manager._resolve_user_peer_id("telegram:dm:1") - assert runtime.startswith(honcho_cli._sanitize_peer_id(f"tg_{user_id}") + "-") + assert runtime.startswith(sanitize_peer_id(f"tg_{user_id}") + "-") assert _preview_peer_resolution(user_id, pin=False, aliases={}, prefix="tg_", peer_name=peer_name) == f"{runtime} (prefixed)" @@ -226,6 +226,44 @@ class TestWorkspaceSwitch: assert [c.workspace_id for c in seen] == ["hermes", "cosmania-dex"] +class _Page: + """The honcho SDK's SyncPage: iterating it walks every page, ``items`` is this page only.""" + + def __init__(self, pages, index=0): + self._pages, self._index = pages, index + self.items = [SimpleNamespace(id=p) for p in pages[index]] + + def __iter__(self): + for page in self._pages[self._index:]: + yield from (SimpleNamespace(id=p) for p in page) + + def has_next_page(self): + return self._index + 1 < len(self._pages) + + def get_next_page(self): + return _Page(self._pages, self._index + 1) + + +def test_api_workspace_peers_reads_only_the_pages_the_cap_needs(): + """Iterating a SyncPage walks the whole workspace; a 300-peer workspace with a 200 cap must touch + four pages, list each peer once, and never fetch page five.""" + pages = [[f"p{i}" for i in range(n, n + 50)] for n in range(0, 300, 50)] + fetched = [] + + class _CountingPage(_Page): + def __init__(self, pages, index=0): + super().__init__(pages, index) + fetched.append(index) + + def get_next_page(self): + return _CountingPage(self._pages, self._index + 1) + + client = SimpleNamespace(peers=lambda page, size: _CountingPage(pages, page - 1)) + peers = honcho_cli._api_workspace_peers(client) + assert len(peers) == len(set(peers)) == honcho_cli._PEERS_MAP_FETCH_CAP + assert fetched == [0, 1, 2, 3] + + class TestSaveScope: @pytest.mark.parametrize("hosts, answers", [ ({"hermes": {}}, ["222", "tek", ""]),