File size: 33,965 Bytes
5655a42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
"""Honcho client construction and ``HonchoClientConfig`` resolution.

Config file resolution: $HERMES_HOME/honcho.json -> ~/.honcho/config.json -> env vars
(HONCHO_API_KEY, HONCHO_ENVIRONMENT). Within a file, host-block fields win over
flat/global fields, which win over defaults.
"""

from __future__ import annotations

import atexit
import contextlib
import hashlib
import ipaddress
import json
import logging
import os
import threading as _threading
import time
import weakref
# --- per-identity client cache ------------------------------------------- One slot per client identity,
# replacing the single process-wide slot that pinned the first profile's workspace and bearer for every
# later profile in multi-profile processes (#69123 multiplexed gateway, #74065 dashboard). The legacy names
# above are retained only for reset bookkeeping.
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable
from urllib.parse import urlparse

from agent.memory_provider import spawn_context_thread as _spawn_context_thread
from agent.secret_scope import get_secret
from hermes_cli.profiles import _get_default_hermes_home
from hermes_constants import get_hermes_home

from plugins.memory.honcho.client_cache import (
    _DEFAULT_HTTP_TIMEOUT, _client_cache_key, _client_slots, _client_slots_lock,
    _honcho_json_timeout_memo, _refresh_oauth, _slot_for,
)

if TYPE_CHECKING:
    from honcho import Honcho

logger = logging.getLogger(__name__)

HOST = "hermes"


def _sanitize_url(url: str | None) -> str | None:
    """``url``, or None (with a warning) if it carries non-printable ASCII: a stray terminal
    escape in a pasted URL otherwise makes the SDK raise at client construction."""
    if url is None or all(0x20 <= ord(c) < 0x7F for c in url):
        return url
    logger.warning("Honcho base_url contains non-printable characters and will be ignored: %r", url)
    return None


def profile_host_key(profile: str | None) -> str:
    """Return the safe Honcho host key for a Hermes profile."""
    if not profile or profile in {"default", "custom"}:
        return HOST
    sanitized = "".join(c if c.isalnum() or c in "_-" else "_" for c in profile).strip("_")
    return f"{HOST}_{sanitized or 'profile'}"


def _host_block(raw: dict, host: str) -> dict:
    """Return host config, accepting legacy dot-form profile host keys."""
    hosts = raw.get("hosts") or {}
    block = hosts.get(host, {})
    if block or not host.startswith(f"{HOST}_"):
        return block
    return hosts.get(f"{HOST}.{host[len(HOST) + 1:]}", {})


def resolve_active_host() -> str:
    """Honcho host key: HERMES_HONCHO_HOST (profile-scoped .env), else the active profile. The config's
    ``defaultHost`` is honored only for the default profile so named profiles stay isolated — which is
    also why the override is read through the secret scope: from raw environ it would fold every
    multiplexed profile onto the default profile's host block and peer."""
    explicit = (get_secret("HERMES_HONCHO_HOST", "") or "").strip()
    if explicit:
        return explicit
    try:
        from hermes_cli.profiles import get_active_profile_name
        profile_host = profile_host_key(get_active_profile_name())
    except Exception:
        profile_host = HOST
    if profile_host == HOST:
        try:
            default_host = str(_read_config(resolve_config_path()).get("defaultHost", "")).strip()
        except Exception:
            default_host = ""
        if default_host:
            return default_host
    return profile_host


def _read_config(path: Path) -> dict:
    """Parse a honcho.json; {} when absent (parse/OS errors propagate)."""
    return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}


def resolve_global_config_path() -> Path:
    """Return the shared Honcho config path for the current HOME."""
    return Path.home() / ".honcho" / "config.json"


def resolve_config_path() -> Path:
    """Active Honcho config path: $HERMES_HOME/honcho.json -> default profile's honcho.json
    (host blocks accumulate there via setup/clone) -> ~/.honcho/config.json (also the
    first-time-setup write target when nothing exists)."""
    local_path = get_hermes_home() / "honcho.json"
    if local_path.exists():
        return local_path
    default_path = _get_default_hermes_home() / "honcho.json"
    if default_path != local_path and default_path.exists():
        return default_path
    return resolve_global_config_path()


# --- config coercion -------------------------------------------------------

_RECALL_MODES = ({"auto": "hybrid"}, {"hybrid", "context", "tools"}, "hybrid")
_OBSERVATION_MODES = (
    {"shared": "unified", "separate": "directional", "cross": "directional"},
    {"unified", "directional"},
    "directional",
)
_VALID_REASONING_LEVELS = ("minimal", "low", "medium", "high", "max")

# Granular observation booleans derived from the legacy string mode; explicit
# per-peer config always wins over these presets.
_OBSERVATION_PRESETS = {
    "directional": {"user_observe_me": True, "user_observe_others": True, "ai_observe_me": True, "ai_observe_others": True},
    "unified": {"user_observe_me": True, "user_observe_others": False, "ai_observe_me": False, "ai_observe_others": True},
}


def _normalize_choice(val: str, spec: tuple[dict, set, str]) -> str:
    """Map aliases, then fall back to the default for unknown values."""
    aliases, valid, default = spec
    val = aliases.get(val, val)
    return val if val in valid else default


def _first_set(*vals, default):
    """First non-None value, else default."""
    return next((val for val in vals if val is not None), default)


def _first_parsed(vals, caster: Callable[[Any], Any], default):
    """First non-None value that ``caster`` accepts, else default."""
    for val in (v for v in vals if v is not None):
        try:
            return caster(val)
        except (ValueError, TypeError):
            pass
    return default


def _positive_float(value: Any) -> float:
    parsed = float(value)
    if parsed <= 0:
        raise ValueError(value)
    return parsed


def _resolve_optional_float(*values: Any) -> float | None:
    """First value (blank strings included) that parses as a positive float, else None."""
    return _first_parsed(values, _positive_float, None)


def _parse_dialectic_depth_levels(vals, depth: int) -> list[str] | None:
    """First list value, validated and padded/truncated to ``depth``; None if unset."""
    for val in vals:
        if isinstance(val, list):
            levels = [lvl if lvl in _VALID_REASONING_LEVELS else "low" for lvl in val[:depth]]
            return levels + ["low"] * (depth - len(levels))
    return None


def _resolve_observation(mode: str, observation_obj: dict | None) -> dict:
    """Per-peer observation booleans: ``observation`` object fields override the mode preset."""
    preset = _OBSERVATION_PRESETS.get(mode, _OBSERVATION_PRESETS["directional"])
    if not isinstance(observation_obj, dict) or not observation_obj:
        return dict(preset)
    return {
        f"{kind}_observe_{who}": (observation_obj.get(kind) or {}).get(json_key, preset[f"{kind}_observe_{who}"])
        for kind in ("user", "ai")
        for who, json_key in (("me", "observeMe"), ("others", "observeOthers"))
    }


class _HostLookup:
    """Host-block-over-root field lookups for one honcho.json."""

    def __init__(self, host_block: dict, raw: dict):
        self.host, self.raw = host_block, raw

    def pick(self, key: str, default=None):
        """Truthy host value, else ``raw.get(key, default)``."""
        return self.host.get(key) or self.raw.get(key, default)

    def pick_set(self, key: str, default=None):
        """Non-None host value, else ``raw.get(key, default)``."""
        return _first_set(self.host.get(key), default=self.raw.get(key, default))

    def vals(self, *keys: str) -> list:
        """Host values then root values for the aliased ``keys`` (lookup order)."""
        return [self.host.get(k) for k in keys] + [self.raw.get(k) for k in keys]

    def flag(self, *keys: str, default: bool) -> bool:
        """First non-None value across host-then-root for the aliased ``keys``, as bool."""
        return bool(_first_set(*self.vals(*keys), default=default))

    def parsed(self, key: str, caster: Callable[[Any], Any], default):
        """First host-then-root value ``caster`` accepts, else default."""
        return _first_parsed(self.vals(key), caster, default)

    def present(self, key: str, default=None):
        """Host value if the key is PRESENT there (even empty/None), else root."""
        return self.host[key] if key in self.host else self.raw.get(key, default)

    def string(self, key: str, default: str = "") -> str:
        """String field where a host-level empty string can override root."""
        value = self.present(key, default)
        return default if value is None else str(value).strip()

    def string_map(self, key: str) -> dict[str, str]:
        """String-to-string map; a host-level map replaces the root map wholesale."""
        source = self.present(key)
        if not isinstance(source, dict):
            return {}
        pairs = ((str(k).strip(), str(v).strip() if v is not None else "") for k, v in source.items())
        return {k: v for k, v in pairs if k and v}


def _is_local_base_url(base_url: str | None) -> bool:
    """True for loopback/RFC1918/link-local/ULA/CGNAT self-hosted Honcho URLs. Local
    deployments can run without auth but the SDK needs a non-empty api_key, so LAN/VPN
    URLs get the same placeholder-key treatment as localhost."""
    if not base_url:
        return False
    try:
        host = (urlparse(base_url).hostname or "").strip().lower()
    except Exception:
        host = ""
    if host in {"localhost", "127.0.0.1", "::1"}:
        return True
    try:
        ip = ipaddress.ip_address(host)
    except ValueError:
        return False
    # Tailscale/other VPN setups often sit in carrier-grade NAT space (100.64.0.0/10).
    cgnat = ip.version == 4 and ipaddress.ip_address("100.64.0.0") <= ip <= ipaddress.ip_address("100.127.255.255")
    return ip.is_loopback or ip.is_private or ip.is_link_local or cgnat


def _env_base_url() -> str | None:
    """HONCHO_BASE_URL / HONCHO_URL (the SDK's own var). A self-hosted URL varies per profile, so it is
    read through the secret scope: the scoped HONCHO_API_KEY beside it must not be sent to the default
    profile's server."""
    return (get_secret("HONCHO_BASE_URL", "") or "").strip() or (get_secret("HONCHO_URL", "") or "").strip() or None


def _connection_fields(look: _HostLookup, host: str, path: Path) -> dict[str, Any]:
    """Resolve identity/credential/transport fields (host block -> root -> env)."""
    raw, host_block = look.raw, look.host
    api_key = look.pick("apiKey") or get_secret("HONCHO_API_KEY")
    # Named-profile host blocks do NOT inherit the default host's apiKey (profiles
    # are credential-isolated); the failure is silent 401s, so warn loudly.
    if not api_key and host_block and host != HOST and _host_block(raw, HOST).get("apiKey"):
        logger.warning("Honcho host block '%s' has no apiKey; the default '%s' host's key "
                       "is NOT inherited (profiles are credential-isolated). Set apiKey on "
                       "hosts.%s in %s or this profile runs unauthenticated.", host, HOST, host, path)
    # The SDK's native format (and Claude Desktop) nests the URL at endpoint.baseUrl;
    # read it before the flat Hermes spellings.
    endpoint_block = raw.get("endpoint")
    native_base_url = endpoint_block.get("baseUrl") if isinstance(endpoint_block, dict) else None
    base_url = _sanitize_url(host_block.get("baseUrl") or host_block.get("base_url") or native_base_url
                             or raw.get("baseUrl") or raw.get("base_url") or _env_base_url())
    return {
        "workspace_id": look.pick("workspace") or host,
        "ai_peer": look.pick("aiPeer") or host,
        "api_key": api_key,
        "environment": look.pick("environment", "production"),
        "base_url": base_url,
        "timeout": _resolve_optional_float(*look.vals("timeout", "requestTimeout"), os.environ.get("HONCHO_TIMEOUT")),
        # Explicit enabled (host, then root) wins; else auto-enable on key/url.
        "enabled": _first_set(*look.vals("enabled"), default=bool(api_key or base_url)),
    }


def _behavior_fields(look: _HostLookup, explicitly_configured: bool) -> dict[str, Any]:
    """Resolve memory-behavior tuning fields (host block -> root -> defaults)."""
    raw_wf = look.pick("writeFrequency") or "async"
    write_frequency: str | int = _first_parsed([raw_wf], int, str(raw_wf))
    depth = look.parsed("dialecticDepth", lambda v: max(1, min(int(v), 3)), 1)
    # Migration guard: configs that predate observationMode keep the old
    # "unified" default; fresh installs get "directional" (all observations on).
    observation_mode = _normalize_choice(
        look.pick("observationMode") or ("unified" if explicitly_configured else "directional"), _OBSERVATION_MODES)
    return {
        "peer_name": look.pick("peerName"),
        # pinUserPeer is the clearer name; the original pinPeerName stays accepted.
        "pin_peer_name": look.flag("pinUserPeer", "pinPeerName", default=False),
        "user_peer_aliases": look.string_map("userPeerAliases"),
        "runtime_peer_prefix": look.string("runtimePeerPrefix"),
        "save_messages": look.pick_set("saveMessages", True),
        "write_frequency": write_frequency,
        "context_tokens": look.parsed("contextTokens", int, None),
        "dialectic_reasoning_level": look.pick("dialecticReasoningLevel") or "low",
        "dialectic_dynamic": look.flag("dialecticDynamic", default=True),
        "dialectic_max_chars": look.parsed("dialecticMaxChars", int, 600),
        "dialectic_depth": depth,
        "dialectic_depth_levels": _parse_dialectic_depth_levels(look.vals("dialecticDepthLevels"), depth),
        "reasoning_heuristic": look.flag("reasoningHeuristic", default=True),
        "reasoning_level_cap": look.pick("reasoningLevelCap") or "high",
        "message_max_chars": look.parsed("messageMaxChars", int, 25000),
        "dialectic_max_input_chars": look.parsed("dialecticMaxInputChars", int, 10000),
        "recall_mode": _normalize_choice(look.pick("recallMode") or "hybrid", _RECALL_MODES),
        "recall_sync": look.flag("recallSync", default=False),
        "init_on_session_start": look.flag("initOnSessionStart", default=False),
        "injection_frequency": look.pick("injectionFrequency", "every-turn"),
        "context_cadence": look.parsed("contextCadence", int, 1),
        "dialectic_cadence": look.parsed("dialecticCadence", int, 1),
        "query_rewrite": look.flag("queryRewrite", default=False),
        "first_turn_base_wait": look.parsed("firstTurnBaseWait", lambda v: max(0.0, float(v)), 3.0),
        "first_turn_dialectic_wait": look.parsed("firstTurnDialecticWait", lambda v: max(0.0, float(v)), 2.0),
        "observation_mode": observation_mode,
        **_resolve_observation(observation_mode, look.pick("observation")),
        "session_strategy": look.pick("sessionStrategy", "per-directory"),
        "session_peer_prefix": look.pick_set("sessionPeerPrefix", False),
        "a2a_sessions": look.flag("a2aSessions", default=True),
        "session_ai_peer_prefix": look.pick_set("sessionAiPeerPrefix", False),
    }


@dataclass
class HonchoClientConfig:
    """Configuration for Honcho client, resolved for a specific host."""

    host: str = HOST
    workspace_id: str = "hermes"
    api_key: str | None = None
    environment: str = "production"
    base_url: str | None = None  # self-hosted override of the environment mapping
    timeout: float | None = None  # SDK HTTP timeout, seconds
    # Identity
    peer_name: str | None = None
    ai_peer: str = "hermes"
    # True: peer_name wins over gateway runtime identity (Telegram UID, ...), so a
    # single-user deployment keeps one memory across platforms.
    # This keeps memory unified across platforms for single-user deployments where Honcho's one peer-name is
    # an unambiguous identity — otherwise each platform would fork memory into its own peer (#14984).
    # Default ``False`` preserves existing multi-user behaviour.
    pin_peer_name: bool = False
    # Gateway runtime user id -> stable Honcho peer; host map replaces root map.
    user_peer_aliases: dict[str, str] = field(default_factory=dict)
    runtime_peer_prefix: str = ""  # prefix for unknown runtime user ids, e.g. "telegram_"
    # Toggles
    enabled: bool = False
    save_messages: bool = True
    write_frequency: str | int = "async"  # "async" | "turn" | "session" | every-N-turns int
    context_tokens: int | None = None  # prefetch budget; None = uncapped
    # Dialectic (peer.chat) settings
    dialectic_reasoning_level: str = "low"  # minimal | low | medium | high | max
    dialectic_dynamic: bool = True  # model may override the level via honcho_reasoning
    dialectic_max_chars: int = 600  # auto-injection cap; explicit calls bypass it
    dialectic_depth: int = 1  # .chat() passes per cycle (1-3): audit / synthesis / reconcile
    dialectic_depth_levels: list[str] | None = None  # per-pass levels; None = proportional defaults
    reasoning_heuristic: bool = True  # scale auto-injected level up on longer queries
    reasoning_level_cap: str = "high"
    # Honcho API limits (Honcho cloud: 25000 / 10000) — configurable for self-hosts
    message_max_chars: int = 25000
    dialectic_max_input_chars: int = 10000
    # "hybrid" (context + tools) | "context" (no tools) | "tools" (no auto context)
    recall_mode: str = "hybrid"
    recall_sync: bool = False  # bounded current-query automatic recall
    init_on_session_start: bool = False  # tools mode: init eagerly instead of on first tool call
    injection_frequency: str = "every-turn"  # or "first-turn"
    context_cadence: int = 1  # min turns between peer.context() calls
    dialectic_cadence: int = 1  # min turns between dialectic prefetches
    query_rewrite: bool = False  # rewrite the user message into a retrieval query (one aux LLM call)
    # Bounded synchronous waits on turn 1, seconds; 0 = fully async first turn.
    first_turn_base_wait: float = 3.0
    first_turn_dialectic_wait: float = 2.0
    # Legacy string shorthand; the granular per-peer booleans below are preferred
    # and map 1:1 to Honcho's SessionPeerConfig.
    observation_mode: str = "directional"
    user_observe_me: bool = True
    user_observe_others: bool = True
    ai_observe_me: bool = True
    ai_observe_others: bool = True
    # Session resolution
    session_strategy: str = "per-directory"
    session_peer_prefix: bool = False
    # Bot-authored DMs write into their own session per sender bot.
    a2a_sessions: bool = True
    # Prefixes every resolved session name with ``{ai_peer}-``. The gateway_session_key branch is
    # AI-peer-agnostic, so several AI peers sharing one workspace + peerName + chat key would collide.
    session_ai_peer_prefix: bool = False
    sessions: dict[str, str] = field(default_factory=dict)
    raw: dict[str, Any] = field(default_factory=dict)
    # A hosts.<host> block or explicit enabled flag, vs auto-enabled from a stray env key.
    explicitly_configured: bool = False
    # Provenance captured at resolution time; bound consumers use these instead of
    # re-resolving (the resolvers read a ContextVar background threads can't see).
    # Provenance: WHERE this config was resolved from, captured at resolution time (inside the caller's
    # profile scope). Bound consumers (session manager, OAuth refresh paths) use these instead of
    # re-resolving resolve_config_path()/get_hermes_home() later — those resolvers read a ContextVar that
    # background threads cannot see, so re-resolution from a daemon thread silently lands on the DEFAULT
    # profile (#69123, #74065).
    config_path: Path | None = None
    hermes_home: Path | None = None

    def bound_config_path(self) -> Path:
        """Config path this was resolved from; ambient fallback for hand-built configs."""
        return self.config_path if self.config_path is not None else resolve_config_path()

    @classmethod
    def from_env(cls, workspace_id: str = "hermes", host: str | None = None) -> HonchoClientConfig:
        """Create config from environment variables (fallback)."""
        resolved_host = host or resolve_active_host()
        api_key = get_secret("HONCHO_API_KEY")
        base_url = _sanitize_url(_env_base_url())
        return cls(
            host=resolved_host, workspace_id=workspace_id, api_key=api_key, base_url=base_url,
            environment=get_secret("HONCHO_ENVIRONMENT", "") or "production",
            timeout=_resolve_optional_float(os.environ.get("HONCHO_TIMEOUT")),
            ai_peer=resolved_host, enabled=bool(api_key or base_url),
            config_path=resolve_config_path(), hermes_home=get_hermes_home(),
        )

    @classmethod
    def from_global_config(cls, host: str | None = None, config_path: Path | None = None) -> HonchoClientConfig:
        """Config from the resolved Honcho config path, falling back to env. ``host=None``
        derives it from the active Hermes profile."""
        resolved_host = host or resolve_active_host()
        path = config_path or resolve_config_path()
        if not path.exists():
            logger.debug("No global Honcho config at %s, falling back to env", path)
            return cls.from_env(host=resolved_host)
        try:
            raw = _read_config(path)
        except (json.JSONDecodeError, OSError) as e:
            logger.warning("Failed to read %s: %s, falling back to env", path, e)
            return cls.from_env(host=resolved_host)

        host_block = _host_block(raw, resolved_host)
        explicitly_configured = bool(host_block) or raw.get("enabled") is True
        look = _HostLookup(host_block, raw)
        return cls(
            host=resolved_host, **_connection_fields(look, resolved_host, path), **_behavior_fields(look, explicitly_configured),
            sessions=raw.get("sessions", {}), raw=raw, explicitly_configured=explicitly_configured,
            config_path=path, hermes_home=get_hermes_home(),
        )

    @staticmethod
    def _git_repo_name(cwd: str) -> str | None:
        """Return the git repo root directory name, or None if not in a repo."""
        import subprocess

        try:
            root = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, encoding='utf-8',
                                  errors='replace', cwd=cwd, timeout=5, stdin=subprocess.DEVNULL)
        except (OSError, subprocess.TimeoutExpired):
            return None
        return Path(root.stdout.strip()).name if root.returncode == 0 else None

    # Honcho rejects session IDs over 100 chars; long gateway keys (Matrix
    # rooms + thread ids, Slack threads) overflow after sanitization.
    _HONCHO_SESSION_ID_MAX_LEN, _HONCHO_SESSION_ID_HASH_LEN = 100, 8

    @classmethod
    def _enforce_session_id_limit(cls, sanitized: str, original: str) -> str:
        """Truncate to the limit with a ``-<sha256 prefix>`` suffix hashed over the ORIGINAL key:
        two long keys sharing a prefix stay distinct; keys that sanitize identically still
        collide intentionally (same logical session)."""
        max_len, hash_len = cls._HONCHO_SESSION_ID_MAX_LEN, cls._HONCHO_SESSION_ID_HASH_LEN
        if len(sanitized) <= max_len:
            return sanitized
        digest = hashlib.sha256(original.encode("utf-8")).hexdigest()[:hash_len]
        prefix = sanitized[: max_len - hash_len - 1].rstrip("-")
        return f"{prefix}-{digest}"

    def _with_peer_prefix(self, name: str) -> str:
        return f"{self.peer_name}-{name}" if self.session_peer_prefix and self.peer_name else name

    def resolve_session_name(
        self, cwd: str | None = None, session_title: str | None = None,
        session_id: str | None = None, gateway_session_key: str | None = None,
    ) -> str | None:
        """Resolve the Honcho session name; with ``session_ai_peer_prefix`` the result is prefixed
        ``{ai_peer}-`` on every path, including the AI-peer-agnostic gateway session key."""
        import re

        result = self._resolve_session_name_base(cwd=cwd, session_title=session_title,
                                                 session_id=session_id, gateway_session_key=gateway_session_key)
        if result and self.session_ai_peer_prefix and self.ai_peer:
            ai = re.sub(r'[^a-zA-Z0-9_-]+', '-', self.ai_peer).strip('-')
            if ai:
                prefixed = f"{ai}-{result}"
                return self._enforce_session_id_limit(prefixed, prefixed)
        return result

    def _resolve_session_name_base(
        self, cwd: str | None = None, session_title: str | None = None,
        session_id: str | None = None, gateway_session_key: str | None = None,
    ) -> str | None:
        """Order: gateway session key (per-chat isolation no cwd/strategy gives) -> per-session
        strategy's session_id (authoritative, so a generated title never remaps a live conversation)
        -> sessions map override -> /title -> per-repo (git root name) -> per-directory (basename)
        -> global (workspace)."""
        import re

        def _slug(text: str) -> str:
            return re.sub(r'[^a-zA-Z0-9_-]+', '-', text).strip('-')

        cwd = cwd or os.getcwd()
        if gateway_session_key and _slug(gateway_session_key):
            return self._enforce_session_id_limit(_slug(gateway_session_key), gateway_session_key)
        if self.session_strategy == "per-session" and session_id:
            return self._with_peer_prefix(session_id)
        manual = self.sessions.get(cwd)
        if manual:
            return manual
        if session_title and _slug(session_title):
            return self._with_peer_prefix(_slug(session_title))
        if self.session_strategy == "per-repo":
            return self._with_peer_prefix(self._git_repo_name(cwd) or Path(cwd).name)
        if self.session_strategy in {"per-directory", "per-session"}:
            return self._with_peer_prefix(Path(cwd).name)
        return self.workspace_id


# Threads keyed by the provider or manager that owns them, so shutdown never waits on another agent's work.
_plugin_threads: "weakref.WeakKeyDictionary[Any, weakref.WeakSet]" = weakref.WeakKeyDictionary()
_plugin_threads_lock = _threading.Lock()


def spawn_context_thread(
    target, *, name: str, daemon: bool = True, args: tuple = (), owner: Any = None,
) -> "_threading.Thread":
    """agent.memory_provider.spawn_context_thread plus an ``owner``: the thread is registered so
    join_plugin_threads can wait on exactly the threads this provider or manager spawned."""
    thread = _spawn_context_thread(target, name=name, daemon=daemon, args=args)
    if owner is not None:
        with _plugin_threads_lock:
            _plugin_threads.setdefault(owner, weakref.WeakSet()).add(thread)
    return thread


def join_plugin_threads(owners, timeout: float) -> list[str]:
    """Join every live thread spawned for ``owners`` within one shared ``timeout``. Returns the names
    of the threads still running when the budget ran out."""
    deadline = time.monotonic() + max(0.0, timeout)
    me = _threading.current_thread()
    with _plugin_threads_lock:
        threads = [t for owner in owners if owner is not None
                   for t in list(_plugin_threads.get(owner, ())) if t.is_alive() and t is not me]
    for thread in threads:
        thread.join(timeout=max(0.0, deadline - time.monotonic()))
    return [t.name for t in threads if t.is_alive()]


def close_honcho_clients() -> None:
    """Close the HTTP pool of every cached client and drop the slots. Process exit only: one client
    serves every manager with the same identity, so a per-agent shutdown must never call this."""
    with _client_slots_lock:
        slots = list(_client_slots.values())
        _client_slots.clear()
    for slot in slots:
        close = getattr(getattr(slot.peek(), "_http", None), "close", None)
        if callable(close):
            with contextlib.suppress(Exception):
                close()


_exit_close_registered = False


def _register_exit_close() -> None:
    global _exit_close_registered
    with _client_slots_lock:
        if _exit_close_registered:
            return
        _exit_close_registered = True
    atexit.register(close_honcho_clients)


def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
    """Get or create the Honcho client for this config's identity. Clients are cached PER
    IDENTITY (host, workspace, provenance paths, credential fingerprint, timeout) so
    multi-profile processes don't share a first-config-wins client. With no config the active
    honcho.json is resolved — correct only on threads that see the profile ContextVar; pass a
    bound config elsewhere. Each identity's client is built once under concurrent first calls.

    See #69123, #74065.
    """
    key = _client_cache_key(config)
    slot = _slot_for(key)
    cached = slot.peek()
    if cached is not None:
        _refresh_oauth(config, cached, slot)
        refreshed = slot.peek()
        if refreshed is not None:
            return refreshed
        # Slot was reset by a failed in-place rotation — rebuild below.

    if config is None:
        config = HonchoClientConfig.from_global_config()

    # Start with a live access token rather than 401ing an hour in.
    _refresh_oauth(config)

    if not config.api_key and not config.base_url:
        raise ValueError("Honcho API key not found. Get your API key at https://app.honcho.dev, "
                         "then run 'hermes honcho setup' or set HONCHO_API_KEY. "
                         "For local instances, set HONCHO_BASE_URL instead.")

    return slot.get(lambda: _build_client(config))


def _build_client(config: HonchoClientConfig) -> "Honcho":
    """Construct the SDK client (runs inside the slot factory so racing callers share one)."""
    with contextlib.suppress(Exception):  # lazy-dep failures fall through to the canonical import error below
        from tools.lazy_deps import ensure as _lazy_ensure
        _lazy_ensure("memory.honcho", prompt=False)
    try:
        from honcho import Honcho
    except ImportError:
        raise ImportError("honcho-ai is required for Honcho integration. Install it with: pip install honcho-ai  "
                          "(or run `hermes honcho setup` to configure).")

    # config.yaml honcho.base_url / timeout fill whatever honcho.json left unset.
    base_url, timeout = config.base_url, config.timeout
    if not base_url or timeout is None:
        with contextlib.suppress(Exception):
            from hermes_cli.config import load_config
            honcho_cfg = load_config().get("honcho", {})
            if isinstance(honcho_cfg, dict):
                base_url = base_url or _sanitize_url(honcho_cfg.get("base_url", "").strip() or None)
                if timeout is None:
                    timeout = _resolve_optional_float(honcho_cfg.get("timeout"), honcho_cfg.get("request_timeout"))
    if timeout is None:
        timeout = _DEFAULT_HTTP_TIMEOUT  # an unconfigured install must not hang on a stalled request

    if base_url:
        logger.info("Initializing Honcho client (base_url: %s, workspace: %s)", base_url, config.workspace_id)
    else:
        # Name the SDK's environment fallback at INFO so a self-hosted user whose
        # config wasn't picked up notices they're talking to the public cloud.
        logger.info("Initializing Honcho client (host: %s, workspace: %s, base_url unset — SDK will resolve from environment=%s)",
                    config.host, config.workspace_id, config.environment)

    # Local instances need no key but the SDK wants a non-empty string: honor a
    # key set EXPLICITLY in honcho.json (host block or root) and treat an
    # env-sourced key as likely-cloud, substituting the placeholder.
    raw = config.raw or {}
    explicit_key = _host_block(raw, config.host).get("apiKey") or raw.get("apiKey")
    api_key = "local" if _is_local_base_url(base_url) and not explicit_key else config.api_key
    kwargs: dict = {"workspace_id": config.workspace_id, "api_key": api_key, "environment": config.environment, "timeout": timeout}
    if base_url:
        # The SDK's route builders already carry the version prefix ("/v3/..."), so
        # strip a trailing version segment from any base_url to avoid "/v3/v3/...".
        import re
        kwargs["base_url"] = re.sub(r"/v\d+/*$", "", base_url).rstrip("/")
    _register_exit_close()
    return Honcho(**kwargs)


def reset_honcho_client() -> None:
    """Reset all cached Honcho clients (tests, OAuth re-login)."""
    with _client_slots_lock:
        _client_slots.clear()
    _honcho_json_timeout_memo.clear()


# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.


_PLUGIN_COMPAT_LAZY = {
    'SingletonSlot': ('plugins.plugin_utils', 'SingletonSlot'),
}


def __getattr__(name):  # PEP 562 — lazy so no import cycles
    target = _PLUGIN_COMPAT_LAZY.get(name)
    if target is None:
        raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
    import importlib
    from hermes_cli.plugin_compat import warn_once
    warn_once(__name__, name, *target)
    return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----