@inline-chat/hermes-agent-adapter 0.0.22 → 1.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,7 +27,7 @@ _CLI_INSTALL_URL = "https://inline.chat/cli/install.sh"
27
27
  _MAX_TOKEN_BYTES = 16 * 1024
28
28
  _MAX_PROBE_RESPONSE_BYTES = 64 * 1024
29
29
  _MACHINE_SETUP_PROTOCOL_VERSION = 1
30
- _PROBE_USER_AGENT = "inline-hermes-agent-adapter/0.0.22"
30
+ _PROBE_USER_AGENT = "inline-hermes-agent-adapter/1.0.0-alpha.1"
31
31
  _ENV_REFERENCE_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
32
32
 
33
33
 
@@ -457,6 +457,10 @@ def _status(args) -> int:
457
457
  compatibility = _compatibility_status() if getattr(args, "check_compatibility", False) else None
458
458
  compatible = compatibility is None or compatibility["ok"]
459
459
  ready = compatible and runtime_usable and configured and (not probe_requested or bool(probe and probe.get("ok")))
460
+ gateway = _gateway_status()
461
+ receiving = _receiving_status(
462
+ configured=configured, runtime_usable=runtime_usable, compatible=compatible, probe=probe, gateway=gateway,
463
+ )
460
464
  result = {
461
465
  "ok": ready,
462
466
  "ready": ready,
@@ -469,7 +473,8 @@ def _status(args) -> int:
469
473
  "sidecar": sidecar,
470
474
  "node": node,
471
475
  "probeRequested": probe_requested,
472
- "gateway": _gateway_status(),
476
+ "gateway": gateway,
477
+ "receiving": receiving,
473
478
  **({"compatibility": compatibility} if compatibility is not None else {}),
474
479
  **({"probe": probe} if probe is not None else {}),
475
480
  }
@@ -482,6 +487,10 @@ def _status(args) -> int:
482
487
  print(f"Inline sidecar usable: {'yes' if sidecar['ok'] else 'no'}")
483
488
  print(f"Node available: {_node_status_text(node)}")
484
489
  print(f"Inline runtime ready: {'yes' if ready else 'no'}")
490
+ # "Ready" means installed and configured; this says whether messages arrive now.
491
+ print(f"Receiving Inline messages: {'yes' if receiving['ok'] else 'no'}")
492
+ if not receiving["ok"] and configured and receiving["next"]:
493
+ print(f"Next: {receiving['next']}")
485
494
  if not configured:
486
495
  print("Next: run `hermes inline setup` for guided bot setup.")
487
496
  elif probe_requested:
@@ -538,10 +547,19 @@ def _gateway_status() -> dict:
538
547
  from gateway.status import read_runtime_status, get_runtime_status_running_pid
539
548
  from hermes_constants import get_hermes_home
540
549
 
541
- runtime = read_runtime_status()
542
- # Also validate this helper contract for an absent runtime: older hosts
543
- # lack expected_home and must not claim support until after installation.
544
- pid = get_runtime_status_running_pid(runtime if isinstance(runtime, dict) else {}, expected_home=get_hermes_home())
550
+ home = get_hermes_home()
551
+ platform_keys = ["inline"]
552
+ served = _multiplexed_gateway_for(home)
553
+ if served is not None:
554
+ # Hermes 0.22+: one gateway serves every profile from the launch profile's
555
+ # status file, keyed <profile>:inline.
556
+ pid, runtime, profile_key = served
557
+ platform_keys = [profile_key, "inline"]
558
+ else:
559
+ runtime = read_runtime_status()
560
+ # Also validate this helper contract for an absent runtime: older hosts
561
+ # lack expected_home and must not claim support until after installation.
562
+ pid = get_runtime_status_running_pid(runtime if isinstance(runtime, dict) else {}, expected_home=home)
545
563
  if not isinstance(runtime, dict):
546
564
  return {"supported": True, "ready": False, "reason": "missing_status"}
547
565
  # Return only an opaque generation, never process IDs or raw state.
@@ -552,7 +570,10 @@ def _gateway_status() -> dict:
552
570
  generation = hashlib.sha256(f"inline-gateway:{pid}:{start_time}".encode()).hexdigest() if valid_identity else None
553
571
  state = runtime.get("gateway_state")
554
572
  platforms = runtime.get("platforms")
555
- inline = platforms.get("inline") if isinstance(platforms, dict) else None
573
+ inline = next(
574
+ (platforms[key] for key in platform_keys if isinstance(platforms, dict) and isinstance(platforms.get(key), dict)),
575
+ None,
576
+ )
556
577
  platform_state = inline.get("state") if isinstance(inline, dict) else None
557
578
  state = state if state in ("starting", "running", "draining", "stopping", "stopped", "startup_failed") else "unknown"
558
579
  platform_state = platform_state if platform_state in ("connected", "connecting", "retrying", "fatal", "disconnected") else "unknown"
@@ -576,6 +597,51 @@ def _gateway_status() -> dict:
576
597
  return unavailable
577
598
 
578
599
 
600
+ def _multiplexed_gateway_for(home) -> "tuple[int, dict, str] | None":
601
+ """The live multiplexed gateway serving this profile, as (pid, status, platform key)."""
602
+ try:
603
+ from gateway.status import multiplexer_liveness_for_profile, profile_name_for_home
604
+ except ImportError:
605
+ return None
606
+ try:
607
+ served = multiplexer_liveness_for_profile(Path(home))
608
+ name = profile_name_for_home(Path(home))
609
+ except Exception:
610
+ return None
611
+ if not served or not name:
612
+ return None
613
+ pid, runtime = served
614
+ return pid, runtime, f"{name}:inline"
615
+
616
+
617
+ def _receiving_status(*, configured: bool, runtime_usable: bool, compatible: bool, probe: "dict | None", gateway: dict) -> dict:
618
+ """Whether Inline messages can reach this agent right now, with the first thing to fix.
619
+
620
+ Separate from ``ready``, which keeps meaning "installed and configured" for setup.
621
+ """
622
+ if not compatible:
623
+ return {"ok": False, "reason": "incompatible_host", "next": "Update Hermes or the Inline adapter to a supported pair."}
624
+ if not configured:
625
+ return {"ok": False, "reason": "not_configured", "next": "Run `hermes inline setup`."}
626
+ if not runtime_usable:
627
+ return {"ok": False, "reason": "runtime_unusable", "next": "Run `inline-hermes doctor --json` for the Node or sidecar problem."}
628
+ if probe is not None and not probe.get("ok"):
629
+ return {"ok": False, "reason": "credential_rejected", "next": "Check the Inline bot token."}
630
+ if not gateway.get("supported"):
631
+ return {"ok": False, "reason": "gateway_unknown", "next": "This Hermes can't report gateway state; check `hermes gateway status`."}
632
+ if not gateway.get("ready"):
633
+ reason = str(gateway.get("reason") or "gateway_not_ready")
634
+ hint = {
635
+ "missing_status": "Start the gateway: `hermes gateway start`.",
636
+ "stale_pid": "Start the gateway: `hermes gateway start`.",
637
+ "gateway_not_running": "Start the gateway: `hermes gateway start`.",
638
+ "platform_status_stale": "Restart the gateway: `hermes gateway restart`.",
639
+ "platform_not_connected": "The gateway runs but Inline isn't connected; check `hermes gateway status` and its logs.",
640
+ }.get(reason, "Check `hermes gateway status`.")
641
+ return {"ok": False, "reason": reason, "next": hint}
642
+ return {"ok": True, "reason": "connected", "next": None}
643
+
644
+
579
645
  def _plugin_version() -> str:
580
646
  """Read the separately installed plugin version without package-manager state."""
581
647
  try:
@@ -3,7 +3,7 @@ label: Inline
3
3
  kind: platform
4
4
  provides_tools:
5
5
  - inline
6
- version: 0.0.22
6
+ version: 1.0.0-alpha.1
7
7
  requires_hermes: ">=0.21.3"
8
8
  description: >
9
9
  Inline platform adapter for Hermes Agent. The adapter runs as a native
@@ -66,11 +66,11 @@ optional_env:
66
66
  prompt: "Allowed group sender ids"
67
67
  password: false
68
68
  - name: INLINE_REQUIRE_MENTION
69
- description: "Require a mention/wake word in group chats. Replies to the bot and dialogs already marked FOLLOWING can continue without one unless strict mention is enabled (default true)."
69
+ description: "Require an @mention in group chats. Replies to the bot and dialogs already marked FOLLOWING can continue without one unless strict mention is enabled (default true)."
70
70
  prompt: "Require group mention? (true/false)"
71
71
  password: false
72
72
  - name: INLINE_STRICT_MENTION
73
- description: "Require an explicit mention/wake word on every group-chat turn, including replies to the bot and followed threads (true/false, default false)"
73
+ description: "Require an explicit @mention on every group-chat turn, including replies to the bot and followed threads (true/false, default false)"
74
74
  prompt: "Require every group turn to mention Hermes? (true/false)"
75
75
  password: false
76
76
  - name: INLINE_ALLOWED_CHATS
@@ -82,8 +82,8 @@ optional_env:
82
82
  prompt: "Free-response Inline chat ids"
83
83
  password: false
84
84
  - name: INLINE_REPLY_THREADS
85
- description: "Reply-thread mode: auto, on, or off. Auto creates child reply threads only for explicit thread requests (default auto)."
86
- prompt: "Reply-thread mode (auto/on/off)"
85
+ description: "Retired: replies stay in the chat unless someone asks for a thread. Turn reply threads on per chat with /threads on."
86
+ prompt: "Reply-thread mode (retired; leave empty)"
87
87
  password: false
88
88
  - name: INLINE_CONTEXT_BACKFILL
89
89
  description: "Automatic context mode: selective, off, or always (default selective)"
@@ -117,10 +117,6 @@ optional_env:
117
117
  description: "Show processing reactions on handled inbound messages: eyes while working, then success or failure (true/false, default false)"
118
118
  prompt: "Show processing reactions? (true/false)"
119
119
  password: false
120
- - name: INLINE_MENTION_PATTERNS
121
- description: "Mention wake-word regexes, JSON list or comma/newline-separated"
122
- prompt: "Mention patterns"
123
- password: false
124
120
  - name: INLINE_HOME_CHANNEL
125
121
  description: "Default target for cron/standalone sends, for example chat:123"
126
122
  prompt: "Home Inline target"
@@ -130,7 +126,7 @@ optional_env:
130
126
  prompt: "Home target display name"
131
127
  password: false
132
128
  - name: INLINE_SIDECAR_PORT
133
- description: "Loopback port for the Node sidecar, 1-65535 (default 8794)"
129
+ description: "Loopback port for the Node sidecar, 1-65535 (default: a free port; 8794 with autostart off)"
134
130
  prompt: "Sidecar port"
135
131
  password: false
136
132
  - name: INLINE_SIDECAR_BIND
@@ -0,0 +1,86 @@
1
+ """Per-profile settings and state for the Inline platform.
2
+
3
+ Since Hermes 0.22 one gateway process can serve every profile. Credentials and
4
+ settings then come from the routed profile's scope rather than ``os.environ``,
5
+ and anything written to disk has to be owned by one profile and one bot.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+
16
+ def inline_env(name: str, default: Optional[str] = None) -> Optional[str]:
17
+ """Read an INLINE_* setting from the active profile, falling back to the process env on older hosts."""
18
+ try:
19
+ from agent.secret_scope import get_secret
20
+ except ImportError:
21
+ return os.getenv(name, default)
22
+ try:
23
+ return get_secret(name, default)
24
+ except Exception as exc:
25
+ # A multiplexed host with no profile scope fails closed rather than
26
+ # handing out another profile's value.
27
+ if type(exc).__name__ == "UnscopedSecretError":
28
+ return default
29
+ raise
30
+
31
+
32
+ def hermes_home() -> Path:
33
+ """The Hermes home of the profile this code is running for."""
34
+ try:
35
+ from hermes_constants import get_hermes_home
36
+
37
+ return Path(get_hermes_home())
38
+ except Exception:
39
+ return Path(os.getenv("HERMES_HOME") or Path.home() / ".hermes").expanduser()
40
+
41
+
42
+ def legacy_state_dir() -> Path:
43
+ """Where releases before the profile split kept state, whatever the profile."""
44
+ return Path.home() / ".hermes" / "inline"
45
+
46
+
47
+ def state_dir() -> Path:
48
+ return hermes_home() / "inline"
49
+
50
+
51
+ def bot_state_key(base_url: str, token: str) -> str:
52
+ """A stable, non-secret name for one bot on one API.
53
+
54
+ Inline bot tokens start with the bot's user id, so the name survives token
55
+ rotation; anything else falls back to a hash of the token.
56
+ """
57
+ server = hashlib.sha256(base_url.encode("utf-8")).hexdigest()[:8]
58
+ bot_id, separator, _ = token.partition(":")
59
+ if separator and bot_id.isdigit():
60
+ return f"bot{bot_id}-{server}"
61
+ return hashlib.sha256(f"{base_url}\n{token}".encode("utf-8")).hexdigest()[:16]
62
+
63
+
64
+ def multiplex_active() -> bool:
65
+ """Whether this process serves several Hermes profiles (Hermes 0.22+)."""
66
+ try:
67
+ from agent.secret_scope import is_multiplex_active
68
+ except ImportError:
69
+ return False
70
+ try:
71
+ return bool(is_multiplex_active())
72
+ except Exception:
73
+ return False
74
+
75
+
76
+ def may_adopt_legacy_state() -> bool:
77
+ """Whether the pre-split shared state file can be trusted as this adapter's own.
78
+
79
+ The legacy file never recorded which bot wrote it. Only the classic install,
80
+ the default profile in a gateway serving just that profile, plausibly owns it.
81
+ Everyone else, including every profile of a multiplexed gateway, starts from
82
+ now: that can miss messages sent while the gateway was down, but never replays
83
+ answered ones. The old file stays in place for recovery.
84
+ """
85
+ default_root = (Path.home() / ".hermes").resolve()
86
+ return hermes_home().resolve() == default_root and not multiplex_active()