@inline-chat/hermes-agent-adapter 0.0.11 → 0.0.12

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 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.11 inline-platform
150
+ enabled user 0.0.12 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.11",
3
+ "version": "0.0.12",
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",
@@ -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[len("chat:"):].strip()}
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.exception("[inline] model picker switch failed")
3964
- result_text = f"Error switching model: {exc}"
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": self._target_for(chat_id, metadata),
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: str, value: Any, limit: int = 512) -> None:
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):
@@ -26,7 +26,7 @@ _CLI_INSTALL_URL = "https://inline.chat/cli/install.sh"
26
26
  _MAX_TOKEN_BYTES = 16 * 1024
27
27
  _MAX_PROBE_RESPONSE_BYTES = 64 * 1024
28
28
  _MACHINE_SETUP_PROTOCOL_VERSION = 1
29
- _PROBE_USER_AGENT = "inline-hermes-agent-adapter/0.0.11"
29
+ _PROBE_USER_AGENT = "inline-hermes-agent-adapter/0.0.12"
30
30
  _ENV_REFERENCE_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
31
31
 
32
32
 
@@ -1,7 +1,7 @@
1
1
  name: inline-platform
2
2
  label: Inline
3
3
  kind: platform
4
- version: 0.0.11
4
+ version: 0.0.12
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"