@inline-chat/hermes-agent-adapter 0.0.22 → 1.0.0-alpha.2
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.
- package/README.md +41 -29
- package/dist/install.js +18 -3
- package/package.json +10 -4
- package/plugin/inline/adapter.py +396 -242
- package/plugin/inline/cli.py +73 -7
- package/plugin/inline/plugin.yaml +6 -10
- package/plugin/inline/profile.py +86 -0
- package/plugin/inline/sidecar/index.mjs +2464 -160
- package/plugin/inline/tools.py +261 -32
package/plugin/inline/cli.py
CHANGED
|
@@ -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.
|
|
30
|
+
_PROBE_USER_AGENT = "inline-hermes-agent-adapter/1.0.0-alpha.2"
|
|
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":
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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 =
|
|
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.
|
|
6
|
+
version: 1.0.0-alpha.2
|
|
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
|
|
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
|
|
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: "
|
|
86
|
-
prompt: "Reply-thread mode (
|
|
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()
|