@inline-chat/hermes-agent-adapter 0.0.4 → 0.0.5-alpha.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inline-chat/hermes-agent-adapter",
3
- "version": "0.0.4",
3
+ "version": "0.0.5-alpha.0",
4
4
  "description": "Hermes Agent platform adapter for Inline, with a native Python plugin and bundled Inline realtime sidecar.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -49,11 +49,12 @@
49
49
  },
50
50
  "scripts": {
51
51
  "build": "mkdir -p plugin/inline/sidecar && bun build ./src/install.ts --outdir dist --entry-naming '[name].js' --target=node --format=esm --packages=bundle && bun build ./src/sidecar/index.ts --outdir plugin/inline/sidecar --entry-naming 'index.mjs' --target=node --format=esm --packages=bundle && tsc -p tsconfig.json --emitDeclarationOnly",
52
- "lint": "bunx oxlint --ignore-path ../.oxlintignore src/install.ts src/sidecar/contract.ts src/sidecar/index.ts tests/adapter-python.test.ts tests/install.test.ts tests/package-artifact.test.ts tests/sidecar-contract.test.ts tests/sidecar-runtime.test.ts vitest.config.ts",
53
- "pretest": "bun run build && rm -rf coverage",
52
+ "lint": "bunx oxlint --ignore-path ../.oxlintignore scripts/release-stage.mjs src/install.ts src/sidecar/contract.ts src/sidecar/index.ts tests/adapter-python.test.ts tests/install.test.ts tests/package-artifact.test.ts tests/sidecar-contract.test.ts tests/sidecar-runtime.test.ts vitest.config.ts",
53
+ "pretest": "bun run build",
54
54
  "prepack": "bun run build",
55
55
  "prepublishOnly": "bun run check",
56
- "release:preflight": "npm publish --dry-run --access public",
56
+ "release:preflight": "node ./scripts/release-stage.mjs --dry-run",
57
+ "release:stage": "node ./scripts/release-stage.mjs --prepare-only",
57
58
  "typecheck": "tsc -p tsconfig.json --noEmit",
58
59
  "test": "vitest run --coverage",
59
60
  "check": "bun run typecheck && bun run lint && bun run test"
@@ -63,10 +64,10 @@
63
64
  "yaml": "2.9.0"
64
65
  },
65
66
  "devDependencies": {
66
- "@types/node": "20.17.19",
67
- "@vitest/coverage-v8": "4.0.16",
67
+ "@types/node": "20.19.43",
68
+ "@vitest/coverage-v8": "4.1.10",
68
69
  "typescript": "5.8.3",
69
- "vitest": "4.0.16"
70
+ "vitest": "4.1.10"
70
71
  },
71
72
  "publishConfig": {
72
73
  "access": "public"
@@ -75,7 +76,7 @@
75
76
  "pluginId": "inline",
76
77
  "pluginPath": "plugin/inline",
77
78
  "minHermesVersion": "0.17.0",
78
- "testedHermesVersion": "0.17.0",
79
- "testedHermesCommit": "824f2279"
79
+ "testedHermesVersion": "0.18.2",
80
+ "testedHermesCommit": "9de9c25"
80
81
  }
81
82
  }
@@ -17,6 +17,7 @@ import re
17
17
  import secrets
18
18
  import shutil
19
19
  import signal
20
+ import socket
20
21
  import subprocess
21
22
  import sys
22
23
  import time
@@ -78,7 +79,6 @@ _INLINE_COMMAND_LIMIT = 100
78
79
  _INLINE_COMMAND_DESCRIPTION_LIMIT = 256
79
80
  _INLINE_COMMAND_RETRY_RATIO = 0.8
80
81
  _INLINE_COMMAND_RE = re.compile(r"^[a-z0-9_]{1,32}$")
81
- _REPLY_THREAD_AUTO_CREATE_MIN_MESSAGES = 50
82
82
  _INLINE_THREADS_COMMAND_DESCRIPTION = "Configure Inline reply-thread routing"
83
83
  _INLINE_THREADS_COMMAND_ARGS = "[status|on|off|auto|reset]"
84
84
  _INLINE_THREADS_ACTION_PREFIX = "th:"
@@ -213,6 +213,14 @@ def _normalize_sidecar_port(value: Any) -> int:
213
213
  return port
214
214
 
215
215
 
216
+ def _available_loopback_port(bind: str) -> int:
217
+ """Choose an unused loopback port for a short-lived standalone sidecar."""
218
+ family = socket.AF_INET6 if ":" in bind else socket.AF_INET
219
+ with socket.socket(family, socket.SOCK_STREAM) as sock:
220
+ sock.bind((bind, 0))
221
+ return int(sock.getsockname()[1])
222
+
223
+
216
224
  def _normalize_positive_float(value: Any, default: float, name: str) -> float:
217
225
  if value is None or str(value).strip() == "":
218
226
  return default
@@ -584,15 +592,19 @@ class InlineAdapter(BasePlatformAdapter):
584
592
  supports_code_blocks = True
585
593
  splits_long_messages = True
586
594
 
587
- def __init__(self, config: PlatformConfig):
595
+ def __init__(self, config: PlatformConfig, *, use_ephemeral_sidecar_port: bool = False):
588
596
  super().__init__(config, Platform("inline"))
589
597
  extra = config.extra or {}
590
598
 
591
599
  self._token = _config_token(config)
592
600
  self._base_url = os.getenv("INLINE_BASE_URL") or extra.get("base_url") or "https://api.inline.chat"
593
- sidecar_port = extra.get("sidecar_port") if "sidecar_port" in extra else os.getenv("INLINE_SIDECAR_PORT")
594
- self._sidecar_port = _normalize_sidecar_port(sidecar_port)
595
601
  self._sidecar_bind = _normalize_sidecar_bind(extra.get("sidecar_bind") or os.getenv("INLINE_SIDECAR_BIND"))
602
+ sidecar_port = extra.get("sidecar_port") if "sidecar_port" in extra else os.getenv("INLINE_SIDECAR_PORT")
603
+ self._sidecar_port = (
604
+ _available_loopback_port(self._sidecar_bind)
605
+ if use_ephemeral_sidecar_port
606
+ else _normalize_sidecar_port(sidecar_port)
607
+ )
596
608
  self._connect_timeout_ms = _normalize_positive_float(
597
609
  extra.get("connect_timeout_ms") if "connect_timeout_ms" in extra else os.getenv("INLINE_CONNECT_TIMEOUT_MS"),
598
610
  _DEFAULT_CONNECT_TIMEOUT_MS,
@@ -860,16 +872,6 @@ class InlineAdapter(BasePlatformAdapter):
860
872
  self._reply_thread_overrides[key] = _reply_thread_mode(value, "auto")
861
873
  self._save_reply_thread_overrides()
862
874
 
863
- @staticmethod
864
- def _positive_int(value: Any) -> Optional[int]:
865
- text = str(value or "").strip()
866
- if not re.fullmatch(r"[1-9]\d*", text):
867
- return None
868
- try:
869
- return int(text)
870
- except ValueError:
871
- return None
872
-
873
875
  @staticmethod
874
876
  def _has_reply_thread_intent(text: str) -> bool:
875
877
  normalized = re.sub(r"\s+", " ", str(text or "").strip())
@@ -884,7 +886,6 @@ class InlineAdapter(BasePlatformAdapter):
884
886
  *,
885
887
  chat_id: str,
886
888
  parent_chat_id: Optional[str],
887
- msg_id: str,
888
889
  text: str,
889
890
  ) -> bool:
890
891
  mode = self._reply_thread_mode_for_chat(chat_id, parent_chat_id)
@@ -894,8 +895,7 @@ class InlineAdapter(BasePlatformAdapter):
894
895
  return True
895
896
  if self._has_reply_thread_intent(text):
896
897
  return True
897
- message_id = self._positive_int(msg_id)
898
- return message_id is not None and message_id >= _REPLY_THREAD_AUTO_CREATE_MIN_MESSAGES
898
+ return False
899
899
 
900
900
  @staticmethod
901
901
  def _id_allowed(entries: set[str], value: str) -> bool:
@@ -978,10 +978,7 @@ class InlineAdapter(BasePlatformAdapter):
978
978
  if mode == "on":
979
979
  behavior = "Top-level replies will start or reuse Inline reply threads."
980
980
  elif mode == "auto":
981
- behavior = (
982
- "Top-level replies stay in the parent chat until "
983
- f"message #{_REPLY_THREAD_AUTO_CREATE_MIN_MESSAGES} or an explicit thread request."
984
- )
981
+ behavior = "Top-level replies stay in the parent chat unless a thread is explicitly requested."
985
982
  else:
986
983
  behavior = "Top-level replies stay in the parent chat."
987
984
  first_line = f"Inline reply threads are {mode} for this chat ({scope})."
@@ -1516,7 +1513,6 @@ class InlineAdapter(BasePlatformAdapter):
1516
1513
  and self._should_create_reply_thread_for_message(
1517
1514
  chat_id=chat_id,
1518
1515
  parent_chat_id=parent_chat_id,
1519
- msg_id=msg_id,
1520
1516
  text=text,
1521
1517
  )
1522
1518
  ):
@@ -3568,7 +3564,9 @@ async def _standalone_send(pconfig: PlatformConfig, chat_id: str, message: str,
3568
3564
  token = _config_token(pconfig)
3569
3565
  if not token:
3570
3566
  return {"error": "Inline token is required in INLINE_TOKEN, INLINE_BOT_TOKEN, or Hermes Inline config"}
3571
- adapter = InlineAdapter(pconfig)
3567
+ # The supervised gateway may already own the configured sidecar port. A
3568
+ # one-shot sender launches its own sidecar, so isolate it on a free port.
3569
+ adapter = InlineAdapter(pconfig, use_ephemeral_sidecar_port=True)
3572
3570
  ok = await adapter.connect()
3573
3571
  if not ok:
3574
3572
  return {"error": "failed to connect Inline adapter"}
@@ -7,6 +7,7 @@ the plugin has already been discovered by Hermes.
7
7
  from __future__ import annotations
8
8
 
9
9
  import argparse
10
+ import json
10
11
  import os
11
12
  import re
12
13
  import shutil
@@ -16,24 +17,194 @@ from pathlib import Path
16
17
 
17
18
  _SIDECAR_ENTRY = Path(__file__).parent / "sidecar" / "index.mjs"
18
19
  _MIN_NODE_MAJOR = 20
20
+ _BOT_USERNAME_RE = re.compile(r"^[A-Za-z0-9_]+bot$", re.IGNORECASE)
19
21
 
20
22
 
21
23
  def gateway_setup() -> None:
22
- if _env_token_configured():
23
- print("Inline env token configured from INLINE_TOKEN/INLINE_BOT_TOKEN.")
24
+ """Interactively create or connect an Inline bot.
25
+
26
+ Hermes invokes this from its messaging-platform wizard. Keep credential
27
+ persistence inside Hermes' own config helper so profiles and file
28
+ permissions behave exactly like the built-in Telegram setup.
29
+ """
30
+ from hermes_cli import gateway as hermes_gateway
31
+ from hermes_cli import setup as hermes_setup
32
+
33
+ hermes_setup.print_header("Inline")
34
+ existing = (
35
+ hermes_gateway.get_env_value("INLINE_TOKEN")
36
+ or hermes_gateway.get_env_value("INLINE_BOT_TOKEN")
37
+ )
38
+ if existing:
39
+ hermes_setup.print_info("Inline is already configured.")
40
+ if not hermes_setup.prompt_yes_no("Reconfigure Inline?", False):
41
+ return
42
+
43
+ hermes_setup.print_info("How would you like to connect Hermes to Inline?")
44
+ print()
45
+ hermes_setup.print_info(" [1] Create a dedicated Hermes bot (recommended)")
46
+ hermes_setup.print_info(" Sign in with the Inline CLI, name the bot, and you're done.")
47
+ print()
48
+ hermes_setup.print_info(" [2] Use an existing bot token")
49
+ hermes_setup.print_info(" Paste a token you created in Inline Settings → Bots.")
50
+ print()
51
+
52
+ choice = hermes_setup.prompt("Choice [1/2]", default="1").strip()
53
+ owner_user_id: str | None = None
54
+ token: str | None = None
55
+ if choice == "1":
56
+ token, owner_user_id = _create_bot_with_inline_cli(hermes_setup)
57
+ if not token:
58
+ print()
59
+ hermes_setup.print_info("Falling back to an existing bot token...")
60
+
61
+ if not token:
62
+ token = _prompt_existing_token(hermes_setup)
63
+ if not token:
64
+ hermes_setup.print_warning("No token saved. Inline setup was cancelled.")
65
+ return
66
+
67
+ hermes_gateway.save_env_value("INLINE_TOKEN", token)
68
+ hermes_setup.print_success("Inline bot token saved securely by Hermes.")
69
+
70
+ if not owner_user_id:
71
+ owner_user_id = _inline_cli_user_id(shutil.which("inline"))
72
+ _configure_access(hermes_gateway, hermes_setup, owner_user_id)
73
+
74
+ print()
75
+ hermes_setup.print_success("💬 Inline is configured!")
76
+ hermes_setup.print_info("Restart the gateway when prompted, then message your bot in Inline.")
77
+ hermes_setup.print_info("Send /sethome in that chat to use it for cron results and notifications.")
78
+
79
+
80
+ def _create_bot_with_inline_cli(hermes_setup) -> tuple[str | None, str | None]:
81
+ inline_bin = shutil.which("inline")
82
+ if not inline_bin:
83
+ hermes_setup.print_warning("The Inline CLI is not installed, so automatic bot creation is unavailable.")
84
+ hermes_setup.print_info("Install it from https://inline.chat/docs/cli, or use an existing bot token.")
85
+ return None, None
86
+
87
+ owner_user_id = _inline_cli_user_id(inline_bin)
88
+ if not owner_user_id:
89
+ print()
90
+ hermes_setup.print_info("Sign in to Inline to create your Hermes bot.")
91
+ if not hermes_setup.prompt_yes_no("Sign in now?", True):
92
+ return None, None
93
+ login = subprocess.run([inline_bin, "auth", "login"], check=False)
94
+ if login.returncode != 0:
95
+ hermes_setup.print_warning("Inline sign-in did not finish successfully.")
96
+ return None, None
97
+ owner_user_id = _inline_cli_user_id(inline_bin)
98
+ if not owner_user_id:
99
+ hermes_setup.print_warning("Inline sign-in could not be verified.")
100
+ return None, None
101
+
102
+ print()
103
+ name = hermes_setup.prompt("Bot name", default="Hermes").strip()
104
+ if not name:
105
+ return None, owner_user_id
106
+
107
+ while True:
108
+ username = hermes_setup.prompt("Bot username (must end in bot)", default="hermesbot").strip().lstrip("@")
109
+ if not _BOT_USERNAME_RE.fullmatch(username):
110
+ hermes_setup.print_warning("Use letters, numbers, or underscores, and end the username with 'bot'.")
111
+ continue
112
+
113
+ payload, error = _run_inline_json(
114
+ inline_bin,
115
+ ["bots", "create", "--name", name, "--username", username],
116
+ )
117
+ token = str((payload or {}).get("token") or "").strip()
118
+ if token:
119
+ bot = (payload or {}).get("bot")
120
+ bot_name = bot.get("name") if isinstance(bot, dict) else None
121
+ hermes_setup.print_success(f"Created {bot_name or name} in Inline.")
122
+ return token, owner_user_id
123
+
124
+ hermes_setup.print_warning(error or "Inline could not create the bot.")
125
+ if not hermes_setup.prompt_yes_no("Try a different username?", True):
126
+ return None, owner_user_id
127
+
128
+
129
+ def _prompt_existing_token(hermes_setup) -> str | None:
130
+ print()
131
+ hermes_setup.print_info("Create or reveal a bot token in Inline → Settings → Bots.")
132
+ hermes_setup.print_info("Guide: https://inline.chat/docs/creating-a-bot")
133
+ token = hermes_setup.prompt("Inline bot token", password=True).strip()
134
+ return token or None
135
+
136
+
137
+ def _configure_access(hermes_gateway, hermes_setup, owner_user_id: str | None) -> None:
138
+ print()
139
+ hermes_setup.print_info("🔒 Choose who can talk to Hermes.")
140
+ allowed: list[str] = []
141
+ if owner_user_id:
142
+ hermes_setup.print_success(f"Detected your Inline user ID: {owner_user_id}")
143
+ if hermes_setup.prompt_yes_no("Allow this Inline account?", True):
144
+ allowed.append(owner_user_id)
145
+
146
+ extra = hermes_setup.prompt("Additional allowed user IDs (comma-separated, optional)").strip()
147
+ for value in extra.replace(" ", "").split(","):
148
+ if value and value not in allowed:
149
+ allowed.append(value)
150
+
151
+ if allowed:
152
+ value = ",".join(allowed)
153
+ hermes_gateway.save_env_value("INLINE_ALLOW_ALL_USERS", "false")
154
+ hermes_gateway.save_env_value("INLINE_ALLOWED_USERS", value)
155
+ hermes_gateway.save_env_value("INLINE_GROUP_ALLOW_FROM", value)
156
+ hermes_gateway.save_env_value("INLINE_DM_POLICY", "allowlist")
157
+ hermes_gateway.save_env_value("INLINE_GROUP_POLICY", "allowlist")
158
+ hermes_setup.print_success("Only the listed Inline users can invoke Hermes.")
159
+ return
160
+
161
+ if hermes_setup.prompt_yes_no("Allow any Inline user who can reach the bot?", False):
162
+ hermes_gateway.save_env_value("INLINE_ALLOW_ALL_USERS", "true")
163
+ hermes_gateway.save_env_value("INLINE_DM_POLICY", "open")
164
+ hermes_gateway.save_env_value("INLINE_GROUP_POLICY", "open")
165
+ hermes_setup.print_warning("Open access enabled. Any reachable Inline user can invoke Hermes.")
24
166
  else:
25
- print("Set INLINE_TOKEN or INLINE_BOT_TOKEN in the Hermes gateway environment.")
26
- print("Alternatively set platforms.inline.token or inline.token in ~/.hermes/config.yaml.")
27
- print("Enable Inline in ~/.hermes/config.yaml:")
28
- print(" platforms:")
29
- print(" inline:")
30
- print(" enabled: true")
31
- print("Then run: inline-hermes doctor --json")
167
+ hermes_gateway.save_env_value("INLINE_ALLOW_ALL_USERS", "false")
168
+ hermes_gateway.save_env_value("INLINE_DM_POLICY", "disabled")
169
+ hermes_gateway.save_env_value("INLINE_GROUP_POLICY", "disabled")
170
+ hermes_setup.print_warning("Messaging is disabled until you add allowed user IDs and re-run setup.")
171
+
172
+
173
+ def _inline_cli_user_id(inline_bin: str | None) -> str | None:
174
+ if not inline_bin:
175
+ return None
176
+ payload, _ = _run_inline_json(inline_bin, ["auth", "me"])
177
+ if not payload:
178
+ return None
179
+ raw = payload.get("id")
180
+ return str(raw).strip() if raw is not None and str(raw).strip() else None
181
+
182
+
183
+ def _run_inline_json(inline_bin: str, args: list[str]) -> tuple[dict | None, str | None]:
184
+ try:
185
+ result = subprocess.run(
186
+ [inline_bin, "--json", "--compact", *args],
187
+ stdout=subprocess.PIPE,
188
+ stderr=subprocess.PIPE,
189
+ text=True,
190
+ timeout=60,
191
+ check=False,
192
+ )
193
+ except (OSError, subprocess.TimeoutExpired) as exc:
194
+ return None, f"Inline CLI failed: {exc}"
195
+ if result.returncode != 0:
196
+ detail = (result.stderr or "").strip().splitlines()
197
+ return None, detail[-1] if detail else "Inline CLI exited unsuccessfully."
198
+ try:
199
+ payload = json.loads(result.stdout)
200
+ except (json.JSONDecodeError, TypeError):
201
+ return None, "Inline CLI returned an unreadable response."
202
+ return payload if isinstance(payload, dict) else None, None
32
203
 
33
204
 
34
205
  def register_cli(parser: argparse.ArgumentParser) -> None:
35
206
  subs = parser.add_subparsers(dest="inline_command", required=False)
36
- subs.add_parser("setup", help="Show Inline setup instructions")
207
+ subs.add_parser("setup", help="Configure Inline interactively")
37
208
  subs.add_parser("status", help="Show Inline adapter status")
38
209
  parser.set_defaults(func=dispatch)
39
210
 
@@ -47,13 +218,12 @@ def dispatch(args) -> int:
47
218
  return 0
48
219
  if command == "status":
49
220
  configured = _env_token_configured()
50
- print(f"Inline env token configured: {'yes' if configured else 'no'}")
51
- print("Hermes config token support: platforms.inline.token or inline.token")
221
+ print(f"Inline configured: {'yes' if configured else 'no'}")
52
222
  print(f"Inline sidecar bundled: {'yes' if _SIDECAR_ENTRY.exists() else 'no'}")
53
223
  print(f"Node available: {_node_status()}")
54
224
  if not configured:
55
- print("Hint: use INLINE_TOKEN/INLINE_BOT_TOKEN for env setup, or platforms.inline.token/inline.token in config.yaml.")
56
- print("Install diagnostics: inline-hermes doctor --json")
225
+ print("Next: run `hermes inline setup` for guided bot setup.")
226
+ print("Advanced diagnostics: inline-hermes doctor --json")
57
227
  return 0
58
228
  raise SystemExit(f"unknown inline command: {command}")
59
229
 
@@ -1,7 +1,7 @@
1
1
  name: inline-platform
2
2
  label: Inline
3
3
  kind: platform
4
- version: 0.0.4
4
+ version: 0.0.5-alpha.0
5
5
  description: >
6
6
  Inline platform adapter for Hermes Agent. The adapter runs as a native
7
7
  Hermes Python platform plugin and supervises a local Node sidecar that uses
@@ -79,7 +79,7 @@ optional_env:
79
79
  prompt: "Free-response Inline chat ids"
80
80
  password: false
81
81
  - name: INLINE_REPLY_THREADS
82
- description: "Reply-thread mode: auto, on, or off. Auto creates child reply threads only for large parent chats or explicit thread requests (default auto)."
82
+ description: "Reply-thread mode: auto, on, or off. Auto creates child reply threads only for explicit thread requests (default auto)."
83
83
  prompt: "Reply-thread mode (auto/on/off)"
84
84
  password: false
85
85
  - name: INLINE_CONTEXT_BACKFILL