@inline-chat/hermes-agent-adapter 0.0.10 → 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.10 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.10",
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,12 +62,18 @@ _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
68
71
  _CHAT_INFO_CACHE_MAX_SIZE = 512
69
72
  _BOT_SETTINGS_CHAT_INFO_TIMEOUT_SECONDS = 2.0
70
- _BOT_SETTINGS_MODEL_CATALOG_CACHE_SECONDS = 60
73
+ # Provider discovery may invoke host CLIs. Keep repeated panel opens and
74
+ # unrelated mutations off that slow path while always injecting the live
75
+ # current/default model into the document below.
76
+ _BOT_SETTINGS_MODEL_CATALOG_CACHE_SECONDS = 5 * 60
71
77
  _DEFAULT_CONTEXT_BACKFILL = "selective"
72
78
  _CONTEXT_BACKFILL_MODES = {"off", "selective", "always"}
73
79
  _DEFAULT_THREAD_CONTEXT_LIMIT = 30
@@ -88,6 +94,7 @@ _SIDECAR_DIR = Path(__file__).parent / "sidecar"
88
94
  _SIDECAR_ENTRY = _SIDECAR_DIR / "index.mjs"
89
95
  _DEFAULT_MEDIA_MAX_MB = 25
90
96
  _DEFAULT_UPLOAD_MAX_MB = 300
97
+ _MAX_INLINE_ID = 9_223_372_036_854_775_807
91
98
  _MIN_NODE_MAJOR = 20
92
99
  _DEFAULT_CONNECT_TIMEOUT_MS = 20_000
93
100
  _INLINE_COMMAND_LIMIT = 100
@@ -348,13 +355,81 @@ def _target_from_chat_id(chat_id: str) -> Dict[str, str]:
348
355
  raw = str(chat_id or "").strip()
349
356
  if raw.startswith("inline:"):
350
357
  raw = raw[len("inline:"):].strip()
351
- if raw.startswith("chat:"):
352
- return {"chatId": raw[len("chat:"):].strip()}
358
+ if raw.startswith(("chat:", "thread:")):
359
+ return {"chatId": raw.split(":", 1)[1].strip()}
353
360
  if raw.startswith("user:"):
354
361
  return {"userId": raw[len("user:"):].strip()}
355
362
  return {"chatId": raw}
356
363
 
357
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
+
358
433
  def _inline_sender_profile(event: Dict[str, Any], message: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
359
434
  raw = event.get("sender")
360
435
  if not isinstance(raw, dict) and isinstance(message, dict):
@@ -670,6 +745,7 @@ def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]:
670
745
  "text_chunk_limit",
671
746
  "reply_threads",
672
747
  "system_events",
748
+ "reactions",
673
749
  "channel_prompts",
674
750
  "channel_skill_bindings",
675
751
  "typing_indicator",
@@ -735,6 +811,10 @@ class InlineAdapter(BasePlatformAdapter):
735
811
  extra.get("system_events") if "system_events" in extra else os.getenv("INLINE_SYSTEM_EVENTS"),
736
812
  False,
737
813
  )
814
+ self._processing_reactions = _truthy(
815
+ extra.get("reactions") if "reactions" in extra else os.getenv("INLINE_REACTIONS"),
816
+ False,
817
+ )
738
818
  self._sync_commands = _truthy(
739
819
  extra.get("sync_commands") if "sync_commands" in extra else os.getenv("INLINE_SYNC_COMMANDS"),
740
820
  True,
@@ -874,7 +954,11 @@ class InlineAdapter(BasePlatformAdapter):
874
954
  self._clarify_sessions: "OrderedDict[str, str]" = OrderedDict()
875
955
  self._approval_sessions: "OrderedDict[str, str]" = OrderedDict()
876
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()
877
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()
878
962
  self._thread_action_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
879
963
  self._chat_info_cache: "OrderedDict[str, tuple[float, Dict[str, Any]]]" = OrderedDict()
880
964
  self._bot_agent_cache: "OrderedDict[str, tuple[float, Dict[str, Any]]]" = OrderedDict()
@@ -1058,7 +1142,7 @@ class InlineAdapter(BasePlatformAdapter):
1058
1142
  "actor_id": actor_id,
1059
1143
  "chat_type": chat_type,
1060
1144
  "is_reply_thread": is_reply_thread,
1061
- "following": self._chat_follow_mode_following(info),
1145
+ "following": None if chat_type == "dm" else self._chat_follow_mode_following(info),
1062
1146
  "reply_threads": self._reply_thread_mode_for_chat(scope_chat_id),
1063
1147
  "can_set_default_model": can_modify,
1064
1148
  "source": source,
@@ -1250,13 +1334,13 @@ class InlineAdapter(BasePlatformAdapter):
1250
1334
  }},
1251
1335
  })
1252
1336
 
1253
- sections: List[Dict[str, Any]] = [
1254
- {"id": "runtime", "items": runtime_items},
1255
- {"id": "attention", "items": [{
1337
+ sections: List[Dict[str, Any]] = [{"id": "runtime", "items": runtime_items}]
1338
+ if context.get("following") is not None:
1339
+ sections.append({"id": "attention", "items": [{
1256
1340
  "id": "following", "label": "Following", "description": "Wake on eligible activity.", **common,
1257
1341
  "control": {"oneofKind": "toggle", "toggle": {"value": bool(context.get("following"))}},
1258
- }]},
1259
- {"id": "replies", "items": [{
1342
+ }]})
1343
+ sections.append({"id": "replies", "items": [{
1260
1344
  "id": "reply-threads", "label": "Reply in threads", **common,
1261
1345
  "control": {"oneofKind": "select", "select": {
1262
1346
  "value": context.get("reply_threads") or "auto",
@@ -1266,8 +1350,7 @@ class InlineAdapter(BasePlatformAdapter):
1266
1350
  {"value": "off", "label": "Off", "description": "Stay in chat.", "disabled": False},
1267
1351
  ],
1268
1352
  }},
1269
- }]},
1270
- ]
1353
+ }]})
1271
1354
  if disabled:
1272
1355
  sections.append({"id": "access", "items": [{
1273
1356
  "id": "read-only", "label": "Read-only", "disabled": False,
@@ -1351,14 +1434,11 @@ class InlineAdapter(BasePlatformAdapter):
1351
1434
  runner = context.get("runner")
1352
1435
  source = context.get("source")
1353
1436
  if item_id == "following" and value_body.get("oneofKind") == "boolValue":
1437
+ if context.get("following") is None:
1438
+ raise ValueError("following unavailable")
1354
1439
  enabled = bool(value_body.get("boolValue"))
1355
- target = (
1356
- {"userId": context["actor_id"]}
1357
- if context.get("chat_type") == "dm"
1358
- else {"chatId": context["chat_id"]}
1359
- )
1360
1440
  await self._sidecar_call("/follow-mode", {
1361
- "target": target,
1441
+ "target": {"chatId": context["chat_id"]},
1362
1442
  "mode": "following" if enabled else "unfollowed",
1363
1443
  })
1364
1444
  self._invalidate_chat_info(context["chat_id"])
@@ -3583,6 +3663,14 @@ class InlineAdapter(BasePlatformAdapter):
3583
3663
  if not await self._action_allowed(event):
3584
3664
  return True
3585
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)
3586
3674
  if action_id.startswith("cl:"):
3587
3675
  if not await self._action_allowed(event):
3588
3676
  return True
@@ -3806,6 +3894,121 @@ class InlineAdapter(BasePlatformAdapter):
3806
3894
  def _is_model_picker_action(action_id: str) -> bool:
3807
3895
  return action_id.startswith(("mp:", "mpg:", "mm:", "mc:", "mg:", "mb:", "mx:"))
3808
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
+
3809
4012
  @staticmethod
3810
4013
  def _split_model_picker_action(action_id: str) -> Optional[tuple[str, str, str]]:
3811
4014
  parts = action_id.split(":", 2)
@@ -3832,6 +4035,9 @@ class InlineAdapter(BasePlatformAdapter):
3832
4035
  if not state:
3833
4036
  await self._answer_action(interaction_id, "Picker expired")
3834
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
3835
4041
 
3836
4042
  if kind == "mp":
3837
4043
  return await self._select_model_provider(event, picker_id, state, value)
@@ -3954,6 +4160,10 @@ class InlineAdapter(BasePlatformAdapter):
3954
4160
  self._model_picker_sessions.pop(picker_id, None)
3955
4161
  await self._answer_action(interaction_id, "Picker expired")
3956
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)
3957
4167
  failed = False
3958
4168
  try:
3959
4169
  result_text = callback(str(state.get("chat_id") or event.get("chatId") or ""), model_id, provider_slug)
@@ -3961,10 +4171,9 @@ class InlineAdapter(BasePlatformAdapter):
3961
4171
  result_text = await result_text
3962
4172
  result_text = str(result_text or "Model switched.")
3963
4173
  except Exception as exc:
3964
- logger.exception("[inline] model picker switch failed")
3965
- 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."
3966
4176
  failed = True
3967
- self._model_picker_sessions.pop(picker_id, None)
3968
4177
  await self._edit_action_message(event, result_text, {"rows": []})
3969
4178
  await self._answer_action(interaction_id, "Switch failed" if failed else "Model switched")
3970
4179
  return True
@@ -4095,6 +4304,35 @@ class InlineAdapter(BasePlatformAdapter):
4095
4304
  ) -> bool:
4096
4305
  return False
4097
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
+
4098
4336
  async def edit_message(
4099
4337
  self,
4100
4338
  chat_id: str,
@@ -4210,6 +4448,88 @@ class InlineAdapter(BasePlatformAdapter):
4210
4448
  except Exception:
4211
4449
  pass
4212
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
+
4213
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:
4214
4534
  return await self._send_attachment(chat_id, image_path, "photo", caption, reply_to, metadata=metadata)
4215
4535
 
@@ -4362,8 +4682,9 @@ class InlineAdapter(BasePlatformAdapter):
4362
4682
  if not clean_providers:
4363
4683
  return await self.send(chat_id, "No authenticated models are available for this session.", metadata=metadata)
4364
4684
  picker_id = secrets.token_hex(6)
4685
+ target = self._target_for(chat_id, metadata)
4365
4686
  result = await self._send_sidecar("/send", {
4366
- "target": self._target_for(chat_id, metadata),
4687
+ "target": target,
4367
4688
  "text": self._model_picker_text(current_model, current_provider),
4368
4689
  "parseMarkdown": self._parse_markdown,
4369
4690
  "actions": self._build_provider_actions(picker_id, clean_providers),
@@ -4378,6 +4699,95 @@ class InlineAdapter(BasePlatformAdapter):
4378
4699
  "current_model": str(current_model or ""),
4379
4700
  "current_provider": str(current_provider or ""),
4380
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(),
4381
4791
  })
4382
4792
  return result
4383
4793
 
@@ -4581,7 +4991,7 @@ class InlineAdapter(BasePlatformAdapter):
4581
4991
  return self._target_for(chat_id, metadata)
4582
4992
 
4583
4993
  @staticmethod
4584
- 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:
4585
4995
  if key in mapping:
4586
4996
  del mapping[key]
4587
4997
  mapping[key] = value
@@ -5047,6 +5457,7 @@ def register(ctx) -> None:
5047
5457
  pii_safe=False,
5048
5458
  allow_update_command=True,
5049
5459
  platform_hint=_tools.INLINE_PLATFORM_GUIDANCE,
5460
+ **_target_registration_hooks(),
5050
5461
  )
5051
5462
  register_command = getattr(ctx, "register_command", None)
5052
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.10"
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.10
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"
@@ -45251,6 +45251,7 @@ var portableCoreV1Vector = {
45251
45251
  };
45252
45252
  // src/sidecar/contract.ts
45253
45253
  var MAX_INLINE_ID = 9223372036854775807n;
45254
+ var MAX_UINT64 = 18446744073709551615n;
45254
45255
  var sensitiveUrlParams = new Set([
45255
45256
  "access_token",
45256
45257
  "auth",
@@ -45553,6 +45554,25 @@ function parseInlineId(value, field) {
45553
45554
  throw new SidecarError(`${field} must be a positive signed 64-bit integer`, "bad_format");
45554
45555
  }
45555
45556
  }
45557
+ function parseUnsigned64Id(value, field) {
45558
+ try {
45559
+ if (typeof value === "number" && (!Number.isSafeInteger(value) || value <= 0)) {
45560
+ throw new Error("unsafe number");
45561
+ }
45562
+ const raw = typeof value === "string" ? value.trim() : value;
45563
+ if (typeof raw === "string" && !/^[1-9][0-9]*$/.test(raw))
45564
+ throw new Error("invalid digits");
45565
+ if (typeof raw !== "string" && typeof raw !== "bigint" && typeof raw !== "number") {
45566
+ throw new Error("invalid type");
45567
+ }
45568
+ const parsed = BigInt(raw);
45569
+ if (parsed <= 0n || parsed > MAX_UINT64)
45570
+ throw new Error("out of range");
45571
+ return parsed;
45572
+ } catch {
45573
+ throw new SidecarError(`${field} must be a positive unsigned 64-bit integer`, "bad_format");
45574
+ }
45575
+ }
45556
45576
  function defaultErrorText(error) {
45557
45577
  return error instanceof Error ? error.message : String(error);
45558
45578
  }
@@ -46579,7 +46599,10 @@ async function endpointAnswerAction(res, body) {
46579
46599
  }
46580
46600
  async function endpointAnswerBotSettings(res, body) {
46581
46601
  const record2 = asRecord(body);
46582
- const requestId = readRequiredInlineId(record2, "requestId");
46602
+ if (record2.requestId == null || record2.requestId === "") {
46603
+ throw new SidecarError("missing requestId", "bad_format");
46604
+ }
46605
+ const requestId = parseUnsigned64Id(record2.requestId, "requestId");
46583
46606
  const response = asRecord(record2.response);
46584
46607
  await client.answerBotChatSettings({ requestId, response });
46585
46608
  writeJson(res, 200, { ok: true, result: {} });