@inline-chat/hermes-agent-adapter 0.0.11 → 0.0.13
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 +9 -2
- package/package.json +1 -1
- package/plugin/inline/adapter.py +419 -7
- package/plugin/inline/cli.py +48 -1
- package/plugin/inline/plugin.yaml +5 -1
package/README.md
CHANGED
|
@@ -147,7 +147,7 @@ uv run ./hermes plugins list --plain --no-bundled
|
|
|
147
147
|
Expected local output includes:
|
|
148
148
|
|
|
149
149
|
```text
|
|
150
|
-
enabled user 0.0.
|
|
150
|
+
enabled user 0.0.13 inline-platform
|
|
151
151
|
```
|
|
152
152
|
|
|
153
153
|
## Update Or Reinstall
|
|
@@ -261,6 +261,12 @@ progress message after a successful final reply. Token streaming requires both
|
|
|
261
261
|
top-level `streaming.enabled: true` and
|
|
262
262
|
`display.platforms.inline.streaming: true`.
|
|
263
263
|
|
|
264
|
+
Hermes-native sends and scheduled deliveries accept Inline's explicit target
|
|
265
|
+
forms: a bare positive ID or `chat:ID` for a chat, `thread:ID` for a routable
|
|
266
|
+
reply thread, and `user:ID` for a direct user target. IDs are validated as
|
|
267
|
+
positive signed 64-bit integers before delivery. The parser hooks activate on
|
|
268
|
+
Hermes versions that expose the native plugin target-resolution contract.
|
|
269
|
+
|
|
264
270
|
Hermes also accepts a top-level `inline:` block for plugin-owned settings, but
|
|
265
271
|
`platforms.inline` matches the shape used by most Hermes platform docs and is
|
|
266
272
|
the safest form to copy into `~/.hermes/config.yaml`.
|
|
@@ -293,6 +299,7 @@ Access control follows Hermes' native platform model:
|
|
|
293
299
|
| `INLINE_CONTEXT_HISTORY_LIMIT` | Legacy compatibility shortcut. `0` maps to `INLINE_CONTEXT_BACKFILL=off`; `1` through `20` maps to `always` with that thread-context limit. Prefer the explicit settings above. |
|
|
294
300
|
| `INLINE_SETTINGS_PATH` | JSON settings file for per-chat `/threads` overrides. `/threads` shows native Auto/On/Off buttons; `reset` clears the chat override back to the global default. Defaults next to `INLINE_STATE_PATH`; `.env`-like paths are refused. |
|
|
295
301
|
| `INLINE_SYSTEM_EVENTS` | Delivers Inline lifecycle events such as edits, deletes, and participant changes as synthetic messages. Defaults to `false`. Reactions on bot messages are always delivered. |
|
|
302
|
+
| `INLINE_REACTIONS` | Shows 👀 while Hermes handles an inbound message, then ✅ on success or ❌ on failure. Cancellation clears the working marker. Defaults to `false`. |
|
|
296
303
|
| `INLINE_MENTION_PATTERNS` | JSON list, comma-separated, or newline-separated regex patterns for group wake words. |
|
|
297
304
|
| `INLINE_PARSE_MARKDOWN` | Controls whether supported outbound Inline Markdown is parsed. Defaults to `true`; `false` preserves the supplied syntax literally. |
|
|
298
305
|
| `INLINE_SYNC_COMMANDS` | Syncs Hermes slash commands into Inline's native `/` bot command menu on gateway connect. Defaults to `true`. |
|
|
@@ -316,7 +323,7 @@ Equivalent Hermes YAML can use `allow_from`, `allowed_users`,
|
|
|
316
323
|
`strict_mention`, `allowed_chats`, `free_response_chats`, `reply_threads`,
|
|
317
324
|
`context_backfill`, `thread_context_limit`, `reply_context_limit`,
|
|
318
325
|
`observed_context_limit`, `observe_unmentioned_messages`, `settings_path`, and
|
|
319
|
-
`mention_patterns` under the Inline platform config. Operational settings such
|
|
326
|
+
`mention_patterns`, and `reactions` under the Inline platform config. Operational settings such
|
|
320
327
|
as `base_url`, `parse_markdown`, `media_max_mb`, `upload_max_mb`,
|
|
321
328
|
`state_path`, `sidecar_port`, `connect_timeout_ms`, `sync_commands`, and
|
|
322
329
|
`command_limit` can also be set there.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inline-chat/hermes-agent-adapter",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
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",
|
package/plugin/inline/adapter.py
CHANGED
|
@@ -62,6 +62,9 @@ _DEFAULT_SIDECAR_PORT = 8794
|
|
|
62
62
|
_DEFAULT_SIDECAR_BIND = "127.0.0.1"
|
|
63
63
|
_MAX_MESSAGE_LENGTH = 4000
|
|
64
64
|
_MODEL_PAGE_SIZE = 8
|
|
65
|
+
_CHOICE_PICKER_MAX_CHOICES = 16
|
|
66
|
+
_CHOICE_PICKER_TTL_SECONDS = 2 * 60
|
|
67
|
+
_UPDATE_PROMPT_TTL_SECONDS = 5 * 60
|
|
65
68
|
_DEDUP_MAX_SIZE = 5000
|
|
66
69
|
_DEDUP_WINDOW_SECONDS = 48 * 3600
|
|
67
70
|
_CHAT_INFO_CACHE_SECONDS = 10 * 60
|
|
@@ -91,6 +94,7 @@ _SIDECAR_DIR = Path(__file__).parent / "sidecar"
|
|
|
91
94
|
_SIDECAR_ENTRY = _SIDECAR_DIR / "index.mjs"
|
|
92
95
|
_DEFAULT_MEDIA_MAX_MB = 25
|
|
93
96
|
_DEFAULT_UPLOAD_MAX_MB = 300
|
|
97
|
+
_MAX_INLINE_ID = 9_223_372_036_854_775_807
|
|
94
98
|
_MIN_NODE_MAJOR = 20
|
|
95
99
|
_DEFAULT_CONNECT_TIMEOUT_MS = 20_000
|
|
96
100
|
_INLINE_COMMAND_LIMIT = 100
|
|
@@ -351,13 +355,81 @@ def _target_from_chat_id(chat_id: str) -> Dict[str, str]:
|
|
|
351
355
|
raw = str(chat_id or "").strip()
|
|
352
356
|
if raw.startswith("inline:"):
|
|
353
357
|
raw = raw[len("inline:"):].strip()
|
|
354
|
-
if raw.startswith("chat:"):
|
|
355
|
-
return {"chatId": raw
|
|
358
|
+
if raw.startswith(("chat:", "thread:")):
|
|
359
|
+
return {"chatId": raw.split(":", 1)[1].strip()}
|
|
356
360
|
if raw.startswith("user:"):
|
|
357
361
|
return {"userId": raw[len("user:"):].strip()}
|
|
358
362
|
return {"chatId": raw}
|
|
359
363
|
|
|
360
364
|
|
|
365
|
+
def _normalize_inline_target_id(value: Any) -> Optional[str]:
|
|
366
|
+
text = str(value or "").strip()
|
|
367
|
+
if not re.fullmatch(r"[0-9]+", text):
|
|
368
|
+
return None
|
|
369
|
+
parsed = int(text)
|
|
370
|
+
if parsed <= 0 or parsed > _MAX_INLINE_ID:
|
|
371
|
+
return None
|
|
372
|
+
return str(parsed)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _parse_inline_target_ref(target_ref: str) -> Optional[tuple[str, Optional[str]]]:
|
|
376
|
+
"""Normalize Hermes delivery refs without losing direct-user routing."""
|
|
377
|
+
raw = str(target_ref or "").strip()
|
|
378
|
+
if raw.lower().startswith("inline:"):
|
|
379
|
+
raw = raw.split(":", 1)[1].strip()
|
|
380
|
+
if not raw:
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
prefix = ""
|
|
384
|
+
if ":" in raw:
|
|
385
|
+
prefix, raw = raw.split(":", 1)
|
|
386
|
+
prefix = prefix.strip().lower()
|
|
387
|
+
raw = raw.strip()
|
|
388
|
+
if prefix not in {"chat", "thread", "user"} or not raw or ":" in raw:
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
normalized = _normalize_inline_target_id(raw)
|
|
392
|
+
if normalized is None:
|
|
393
|
+
# Preserve recognized explicit refs for the validator so callers get
|
|
394
|
+
# the same signed-Int64 diagnostic as other Inline ID entry points.
|
|
395
|
+
if prefix:
|
|
396
|
+
return (f"user:{raw}" if prefix == "user" else raw), None
|
|
397
|
+
return None
|
|
398
|
+
if prefix == "user":
|
|
399
|
+
return f"user:{normalized}", None
|
|
400
|
+
return normalized, None
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _validate_inline_target_ref(chat_id: str) -> bool | str:
|
|
404
|
+
raw = str(chat_id or "").strip()
|
|
405
|
+
if raw.lower().startswith("inline:"):
|
|
406
|
+
raw = raw.split(":", 1)[1].strip()
|
|
407
|
+
if ":" in raw:
|
|
408
|
+
prefix, raw = raw.split(":", 1)
|
|
409
|
+
if prefix.strip().lower() not in {"chat", "thread", "user"}:
|
|
410
|
+
return "Use a bare Inline ID or a chat:, thread:, or user: target"
|
|
411
|
+
raw = raw.strip()
|
|
412
|
+
if _normalize_inline_target_id(raw) is None:
|
|
413
|
+
return "Inline targets must use a positive signed 64-bit integer ID"
|
|
414
|
+
return True
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _target_registration_hooks() -> Dict[str, Callable[..., Any]]:
|
|
418
|
+
"""Expose newer Hermes target hooks without breaking older runtimes."""
|
|
419
|
+
try:
|
|
420
|
+
from gateway.platform_registry import PlatformEntry
|
|
421
|
+
|
|
422
|
+
fields = getattr(PlatformEntry, "__dataclass_fields__", {})
|
|
423
|
+
if {"parse_target_ref_fn", "validate_target_ref_fn"}.issubset(fields):
|
|
424
|
+
return {
|
|
425
|
+
"parse_target_ref_fn": _parse_inline_target_ref,
|
|
426
|
+
"validate_target_ref_fn": _validate_inline_target_ref,
|
|
427
|
+
}
|
|
428
|
+
except Exception:
|
|
429
|
+
logger.debug("[inline] Hermes target parser hooks are unavailable", exc_info=True)
|
|
430
|
+
return {}
|
|
431
|
+
|
|
432
|
+
|
|
361
433
|
def _inline_sender_profile(event: Dict[str, Any], message: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
362
434
|
raw = event.get("sender")
|
|
363
435
|
if not isinstance(raw, dict) and isinstance(message, dict):
|
|
@@ -673,6 +745,7 @@ def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]:
|
|
|
673
745
|
"text_chunk_limit",
|
|
674
746
|
"reply_threads",
|
|
675
747
|
"system_events",
|
|
748
|
+
"reactions",
|
|
676
749
|
"channel_prompts",
|
|
677
750
|
"channel_skill_bindings",
|
|
678
751
|
"typing_indicator",
|
|
@@ -738,6 +811,10 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
738
811
|
extra.get("system_events") if "system_events" in extra else os.getenv("INLINE_SYSTEM_EVENTS"),
|
|
739
812
|
False,
|
|
740
813
|
)
|
|
814
|
+
self._processing_reactions = _truthy(
|
|
815
|
+
extra.get("reactions") if "reactions" in extra else os.getenv("INLINE_REACTIONS"),
|
|
816
|
+
False,
|
|
817
|
+
)
|
|
741
818
|
self._sync_commands = _truthy(
|
|
742
819
|
extra.get("sync_commands") if "sync_commands" in extra else os.getenv("INLINE_SYNC_COMMANDS"),
|
|
743
820
|
True,
|
|
@@ -877,7 +954,11 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
877
954
|
self._clarify_sessions: "OrderedDict[str, str]" = OrderedDict()
|
|
878
955
|
self._approval_sessions: "OrderedDict[str, str]" = OrderedDict()
|
|
879
956
|
self._slash_sessions: "OrderedDict[str, str]" = OrderedDict()
|
|
957
|
+
self._choice_picker_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
|
|
958
|
+
self._update_prompt_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
|
|
880
959
|
self._model_picker_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
|
|
960
|
+
self._status_message_ids: "OrderedDict[tuple[str, str, str], str]" = OrderedDict()
|
|
961
|
+
self._processing_reaction_messages: "OrderedDict[tuple[str, str, str], Dict[str, str]]" = OrderedDict()
|
|
881
962
|
self._thread_action_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
|
|
882
963
|
self._chat_info_cache: "OrderedDict[str, tuple[float, Dict[str, Any]]]" = OrderedDict()
|
|
883
964
|
self._bot_agent_cache: "OrderedDict[str, tuple[float, Dict[str, Any]]]" = OrderedDict()
|
|
@@ -3582,6 +3663,14 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3582
3663
|
if not await self._action_allowed(event):
|
|
3583
3664
|
return True
|
|
3584
3665
|
return await self._handle_model_picker_action(event)
|
|
3666
|
+
if action_id.startswith("cp:"):
|
|
3667
|
+
if not await self._action_allowed(event):
|
|
3668
|
+
return True
|
|
3669
|
+
return await self._handle_choice_picker_action(event)
|
|
3670
|
+
if action_id.startswith("up:"):
|
|
3671
|
+
if not await self._action_allowed(event):
|
|
3672
|
+
return True
|
|
3673
|
+
return await self._handle_update_prompt_action(event)
|
|
3585
3674
|
if action_id.startswith("cl:"):
|
|
3586
3675
|
if not await self._action_allowed(event):
|
|
3587
3676
|
return True
|
|
@@ -3805,6 +3894,121 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3805
3894
|
def _is_model_picker_action(action_id: str) -> bool:
|
|
3806
3895
|
return action_id.startswith(("mp:", "mpg:", "mm:", "mc:", "mg:", "mb:", "mx:"))
|
|
3807
3896
|
|
|
3897
|
+
def _action_matches_state_target(self, event: Dict[str, Any], state: Dict[str, Any]) -> bool:
|
|
3898
|
+
target = state.get("target")
|
|
3899
|
+
if not isinstance(target, dict):
|
|
3900
|
+
return False
|
|
3901
|
+
expected_chat_id = str(target.get("chatId") or "").strip()
|
|
3902
|
+
expected_user_id = str(target.get("userId") or "").strip()
|
|
3903
|
+
target_matches = (
|
|
3904
|
+
bool(expected_chat_id) and expected_chat_id == self._chat_key(event.get("chatId"))
|
|
3905
|
+
) or (
|
|
3906
|
+
bool(expected_user_id) and expected_user_id == str(event.get("actorUserId") or "").strip()
|
|
3907
|
+
)
|
|
3908
|
+
expected_message_id = str(state.get("message_id") or "").strip()
|
|
3909
|
+
actual_message_id = str(event.get("messageId") or "").strip()
|
|
3910
|
+
return bool(
|
|
3911
|
+
target_matches
|
|
3912
|
+
and expected_message_id
|
|
3913
|
+
and expected_message_id == actual_message_id
|
|
3914
|
+
)
|
|
3915
|
+
|
|
3916
|
+
async def _handle_choice_picker_action(self, event: Dict[str, Any]) -> bool:
|
|
3917
|
+
action_id = str(event.get("actionId") or "")
|
|
3918
|
+
parts = action_id.split(":", 2)
|
|
3919
|
+
if len(parts) != 3 or parts[0] != "cp":
|
|
3920
|
+
return False
|
|
3921
|
+
_, picker_id, index_raw = parts
|
|
3922
|
+
interaction_id = str(event.get("interactionId") or "")
|
|
3923
|
+
state = self._choice_picker_sessions.get(picker_id)
|
|
3924
|
+
if not state:
|
|
3925
|
+
await self._answer_action(interaction_id, "Picker expired")
|
|
3926
|
+
return True
|
|
3927
|
+
if not self._action_matches_state_target(event, state):
|
|
3928
|
+
await self._answer_action(interaction_id, "Picker expired")
|
|
3929
|
+
return True
|
|
3930
|
+
if time.monotonic() - float(state.get("created_at") or 0) > _CHOICE_PICKER_TTL_SECONDS:
|
|
3931
|
+
self._choice_picker_sessions.pop(picker_id, None)
|
|
3932
|
+
await self._finish_action(event, "Picker expired", "Selection expired; no change was made.")
|
|
3933
|
+
return True
|
|
3934
|
+
try:
|
|
3935
|
+
index = int(index_raw)
|
|
3936
|
+
state_choices = state["choices"]
|
|
3937
|
+
if index < 0 or index >= len(state_choices):
|
|
3938
|
+
raise IndexError
|
|
3939
|
+
choice = state_choices[index]
|
|
3940
|
+
except (KeyError, TypeError, ValueError, IndexError):
|
|
3941
|
+
await self._answer_action(interaction_id, "Invalid selection")
|
|
3942
|
+
return True
|
|
3943
|
+
|
|
3944
|
+
callback = state.get("on_choice_selected")
|
|
3945
|
+
if not callable(callback):
|
|
3946
|
+
self._choice_picker_sessions.pop(picker_id, None)
|
|
3947
|
+
await self._finish_action(event, "Picker expired", "Selection expired; no change was made.")
|
|
3948
|
+
return True
|
|
3949
|
+
|
|
3950
|
+
# Claim the picker before awaiting the callback so concurrent taps cannot
|
|
3951
|
+
# apply the same setting more than once.
|
|
3952
|
+
self._choice_picker_sessions.pop(picker_id, None)
|
|
3953
|
+
failed = False
|
|
3954
|
+
try:
|
|
3955
|
+
result_text = callback(str(state.get("chat_id") or event.get("chatId") or ""), str(choice.get("value") or ""))
|
|
3956
|
+
if asyncio.iscoroutine(result_text):
|
|
3957
|
+
result_text = await result_text
|
|
3958
|
+
result_text = str(result_text or "Selection applied.")
|
|
3959
|
+
except Exception as exc:
|
|
3960
|
+
logger.error("[inline] choice picker selection failed (%s)", type(exc).__name__)
|
|
3961
|
+
result_text = "Selection failed; try again."
|
|
3962
|
+
failed = True
|
|
3963
|
+
await self._finish_action(
|
|
3964
|
+
event,
|
|
3965
|
+
"Selection failed" if failed else "Selection applied",
|
|
3966
|
+
result_text,
|
|
3967
|
+
)
|
|
3968
|
+
return True
|
|
3969
|
+
|
|
3970
|
+
async def _handle_update_prompt_action(self, event: Dict[str, Any]) -> bool:
|
|
3971
|
+
action_id = str(event.get("actionId") or "")
|
|
3972
|
+
parts = action_id.split(":", 2)
|
|
3973
|
+
if len(parts) != 3 or parts[0] != "up":
|
|
3974
|
+
return False
|
|
3975
|
+
_, prompt_id, answer = parts
|
|
3976
|
+
interaction_id = str(event.get("interactionId") or "")
|
|
3977
|
+
state = self._update_prompt_sessions.get(prompt_id)
|
|
3978
|
+
if not state:
|
|
3979
|
+
await self._answer_action(interaction_id, "Prompt expired")
|
|
3980
|
+
return True
|
|
3981
|
+
if not self._action_matches_state_target(event, state):
|
|
3982
|
+
await self._answer_action(interaction_id, "Prompt expired")
|
|
3983
|
+
return True
|
|
3984
|
+
if time.monotonic() - float(state.get("created_at") or 0) > _UPDATE_PROMPT_TTL_SECONDS:
|
|
3985
|
+
self._update_prompt_sessions.pop(prompt_id, None)
|
|
3986
|
+
await self._finish_action(event, "Prompt expired", "Update prompt expired; no response was sent.")
|
|
3987
|
+
return True
|
|
3988
|
+
if answer not in {"y", "n"}:
|
|
3989
|
+
await self._answer_action(interaction_id, "Invalid response")
|
|
3990
|
+
return True
|
|
3991
|
+
|
|
3992
|
+
try:
|
|
3993
|
+
from hermes_constants import get_hermes_home
|
|
3994
|
+
response_path = Path(get_hermes_home()) / ".update_response"
|
|
3995
|
+
tmp_path = response_path.with_suffix(".tmp")
|
|
3996
|
+
tmp_path.write_text(answer, encoding="utf-8")
|
|
3997
|
+
tmp_path.replace(response_path)
|
|
3998
|
+
except Exception:
|
|
3999
|
+
logger.exception("[inline] failed to write Hermes update response")
|
|
4000
|
+
await self._answer_action(interaction_id, "Response failed; try again")
|
|
4001
|
+
return True
|
|
4002
|
+
|
|
4003
|
+
self._update_prompt_sessions.pop(prompt_id, None)
|
|
4004
|
+
label = "Yes" if answer == "y" else "No"
|
|
4005
|
+
await self._finish_action(
|
|
4006
|
+
event,
|
|
4007
|
+
f"Update response: {label}",
|
|
4008
|
+
f"Hermes update prompt answered: {label}.",
|
|
4009
|
+
)
|
|
4010
|
+
return True
|
|
4011
|
+
|
|
3808
4012
|
@staticmethod
|
|
3809
4013
|
def _split_model_picker_action(action_id: str) -> Optional[tuple[str, str, str]]:
|
|
3810
4014
|
parts = action_id.split(":", 2)
|
|
@@ -3831,6 +4035,9 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3831
4035
|
if not state:
|
|
3832
4036
|
await self._answer_action(interaction_id, "Picker expired")
|
|
3833
4037
|
return True
|
|
4038
|
+
if not self._action_matches_state_target(event, state):
|
|
4039
|
+
await self._answer_action(interaction_id, "Picker expired")
|
|
4040
|
+
return True
|
|
3834
4041
|
|
|
3835
4042
|
if kind == "mp":
|
|
3836
4043
|
return await self._select_model_provider(event, picker_id, state, value)
|
|
@@ -3953,6 +4160,10 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3953
4160
|
self._model_picker_sessions.pop(picker_id, None)
|
|
3954
4161
|
await self._answer_action(interaction_id, "Picker expired")
|
|
3955
4162
|
return True
|
|
4163
|
+
# Claim the picker before awaiting provider work. The sidecar currently
|
|
4164
|
+
# dispatches serially, but this keeps the callback single-use if that
|
|
4165
|
+
# delivery contract changes.
|
|
4166
|
+
self._model_picker_sessions.pop(picker_id, None)
|
|
3956
4167
|
failed = False
|
|
3957
4168
|
try:
|
|
3958
4169
|
result_text = callback(str(state.get("chat_id") or event.get("chatId") or ""), model_id, provider_slug)
|
|
@@ -3960,10 +4171,9 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3960
4171
|
result_text = await result_text
|
|
3961
4172
|
result_text = str(result_text or "Model switched.")
|
|
3962
4173
|
except Exception as exc:
|
|
3963
|
-
logger.
|
|
3964
|
-
result_text =
|
|
4174
|
+
logger.error("[inline] model picker switch failed (%s)", type(exc).__name__)
|
|
4175
|
+
result_text = "Model switch failed; try again."
|
|
3965
4176
|
failed = True
|
|
3966
|
-
self._model_picker_sessions.pop(picker_id, None)
|
|
3967
4177
|
await self._edit_action_message(event, result_text, {"rows": []})
|
|
3968
4178
|
await self._answer_action(interaction_id, "Switch failed" if failed else "Model switched")
|
|
3969
4179
|
return True
|
|
@@ -4094,6 +4304,35 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4094
4304
|
) -> bool:
|
|
4095
4305
|
return False
|
|
4096
4306
|
|
|
4307
|
+
async def send_or_update_status(
|
|
4308
|
+
self,
|
|
4309
|
+
chat_id: str,
|
|
4310
|
+
status_key: str,
|
|
4311
|
+
content: str,
|
|
4312
|
+
*,
|
|
4313
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
4314
|
+
) -> SendResult:
|
|
4315
|
+
target = self._target_for(chat_id, metadata)
|
|
4316
|
+
target_kind = "userId" if "userId" in target else "chatId"
|
|
4317
|
+
key = (target_kind, str(target.get(target_kind) or ""), str(status_key))
|
|
4318
|
+
cached_id = self._status_message_ids.get(key)
|
|
4319
|
+
if cached_id:
|
|
4320
|
+
result = await self.edit_message(
|
|
4321
|
+
chat_id,
|
|
4322
|
+
cached_id,
|
|
4323
|
+
content,
|
|
4324
|
+
metadata=metadata,
|
|
4325
|
+
)
|
|
4326
|
+
if result.success:
|
|
4327
|
+
self._remember(self._status_message_ids, key, str(result.message_id or cached_id))
|
|
4328
|
+
return result
|
|
4329
|
+
self._status_message_ids.pop(key, None)
|
|
4330
|
+
|
|
4331
|
+
result = await self.send(chat_id, content, metadata=metadata)
|
|
4332
|
+
if result.success and result.message_id:
|
|
4333
|
+
self._remember(self._status_message_ids, key, str(result.message_id))
|
|
4334
|
+
return result
|
|
4335
|
+
|
|
4097
4336
|
async def edit_message(
|
|
4098
4337
|
self,
|
|
4099
4338
|
chat_id: str,
|
|
@@ -4209,6 +4448,88 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4209
4448
|
except Exception:
|
|
4210
4449
|
pass
|
|
4211
4450
|
|
|
4451
|
+
def _processing_reaction_target(
|
|
4452
|
+
self,
|
|
4453
|
+
event: MessageEvent,
|
|
4454
|
+
) -> Optional[tuple[tuple[str, str, str], Dict[str, str], str]]:
|
|
4455
|
+
if not self._processing_reactions:
|
|
4456
|
+
return None
|
|
4457
|
+
raw_message = getattr(event, "raw_message", None)
|
|
4458
|
+
if isinstance(raw_message, dict):
|
|
4459
|
+
# Lifecycle and reaction events can themselves invoke Hermes when
|
|
4460
|
+
# system events are enabled. Reacting to those would add noise and
|
|
4461
|
+
# could create a reaction-event loop. Agent-action turns resolve
|
|
4462
|
+
# their own source card in place and must not react to that card.
|
|
4463
|
+
kind = str(raw_message.get("kind") or "")
|
|
4464
|
+
if (kind and kind != "message.new") or raw_message.get("_inlineAgentAction"):
|
|
4465
|
+
return None
|
|
4466
|
+
source = getattr(event, "source", None)
|
|
4467
|
+
chat_id = str(getattr(source, "chat_id", None) or "").strip()
|
|
4468
|
+
message_id = str(getattr(event, "message_id", None) or "").strip()
|
|
4469
|
+
if not chat_id or not message_id:
|
|
4470
|
+
return None
|
|
4471
|
+
target = _target_from_chat_id(chat_id)
|
|
4472
|
+
target_kind = "userId" if "userId" in target else "chatId"
|
|
4473
|
+
target_id = str(target.get(target_kind) or "").strip()
|
|
4474
|
+
if not target_id:
|
|
4475
|
+
return None
|
|
4476
|
+
return (target_kind, target_id, message_id), target, message_id
|
|
4477
|
+
|
|
4478
|
+
async def _set_processing_reaction(
|
|
4479
|
+
self,
|
|
4480
|
+
target: Dict[str, str],
|
|
4481
|
+
message_id: str,
|
|
4482
|
+
emoji: str,
|
|
4483
|
+
*,
|
|
4484
|
+
remove: bool = False,
|
|
4485
|
+
) -> bool:
|
|
4486
|
+
body: Dict[str, Any] = {
|
|
4487
|
+
"target": target,
|
|
4488
|
+
"messageId": message_id,
|
|
4489
|
+
"emoji": emoji,
|
|
4490
|
+
}
|
|
4491
|
+
if remove:
|
|
4492
|
+
body["remove"] = True
|
|
4493
|
+
try:
|
|
4494
|
+
await self._sidecar_call("/reaction", body)
|
|
4495
|
+
return True
|
|
4496
|
+
except Exception as exc:
|
|
4497
|
+
logger.debug("[inline] processing reaction failed: %s", exc)
|
|
4498
|
+
return False
|
|
4499
|
+
|
|
4500
|
+
async def on_processing_start(self, event: MessageEvent) -> None:
|
|
4501
|
+
"""Mark an inbound message while Hermes is actively processing it."""
|
|
4502
|
+
reaction_target = self._processing_reaction_target(event)
|
|
4503
|
+
if not reaction_target:
|
|
4504
|
+
return
|
|
4505
|
+
key, target, message_id = reaction_target
|
|
4506
|
+
if key not in self._processing_reaction_messages and len(self._processing_reaction_messages) >= 512:
|
|
4507
|
+
evicted_key = next(iter(self._processing_reaction_messages))
|
|
4508
|
+
evicted_target = self._processing_reaction_messages[evicted_key]
|
|
4509
|
+
await self._set_processing_reaction(evicted_target, evicted_key[2], "👀", remove=True)
|
|
4510
|
+
self._processing_reaction_messages.pop(evicted_key, None)
|
|
4511
|
+
# Record cleanup ownership before awaiting the add. Cancellation or a
|
|
4512
|
+
# lost acknowledgement can otherwise strand a bot-owned marker.
|
|
4513
|
+
self._remember(self._processing_reaction_messages, key, target)
|
|
4514
|
+
if not await self._set_processing_reaction(target, message_id, "👀"):
|
|
4515
|
+
self._processing_reaction_messages.pop(key, None)
|
|
4516
|
+
|
|
4517
|
+
async def on_processing_complete(self, event: MessageEvent, outcome: Any) -> None:
|
|
4518
|
+
"""Replace the processing marker without affecting response delivery."""
|
|
4519
|
+
reaction_target = self._processing_reaction_target(event)
|
|
4520
|
+
if not reaction_target:
|
|
4521
|
+
return
|
|
4522
|
+
key, _, message_id = reaction_target
|
|
4523
|
+
target = self._processing_reaction_messages.pop(key, None)
|
|
4524
|
+
if not target:
|
|
4525
|
+
return
|
|
4526
|
+
await self._set_processing_reaction(target, message_id, "👀", remove=True)
|
|
4527
|
+
outcome_name = str(getattr(outcome, "value", outcome) or "").strip().lower()
|
|
4528
|
+
if outcome_name == "success":
|
|
4529
|
+
await self._set_processing_reaction(target, message_id, "✅")
|
|
4530
|
+
elif outcome_name == "failure":
|
|
4531
|
+
await self._set_processing_reaction(target, message_id, "❌")
|
|
4532
|
+
|
|
4212
4533
|
async def send_image_file(self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult:
|
|
4213
4534
|
return await self._send_attachment(chat_id, image_path, "photo", caption, reply_to, metadata=metadata)
|
|
4214
4535
|
|
|
@@ -4361,8 +4682,9 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4361
4682
|
if not clean_providers:
|
|
4362
4683
|
return await self.send(chat_id, "No authenticated models are available for this session.", metadata=metadata)
|
|
4363
4684
|
picker_id = secrets.token_hex(6)
|
|
4685
|
+
target = self._target_for(chat_id, metadata)
|
|
4364
4686
|
result = await self._send_sidecar("/send", {
|
|
4365
|
-
"target":
|
|
4687
|
+
"target": target,
|
|
4366
4688
|
"text": self._model_picker_text(current_model, current_provider),
|
|
4367
4689
|
"parseMarkdown": self._parse_markdown,
|
|
4368
4690
|
"actions": self._build_provider_actions(picker_id, clean_providers),
|
|
@@ -4377,6 +4699,95 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4377
4699
|
"current_model": str(current_model or ""),
|
|
4378
4700
|
"current_provider": str(current_provider or ""),
|
|
4379
4701
|
"message_id": result.message_id,
|
|
4702
|
+
"target": target,
|
|
4703
|
+
})
|
|
4704
|
+
return result
|
|
4705
|
+
|
|
4706
|
+
async def send_choice_picker(
|
|
4707
|
+
self,
|
|
4708
|
+
chat_id: str,
|
|
4709
|
+
title: str,
|
|
4710
|
+
choices: list,
|
|
4711
|
+
session_key: str,
|
|
4712
|
+
on_choice_selected,
|
|
4713
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
4714
|
+
) -> SendResult:
|
|
4715
|
+
clean_choices: list[Dict[str, Any]] = []
|
|
4716
|
+
for choice in choices or []:
|
|
4717
|
+
if not isinstance(choice, dict):
|
|
4718
|
+
continue
|
|
4719
|
+
value = str(choice.get("value") or "").strip()
|
|
4720
|
+
label = str(choice.get("label") or value).strip()
|
|
4721
|
+
if not value or not label:
|
|
4722
|
+
continue
|
|
4723
|
+
clean_choices.append({
|
|
4724
|
+
"value": value,
|
|
4725
|
+
"label": label,
|
|
4726
|
+
"is_current": bool(choice.get("is_current")),
|
|
4727
|
+
})
|
|
4728
|
+
# Inline accepts at most eight action rows. Picker actions use two
|
|
4729
|
+
# buttons per row, so keep the native card within that hard limit.
|
|
4730
|
+
if len(clean_choices) >= _CHOICE_PICKER_MAX_CHOICES:
|
|
4731
|
+
break
|
|
4732
|
+
if not clean_choices:
|
|
4733
|
+
return _send_result(success=False, error="No choices")
|
|
4734
|
+
|
|
4735
|
+
picker_id = secrets.token_hex(6)
|
|
4736
|
+
actions = []
|
|
4737
|
+
for index, choice in enumerate(clean_choices):
|
|
4738
|
+
label = str(choice["label"])
|
|
4739
|
+
if choice["is_current"]:
|
|
4740
|
+
label = f"✓ {label}"
|
|
4741
|
+
actions.append(self._action(f"cp:{picker_id}:{index}", self._short_label(label)))
|
|
4742
|
+
target = self._target_for(chat_id, metadata)
|
|
4743
|
+
result = await self._send_sidecar("/send", {
|
|
4744
|
+
"target": target,
|
|
4745
|
+
"text": str(title or "Choose an option"),
|
|
4746
|
+
"parseMarkdown": self._parse_markdown,
|
|
4747
|
+
"actions": {"rows": self._action_rows(actions)},
|
|
4748
|
+
})
|
|
4749
|
+
if result.success:
|
|
4750
|
+
self._remember(self._choice_picker_sessions, picker_id, {
|
|
4751
|
+
"chat_id": str(chat_id),
|
|
4752
|
+
"choices": clean_choices,
|
|
4753
|
+
"session_key": str(session_key),
|
|
4754
|
+
"on_choice_selected": on_choice_selected,
|
|
4755
|
+
"target": target,
|
|
4756
|
+
"message_id": result.message_id,
|
|
4757
|
+
"created_at": time.monotonic(),
|
|
4758
|
+
})
|
|
4759
|
+
return result
|
|
4760
|
+
|
|
4761
|
+
async def send_update_prompt(
|
|
4762
|
+
self,
|
|
4763
|
+
chat_id: str,
|
|
4764
|
+
prompt: str,
|
|
4765
|
+
default: str = "",
|
|
4766
|
+
session_key: str = "",
|
|
4767
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
4768
|
+
) -> SendResult:
|
|
4769
|
+
prompt_id = secrets.token_hex(6)
|
|
4770
|
+
default_hint = f" (default: {default})" if default else ""
|
|
4771
|
+
target = self._target_for(chat_id, metadata)
|
|
4772
|
+
result = await self._send_sidecar("/send", {
|
|
4773
|
+
"target": target,
|
|
4774
|
+
"text": f"Hermes update needs your input\n\n{prompt}{default_hint}",
|
|
4775
|
+
"parseMarkdown": self._parse_markdown,
|
|
4776
|
+
"actions": {"rows": [{"actions": [
|
|
4777
|
+
self._action(f"up:{prompt_id}:y", "✓ Yes"),
|
|
4778
|
+
self._action(f"up:{prompt_id}:n", "✗ No"),
|
|
4779
|
+
]}]},
|
|
4780
|
+
})
|
|
4781
|
+
if not result.success:
|
|
4782
|
+
# Hermes 0.20.6 treats any non-raising hook call as delivered and
|
|
4783
|
+
# otherwise suppresses its plaintext /approve and /deny fallback.
|
|
4784
|
+
raise RuntimeError(result.error or "failed to send Inline update prompt")
|
|
4785
|
+
self._remember(self._update_prompt_sessions, prompt_id, {
|
|
4786
|
+
"chat_id": str(chat_id),
|
|
4787
|
+
"session_key": str(session_key),
|
|
4788
|
+
"target": target,
|
|
4789
|
+
"message_id": result.message_id,
|
|
4790
|
+
"created_at": time.monotonic(),
|
|
4380
4791
|
})
|
|
4381
4792
|
return result
|
|
4382
4793
|
|
|
@@ -4580,7 +4991,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4580
4991
|
return self._target_for(chat_id, metadata)
|
|
4581
4992
|
|
|
4582
4993
|
@staticmethod
|
|
4583
|
-
def _remember(mapping: OrderedDict, key:
|
|
4994
|
+
def _remember(mapping: OrderedDict, key: Any, value: Any, limit: int = 512) -> None:
|
|
4584
4995
|
if key in mapping:
|
|
4585
4996
|
del mapping[key]
|
|
4586
4997
|
mapping[key] = value
|
|
@@ -5046,6 +5457,7 @@ def register(ctx) -> None:
|
|
|
5046
5457
|
pii_safe=False,
|
|
5047
5458
|
allow_update_command=True,
|
|
5048
5459
|
platform_hint=_tools.INLINE_PLATFORM_GUIDANCE,
|
|
5460
|
+
**_target_registration_hooks(),
|
|
5049
5461
|
)
|
|
5050
5462
|
register_command = getattr(ctx, "register_command", None)
|
|
5051
5463
|
if callable(register_command):
|
package/plugin/inline/cli.py
CHANGED
|
@@ -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 hashlib
|
|
10
11
|
import json
|
|
11
12
|
import os
|
|
12
13
|
import re
|
|
@@ -26,7 +27,7 @@ _CLI_INSTALL_URL = "https://inline.chat/cli/install.sh"
|
|
|
26
27
|
_MAX_TOKEN_BYTES = 16 * 1024
|
|
27
28
|
_MAX_PROBE_RESPONSE_BYTES = 64 * 1024
|
|
28
29
|
_MACHINE_SETUP_PROTOCOL_VERSION = 1
|
|
29
|
-
_PROBE_USER_AGENT = "inline-hermes-agent-adapter/0.0.
|
|
30
|
+
_PROBE_USER_AGENT = "inline-hermes-agent-adapter/0.0.13"
|
|
30
31
|
_ENV_REFERENCE_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
|
|
31
32
|
|
|
32
33
|
|
|
@@ -463,6 +464,7 @@ def _status(args) -> int:
|
|
|
463
464
|
"sidecar": sidecar,
|
|
464
465
|
"node": node,
|
|
465
466
|
"probeRequested": probe_requested,
|
|
467
|
+
"gateway": _gateway_status(),
|
|
466
468
|
**({"probe": probe} if probe is not None else {}),
|
|
467
469
|
}
|
|
468
470
|
if getattr(args, "json", False):
|
|
@@ -480,6 +482,51 @@ def _status(args) -> int:
|
|
|
480
482
|
return 0 if runtime_usable and (not probe_requested or ready) else 1
|
|
481
483
|
|
|
482
484
|
|
|
485
|
+
def _gateway_status() -> dict:
|
|
486
|
+
"""Project local runtime facts without changing the credential-status contract."""
|
|
487
|
+
unavailable = {"supported": False, "ready": False, "reason": "runtime_status_unavailable"}
|
|
488
|
+
try:
|
|
489
|
+
from gateway.status import read_runtime_status, get_runtime_status_running_pid
|
|
490
|
+
from hermes_constants import get_hermes_home
|
|
491
|
+
|
|
492
|
+
runtime = read_runtime_status()
|
|
493
|
+
# Also validate this helper contract for an absent runtime: older hosts
|
|
494
|
+
# lack expected_home and must not claim support until after installation.
|
|
495
|
+
pid = get_runtime_status_running_pid(runtime if isinstance(runtime, dict) else {}, expected_home=get_hermes_home())
|
|
496
|
+
if not isinstance(runtime, dict):
|
|
497
|
+
return {"supported": True, "ready": False, "reason": "missing_status"}
|
|
498
|
+
# Return only an opaque generation, never process IDs or raw state.
|
|
499
|
+
start_time = runtime.get("start_time")
|
|
500
|
+
valid_identity = type(pid) is int and pid > 0 and type(start_time) is int and start_time >= 0
|
|
501
|
+
if pid and not valid_identity:
|
|
502
|
+
return {"supported": False, "ready": False, "reason": "runtime_identity_unavailable"}
|
|
503
|
+
generation = hashlib.sha256(f"inline-gateway:{pid}:{start_time}".encode()).hexdigest() if valid_identity else None
|
|
504
|
+
state = runtime.get("gateway_state")
|
|
505
|
+
platforms = runtime.get("platforms")
|
|
506
|
+
inline = platforms.get("inline") if isinstance(platforms, dict) else None
|
|
507
|
+
platform_state = inline.get("state") if isinstance(inline, dict) else None
|
|
508
|
+
state = state if state in ("starting", "running", "draining", "stopping", "stopped", "startup_failed") else "unknown"
|
|
509
|
+
platform_state = platform_state if platform_state in ("connected", "connecting", "retrying", "fatal", "disconnected") else "unknown"
|
|
510
|
+
if pid and isinstance(inline, dict) and ("writer_pid" not in inline or "writer_start_time" not in inline):
|
|
511
|
+
return {"supported": False, "ready": False, "reason": "runtime_writer_identity_unavailable"}
|
|
512
|
+
# Runtime status can retain platform entries from the prior process.
|
|
513
|
+
# Only the current process's own connected projection is readiness proof.
|
|
514
|
+
writer_matches = isinstance(inline, dict) and inline.get("writer_pid") == pid and inline.get("writer_start_time") == start_time
|
|
515
|
+
ready = generation is not None and writer_matches and state == "running" and platform_state == "connected"
|
|
516
|
+
return {
|
|
517
|
+
"supported": True,
|
|
518
|
+
"ready": ready,
|
|
519
|
+
**({"generation": generation} if generation is not None else {}),
|
|
520
|
+
"gatewayState": state,
|
|
521
|
+
"platformState": platform_state,
|
|
522
|
+
"reason": "ready" if ready else "stale_pid" if not pid else "gateway_not_running" if state != "running" else "platform_status_stale" if not writer_matches else "platform_not_connected",
|
|
523
|
+
}
|
|
524
|
+
except Exception:
|
|
525
|
+
# Older Hermes versions may not expose the helpers. Never return raw
|
|
526
|
+
# runtime files or exceptions: they can contain provider/credential data.
|
|
527
|
+
return unavailable
|
|
528
|
+
|
|
529
|
+
|
|
483
530
|
def _plugin_version() -> str:
|
|
484
531
|
"""Read the separately installed plugin version without package-manager state."""
|
|
485
532
|
try:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
name: inline-platform
|
|
2
2
|
label: Inline
|
|
3
3
|
kind: platform
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.13
|
|
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
|
|
@@ -110,6 +110,10 @@ optional_env:
|
|
|
110
110
|
description: "Deliver Inline lifecycle events such as edits, deletes, and participant changes as synthetic messages (true/false, default false)"
|
|
111
111
|
prompt: "Deliver lifecycle events? (true/false)"
|
|
112
112
|
password: false
|
|
113
|
+
- name: INLINE_REACTIONS
|
|
114
|
+
description: "Show processing reactions on handled inbound messages: eyes while working, then success or failure (true/false, default false)"
|
|
115
|
+
prompt: "Show processing reactions? (true/false)"
|
|
116
|
+
password: false
|
|
113
117
|
- name: INLINE_MENTION_PATTERNS
|
|
114
118
|
description: "Mention wake-word regexes, JSON list or comma/newline-separated"
|
|
115
119
|
prompt: "Mention patterns"
|