mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 09:41:58 +00:00
Merge pull request #44431 from erosika/feat/honcho-identity-tree
feat(honcho): gateway-gated identity tree + canonicalize on pinUserPeer
This commit is contained in:
commit
d2b34e89b0
7 changed files with 504 additions and 272 deletions
|
|
@ -137,10 +137,11 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a
|
|||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `pinUserPeer` | bool | `false` | When `true`, every gateway runtime user collapses to `peerName`. Single-operator deployments where you want all your platforms (and any other users) to share one peer. Also accepted as `pinPeerName` |
|
||||
| `pinPeerName` | bool | `false` | Alias for `pinUserPeer`; same effect |
|
||||
| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"86701400": "eri"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer |
|
||||
| `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"` → `telegram_86701400`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape |
|
||||
| `pinUserPeer` | bool | `false` | When `true`, every gateway runtime user collapses to `peerName`. Single-operator deployments where you want all your platforms (and any other users) to share one peer |
|
||||
| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"7654321": "alice"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer |
|
||||
| `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"` → `telegram_7654321`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape |
|
||||
|
||||
> **Deprecated:** `pinPeerName` is a legacy alias for `pinUserPeer`, still read for back-compat (`pinUserPeer` wins where both are set). `hermes honcho setup` migrates it onto `pinUserPeer` on touch and never writes it.
|
||||
|
||||
**Resolver ladder** (first match wins):
|
||||
|
||||
|
|
@ -158,13 +159,15 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a
|
|||
|
||||
**Host vs root semantics.** All three keys are accepted at both root and `hosts.<host>` levels. Host-level wins. For maps and prefixes, host-level *replaces* the root value as a whole (not merge), so a host can intentionally own its identity universe or wipe it with `userPeerAliases: {}` / `runtimePeerPrefix: ""`.
|
||||
|
||||
**Deployment shapes** (`hermes memory setup honcho` asks one prompt to set these):
|
||||
**Setup — gateway identity tree.** `hermes honcho setup` only asks about identity mapping when it detects a connected gateway platform (it inspects the gateway config; off-gateway the step is skipped because these keys do nothing without a runtime user ID). When it runs, it asks *who talks to this gateway?* and derives the keys:
|
||||
|
||||
- **Single-operator** — `pinUserPeer: true`. All gateway users → `peerName`. Recommended for personal use where you connect Hermes to your own Telegram/Discord/etc.
|
||||
- **Multi-user gateway** — `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. Recommended for bots serving many humans.
|
||||
- **Hybrid** — `pinUserPeer: false`, `userPeerAliases` mapping the operator's runtime IDs to `peerName`. Multi-user gateway where YOU are routed but others stay distinct.
|
||||
- **just me** → `pinUserPeer: true`. Every non-agent gateway user collapses to `peerName`; the pin overrides all aliases, so pick this only when no user-side identity needs its own peer. Personal use where you connect Hermes to your own Telegram/Discord/etc. If separate agents reach the gateway and each needs a distinct peer, do **not** pin — leave `pinUserPeer: false` and map them via `userPeerAliases` (the `[e]` editor).
|
||||
- **me + other people, pooled** → `pinUserPeer: false` + `userPeerAliases` mapping your runtime IDs to `peerName`. You stay on the shared history; everyone else gets their own peer.
|
||||
- **me + other people / only other people** → `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. For bots serving many humans.
|
||||
|
||||
**Migrating single → multi.** Flipping `pinUserPeer` from `true` to `false` does not migrate data. Memory accumulated under `peerName` while pinned stays there; runtime users now resolve to fresh, empty peers. To preserve your own continuity, use the **hybrid** shape — alias your runtime IDs back to `peerName` so your turns keep landing on the pooled history while other users get their own peers. The setup wizard offers this path automatically when it detects a single → multi transition.
|
||||
Pick **[e]** at the prompt to set the three keys directly instead of going through the tree.
|
||||
|
||||
**Un-pinning (single → per-user).** Flipping `pinUserPeer` from `true` to `false` does not migrate data. Memory accumulated under `peerName` while pinned stays there; runtime users now resolve to fresh, empty peers. To preserve your own continuity, choose the **pooled** path — alias your runtime IDs back to `peerName` so your turns keep landing on the pooled history while other users get their own peers. The wizard offers this steer automatically when it detects you're un-pinning a previously pinned profile.
|
||||
|
||||
### Memory & Recall
|
||||
|
||||
|
|
@ -205,7 +208,7 @@ The Honcho session name determines which conversation bucket memory lands in. Re
|
|||
|
||||
Gateway platforms always resolve via priority 3 (per-chat isolation) regardless of `sessionStrategy`. The strategy setting only affects CLI sessions.
|
||||
|
||||
If `sessionPeerPrefix` is `true`, the peer name is prepended: `eri-hermes-agent`.
|
||||
If `sessionPeerPrefix` is `true`, the peer name is prepended: `alice-hermes-agent`.
|
||||
|
||||
#### What each strategy produces
|
||||
|
||||
|
|
|
|||
|
|
@ -41,22 +41,20 @@ def clone_honcho_for_profile(profile_name: str) -> bool:
|
|||
return False # already exists
|
||||
|
||||
# Clone settings from default block, override identity fields.
|
||||
# Identity-mapping keys (pinPeerName/pinUserPeer, userPeerAliases,
|
||||
# runtimePeerPrefix) carry the operator's runtime-to-peer routing
|
||||
# intent from #27371. Both pin keys are inherited because
|
||||
# HonchoClientConfig prefers pinUserPeer over pinPeerName — leaving
|
||||
# the canonical key off this allowlist silently drops the pin on
|
||||
# cloned profiles when the default uses the newer name.
|
||||
# Identity-mapping keys (pinUserPeer, userPeerAliases, runtimePeerPrefix)
|
||||
# carry the operator's runtime-to-peer routing intent from #27371.
|
||||
new_block = {}
|
||||
for key in ("recallMode", "writeFrequency", "sessionStrategy",
|
||||
"sessionPeerPrefix", "contextTokens", "dialecticReasoningLevel",
|
||||
"dialecticDynamic", "dialecticMaxChars", "messageMaxChars",
|
||||
"dialecticMaxInputChars", "saveMessages", "observation",
|
||||
"pinPeerName", "pinUserPeer", "userPeerAliases",
|
||||
"runtimePeerPrefix"):
|
||||
"pinUserPeer", "userPeerAliases", "runtimePeerPrefix"):
|
||||
val = default_block.get(key)
|
||||
if val is not None:
|
||||
new_block[key] = val
|
||||
# Carry a legacy default-block pinPeerName forward under the canonical key.
|
||||
if "pinUserPeer" not in new_block and default_block.get("pinPeerName") is not None:
|
||||
new_block["pinUserPeer"] = default_block["pinPeerName"]
|
||||
|
||||
# Inherit peer name from default
|
||||
peer_name = default_block.get("peerName") or cfg.get("peerName")
|
||||
|
|
@ -371,15 +369,122 @@ def _resolve_effective_identity_mapping(
|
|||
def _scrub_identity_mapping(hermes_host: dict) -> None:
|
||||
"""Drop every peer-mapping key from the host block.
|
||||
|
||||
Called before the wizard writes a chosen shape so latent precedence
|
||||
conflicts can't survive — e.g. a stray host ``pinUserPeer: false``
|
||||
that would silently outrank a freshly written ``pinPeerName: true``
|
||||
(host ``pinUserPeer`` is first in the resolver ladder).
|
||||
Called before the wizard writes a chosen shape so a stale alias, prefix,
|
||||
or pin from an earlier run can't bleed into the new mapping.
|
||||
"""
|
||||
for key in _IDENTITY_MAPPING_KEYS:
|
||||
hermes_host.pop(key, None)
|
||||
|
||||
|
||||
def _migrate_pin_key(block: dict) -> bool:
|
||||
"""Rewrite a legacy ``pinPeerName`` to canonical ``pinUserPeer`` in place.
|
||||
|
||||
``pinUserPeer`` wins over ``pinPeerName`` in the resolver, so setup writes
|
||||
only the canonical form and migrates on touch to stop configs carrying
|
||||
both. Returns True if the block changed.
|
||||
"""
|
||||
if "pinPeerName" not in block:
|
||||
return False
|
||||
legacy = block.pop("pinPeerName")
|
||||
if "pinUserPeer" not in block:
|
||||
block["pinUserPeer"] = legacy
|
||||
return True
|
||||
|
||||
|
||||
def _gateway_platforms() -> list[str] | None:
|
||||
"""Connected gateway platforms, or None if undetectable.
|
||||
|
||||
Identity mapping only affects gateway runtime users, so setup gates the
|
||||
whole step on this. Best-effort and dependency-free: the memory plugin
|
||||
must not hard-depend on the gateway package, so the import is lazy and
|
||||
guarded (matching the idiom hermes_cli already uses for gateway refs).
|
||||
"""
|
||||
try:
|
||||
from gateway.config import load_gateway_config
|
||||
return [p.value for p in load_gateway_config().get_connected_platforms()]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _collect_operator_aliases(existing: dict, peer_target: str) -> dict:
|
||||
"""Prompt for the operator's per-platform runtime IDs, aliasing each to
|
||||
``peer_target``. Existing entries are preserved."""
|
||||
aliases = dict(existing)
|
||||
print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.")
|
||||
print(" Leave blank to skip a platform. Existing aliases are preserved.")
|
||||
for platform_label, alias_hint in (
|
||||
("Telegram UID", "e.g. 7654321"),
|
||||
("Discord snowflake", "e.g. 491827364"),
|
||||
("Slack user ID", "e.g. U04ABCDEF"),
|
||||
("Matrix MXID", "e.g. @you:matrix.org"),
|
||||
):
|
||||
entered = _prompt(f" {platform_label} ({alias_hint})", default="").strip()
|
||||
if entered:
|
||||
aliases[entered] = peer_target
|
||||
return aliases
|
||||
|
||||
|
||||
def _apply_runtime_prefix(
|
||||
hermes_host: dict, current_prefix: str, prefix_from_root: bool, label: str
|
||||
) -> None:
|
||||
"""Write a host-level runtimePeerPrefix only when it diverges from an
|
||||
inherited root value; otherwise let the root cascade stand."""
|
||||
new_prefix = _prompt(label, default=current_prefix or "").strip()
|
||||
if new_prefix and not (prefix_from_root and new_prefix == current_prefix):
|
||||
hermes_host["runtimePeerPrefix"] = new_prefix
|
||||
|
||||
|
||||
def _echo_identity_mapping(hermes_host: dict) -> None:
|
||||
"""Show the resulting keys so the operator can verify what was written."""
|
||||
aliases = hermes_host.get("userPeerAliases")
|
||||
prefix = hermes_host.get("runtimePeerPrefix")
|
||||
print(" resolved →")
|
||||
print(f" pinUserPeer = {bool(hermes_host.get('pinUserPeer'))}")
|
||||
print(f" userPeerAliases = {aliases if aliases else '{}'}")
|
||||
print(f" runtimePeerPrefix = {prefix if prefix else '(none)'}")
|
||||
|
||||
|
||||
def _configure_raw_identity_mapping(
|
||||
hermes_host: dict,
|
||||
current_pin: bool,
|
||||
current_aliases: dict,
|
||||
current_prefix: str,
|
||||
aliases_from_root: bool,
|
||||
prefix_from_root: bool,
|
||||
) -> None:
|
||||
"""Power-user escape hatch: set the three resolver knobs directly."""
|
||||
print("\n Raw identity-mapping keys (resolver tries them top-down):")
|
||||
pin_in = _prompt(
|
||||
"pinUserPeer — pin all gateway users to your peer? (true/false)",
|
||||
default=str(bool(current_pin)).lower(),
|
||||
).strip().lower()
|
||||
pin = pin_in in {"true", "t", "yes", "y", "1"}
|
||||
_scrub_identity_mapping(hermes_host)
|
||||
hermes_host["pinUserPeer"] = pin
|
||||
if pin:
|
||||
return
|
||||
aliases = (
|
||||
dict(current_aliases)
|
||||
if isinstance(current_aliases, dict) and not aliases_from_root
|
||||
else {}
|
||||
)
|
||||
print(" userPeerAliases — 'runtime_id=peer' pairs (blank line to finish):")
|
||||
while True:
|
||||
entry = _prompt(" alias", default="").strip()
|
||||
if not entry:
|
||||
break
|
||||
if "=" in entry:
|
||||
rid, peer = (p.strip() for p in entry.split("=", 1))
|
||||
if rid and peer:
|
||||
aliases[rid] = peer
|
||||
if aliases:
|
||||
hermes_host["userPeerAliases"] = aliases
|
||||
_apply_runtime_prefix(
|
||||
hermes_host, current_prefix, prefix_from_root,
|
||||
"runtimePeerPrefix — namespace for unknown IDs (blank for none)",
|
||||
)
|
||||
|
||||
|
||||
def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
|
||||
suffix = f" [{default}]" if default else ""
|
||||
sys.stdout.write(f" {label}{suffix}: ")
|
||||
|
|
@ -446,6 +551,10 @@ def cmd_setup(args) -> None:
|
|||
hosts = cfg.setdefault("hosts", {})
|
||||
hermes_host = hosts.setdefault(_host_key(), {})
|
||||
|
||||
# Canonicalize any legacy pinPeerName before detection/writes.
|
||||
_migrate_pin_key(cfg)
|
||||
_migrate_pin_key(hermes_host)
|
||||
|
||||
# --- 1. Cloud or local? ---
|
||||
print(" Deployment:")
|
||||
print(" cloud -- Honcho cloud (api.honcho.dev)")
|
||||
|
|
@ -545,18 +654,15 @@ def cmd_setup(args) -> None:
|
|||
if new_workspace:
|
||||
hermes_host["workspace"] = new_workspace
|
||||
|
||||
# --- 3b. Deployment shape ---
|
||||
# Determines how runtime user identities (Telegram UIDs, Discord
|
||||
# snowflakes, etc.) map to Honcho peers in gateway sessions. Three
|
||||
# shapes cover the realistic deployments; each writes a different
|
||||
# combination of pinPeerName / userPeerAliases / runtimePeerPrefix.
|
||||
# See plugins/memory/honcho/README.md for the resolver ladder.
|
||||
# --- 3b. Gateway identity mapping ---
|
||||
# These keys only affect the Hermes GATEWAY (Telegram/Discord/Slack/...),
|
||||
# the one entrypoint that supplies a runtime user ID. CLI/TUI/desktop/ACP
|
||||
# sessions have no runtime ID and fall through to peerName, so the step is
|
||||
# moot off-gateway — gate it behind detection.
|
||||
#
|
||||
# Detection must mirror the gateway resolver: root-level config and
|
||||
# ``pinUserPeer`` (which outranks ``pinPeerName`` at the same level)
|
||||
# both affect effective routing, so reading host-only fields would
|
||||
# mis-classify a profile that inherits its mapping from root or uses
|
||||
# the newer canonical key.
|
||||
# Detection mirrors the gateway resolver: root-level config and the
|
||||
# canonical ``pinUserPeer`` both affect routing, so host-only reads would
|
||||
# mis-classify a profile that inherits its mapping from root.
|
||||
(
|
||||
current_pin,
|
||||
current_aliases,
|
||||
|
|
@ -572,103 +678,109 @@ def cmd_setup(args) -> None:
|
|||
else:
|
||||
current_shape = "multi"
|
||||
|
||||
print("\n Deployment shape (how gateway users map to peers):")
|
||||
print(" single -- all platforms route to your peer (recommended for personal use)")
|
||||
print(" multi -- each platform user gets their own peer (multi-user bots)")
|
||||
print(" hybrid -- multi-user, but YOUR runtime IDs alias to your peer")
|
||||
print(" skip -- don't touch identity-mapping config")
|
||||
new_shape = _prompt("Deployment shape", default=current_shape).strip().lower()
|
||||
|
||||
# Transitioning single → multi orphans the peerName pool for runtime users
|
||||
# (their resolved peers go from peerName to runtime-derived IDs with empty
|
||||
# history). Steer the operator toward hybrid so their own continuity is
|
||||
# preserved via alias mappings.
|
||||
if current_shape == "single" and new_shape == "multi":
|
||||
peer_target = hermes_host.get("peerName") or current_peer or "user"
|
||||
print(
|
||||
f"\n ⚠ Switching from single to multi will orphan memory accumulated\n"
|
||||
f" under peer '{peer_target}'. Existing runtime users (Telegram,\n"
|
||||
f" Discord, etc.) will resolve to fresh, empty peers."
|
||||
)
|
||||
print(" To keep your own continuity, choose 'hybrid' and alias your\n"
|
||||
" runtime IDs back to peerName.")
|
||||
confirm = _prompt("Continue with multi anyway? (yes/hybrid/no)", default="hybrid").strip().lower()
|
||||
if confirm in {"hybrid", "h"}:
|
||||
new_shape = "hybrid"
|
||||
elif confirm not in {"yes", "y"}:
|
||||
new_shape = "skip"
|
||||
|
||||
# Each shape branch scrubs every peer-mapping key before writing its own,
|
||||
# so a stale ``pinUserPeer`` left behind by an earlier setup run can't
|
||||
# outrank the freshly written ``pinPeerName`` via host-level precedence.
|
||||
if new_shape == "single":
|
||||
_scrub_identity_mapping(hermes_host)
|
||||
hermes_host["pinPeerName"] = True
|
||||
print(f" pinPeerName=true → all gateway users route to '{hermes_host.get('peerName', '?')}'.")
|
||||
elif new_shape == "multi":
|
||||
# Preserve operator-curated, host-level aliases so multi → multi
|
||||
# re-runs don't drop them. Root-sourced aliases are left to
|
||||
# cascade naturally and are NOT copied down into the host.
|
||||
prior_aliases = (
|
||||
dict(current_aliases)
|
||||
if isinstance(current_aliases, dict) and not aliases_from_root
|
||||
else {}
|
||||
)
|
||||
_scrub_identity_mapping(hermes_host)
|
||||
hermes_host["pinPeerName"] = False
|
||||
# Do NOT auto-write ``userPeerAliases: {}``: an empty host map
|
||||
# would override any root-level ``userPeerAliases`` the operator
|
||||
# set as a cross-host baseline, silently disabling those aliases.
|
||||
# Absence is the right "no host opinion" signal.
|
||||
if prior_aliases:
|
||||
hermes_host["userPeerAliases"] = prior_aliases
|
||||
_prefix_default = current_prefix or ""
|
||||
_new_prefix = _prompt(
|
||||
"Runtime peer prefix (e.g. 'telegram_', blank for none)",
|
||||
default=_prefix_default,
|
||||
).strip()
|
||||
# Only write a host-level prefix when the operator typed one that
|
||||
# diverges from the inherited root value; otherwise let the root
|
||||
# cascade continue unmodified.
|
||||
if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix):
|
||||
hermes_host["runtimePeerPrefix"] = _new_prefix
|
||||
print(" Multi-user mode: each runtime ID → own peer. Use 'hermes honcho status' to inspect.")
|
||||
elif new_shape == "hybrid":
|
||||
# Hybrid encodes operator intent at the host level: collect existing
|
||||
# entries (host or root) so the wizard never silently drops a known
|
||||
# alias, then write the combined map. Materialising root entries
|
||||
# into the host is the right move here — once the operator answers
|
||||
# the alias prompts for a host, they're declaring "this host owns
|
||||
# the mapping".
|
||||
existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {}
|
||||
_scrub_identity_mapping(hermes_host)
|
||||
hermes_host["pinPeerName"] = False
|
||||
peer_target = hermes_host.get("peerName") or current_peer or "user"
|
||||
print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.")
|
||||
print(" Leave blank to skip a platform. Existing aliases are preserved.")
|
||||
for platform_label, alias_hint in (
|
||||
("Telegram UID", "e.g. 86701400"),
|
||||
("Discord snowflake", "e.g. 491827364"),
|
||||
("Slack user ID", "e.g. U04ABCDEF"),
|
||||
("Matrix MXID", "e.g. @you:matrix.org"),
|
||||
):
|
||||
entered = _prompt(f" {platform_label} ({alias_hint})", default="").strip()
|
||||
if entered:
|
||||
existing_aliases[entered] = peer_target
|
||||
if existing_aliases:
|
||||
hermes_host["userPeerAliases"] = existing_aliases
|
||||
_prefix_default = current_prefix or ""
|
||||
_new_prefix = _prompt(
|
||||
"Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)",
|
||||
default=_prefix_default,
|
||||
).strip()
|
||||
if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix):
|
||||
hermes_host["runtimePeerPrefix"] = _new_prefix
|
||||
print(f" Hybrid mode: your runtime IDs → '{peer_target}', others → own peer.")
|
||||
elif new_shape == "skip":
|
||||
pass # leave config untouched
|
||||
gw_platforms = _gateway_platforms()
|
||||
if gw_platforms is None:
|
||||
print("\n Gateway identity mapping routes platform users to memory peers.")
|
||||
run_mapping = _prompt(
|
||||
"Running the Hermes gateway (Telegram/Discord/etc.)? (y/N)",
|
||||
default="n",
|
||||
).strip().lower() in {"y", "yes"}
|
||||
elif not gw_platforms:
|
||||
print("\n No gateway platforms connected — identity mapping only affects")
|
||||
print(" gateway users, so this step doesn't apply here.")
|
||||
run_mapping = _prompt(
|
||||
"Configure gateway mapping anyway? (y/N)", default="n",
|
||||
).strip().lower() in {"y", "yes"}
|
||||
else:
|
||||
print(f" Unknown shape '{new_shape}' — leaving identity-mapping config untouched.")
|
||||
print(f"\n Gateway platforms detected: {', '.join(gw_platforms)}")
|
||||
run_mapping = True
|
||||
|
||||
if run_mapping:
|
||||
peer_target = hermes_host.get("peerName") or current_peer or "user"
|
||||
default_choice = {"single": "1", "hybrid": "2", "multi": "3"}.get(current_shape, "3")
|
||||
print("\n How should gateway users map to memory peers?")
|
||||
print(" [1] just me — every non-agent user collapses to your peer")
|
||||
print(" [2] me + other people — keep mine pooled, others separate")
|
||||
print(" [3] only other people — everyone gets their own peer")
|
||||
print(" [s] skip (leave untouched) [e] edit raw keys")
|
||||
choice = _prompt("Choice", default=default_choice).strip().lower()
|
||||
|
||||
if choice in {"2", "me+others", "both"}:
|
||||
pooled = _prompt(
|
||||
" Keep my own memory pooled across platforms? (Y/n)", default="y",
|
||||
).strip().lower()
|
||||
shape = "hybrid" if pooled in {"y", "yes", ""} else "multi"
|
||||
elif choice in {"1", "me", "just-me"}:
|
||||
shape = "single"
|
||||
elif choice in {"3", "others"}:
|
||||
shape = "multi"
|
||||
elif choice in {"e", "edit", "raw"}:
|
||||
shape = "raw"
|
||||
else:
|
||||
shape = "skip"
|
||||
|
||||
# Un-pinning a currently-pinned profile without aliasing strands the
|
||||
# pooled peerName history; steer the operator toward pooling instead.
|
||||
if current_pin and shape == "multi":
|
||||
print(
|
||||
f"\n ⚠ Un-pinning will orphan memory accumulated under peer\n"
|
||||
f" '{peer_target}'. Existing gateway users resolve to fresh,\n"
|
||||
f" empty peers."
|
||||
)
|
||||
confirm = _prompt(
|
||||
" Pool my own memory instead (alias my IDs to peerName)? (Y/n)",
|
||||
default="y",
|
||||
).strip().lower()
|
||||
if confirm in {"y", "yes", ""}:
|
||||
shape = "hybrid"
|
||||
|
||||
# Each branch scrubs every peer-mapping key first so a stale alias,
|
||||
# prefix, or pin from an earlier run starts clean.
|
||||
if shape == "single":
|
||||
_scrub_identity_mapping(hermes_host)
|
||||
hermes_host["pinUserPeer"] = True
|
||||
print(f" All non-agent gateway users route to '{peer_target}' (pin overrides aliases).")
|
||||
_echo_identity_mapping(hermes_host)
|
||||
elif shape == "multi":
|
||||
# Preserve operator-curated host-level aliases across multi → multi
|
||||
# re-runs. Root-sourced aliases cascade naturally and are NOT
|
||||
# copied down — an empty host map would mask a root baseline.
|
||||
prior_aliases = (
|
||||
dict(current_aliases)
|
||||
if isinstance(current_aliases, dict) and not aliases_from_root
|
||||
else {}
|
||||
)
|
||||
_scrub_identity_mapping(hermes_host)
|
||||
hermes_host["pinUserPeer"] = False
|
||||
if prior_aliases:
|
||||
hermes_host["userPeerAliases"] = prior_aliases
|
||||
_apply_runtime_prefix(
|
||||
hermes_host, current_prefix, prefix_from_root,
|
||||
"Runtime peer prefix (e.g. 'telegram_', blank for none)",
|
||||
)
|
||||
print(" Each gateway user → own peer.")
|
||||
_echo_identity_mapping(hermes_host)
|
||||
elif shape == "hybrid":
|
||||
existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {}
|
||||
_scrub_identity_mapping(hermes_host)
|
||||
hermes_host["pinUserPeer"] = False
|
||||
merged = _collect_operator_aliases(existing_aliases, peer_target)
|
||||
if merged:
|
||||
hermes_host["userPeerAliases"] = merged
|
||||
_apply_runtime_prefix(
|
||||
hermes_host, current_prefix, prefix_from_root,
|
||||
"Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)",
|
||||
)
|
||||
print(f" Your runtime IDs → '{peer_target}', others → own peer.")
|
||||
_echo_identity_mapping(hermes_host)
|
||||
elif shape == "raw":
|
||||
_configure_raw_identity_mapping(
|
||||
hermes_host, current_pin, current_aliases, current_prefix,
|
||||
aliases_from_root, prefix_from_root,
|
||||
)
|
||||
_echo_identity_mapping(hermes_host)
|
||||
else: # skip
|
||||
print(" Identity mapping left untouched.")
|
||||
|
||||
# --- 4. Observation mode ---
|
||||
current_obs = hermes_host.get("observationMode") or cfg.get("observationMode", "directional")
|
||||
|
|
|
|||
|
|
@ -1498,7 +1498,7 @@ class TestAgentConfigSignatureUserId:
|
|||
from gateway.run import GatewayRunner
|
||||
runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"}
|
||||
sig_a = GatewayRunner._agent_config_signature(
|
||||
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400"
|
||||
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321"
|
||||
)
|
||||
sig_b = GatewayRunner._agent_config_signature(
|
||||
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="491827364"
|
||||
|
|
@ -1509,10 +1509,10 @@ class TestAgentConfigSignatureUserId:
|
|||
from gateway.run import GatewayRunner
|
||||
runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"}
|
||||
sig_1 = GatewayRunner._agent_config_signature(
|
||||
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400"
|
||||
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321"
|
||||
)
|
||||
sig_2 = GatewayRunner._agent_config_signature(
|
||||
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400"
|
||||
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321"
|
||||
)
|
||||
assert sig_1 == sig_2
|
||||
|
||||
|
|
@ -1521,11 +1521,11 @@ class TestAgentConfigSignatureUserId:
|
|||
runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"}
|
||||
sig_a = GatewayRunner._agent_config_signature(
|
||||
"claude-sonnet-4", runtime, ["hermes-telegram"], "",
|
||||
user_id="86701400", user_id_alt="@igor_tg",
|
||||
user_id="7654321", user_id_alt="@igor_tg",
|
||||
)
|
||||
sig_b = GatewayRunner._agent_config_signature(
|
||||
"claude-sonnet-4", runtime, ["hermes-telegram"], "",
|
||||
user_id="86701400", user_id_alt="@erosika_tg",
|
||||
user_id="7654321", user_id_alt="@erosika_tg",
|
||||
)
|
||||
assert sig_a != sig_b
|
||||
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ class TestCloneHonchoForProfile:
|
|||
"""Identity-key carryover during profile cloning.
|
||||
|
||||
The host-scoped identity-mapping keys (``userPeerAliases``,
|
||||
``runtimePeerPrefix``, ``pinPeerName``) must survive a clone; otherwise
|
||||
``runtimePeerPrefix``, ``pinUserPeer``) must survive a clone; otherwise
|
||||
the new profile silently fragments memory by resolving gateway users to
|
||||
raw runtime IDs instead of operator-declared peers.
|
||||
"""
|
||||
|
|
@ -263,7 +263,7 @@ class TestCloneHonchoForProfile:
|
|||
"apiKey": "***",
|
||||
"hosts": {
|
||||
"hermes": {
|
||||
"userPeerAliases": {"86701400": "eri", "discord-491827364": "eri"},
|
||||
"userPeerAliases": {"7654321": "eri", "discord-491827364": "eri"},
|
||||
"peerName": "eri",
|
||||
},
|
||||
},
|
||||
|
|
@ -272,7 +272,7 @@ class TestCloneHonchoForProfile:
|
|||
ok = honcho_cli.clone_honcho_for_profile("coder")
|
||||
assert ok is True
|
||||
new_block = written["cfg"]["hosts"]["hermes_coder"]
|
||||
assert new_block["userPeerAliases"] == {"86701400": "eri", "discord-491827364": "eri"}
|
||||
assert new_block["userPeerAliases"] == {"7654321": "eri", "discord-491827364": "eri"}
|
||||
|
||||
def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_path):
|
||||
cfg = {
|
||||
|
|
@ -290,7 +290,7 @@ class TestCloneHonchoForProfile:
|
|||
new_block = written["cfg"]["hosts"]["hermes_coder"]
|
||||
assert new_block["runtimePeerPrefix"] == "telegram_"
|
||||
|
||||
def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path):
|
||||
def test_legacy_pin_peer_name_migrates_to_canonical_on_clone(self, monkeypatch, tmp_path):
|
||||
cfg = {
|
||||
"apiKey": "***",
|
||||
"hosts": {
|
||||
|
|
@ -304,7 +304,8 @@ class TestCloneHonchoForProfile:
|
|||
ok = honcho_cli.clone_honcho_for_profile("coder")
|
||||
assert ok is True
|
||||
new_block = written["cfg"]["hosts"]["hermes_coder"]
|
||||
assert new_block["pinPeerName"] is True
|
||||
assert new_block["pinUserPeer"] is True
|
||||
assert "pinPeerName" not in new_block
|
||||
|
||||
def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch, tmp_path):
|
||||
cfg = {
|
||||
|
|
@ -317,23 +318,25 @@ class TestCloneHonchoForProfile:
|
|||
new_block = written["cfg"]["hosts"]["hermes_coder"]
|
||||
assert "userPeerAliases" not in new_block
|
||||
assert "runtimePeerPrefix" not in new_block
|
||||
assert "pinUserPeer" not in new_block
|
||||
assert "pinPeerName" not in new_block
|
||||
|
||||
|
||||
class TestSetupWizardDeploymentShape:
|
||||
"""The deployment-shape step writes pinPeerName / userPeerAliases /
|
||||
runtimePeerPrefix based on the operator's chosen shape.
|
||||
"""The gateway identity-mapping tree writes pinUserPeer / userPeerAliases /
|
||||
runtimePeerPrefix based on the operator's intent.
|
||||
|
||||
Single-operator deployments collapse all platforms to peerName.
|
||||
Multi-user gateways leave the resolver to route per-runtime.
|
||||
Hybrid deployments alias the operator's own runtime IDs only.
|
||||
Choice [1] (just me) collapses all platforms to peerName.
|
||||
Choice [3] (only other people) leaves the resolver to route per-runtime.
|
||||
Choice [2] (me + others, pooled) aliases the operator's own runtime IDs.
|
||||
|
||||
These tests script the interactive _prompt calls and assert the
|
||||
resulting hermes_host block, so the wizard's deployment-shape
|
||||
These tests mock gateway detection and script the interactive _prompt
|
||||
calls, asserting the resulting hermes_host block so the tree's routing
|
||||
semantics stay locked even as adjacent prompts are added.
|
||||
"""
|
||||
|
||||
def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None):
|
||||
def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None,
|
||||
gateway_platforms=("telegram",)):
|
||||
import plugins.memory.honcho.cli as honcho_cli
|
||||
|
||||
cfg_path = tmp_path / "config.json"
|
||||
|
|
@ -346,6 +349,10 @@ class TestSetupWizardDeploymentShape:
|
|||
monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
|
||||
monkeypatch.setattr(honcho_cli, "_ensure_sdk_installed", lambda: True)
|
||||
monkeypatch.setattr(honcho_cli, "_write_config", lambda *a, **k: None)
|
||||
# Gate detection is mocked so tests control whether the tree runs.
|
||||
# None → undetectable; list (possibly empty) → connected platforms.
|
||||
gw = None if gateway_platforms is None else list(gateway_platforms)
|
||||
monkeypatch.setattr(honcho_cli, "_gateway_platforms", lambda: gw)
|
||||
|
||||
# Bypass config.yaml + connection test side effects.
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -391,14 +398,14 @@ class TestSetupWizardDeploymentShape:
|
|||
honcho_cli.cmd_setup(SimpleNamespace())
|
||||
return cfg["hosts"]["hermes"]
|
||||
|
||||
def test_single_shape_sets_pin_peer_name_and_clears_aliases(self, monkeypatch, tmp_path):
|
||||
def test_just_me_pins_and_clears_aliases(self, monkeypatch, tmp_path):
|
||||
answers = [
|
||||
"cloud", # deployment
|
||||
"", # api key (keep)
|
||||
"eri", # peer name
|
||||
"hermetika", # ai peer
|
||||
"hermes", # workspace
|
||||
"single", # deployment shape ← key answer
|
||||
"1", # tree: just me ← key answer
|
||||
# remaining prompts fall through to defaults
|
||||
]
|
||||
initial_cfg = {
|
||||
|
|
@ -409,51 +416,54 @@ class TestSetupWizardDeploymentShape:
|
|||
}},
|
||||
}
|
||||
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
|
||||
assert host["pinPeerName"] is True
|
||||
assert host["pinUserPeer"] is True
|
||||
assert "userPeerAliases" not in host
|
||||
assert "runtimePeerPrefix" not in host
|
||||
|
||||
def test_multi_shape_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path):
|
||||
def test_only_others_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path):
|
||||
answers = [
|
||||
"cloud", # deployment
|
||||
"", # api key (keep)
|
||||
"eri", # peer name
|
||||
"hermetika", # ai peer
|
||||
"hermes", # workspace
|
||||
"multi", # deployment shape
|
||||
"3", # tree: only other people
|
||||
"telegram_", # runtime peer prefix
|
||||
]
|
||||
host = self._run_setup(monkeypatch, tmp_path, answers=answers)
|
||||
assert host["pinPeerName"] is False
|
||||
assert host["pinUserPeer"] is False
|
||||
# Multi must NOT auto-write ``userPeerAliases: {}``: an empty host
|
||||
# map would silently override a root-level baseline. Absence is
|
||||
# the correct "no host opinion" signal.
|
||||
assert "userPeerAliases" not in host
|
||||
assert host["runtimePeerPrefix"] == "telegram_"
|
||||
|
||||
def test_hybrid_shape_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path):
|
||||
def test_pooled_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path):
|
||||
answers = [
|
||||
"cloud", # deployment
|
||||
"", # api key (keep)
|
||||
"eri", # peer name
|
||||
"hermetika", # ai peer
|
||||
"hermes", # workspace
|
||||
"hybrid", # deployment shape
|
||||
"86701400", # telegram uid
|
||||
"2", # tree: me + other people
|
||||
"y", # keep my memory pooled? → hybrid
|
||||
"7654321", # telegram uid
|
||||
"491827364", # discord snowflake
|
||||
"", # slack (skip)
|
||||
"", # matrix (skip)
|
||||
"", # runtime peer prefix (skip)
|
||||
]
|
||||
host = self._run_setup(monkeypatch, tmp_path, answers=answers)
|
||||
assert host["pinPeerName"] is False
|
||||
assert host["pinUserPeer"] is False
|
||||
assert host["userPeerAliases"] == {
|
||||
"86701400": "eri",
|
||||
"7654321": "eri",
|
||||
"491827364": "eri",
|
||||
}
|
||||
assert "runtimePeerPrefix" not in host
|
||||
|
||||
def test_skip_shape_preserves_existing_identity_config(self, monkeypatch, tmp_path):
|
||||
# Seeds the legacy ``pinPeerName``: skip must leave the mapping intact
|
||||
# except for the on-load migration onto the canonical key.
|
||||
initial_cfg = {
|
||||
"apiKey": "***",
|
||||
"hosts": {"hermes": {
|
||||
|
|
@ -463,17 +473,18 @@ class TestSetupWizardDeploymentShape:
|
|||
}},
|
||||
}
|
||||
answers = [
|
||||
"cloud", "", "eri", "hermetika", "hermes", "skip",
|
||||
"cloud", "", "eri", "hermetika", "hermes", "s",
|
||||
]
|
||||
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
|
||||
assert host["pinPeerName"] is True
|
||||
assert host["pinUserPeer"] is True
|
||||
assert "pinPeerName" not in host
|
||||
assert host["userPeerAliases"] == {"keep": "me"}
|
||||
assert host["runtimePeerPrefix"] == "keep_"
|
||||
|
||||
def test_single_to_multi_steers_to_hybrid_by_default(self, monkeypatch, tmp_path):
|
||||
"""Flipping single → multi triggers a warning that auto-steers the
|
||||
operator to ``hybrid`` (default), so their own runtime IDs keep
|
||||
landing on peerName instead of orphaning the pinned-pool history.
|
||||
def test_unpin_steers_to_pooled_by_default(self, monkeypatch, tmp_path):
|
||||
"""Choosing 'only other people' on a currently-pinned profile triggers
|
||||
the orphan warning, which auto-steers to pooled (hybrid) so the
|
||||
operator's own runtime IDs keep landing on peerName.
|
||||
"""
|
||||
initial_cfg = {
|
||||
"apiKey": "***",
|
||||
|
|
@ -485,60 +496,57 @@ class TestSetupWizardDeploymentShape:
|
|||
"eri", # peer name
|
||||
"hermetika", # ai peer
|
||||
"hermes", # workspace
|
||||
"multi", # deployment shape — triggers the guard
|
||||
"hybrid", # guard response: accept the steer
|
||||
"86701400", # telegram uid
|
||||
"3", # tree: only others — triggers the orphan guard
|
||||
"y", # pool my own memory instead? → hybrid
|
||||
"7654321", # telegram uid
|
||||
"", # discord (skip)
|
||||
"", # slack (skip)
|
||||
"", # matrix (skip)
|
||||
"", # runtime prefix (skip)
|
||||
]
|
||||
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
|
||||
assert host["pinPeerName"] is False
|
||||
assert host["userPeerAliases"] == {"86701400": "eri"}
|
||||
assert host["pinUserPeer"] is False
|
||||
assert host["userPeerAliases"] == {"7654321": "eri"}
|
||||
|
||||
def test_single_to_multi_yes_override_keeps_multi(self, monkeypatch, tmp_path):
|
||||
"""Operator can override the steer by answering ``yes`` and accept
|
||||
the orphaning consequences. This is the explicit undo-the-pin path.
|
||||
"""
|
||||
def test_unpin_decline_steer_keeps_per_user(self, monkeypatch, tmp_path):
|
||||
"""Operator can decline the steer ('n') and accept orphaning, ending
|
||||
up with per-user peers (no aliases)."""
|
||||
initial_cfg = {
|
||||
"apiKey": "***",
|
||||
"hosts": {"hermes": {"pinPeerName": True, "peerName": "eri"}},
|
||||
}
|
||||
answers = [
|
||||
"cloud", "", "eri", "hermetika", "hermes",
|
||||
"multi", # deployment shape — triggers the guard
|
||||
"yes", # guard response: confirm multi
|
||||
"3", # tree: only others — triggers the orphan guard
|
||||
"n", # decline pooling, accept orphaning
|
||||
"telegram_", # runtime peer prefix
|
||||
]
|
||||
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
|
||||
assert host["pinPeerName"] is False
|
||||
# See test_multi_shape_leaves_pin_false_and_accepts_prefix.
|
||||
assert host["pinUserPeer"] is False
|
||||
assert "userPeerAliases" not in host
|
||||
assert host["runtimePeerPrefix"] == "telegram_"
|
||||
|
||||
def test_host_pin_user_peer_true_is_detected_as_single(self, monkeypatch, tmp_path):
|
||||
"""Host-level ``pinUserPeer: true`` must classify as ``single``.
|
||||
|
||||
Pressing Enter at the shape prompt then preserves the pin instead
|
||||
of falling through to ``multi`` and orphaning the user's memory
|
||||
pool — the bug the wizard regressed when ``pinUserPeer`` landed
|
||||
as a higher-precedence alias.
|
||||
Pressing Enter at the choice prompt then preserves the pin instead
|
||||
of falling through to per-user routing and orphaning the user's
|
||||
memory pool — the bug the wizard regressed when ``pinUserPeer``
|
||||
landed as a higher-precedence alias.
|
||||
"""
|
||||
initial_cfg = {
|
||||
"apiKey": "***",
|
||||
"hosts": {"hermes": {"pinUserPeer": True, "peerName": "eri"}},
|
||||
}
|
||||
# Exhaust the iterator before the shape prompt so the scripted
|
||||
# mock falls through to the prompt's default (which is the
|
||||
# wizard-detected shape). Scripting an explicit "" would NOT
|
||||
# exercise that fallthrough — the mock returns it literally.
|
||||
# Exhaust the iterator before the choice prompt so the scripted
|
||||
# mock falls through to the prompt's default (the detected shape →
|
||||
# choice "1"). Scripting an explicit "" would NOT exercise that
|
||||
# fallthrough — the mock returns it literally.
|
||||
answers = ["cloud", "", "eri", "hermetika", "hermes"]
|
||||
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
|
||||
# Scrub-then-write normalises onto pinPeerName and drops the alias
|
||||
# so resolver precedence can't reintroduce ambiguity.
|
||||
assert host["pinPeerName"] is True
|
||||
assert "pinUserPeer" not in host
|
||||
# Scrub-then-write normalises onto the canonical pinUserPeer.
|
||||
assert host["pinUserPeer"] is True
|
||||
assert "pinPeerName" not in host
|
||||
|
||||
def test_host_pin_user_peer_false_overrides_root_pin_peer_name(
|
||||
self, monkeypatch, tmp_path
|
||||
|
|
@ -558,8 +566,8 @@ class TestSetupWizardDeploymentShape:
|
|||
}
|
||||
answers = ["cloud", "", "eri", "hermetika", "hermes"]
|
||||
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
|
||||
assert host["pinPeerName"] is False
|
||||
assert "pinUserPeer" not in host
|
||||
assert host["pinUserPeer"] is False
|
||||
assert "pinPeerName" not in host
|
||||
|
||||
def test_root_user_peer_aliases_detected_as_hybrid(self, monkeypatch, tmp_path):
|
||||
"""Root-level ``userPeerAliases`` must classify as ``hybrid`` even
|
||||
|
|
@ -567,26 +575,26 @@ class TestSetupWizardDeploymentShape:
|
|||
"""
|
||||
initial_cfg = {
|
||||
"apiKey": "***",
|
||||
"userPeerAliases": {"86701400": "eri"},
|
||||
"userPeerAliases": {"7654321": "eri"},
|
||||
"hosts": {"hermes": {"peerName": "eri"}},
|
||||
}
|
||||
answers = ["cloud", "", "eri", "hermetika", "hermes"]
|
||||
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
|
||||
assert host["pinPeerName"] is False
|
||||
assert host["pinUserPeer"] is False
|
||||
# Hybrid materialises the root aliases into the host so subsequent
|
||||
# operator edits live on the host block they're inspecting.
|
||||
assert host["userPeerAliases"] == {"86701400": "eri"}
|
||||
assert host["userPeerAliases"] == {"7654321": "eri"}
|
||||
|
||||
def test_multi_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path):
|
||||
"""Explicit ``multi`` must leave the host ``userPeerAliases`` key
|
||||
absent, preserving any root-level aliases as a cross-host baseline.
|
||||
def test_only_others_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path):
|
||||
"""Explicitly choosing 'only other people' must leave the host
|
||||
``userPeerAliases`` key absent, preserving any root-level aliases as a
|
||||
cross-host baseline.
|
||||
|
||||
Picking ``multi`` here is an active choice — detection would have
|
||||
defaulted to ``hybrid`` because root aliases exist — so the
|
||||
operator's intent is to drop the alias mapping for this host.
|
||||
We honor that by writing ``pinPeerName: false`` only, and rely
|
||||
on the host's absence of ``userPeerAliases`` to inherit root.
|
||||
That inheritance is intentional: a true wipe would require the
|
||||
Picking [3] here is an active choice — detection would have defaulted
|
||||
to [2]/hybrid because root aliases exist — so the operator's intent is
|
||||
to drop the alias mapping for this host. We honor that by writing
|
||||
``pinUserPeer: false`` only, relying on the host's absence of
|
||||
``userPeerAliases`` to inherit root. A true wipe would require the
|
||||
operator to delete the root key explicitly.
|
||||
"""
|
||||
initial_cfg = {
|
||||
|
|
@ -596,17 +604,15 @@ class TestSetupWizardDeploymentShape:
|
|||
}
|
||||
answers = [
|
||||
"cloud", "", "eri", "hermetika", "hermes",
|
||||
"multi", # explicit multi override of detected hybrid
|
||||
"3", # explicit per-user override of detected hybrid
|
||||
]
|
||||
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
|
||||
assert host["pinPeerName"] is False
|
||||
assert host["pinUserPeer"] is False
|
||||
assert "userPeerAliases" not in host
|
||||
|
||||
def test_single_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path):
|
||||
"""Choosing ``single`` must drop any host-level ``pinUserPeer``,
|
||||
otherwise an existing ``pinUserPeer: false`` would outrank the
|
||||
freshly written ``pinPeerName: true`` and leave the profile
|
||||
effectively unpinned (the P1 latent-precedence regression).
|
||||
def test_just_me_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path):
|
||||
"""Choosing 'just me' must overwrite a stale ``pinUserPeer: false``
|
||||
with ``pinUserPeer: true`` so the profile ends up genuinely pinned.
|
||||
"""
|
||||
initial_cfg = {
|
||||
"apiKey": "***",
|
||||
|
|
@ -617,11 +623,56 @@ class TestSetupWizardDeploymentShape:
|
|||
}
|
||||
answers = [
|
||||
"cloud", "", "eri", "hermetika", "hermes",
|
||||
"single",
|
||||
"1",
|
||||
]
|
||||
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
|
||||
assert host["pinPeerName"] is True
|
||||
assert host["pinUserPeer"] is True
|
||||
|
||||
def test_no_gateway_connected_skips_mapping_when_declined(self, monkeypatch, tmp_path):
|
||||
"""With no gateway platforms connected, the tree is gated off; declining
|
||||
the 'configure anyway?' prompt leaves identity mapping untouched."""
|
||||
initial_cfg = {
|
||||
"apiKey": "***",
|
||||
"hosts": {"hermes": {"peerName": "eri"}},
|
||||
}
|
||||
answers = ["cloud", "", "eri", "hermetika", "hermes", "n"]
|
||||
host = self._run_setup(
|
||||
monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg,
|
||||
gateway_platforms=[],
|
||||
)
|
||||
assert "pinUserPeer" not in host
|
||||
assert "userPeerAliases" not in host
|
||||
assert "runtimePeerPrefix" not in host
|
||||
|
||||
def test_undetectable_gateway_skips_mapping_when_declined(self, monkeypatch, tmp_path):
|
||||
"""When the gateway package can't be inspected (None), the wizard asks
|
||||
whether the gateway is running; 'no' skips the mapping step."""
|
||||
initial_cfg = {
|
||||
"apiKey": "***",
|
||||
"hosts": {"hermes": {"peerName": "eri"}},
|
||||
}
|
||||
answers = ["cloud", "", "eri", "hermetika", "hermes", "n"]
|
||||
host = self._run_setup(
|
||||
monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg,
|
||||
gateway_platforms=None,
|
||||
)
|
||||
assert "pinUserPeer" not in host
|
||||
|
||||
def test_raw_edit_sets_resolver_knobs_directly(self, monkeypatch, tmp_path):
|
||||
"""The [e] escape hatch lets a power user set pinUserPeer + an alias +
|
||||
prefix directly, bypassing the intent tree."""
|
||||
answers = [
|
||||
"cloud", "", "eri", "hermetika", "hermes",
|
||||
"e", # tree: edit raw keys
|
||||
"false", # pinUserPeer
|
||||
"99887766=eri", # one alias pair
|
||||
"", # finish aliases
|
||||
"discord_", # runtimePeerPrefix
|
||||
]
|
||||
host = self._run_setup(monkeypatch, tmp_path, answers=answers)
|
||||
assert host["pinUserPeer"] is False
|
||||
assert host["userPeerAliases"] == {"99887766": "eri"}
|
||||
assert host["runtimePeerPrefix"] == "discord_"
|
||||
|
||||
|
||||
class TestCloneCarriesPinUserPeer:
|
||||
|
|
@ -653,3 +704,27 @@ class TestCloneCarriesPinUserPeer:
|
|||
assert ok is True
|
||||
new_block = written["cfg"]["hosts"]["hermes_partner"]
|
||||
assert new_block["pinUserPeer"] is True
|
||||
|
||||
|
||||
class TestMigratePinKey:
|
||||
"""``_migrate_pin_key`` rewrites the legacy ``pinPeerName`` onto the
|
||||
canonical ``pinUserPeer`` in place, without clobbering an existing
|
||||
canonical value."""
|
||||
|
||||
def test_legacy_key_renamed_to_canonical(self):
|
||||
import plugins.memory.honcho.cli as honcho_cli
|
||||
block = {"pinPeerName": True}
|
||||
assert honcho_cli._migrate_pin_key(block) is True
|
||||
assert block == {"pinUserPeer": True}
|
||||
|
||||
def test_canonical_key_wins_when_both_present(self):
|
||||
import plugins.memory.honcho.cli as honcho_cli
|
||||
block = {"pinPeerName": True, "pinUserPeer": False}
|
||||
assert honcho_cli._migrate_pin_key(block) is True
|
||||
assert block == {"pinUserPeer": False}
|
||||
|
||||
def test_noop_when_no_legacy_key(self):
|
||||
import plugins.memory.honcho.cli as honcho_cli
|
||||
block = {"pinUserPeer": True}
|
||||
assert honcho_cli._migrate_pin_key(block) is False
|
||||
assert block == {"pinUserPeer": True}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ class TestRuntimePeerMappingConfigParsing:
|
|||
config_file.write_text(json.dumps({
|
||||
"apiKey": "k",
|
||||
"userPeerAliases": {
|
||||
" 86701400 ": " Igor ",
|
||||
" 7654321 ": " Igor ",
|
||||
"": "ignored",
|
||||
"empty-value": " ",
|
||||
"null-value": None,
|
||||
|
|
@ -115,7 +115,7 @@ class TestRuntimePeerMappingConfigParsing:
|
|||
|
||||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||
|
||||
assert config.user_peer_aliases == {"86701400": "Igor"}
|
||||
assert config.user_peer_aliases == {"7654321": "Igor"}
|
||||
assert config.runtime_peer_prefix == "telegram_"
|
||||
|
||||
def test_host_aliases_override_root_aliases_as_whole_map(self, tmp_path):
|
||||
|
|
@ -226,12 +226,12 @@ class TestPeerResolutionOrder:
|
|||
mgr = HonchoSessionManager(
|
||||
honcho=MagicMock(),
|
||||
config=self._config(peer_name="Igor", pin_peer_name=False),
|
||||
runtime_user_peer_name="86701400", # e.g. Telegram UID
|
||||
runtime_user_peer_name="7654321", # e.g. Telegram UID
|
||||
)
|
||||
_patch_manager_for_resolution_test(mgr)
|
||||
|
||||
session = mgr.get_or_create("telegram:86701400")
|
||||
assert session.user_peer_id == "86701400", (
|
||||
session = mgr.get_or_create("telegram:7654321")
|
||||
assert session.user_peer_id == "7654321", (
|
||||
"pin_peer_name=False is the multi-user default — the gateway's "
|
||||
"platform-native user ID must win so each user gets their own "
|
||||
"peer scope. If this regresses, every Telegram/Discord/Slack "
|
||||
|
|
@ -245,14 +245,14 @@ class TestPeerResolutionOrder:
|
|||
config=self._config(
|
||||
peer_name="Igor",
|
||||
pin_peer_name=False,
|
||||
user_peer_aliases={"86701400": "Igor"},
|
||||
user_peer_aliases={"7654321": "Igor"},
|
||||
runtime_peer_prefix="telegram_",
|
||||
),
|
||||
runtime_user_peer_name="86701400",
|
||||
runtime_user_peer_name="7654321",
|
||||
)
|
||||
_patch_manager_for_resolution_test(mgr)
|
||||
|
||||
session = mgr.get_or_create("telegram:86701400")
|
||||
session = mgr.get_or_create("telegram:7654321")
|
||||
assert session.user_peer_id == "Igor"
|
||||
|
||||
def test_unknown_runtime_id_uses_prefix(self):
|
||||
|
|
@ -264,12 +264,12 @@ class TestPeerResolutionOrder:
|
|||
pin_peer_name=False,
|
||||
runtime_peer_prefix="telegram_",
|
||||
),
|
||||
runtime_user_peer_name="86701400",
|
||||
runtime_user_peer_name="7654321",
|
||||
)
|
||||
_patch_manager_for_resolution_test(mgr)
|
||||
|
||||
session = mgr.get_or_create("telegram:86701400")
|
||||
assert session.user_peer_id == "telegram_86701400"
|
||||
session = mgr.get_or_create("telegram:7654321")
|
||||
assert session.user_peer_id == "telegram_7654321"
|
||||
|
||||
def test_prefixed_runtime_id_hashes_when_sanitization_is_lossy(self):
|
||||
"""Generated prefixed IDs avoid merges caused by lossy sanitization."""
|
||||
|
|
@ -291,43 +291,43 @@ class TestPeerResolutionOrder:
|
|||
|
||||
def test_prefixed_runtime_id_hashes_when_it_collides_with_peer_name(self):
|
||||
"""Unknown generated peers should not silently merge into peerName."""
|
||||
raw_peer_id = "telegram_86701400"
|
||||
raw_peer_id = "telegram_7654321"
|
||||
expected_hash = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()[:8]
|
||||
mgr = HonchoSessionManager(
|
||||
honcho=MagicMock(),
|
||||
config=self._config(
|
||||
peer_name="telegram_86701400",
|
||||
peer_name="telegram_7654321",
|
||||
pin_peer_name=False,
|
||||
runtime_peer_prefix="telegram_",
|
||||
),
|
||||
runtime_user_peer_name="86701400",
|
||||
runtime_user_peer_name="7654321",
|
||||
)
|
||||
_patch_manager_for_resolution_test(mgr)
|
||||
|
||||
session = mgr.get_or_create("telegram:86701400")
|
||||
assert session.user_peer_id == f"telegram_86701400-{expected_hash}"
|
||||
session = mgr.get_or_create("telegram:7654321")
|
||||
assert session.user_peer_id == f"telegram_7654321-{expected_hash}"
|
||||
|
||||
def test_prefixed_runtime_id_hashes_when_it_collides_with_alias_target(self):
|
||||
"""Unknown generated peers should not silently merge into alias targets."""
|
||||
raw_peer_id = "telegram_86701400"
|
||||
raw_peer_id = "telegram_7654321"
|
||||
expected_hash = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()[:8]
|
||||
mgr = HonchoSessionManager(
|
||||
honcho=MagicMock(),
|
||||
config=self._config(
|
||||
peer_name=None,
|
||||
pin_peer_name=False,
|
||||
user_peer_aliases={"known-user": "telegram_86701400"},
|
||||
user_peer_aliases={"known-user": "telegram_7654321"},
|
||||
runtime_peer_prefix="telegram_",
|
||||
),
|
||||
runtime_user_peer_name="86701400",
|
||||
runtime_user_peer_name="7654321",
|
||||
)
|
||||
_patch_manager_for_resolution_test(mgr)
|
||||
|
||||
session = mgr.get_or_create("telegram:86701400")
|
||||
assert session.user_peer_id == f"telegram_86701400-{expected_hash}"
|
||||
session = mgr.get_or_create("telegram:7654321")
|
||||
assert session.user_peer_id == f"telegram_7654321-{expected_hash}"
|
||||
|
||||
def test_prefixed_runtime_id_extends_hash_when_short_hash_collides(self):
|
||||
raw_peer_id = "telegram_86701400"
|
||||
raw_peer_id = "telegram_7654321"
|
||||
digest = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()
|
||||
mgr = HonchoSessionManager(
|
||||
honcho=MagicMock(),
|
||||
|
|
@ -335,17 +335,17 @@ class TestPeerResolutionOrder:
|
|||
peer_name=None,
|
||||
pin_peer_name=False,
|
||||
user_peer_aliases={
|
||||
"known-user": "telegram_86701400",
|
||||
"reserved-user": f"telegram_86701400-{digest[:8]}",
|
||||
"known-user": "telegram_7654321",
|
||||
"reserved-user": f"telegram_7654321-{digest[:8]}",
|
||||
},
|
||||
runtime_peer_prefix="telegram_",
|
||||
),
|
||||
runtime_user_peer_name="86701400",
|
||||
runtime_user_peer_name="7654321",
|
||||
)
|
||||
_patch_manager_for_resolution_test(mgr)
|
||||
|
||||
session = mgr.get_or_create("telegram:86701400")
|
||||
assert session.user_peer_id == f"telegram_86701400-{digest[:12]}"
|
||||
session = mgr.get_or_create("telegram:7654321")
|
||||
assert session.user_peer_id == f"telegram_7654321-{digest[:12]}"
|
||||
|
||||
def test_alias_value_is_sanitized_after_selection(self):
|
||||
mgr = HonchoSessionManager(
|
||||
|
|
@ -353,13 +353,13 @@ class TestPeerResolutionOrder:
|
|||
config=self._config(
|
||||
peer_name=None,
|
||||
pin_peer_name=False,
|
||||
user_peer_aliases={"86701400": "Alice Smith!"},
|
||||
user_peer_aliases={"7654321": "Alice Smith!"},
|
||||
),
|
||||
runtime_user_peer_name="86701400",
|
||||
runtime_user_peer_name="7654321",
|
||||
)
|
||||
_patch_manager_for_resolution_test(mgr)
|
||||
|
||||
session = mgr.get_or_create("telegram:86701400")
|
||||
session = mgr.get_or_create("telegram:7654321")
|
||||
assert session.user_peer_id == "Alice-Smith-"
|
||||
|
||||
def test_alias_keys_match_raw_runtime_id_before_sanitization(self):
|
||||
|
|
@ -391,13 +391,13 @@ class TestPeerResolutionOrder:
|
|||
runtime_peer_prefix="telegram_",
|
||||
session_peer_prefix=True,
|
||||
),
|
||||
runtime_user_peer_name="86701400",
|
||||
runtime_user_peer_name="7654321",
|
||||
)
|
||||
_patch_manager_for_resolution_test(mgr)
|
||||
|
||||
session = mgr.get_or_create("telegram:86701400")
|
||||
assert session.user_peer_id == "telegram_86701400"
|
||||
assert session.honcho_session_id == "telegram-86701400"
|
||||
session = mgr.get_or_create("telegram:7654321")
|
||||
assert session.user_peer_id == "telegram_7654321"
|
||||
assert session.honcho_session_id == "telegram-7654321"
|
||||
|
||||
def test_config_wins_when_pin_is_true(self):
|
||||
"""With pin enabled, configured peer_name beats runtime ID."""
|
||||
|
|
@ -406,14 +406,14 @@ class TestPeerResolutionOrder:
|
|||
config=self._config(
|
||||
peer_name="Igor",
|
||||
pin_peer_name=True,
|
||||
user_peer_aliases={"86701400": "Alias"},
|
||||
user_peer_aliases={"7654321": "Alias"},
|
||||
runtime_peer_prefix="telegram_",
|
||||
),
|
||||
runtime_user_peer_name="86701400", # Telegram pushes this in
|
||||
runtime_user_peer_name="7654321", # Telegram pushes this in
|
||||
)
|
||||
_patch_manager_for_resolution_test(mgr)
|
||||
|
||||
session = mgr.get_or_create("telegram:86701400")
|
||||
session = mgr.get_or_create("telegram:7654321")
|
||||
assert session.user_peer_id == "Igor", (
|
||||
"With pinPeerName=true the user's configured peer_name must "
|
||||
"beat the platform-native runtime ID so memory stays unified "
|
||||
|
|
@ -429,26 +429,26 @@ class TestPeerResolutionOrder:
|
|||
config=self._config(
|
||||
peer_name=None,
|
||||
pin_peer_name=True,
|
||||
user_peer_aliases={"86701400": "Igor"},
|
||||
user_peer_aliases={"7654321": "Igor"},
|
||||
runtime_peer_prefix="telegram_",
|
||||
),
|
||||
runtime_user_peer_name="86701400",
|
||||
runtime_user_peer_name="7654321",
|
||||
)
|
||||
_patch_manager_for_resolution_test(mgr)
|
||||
|
||||
session = mgr.get_or_create("telegram:86701400")
|
||||
session = mgr.get_or_create("telegram:7654321")
|
||||
assert session.user_peer_id == "Igor"
|
||||
|
||||
def test_pin_noop_without_peer_name_or_mapping_preserves_runtime(self):
|
||||
mgr = HonchoSessionManager(
|
||||
honcho=MagicMock(),
|
||||
config=self._config(peer_name=None, pin_peer_name=True),
|
||||
runtime_user_peer_name="86701400",
|
||||
runtime_user_peer_name="7654321",
|
||||
)
|
||||
_patch_manager_for_resolution_test(mgr)
|
||||
|
||||
session = mgr.get_or_create("telegram:86701400")
|
||||
assert session.user_peer_id == "86701400"
|
||||
session = mgr.get_or_create("telegram:7654321")
|
||||
assert session.user_peer_id == "7654321"
|
||||
|
||||
def test_alt_runtime_id_can_match_alias_without_changing_raw_fallback(self):
|
||||
"""Stable alternate IDs can map known users while primary ID fallback stays unchanged."""
|
||||
|
|
@ -526,11 +526,11 @@ class TestPeerResolutionOrder:
|
|||
mgr = HonchoSessionManager(
|
||||
honcho=MagicMock(),
|
||||
config=cfg,
|
||||
runtime_user_peer_name="86701400",
|
||||
runtime_user_peer_name="7654321",
|
||||
)
|
||||
_patch_manager_for_resolution_test(mgr)
|
||||
|
||||
session = mgr.get_or_create("telegram:86701400")
|
||||
session = mgr.get_or_create("telegram:7654321")
|
||||
assert session.user_peer_id == "Igor"
|
||||
assert session.assistant_peer_id == "hermes-assistant"
|
||||
|
||||
|
|
@ -556,10 +556,10 @@ class TestCrossPlatformMemoryUnification:
|
|||
mgr_telegram = HonchoSessionManager(
|
||||
honcho=MagicMock(),
|
||||
config=self._config_pinned(),
|
||||
runtime_user_peer_name="86701400",
|
||||
runtime_user_peer_name="7654321",
|
||||
)
|
||||
_patch_manager_for_resolution_test(mgr_telegram)
|
||||
telegram_session = mgr_telegram.get_or_create("telegram:86701400")
|
||||
telegram_session = mgr_telegram.get_or_create("telegram:7654321")
|
||||
|
||||
# Discord turn (separate manager instance — simulates a fresh
|
||||
# platform-adapter invocation)
|
||||
|
|
@ -701,20 +701,20 @@ class TestPinTransition:
|
|||
pinned_mgr = HonchoSessionManager(
|
||||
honcho=MagicMock(),
|
||||
config=self._pinned(),
|
||||
runtime_user_peer_name="86701400",
|
||||
runtime_user_peer_name="7654321",
|
||||
)
|
||||
_patch_manager_for_resolution_test(pinned_mgr)
|
||||
before = pinned_mgr.get_or_create("telegram:86701400")
|
||||
before = pinned_mgr.get_or_create("telegram:7654321")
|
||||
assert before.user_peer_id == "Igor"
|
||||
|
||||
unpinned_mgr = HonchoSessionManager(
|
||||
honcho=MagicMock(),
|
||||
config=self._unpinned(),
|
||||
runtime_user_peer_name="86701400",
|
||||
runtime_user_peer_name="7654321",
|
||||
)
|
||||
_patch_manager_for_resolution_test(unpinned_mgr)
|
||||
after = unpinned_mgr.get_or_create("telegram:86701400")
|
||||
assert after.user_peer_id == "86701400", (
|
||||
after = unpinned_mgr.get_or_create("telegram:7654321")
|
||||
assert after.user_peer_id == "7654321", (
|
||||
"After flipping pinPeerName off, the same runtime ID must resolve "
|
||||
"to its own peer — otherwise multi-user mode silently merges users."
|
||||
)
|
||||
|
|
@ -723,14 +723,14 @@ class TestPinTransition:
|
|||
mgr = HonchoSessionManager(
|
||||
honcho=MagicMock(),
|
||||
config=self._pinned(),
|
||||
runtime_user_peer_name="86701400",
|
||||
runtime_user_peer_name="7654321",
|
||||
)
|
||||
_patch_manager_for_resolution_test(mgr)
|
||||
first = mgr.get_or_create("telegram:86701400")
|
||||
first = mgr.get_or_create("telegram:7654321")
|
||||
assert first.user_peer_id == "Igor"
|
||||
|
||||
mgr._config = self._unpinned()
|
||||
second = mgr.get_or_create("telegram:86701400")
|
||||
second = mgr.get_or_create("telegram:7654321")
|
||||
assert second.user_peer_id == "Igor", (
|
||||
"The per-key session cache is keyed by session-key, not by "
|
||||
"resolved peer. In-process flips don't invalidate it — the "
|
||||
|
|
@ -764,7 +764,7 @@ class TestPinTransition:
|
|||
cfg_path.write_text(json.dumps({
|
||||
"apiKey": "k",
|
||||
"peerName": "Igor",
|
||||
"userPeerAliases": {"86701400": "Igor"},
|
||||
"userPeerAliases": {"7654321": "Igor"},
|
||||
}))
|
||||
sig_with_aliases = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
|
||||
|
||||
|
|
@ -839,18 +839,18 @@ class TestProfilePeerUniqueness:
|
|||
mgr_a = HonchoSessionManager(
|
||||
honcho=MagicMock(),
|
||||
config=self._pinned_to("alice"),
|
||||
runtime_user_peer_name="86701400",
|
||||
runtime_user_peer_name="7654321",
|
||||
)
|
||||
_patch_manager_for_resolution_test(mgr_a)
|
||||
sess_a = mgr_a.get_or_create("telegram:86701400")
|
||||
sess_a = mgr_a.get_or_create("telegram:7654321")
|
||||
|
||||
mgr_b = HonchoSessionManager(
|
||||
honcho=MagicMock(),
|
||||
config=self._pinned_to("bob"),
|
||||
runtime_user_peer_name="86701400",
|
||||
runtime_user_peer_name="7654321",
|
||||
)
|
||||
_patch_manager_for_resolution_test(mgr_b)
|
||||
sess_b = mgr_b.get_or_create("telegram:86701400")
|
||||
sess_b = mgr_b.get_or_create("telegram:7654321")
|
||||
|
||||
assert sess_a.user_peer_id == "alice"
|
||||
assert sess_b.user_peer_id == "bob"
|
||||
|
|
|
|||
|
|
@ -129,6 +129,9 @@ When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and
|
|||
| `messageMaxChars` | `25000` | Max chars per message sent via `add_messages()`. Chunked if exceeded |
|
||||
| `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input to `peer.chat()` |
|
||||
| `sessionStrategy` | `'per-directory'` | `per-directory`, `per-repo`, `per-session`, or `global` |
|
||||
| `pinUserPeer` | `false` | Gateway only. When `true`, every platform user collapses to `peerName` |
|
||||
| `userPeerAliases` | `{}` | Gateway only. Map of runtime IDs to peers (`{"7654321": "alice"}`). Many-to-one |
|
||||
| `runtimePeerPrefix` | `""` | Gateway only. Namespaces unknown runtime IDs (`telegram_7654321`) when no alias matches |
|
||||
|
||||
**Session strategy** controls how Honcho sessions map to your work:
|
||||
- `per-session` — each `hermes` run gets a fresh session. Clean starts, memory via tools. Recommended for new users.
|
||||
|
|
@ -154,6 +157,30 @@ When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and
|
|||
|
||||
In `tools` mode, the model is fully in control — it calls `honcho_reasoning` when it wants, at whatever `reasoning_level` it picks. Cadence and budget settings only apply to modes with auto-injection (`hybrid` and `context`).
|
||||
|
||||
## Gateway Identity Mapping
|
||||
|
||||
These settings only matter when you run the [Hermes gateway](../../developer-guide/gateway-internals.md) — the one entrypoint where users arrive with platform-native runtime IDs (Telegram UID, Discord snowflake, Slack user). CLI, TUI, and desktop sessions have no runtime ID and always resolve to `peerName`, so off-gateway these keys do nothing.
|
||||
|
||||
The setup wizard detects whether a gateway platform is connected and skips this step entirely if not. When it runs, it asks one question — *who talks to this gateway?* — and derives the keys:
|
||||
|
||||
| Answer | Result |
|
||||
|--------|--------|
|
||||
| **just me** | `pinUserPeer: true` — every non-agent gateway user collapses to your peer. Pin overrides all aliases, so pick this only when no user-side identity needs its own peer. If separate agents reach the gateway and each needs a distinct peer, do **not** pin — leave `pinUserPeer: false` and map them via `userPeerAliases` (the `[e]` editor) instead |
|
||||
| **me + other people** (pooled) | `pinUserPeer: false` + `userPeerAliases` mapping your runtime IDs to `peerName` — you stay on your shared history, others get their own peers |
|
||||
| **only other people** | `pinUserPeer: false`, optional `runtimePeerPrefix` — each user gets their own peer |
|
||||
|
||||
Pick `[e]` at the prompt to set the three keys directly instead.
|
||||
|
||||
The resolver tries the keys top-down, first match wins: `pinUserPeer` → `userPeerAliases[id]` → `runtimePeerPrefix + id` → raw runtime ID → `peerName` → session-key fallback.
|
||||
|
||||
:::warning Un-pinning orphans pooled memory
|
||||
Flipping `pinUserPeer` from `true` to `false` does not migrate data — memory accumulated under `peerName` stays there, and platform users resolve to fresh, empty peers. To keep your own continuity, choose the **pooled** path so your runtime IDs alias back to `peerName`. The wizard offers this steer automatically when it detects the transition.
|
||||
:::
|
||||
|
||||
:::note Deprecated key
|
||||
`pinPeerName` is a legacy alias for `pinUserPeer` — still read for back-compat (`pinUserPeer` wins where both are set), never written. Re-running setup migrates it onto the canonical key.
|
||||
:::
|
||||
|
||||
## Observation (Directional vs. Unified)
|
||||
|
||||
Honcho models a conversation as peers exchanging messages. Each peer has two observation toggles that map 1:1 to Honcho's `SessionPeerConfig`:
|
||||
|
|
|
|||
|
|
@ -95,6 +95,9 @@ The legacy `hermes honcho setup` command still works (it now redirects to `herme
|
|||
| `messageMaxChars` | `25000` | Max chars per message (chunked if exceeded) |
|
||||
| `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input to `peer.chat()` |
|
||||
| `sessionStrategy` | `'per-directory'` | `per-directory`, `per-repo`, `per-session`, `global` |
|
||||
| `pinUserPeer` | `false` | Gateway only. When `true`, every non-agent gateway user collapses to `peerName`; the pin overrides all aliases |
|
||||
| `userPeerAliases` | `{}` | Gateway only. Maps runtime IDs to peers (`{"7654321": "alice"}`). Many-to-one |
|
||||
| `runtimePeerPrefix` | `""` | Gateway only. Namespaces unknown runtime IDs (`telegram_7654321`) when no alias matches |
|
||||
|
||||
</details>
|
||||
|
||||
|
|
@ -199,6 +202,18 @@ Server-side toggles set via the [Honcho dashboard](https://app.honcho.dev) win o
|
|||
|
||||
See the [Honcho page](./honcho.md#observation-directional-vs-unified) for the full observation reference.
|
||||
|
||||
### Gateway identity mapping
|
||||
|
||||
The peer model above covers CLI, TUI, and desktop sessions, where every conversation resolves to `peerName`. The [gateway](../../developer-guide/gateway-internals.md) adds a second axis: users arrive with platform-native runtime IDs (Telegram UID, Discord snowflake, Slack user), and three keys decide which peer each ID resolves to.
|
||||
|
||||
| Key | Effect |
|
||||
|-----|--------|
|
||||
| `pinUserPeer: true` | Every non-agent gateway user collapses to `peerName`. The pin is checked first, so it overrides all aliases — pick it only when no user-side identity needs its own peer |
|
||||
| `userPeerAliases` | Maps specific runtime IDs to peers (`{"7654321": "alice"}`). The home for routing distinct identities — including agents that each carry their own peer |
|
||||
| `runtimePeerPrefix` | Namespaces any unmapped runtime ID (`telegram_7654321`) so platforms with same-shaped IDs don't collide |
|
||||
|
||||
Off-gateway these keys do nothing. `hermes memory setup` only prompts for them when it detects a connected gateway platform. See the [Honcho page](./honcho.md#gateway-identity-mapping) for the resolver ladder and the setup flow.
|
||||
|
||||
<details>
|
||||
<summary>Full honcho.json example (multi-profile)</summary>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue