@inline-chat/hermes-agent-adapter 0.0.2 → 0.0.4

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
@@ -27,7 +27,7 @@ Supported:
27
27
  - Realtime inbound messages, catch-up, replies to bot messages, and action callbacks.
28
28
  - Outbound text, Markdown parsing, opt-in edit-message streaming, long-message splitting, edits, deletes, typing, and presence.
29
29
  - Inline reply-thread routing, default DM/group auto-threading, `/threads` controls, parent chat metadata, parent/thread prompt fallback, and thread-specific skill bindings.
30
- - Native Hermes `inline` tool for current-chat/thread reads, bounded history and search, exact message lookup, sending, editing, deleting bot-owned messages, reactions, pin/unpin/list pins, typing/presence, and reply-thread creation.
30
+ - Native Hermes `inline` tool for current-chat/thread reads, bounded history and search, exact message lookup, editing/deleting bot-owned messages, reactions, pin/unpin/list pins, reply-thread creation, and avatar presence/status.
31
31
  - Per-turn Inline sender/chat/thread IDs, selective reply/thread/observed context, and parent-thread context, with prompt guidance for sender mentions and current chat/thread Markdown links.
32
32
  - OpenClaw-style entity summaries for live turns and tool-fetched history, including mentions, text links, thread links, thread-title links, code/pre blocks, bot commands, and group mentions as untrusted Hermes context.
33
33
  - DM and group policies, user allowlists, group sender allowlists, mention requirements, strict mention mode, allowed chats, and free-response chats.
@@ -116,7 +116,7 @@ uv run ./hermes plugins list --plain --no-bundled
116
116
  Expected local output includes:
117
117
 
118
118
  ```text
119
- enabled user 0.0.2 inline-platform
119
+ enabled user 0.0.4 inline-platform
120
120
  ```
121
121
 
122
122
  ## Update Or Reinstall
@@ -142,7 +142,7 @@ mismatch, rerun the same command after rebuilding or upgrading the package.
142
142
  against Hermes Agent `0.17.0` from source commit `824f2279`.
143
143
  - Node.js: `>=20` is required for the bundled sidecar. Hermes-managed Node 22,
144
144
  system Node, or an explicit `INLINE_NODE_BIN` path all work.
145
- - Inline transport: the sidecar uses `@inline-chat/realtime-sdk@0.0.11` and is
145
+ - Inline transport: the sidecar uses `@inline-chat/realtime-sdk@0.0.13` and is
146
146
  bundled into the npm package, so Hermes startup does not run `npm install`.
147
147
  - Live sends require a valid Inline user or bot token in `INLINE_TOKEN`,
148
148
  `INLINE_BOT_TOKEN`, `platforms.inline.token`, or `inline.token`.
@@ -249,18 +249,18 @@ Access control follows Hermes' native platform model:
249
249
  | `INLINE_DM_POLICY=open|allowlist|disabled` | Controls direct-message intake. `allowlist` requires `INLINE_ALLOWED_USERS` or config `allow_from`. |
250
250
  | `INLINE_GROUP_POLICY=open|allowlist|disabled` | Controls group intake. `allowlist` requires `INLINE_GROUP_ALLOW_FROM`. |
251
251
  | `INLINE_GROUP_ALLOW_FROM` | Comma-separated Inline user ids allowed to invoke the bot from group chats. |
252
- | `INLINE_REQUIRE_MENTION` | Requires a mention or wake word in groups by default. Replies to the bot are accepted without the wake word. |
253
- | `INLINE_STRICT_MENTION` | Requires a mention or wake word on every group turn, including replies to the bot. Defaults to `false`. |
252
+ | `INLINE_REQUIRE_MENTION` | Requires a mention or wake word in groups by default. Replies to the bot and followed eligible reply/fresh threads are accepted without the wake word. |
253
+ | `INLINE_STRICT_MENTION` | Requires a mention or wake word on every group turn, including replies to the bot and followed threads. Defaults to `false`. |
254
254
  | `INLINE_ALLOWED_CHATS` | Comma-separated group/thread chat ids where the bot may respond. Parent chat ids also match their Inline reply threads. DMs are not filtered. Empty means no chat restriction. |
255
255
  | `INLINE_FREE_RESPONSE_CHATS` | Comma-separated group/thread chat ids where no mention is required. Parent chat ids also match their Inline reply threads. Useful for dedicated agent rooms. |
256
- | `INLINE_REPLY_THREADS` | Creates/uses Inline reply threads for top-level DM and group replies by default. Accepts `true`/`false`, `on`/`off`, `auto`, `thread`, or `flat`; defaults to `true`. |
256
+ | `INLINE_REPLY_THREADS` | Controls top-level reply-thread creation. `auto` creates child reply threads only for large parent chats (`messageId >= 50`) or explicit thread requests; `on` always creates; `off` stays flat. Defaults to `auto`. |
257
257
  | `INLINE_CONTEXT_BACKFILL` | Automatic context mode. `selective` is the default, `off` disables automatic history windows, and `always` restores recent-history backfill on every turn. |
258
258
  | `INLINE_THREAD_CONTEXT_LIMIT` | Max current chat/thread messages for selective thread or mention-gap context. Must be `0` through `100`; defaults to `30`. |
259
259
  | `INLINE_REPLY_CONTEXT_LIMIT` | Max messages in the anchored window around a replied-to Inline message. Must be `0` through `50`; defaults to `10`. |
260
260
  | `INLINE_OBSERVED_CONTEXT_LIMIT` | Max unmentioned group messages kept in the observed-context buffer. Must be `0` through `100`; defaults to `20`. |
261
261
  | `INLINE_OBSERVE_UNMENTIONED_MESSAGES` | Buffers unmentioned group messages that pass chat/user policy but do not wake the bot. Defaults to `true`; set to `false` to disable. |
262
262
  | `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. |
263
- | `INLINE_SETTINGS_PATH` | JSON settings file for per-chat `/threads` overrides. Defaults next to `INLINE_STATE_PATH`; `.env`-like paths are refused. |
263
+ | `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. |
264
264
  | `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. |
265
265
  | `INLINE_MENTION_PATTERNS` | JSON list, comma-separated, or newline-separated regex patterns for group wake words. |
266
266
  | `INLINE_PARSE_MARKDOWN` | Controls whether outbound Hermes Markdown is parsed by Inline. Defaults to `true`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inline-chat/hermes-agent-adapter",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
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",
@@ -59,7 +59,7 @@
59
59
  "check": "bun run typecheck && bun run lint && bun run test"
60
60
  },
61
61
  "dependencies": {
62
- "@inline-chat/realtime-sdk": "0.0.11",
62
+ "@inline-chat/realtime-sdk": "0.0.13",
63
63
  "yaml": "2.9.0"
64
64
  },
65
65
  "devDependencies": {
@@ -78,12 +78,34 @@ _INLINE_COMMAND_LIMIT = 100
78
78
  _INLINE_COMMAND_DESCRIPTION_LIMIT = 256
79
79
  _INLINE_COMMAND_RETRY_RATIO = 0.8
80
80
  _INLINE_COMMAND_RE = re.compile(r"^[a-z0-9_]{1,32}$")
81
+ _REPLY_THREAD_AUTO_CREATE_MIN_MESSAGES = 50
81
82
  _INLINE_THREADS_COMMAND_DESCRIPTION = "Configure Inline reply-thread routing"
82
- _INLINE_THREADS_COMMAND_ARGS = "[status|on|off|auto]"
83
+ _INLINE_THREADS_COMMAND_ARGS = "[status|on|off|auto|reset]"
84
+ _INLINE_THREADS_ACTION_PREFIX = "th:"
85
+ _INLINE_THREADS_ACTION_TTL_SECONDS = 15 * 60
83
86
  _INLINE_LOCAL_COMMANDS = (
84
87
  ("threads", _INLINE_THREADS_COMMAND_DESCRIPTION),
85
88
  )
86
89
  _INLINE_THREAD_COMMAND_RE = re.compile(r"^/(?:thread|threads)(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$", re.IGNORECASE)
90
+ _INLINE_REPLY_THREAD_NEGATION_RE = re.compile(
91
+ r"\b(?:do\s+not|don't|dont|please\s+don't|please\s+dont|no\s+need\s+to)\s+"
92
+ r"(?:create|start|open|make|use|move|take|reply|respond|answer|send|thread)\b[^.!?\n]*\bthread\b|"
93
+ r"\b(?:reply|respond|answer|keep)\s+(?:here|in\s+the\s+main\s+chat|in\s+main\s+chat|"
94
+ r"in\s+the\s+parent\s+chat|in\s+parent\s+chat)\b",
95
+ re.IGNORECASE,
96
+ )
97
+ _INLINE_REPLY_THREAD_INTENT_RE = re.compile(
98
+ r"\b(?:reply|respond|answer|send)\s+(?:in|inside|into|to)\s+(?:a\s+)?"
99
+ r"(?:(?:new|child|reply)\s+)?thread\b|"
100
+ r"\b(?:create|start|open|make|use)\s+(?:a\s+)?(?:(?:new|child|reply)\s+)?thread\b|"
101
+ r"\bkeep\s+(?:(?:this|it|the\s+answer|the\s+reply|the\s+response)\s+)?"
102
+ r"(?:in|inside|into)\s+(?:a\s+)?(?:(?:new|child|reply)\s+)?thread\b|"
103
+ r"\b(?:move|take)\s+(?:(?:this|it|the\s+answer|the\s+reply|the\s+response)\s+)?"
104
+ r"(?:to|into)\s+(?:a\s+)?(?:(?:new|child|reply)\s+)?thread\b|"
105
+ r"\bthread\s+(?:this|the\s+answer|the\s+reply|the\s+response)\b|"
106
+ r"\b(?:threaded\s+(?:reply|response)|(?:reply|respond|answer)\s+threaded)\b",
107
+ re.IGNORECASE,
108
+ )
87
109
  _INLINE_SETTINGS_VERSION = 1
88
110
  _INLINE_ENTITY_LIMIT = 12
89
111
  _INLINE_ENTITY_TEXT_LIMIT = 120
@@ -165,14 +187,16 @@ def _install_inline_display_defaults() -> None:
165
187
  logger.debug("[inline] failed to install display defaults", exc_info=True)
166
188
 
167
189
 
168
- def _thread_replies_enabled(value: Any, default: bool = True) -> bool:
190
+ def _reply_thread_mode(value: Any, default: str = "auto") -> str:
169
191
  if value is None or str(value).strip() == "":
170
192
  return default
171
193
  text = str(value).strip().lower()
172
- if text in {"1", "true", "yes", "on", "auto", "default", "always", "thread", "threads"}:
173
- return True
174
- if text in {"0", "false", "no", "off", "never", "flat", "channel"}:
175
- return False
194
+ if text in {"auto", "default", "reset", "config"}:
195
+ return "auto"
196
+ if text in {"1", "true", "yes", "on", "always", "thread", "threads"}:
197
+ return "on"
198
+ if text in {"0", "false", "no", "off", "never", "flat", "channel", "main"}:
199
+ return "off"
176
200
  logger.warning("[inline] unknown reply_threads value %r; using %s", value, default)
177
201
  return default
178
202
 
@@ -655,9 +679,9 @@ class InlineAdapter(BasePlatformAdapter):
655
679
  else os.getenv("INLINE_OBSERVE_UNMENTIONED_MESSAGES"),
656
680
  True,
657
681
  )
658
- self._reply_threads = _thread_replies_enabled(
682
+ self._reply_thread_mode = _reply_thread_mode(
659
683
  extra.get("reply_threads") if "reply_threads" in extra else os.getenv("INLINE_REPLY_THREADS"),
660
- True,
684
+ "auto",
661
685
  )
662
686
 
663
687
  state_path = extra.get("state_path") or os.getenv("INLINE_STATE_PATH")
@@ -719,6 +743,7 @@ class InlineAdapter(BasePlatformAdapter):
719
743
 
720
744
  self._sidecar_proc: Optional[subprocess.Popen] = None
721
745
  self._sidecar_supervisor_task: Optional[asyncio.Task] = None
746
+ self._command_sync_task: Optional[asyncio.Task] = None
722
747
  self._inbound_task: Optional[asyncio.Task] = None
723
748
  self._inbound_running = False
724
749
  self._http_client: Optional[httpx.AsyncClient] = None
@@ -729,9 +754,13 @@ class InlineAdapter(BasePlatformAdapter):
729
754
  self._approval_sessions: "OrderedDict[str, str]" = OrderedDict()
730
755
  self._slash_sessions: "OrderedDict[str, str]" = OrderedDict()
731
756
  self._model_picker_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
757
+ self._thread_action_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
732
758
  self._chat_info_cache: "OrderedDict[str, tuple[float, Dict[str, Any]]]" = OrderedDict()
733
759
  self._reply_thread_cache: "OrderedDict[str, str]" = OrderedDict()
734
760
  self._reply_thread_parent_reply_ids: "OrderedDict[str, set[str]]" = OrderedDict()
761
+ self._reply_thread_parent_typing_targets: "OrderedDict[str, str]" = OrderedDict()
762
+ self._visible_reply_thread_targets: "OrderedDict[str, None]" = OrderedDict()
763
+ self._active_typing_targets: Dict[str, Dict[str, str]] = {}
735
764
  self._observed_context: "OrderedDict[str, List[Dict[str, Any]]]" = OrderedDict()
736
765
  self._context_backfill_seen: "OrderedDict[str, float]" = OrderedDict()
737
766
  self._reply_thread_overrides = self._load_reply_thread_overrides()
@@ -771,7 +800,7 @@ class InlineAdapter(BasePlatformAdapter):
771
800
  return False
772
801
  return True
773
802
 
774
- def _load_reply_thread_overrides(self) -> Dict[str, bool]:
803
+ def _load_reply_thread_overrides(self) -> Dict[str, str]:
775
804
  if not self._settings_path_allowed():
776
805
  return {}
777
806
  try:
@@ -784,11 +813,17 @@ class InlineAdapter(BasePlatformAdapter):
784
813
  raw = data.get("reply_threads") if isinstance(data, dict) else None
785
814
  if not isinstance(raw, dict):
786
815
  return {}
787
- overrides: Dict[str, bool] = {}
816
+ overrides: Dict[str, str] = {}
788
817
  for chat_id, enabled in raw.items():
789
818
  key = self._chat_key(chat_id)
790
- if key and isinstance(enabled, bool):
791
- overrides[key] = enabled
819
+ if not key:
820
+ continue
821
+ if isinstance(enabled, bool):
822
+ overrides[key] = "on" if enabled else "off"
823
+ continue
824
+ mode = _reply_thread_mode(enabled, "")
825
+ if mode:
826
+ overrides[key] = mode
792
827
  return overrides
793
828
 
794
829
  def _save_reply_thread_overrides(self) -> None:
@@ -806,22 +841,62 @@ class InlineAdapter(BasePlatformAdapter):
806
841
  except Exception as exc:
807
842
  logger.warning("[inline] failed to save Inline adapter settings: %s", exc)
808
843
 
809
- def _reply_threads_for_chat(self, chat_id: str, parent_chat_id: Optional[str] = None) -> bool:
844
+ def _reply_thread_mode_for_chat(self, chat_id: str, parent_chat_id: Optional[str] = None) -> str:
810
845
  key = self._chat_key(parent_chat_id or chat_id)
811
846
  if key in self._reply_thread_overrides:
812
847
  return self._reply_thread_overrides[key]
813
- return self._reply_threads
848
+ return self._reply_thread_mode
814
849
 
815
- def _set_reply_threads_for_chat(self, chat_id: str, value: Optional[bool]) -> None:
850
+ def _reply_threads_for_chat(self, chat_id: str, parent_chat_id: Optional[str] = None) -> bool:
851
+ return self._reply_thread_mode_for_chat(chat_id, parent_chat_id) != "off"
852
+
853
+ def _set_reply_threads_for_chat(self, chat_id: str, value: Optional[str]) -> None:
816
854
  key = self._chat_key(chat_id)
817
855
  if not key:
818
856
  return
819
857
  if value is None:
820
858
  self._reply_thread_overrides.pop(key, None)
821
859
  else:
822
- self._reply_thread_overrides[key] = value
860
+ self._reply_thread_overrides[key] = _reply_thread_mode(value, "auto")
823
861
  self._save_reply_thread_overrides()
824
862
 
863
+ @staticmethod
864
+ def _positive_int(value: Any) -> Optional[int]:
865
+ text = str(value or "").strip()
866
+ if not re.fullmatch(r"[1-9]\d*", text):
867
+ return None
868
+ try:
869
+ return int(text)
870
+ except ValueError:
871
+ return None
872
+
873
+ @staticmethod
874
+ def _has_reply_thread_intent(text: str) -> bool:
875
+ normalized = re.sub(r"\s+", " ", str(text or "").strip())
876
+ if not normalized:
877
+ return False
878
+ if _INLINE_REPLY_THREAD_NEGATION_RE.search(normalized):
879
+ return False
880
+ return bool(_INLINE_REPLY_THREAD_INTENT_RE.search(normalized))
881
+
882
+ def _should_create_reply_thread_for_message(
883
+ self,
884
+ *,
885
+ chat_id: str,
886
+ parent_chat_id: Optional[str],
887
+ msg_id: str,
888
+ text: str,
889
+ ) -> bool:
890
+ mode = self._reply_thread_mode_for_chat(chat_id, parent_chat_id)
891
+ if mode == "off":
892
+ return False
893
+ if mode == "on":
894
+ return True
895
+ if self._has_reply_thread_intent(text):
896
+ return True
897
+ message_id = self._positive_int(msg_id)
898
+ return message_id is not None and message_id >= _REPLY_THREAD_AUTO_CREATE_MIN_MESSAGES
899
+
825
900
  @staticmethod
826
901
  def _id_allowed(entries: set[str], value: str) -> bool:
827
902
  normalized = str(value or "").strip().lower()
@@ -889,10 +964,89 @@ class InlineAdapter(BasePlatformAdapter):
889
964
  return "on"
890
965
  if action in {"off", "disable", "disabled", "false", "flat", "channel"}:
891
966
  return "off"
892
- if action in {"auto", "default", "reset", "config"}:
967
+ if action in {"auto", "config"}:
893
968
  return "auto"
969
+ if action in {"reset", "default", "inherit", "clear"}:
970
+ return "reset"
894
971
  return "help"
895
972
 
973
+ def _reply_thread_status_body(self, target_chat_id: str, *, controls: bool = True, updated: bool = False) -> str:
974
+ mode = self._reply_thread_mode_for_chat(target_chat_id)
975
+ key = self._chat_key(target_chat_id)
976
+ has_override = key in self._reply_thread_overrides
977
+ scope = "chat override" if has_override else "global default"
978
+ if mode == "on":
979
+ behavior = "Top-level replies will start or reuse Inline reply threads."
980
+ elif mode == "auto":
981
+ behavior = (
982
+ "Top-level replies stay in the parent chat until "
983
+ f"message #{_REPLY_THREAD_AUTO_CREATE_MIN_MESSAGES} or an explicit thread request."
984
+ )
985
+ else:
986
+ behavior = "Top-level replies stay in the parent chat."
987
+ first_line = f"Inline reply threads are {mode} for this chat ({scope})."
988
+ if updated:
989
+ first_line = f"Inline reply threads updated: {mode} for this chat ({scope})."
990
+ footer = "Existing Inline reply threads are always preserved."
991
+ if controls:
992
+ footer = f"{footer} Use Auto, On, Off, or Reset."
993
+ return (
994
+ f"{first_line}\n"
995
+ f"{behavior}\n"
996
+ f"{footer}"
997
+ )
998
+
999
+ def _new_thread_action_session(self, *, display_chat_id: str, target_chat_id: str) -> str:
1000
+ session_id = secrets.token_hex(6)
1001
+ self._remember(self._thread_action_sessions, session_id, {
1002
+ "created_at": time.time(),
1003
+ "display_chat_id": self._chat_key(display_chat_id),
1004
+ "target_chat_id": self._chat_key(target_chat_id),
1005
+ })
1006
+ return session_id
1007
+
1008
+ def _thread_action_state(self, session_id: str, chat_id: str) -> Optional[Dict[str, Any]]:
1009
+ state = self._thread_action_sessions.get(session_id)
1010
+ if not state:
1011
+ return None
1012
+ created_at = float(state.get("created_at") or 0)
1013
+ if created_at and time.time() - created_at > _INLINE_THREADS_ACTION_TTL_SECONDS:
1014
+ self._thread_action_sessions.pop(session_id, None)
1015
+ return None
1016
+ display_chat_id = self._chat_key(state.get("display_chat_id"))
1017
+ if display_chat_id and display_chat_id != self._chat_key(chat_id):
1018
+ return None
1019
+ return state
1020
+
1021
+ def _thread_status_actions(self, *, display_chat_id: str, target_chat_id: str, session_id: Optional[str] = None) -> Dict[str, Any]:
1022
+ if not session_id:
1023
+ session_id = self._new_thread_action_session(display_chat_id=display_chat_id, target_chat_id=target_chat_id)
1024
+ prefix = f"{_INLINE_THREADS_ACTION_PREFIX}{session_id}:"
1025
+ rows = [{"actions": [
1026
+ self._action(f"{prefix}auto", "Auto"),
1027
+ self._action(f"{prefix}on", "On"),
1028
+ self._action(f"{prefix}off", "Off"),
1029
+ ]}]
1030
+ if self._chat_key(target_chat_id) in self._reply_thread_overrides:
1031
+ rows.append({"actions": [self._action(f"{prefix}reset", "Reset")]})
1032
+ return {"rows": rows}
1033
+
1034
+ async def _send_thread_status(
1035
+ self,
1036
+ *,
1037
+ chat_id: str,
1038
+ target_chat_id: str,
1039
+ reply_to: Optional[str],
1040
+ metadata: Optional[Dict[str, Any]],
1041
+ ) -> SendResult:
1042
+ return await self.send(
1043
+ chat_id,
1044
+ self._reply_thread_status_body(target_chat_id),
1045
+ reply_to=reply_to,
1046
+ metadata=metadata,
1047
+ actions=self._thread_status_actions(display_chat_id=chat_id, target_chat_id=target_chat_id),
1048
+ )
1049
+
896
1050
  async def _handle_thread_command(
897
1051
  self,
898
1052
  *,
@@ -910,31 +1064,24 @@ class InlineAdapter(BasePlatformAdapter):
910
1064
  metadata = {"thread_id": thread_id} if thread_id else None
911
1065
  target_chat_id = parent_chat_id or chat_id
912
1066
  if action == "on":
913
- self._set_reply_threads_for_chat(target_chat_id, True)
1067
+ self._set_reply_threads_for_chat(target_chat_id, "on")
914
1068
  elif action == "off":
915
- self._set_reply_threads_for_chat(target_chat_id, False)
1069
+ self._set_reply_threads_for_chat(target_chat_id, "off")
916
1070
  elif action == "auto":
1071
+ self._set_reply_threads_for_chat(target_chat_id, "auto")
1072
+ elif action == "reset":
917
1073
  self._set_reply_threads_for_chat(target_chat_id, None)
918
1074
 
919
1075
  if action == "help":
920
- body = "Usage: /threads status, /threads on, /threads off, or /threads auto."
1076
+ body = "Usage: /threads status, /threads auto, /threads on, /threads off, or /threads reset."
1077
+ await self.send(chat_id, body, reply_to=msg_id, metadata=metadata)
921
1078
  else:
922
- enabled = self._reply_threads_for_chat(target_chat_id)
923
- key = self._chat_key(target_chat_id)
924
- has_override = key in self._reply_thread_overrides
925
- scope = "chat override" if has_override else "default"
926
- state = "on" if enabled else "off"
927
- behavior = (
928
- "Top-level replies will start or reuse Inline reply threads."
929
- if enabled
930
- else "Top-level replies stay in the parent chat."
931
- )
932
- body = (
933
- f"Inline reply threads are {state} for this chat ({scope}).\n"
934
- f"{behavior}\n"
935
- "Existing Inline reply threads are always preserved. Use /threads on, /threads off, or /threads auto."
1079
+ await self._send_thread_status(
1080
+ chat_id=chat_id,
1081
+ target_chat_id=target_chat_id,
1082
+ reply_to=msg_id,
1083
+ metadata=metadata,
936
1084
  )
937
- await self.send(chat_id, body, reply_to=msg_id, metadata=metadata)
938
1085
  return True
939
1086
 
940
1087
  @property
@@ -953,7 +1100,7 @@ class InlineAdapter(BasePlatformAdapter):
953
1100
  if node_error:
954
1101
  self._set_fatal_error("NODE_UNSUPPORTED", node_error, retryable=False)
955
1102
  return False
956
- self._http_client = httpx.AsyncClient(timeout=30.0)
1103
+ self._http_client = httpx.AsyncClient(timeout=30.0, trust_env=False)
957
1104
  if self._autostart_sidecar:
958
1105
  try:
959
1106
  await self._start_sidecar()
@@ -963,15 +1110,24 @@ class InlineAdapter(BasePlatformAdapter):
963
1110
  await self._http_client.aclose()
964
1111
  self._http_client = None
965
1112
  return False
966
- await self._sync_bot_commands()
967
1113
  self._inbound_running = True
968
1114
  self._inbound_task = asyncio.get_event_loop().create_task(self._inbound_loop())
969
1115
  self._mark_connected()
970
1116
  logger.info("[inline] connected via sidecar on %s:%d", self._sidecar_bind, self._sidecar_port)
1117
+ self._schedule_bot_command_sync()
971
1118
  return True
972
1119
 
973
1120
  async def disconnect(self) -> None:
974
1121
  self._inbound_running = False
1122
+ if self._command_sync_task is not None:
1123
+ self._command_sync_task.cancel()
1124
+ try:
1125
+ await self._command_sync_task
1126
+ except asyncio.CancelledError:
1127
+ pass
1128
+ except Exception:
1129
+ pass
1130
+ self._command_sync_task = None
975
1131
  if self._inbound_task is not None:
976
1132
  self._inbound_task.cancel()
977
1133
  try:
@@ -999,7 +1155,7 @@ class InlineAdapter(BasePlatformAdapter):
999
1155
  env["INLINE_SIDECAR_TOKEN"] = self._sidecar_token
1000
1156
  env["INLINE_STATE_PATH"] = str(self._state_path)
1001
1157
  env["INLINE_UPLOAD_MAX_MB"] = f"{self._upload_max_mb:g}"
1002
- env["INLINE_SIDECAR_WATCH_STDIN"] = "1"
1158
+ env["INLINE_SIDECAR_WATCH_STDIN"] = "0" if env.get("INLINE_SIDECAR_TEST_MOCK") == "1" else "1"
1003
1159
 
1004
1160
  self._sidecar_proc = subprocess.Popen(
1005
1161
  [self._node_bin, str(_SIDECAR_ENTRY)],
@@ -1013,7 +1169,7 @@ class InlineAdapter(BasePlatformAdapter):
1013
1169
 
1014
1170
  deadline = time.time() + (self._connect_timeout_ms / 1000.0)
1015
1171
  last_error: Optional[Exception] = None
1016
- async with httpx.AsyncClient(timeout=2.0) as client:
1172
+ async with httpx.AsyncClient(timeout=2.0, trust_env=False) as client:
1017
1173
  while time.time() < deadline:
1018
1174
  if self._sidecar_proc.poll() is not None:
1019
1175
  raise RuntimeError(f"Inline sidecar exited with code {self._sidecar_proc.returncode}")
@@ -1093,6 +1249,24 @@ class InlineAdapter(BasePlatformAdapter):
1093
1249
  self._sidecar_supervisor_task.cancel()
1094
1250
  self._sidecar_supervisor_task = None
1095
1251
 
1252
+ def _schedule_bot_command_sync(self) -> None:
1253
+ if not self._sync_commands:
1254
+ return
1255
+ if self._command_sync_task is not None and not self._command_sync_task.done():
1256
+ return
1257
+ self._command_sync_task = asyncio.get_event_loop().create_task(self._run_bot_command_sync())
1258
+
1259
+ async def _run_bot_command_sync(self) -> None:
1260
+ try:
1261
+ await self._sync_bot_commands()
1262
+ except asyncio.CancelledError:
1263
+ raise
1264
+ except Exception as exc:
1265
+ logger.warning("[inline] bot command sync failed: %s", exc)
1266
+ finally:
1267
+ if asyncio.current_task() is self._command_sync_task:
1268
+ self._command_sync_task = None
1269
+
1096
1270
  async def _sync_bot_commands(self) -> None:
1097
1271
  if not self._sync_commands:
1098
1272
  return
@@ -1324,7 +1498,12 @@ class InlineAdapter(BasePlatformAdapter):
1324
1498
  if mention_gate_active:
1325
1499
  mentioned = bool(msg.get("mentioned")) or self._matches_mention(text)
1326
1500
  reply_wakes_thread = reply_to_is_own and not self._strict_mention
1327
- if not mentioned and not reply_wakes_thread:
1501
+ follow_mode_wakes_thread = (
1502
+ self._chat_follow_mode_following(chat_info)
1503
+ and self._chat_follow_mode_mention_eligible(chat_info)
1504
+ and not self._strict_mention
1505
+ )
1506
+ if not mentioned and not reply_wakes_thread and not follow_mode_wakes_thread:
1328
1507
  self._remember_observed_context(chat_id, msg, text)
1329
1508
  return
1330
1509
  if mentioned:
@@ -1334,7 +1513,12 @@ class InlineAdapter(BasePlatformAdapter):
1334
1513
  if (
1335
1514
  not edit
1336
1515
  and not thread_id
1337
- and self._reply_threads_for_chat(chat_id, parent_chat_id)
1516
+ and self._should_create_reply_thread_for_message(
1517
+ chat_id=chat_id,
1518
+ parent_chat_id=parent_chat_id,
1519
+ msg_id=msg_id,
1520
+ text=text,
1521
+ )
1338
1522
  ):
1339
1523
  created_thread_id = await self._create_reply_thread(chat_id, msg_id, text, reply_to_id)
1340
1524
  if created_thread_id:
@@ -2203,6 +2387,7 @@ class InlineAdapter(BasePlatformAdapter):
2203
2387
  if cached:
2204
2388
  self._reply_thread_cache.move_to_end(key)
2205
2389
  self._remember_reply_thread_parent_reply_ids(cached, msg_id, reply_to_id)
2390
+ self._remember_reply_thread_parent_typing_target(cached, chat_id)
2206
2391
  return cached
2207
2392
  body = {
2208
2393
  "parentChatId": str(chat_id),
@@ -2222,6 +2407,7 @@ class InlineAdapter(BasePlatformAdapter):
2222
2407
  if len(self._reply_thread_cache) > _CHAT_INFO_CACHE_MAX_SIZE:
2223
2408
  self._reply_thread_cache.popitem(last=False)
2224
2409
  self._remember_reply_thread_parent_reply_ids(thread_id, msg_id, reply_to_id)
2410
+ self._remember_reply_thread_parent_typing_target(thread_id, chat_id)
2225
2411
  return thread_id
2226
2412
 
2227
2413
  def _remember_reply_thread_parent_reply_ids(self, thread_id: str, *message_ids: Optional[str]) -> None:
@@ -2235,6 +2421,27 @@ class InlineAdapter(BasePlatformAdapter):
2235
2421
  if len(self._reply_thread_parent_reply_ids) > _CHAT_INFO_CACHE_MAX_SIZE:
2236
2422
  self._reply_thread_parent_reply_ids.popitem(last=False)
2237
2423
 
2424
+ def _remember_reply_thread_parent_typing_target(self, thread_id: str, parent_chat_id: str) -> None:
2425
+ thread_key = self._chat_key(thread_id)
2426
+ parent_key = self._chat_key(parent_chat_id)
2427
+ if not thread_key or not parent_key:
2428
+ return
2429
+ if thread_key in self._visible_reply_thread_targets:
2430
+ return
2431
+ self._reply_thread_parent_typing_targets[thread_key] = parent_key
2432
+ self._reply_thread_parent_typing_targets.move_to_end(thread_key)
2433
+ if len(self._reply_thread_parent_typing_targets) > _CHAT_INFO_CACHE_MAX_SIZE:
2434
+ self._reply_thread_parent_typing_targets.popitem(last=False)
2435
+
2436
+ def _mark_reply_thread_visible(self, target: Dict[str, str]) -> None:
2437
+ chat_id = self._chat_key(target.get("chatId"))
2438
+ if chat_id:
2439
+ self._reply_thread_parent_typing_targets.pop(chat_id, None)
2440
+ self._visible_reply_thread_targets[chat_id] = None
2441
+ self._visible_reply_thread_targets.move_to_end(chat_id)
2442
+ if len(self._visible_reply_thread_targets) > _CHAT_INFO_CACHE_MAX_SIZE:
2443
+ self._visible_reply_thread_targets.popitem(last=False)
2444
+
2238
2445
  def _reply_to_for_target(self, reply_to: Optional[str], target: Dict[str, str]) -> Optional[str]:
2239
2446
  if not reply_to:
2240
2447
  return None
@@ -2277,6 +2484,34 @@ class InlineAdapter(BasePlatformAdapter):
2277
2484
  text = str(value).strip()
2278
2485
  return text or None
2279
2486
 
2487
+ @staticmethod
2488
+ def _chat_follow_mode_following(info: Dict[str, Any]) -> bool:
2489
+ value = info.get("dialogFollowMode")
2490
+ dialog = info.get("dialog")
2491
+ if value is None and isinstance(dialog, dict):
2492
+ value = dialog.get("followMode")
2493
+ if value is None and isinstance(dialog, dict):
2494
+ value = dialog.get("follow_mode")
2495
+ if value is None:
2496
+ return False
2497
+ if isinstance(value, bool):
2498
+ return False
2499
+ if isinstance(value, int):
2500
+ return value == 1
2501
+ text = str(value).strip().lower()
2502
+ return text in {"1", "following", "follow_mode_following", "dialog_following"}
2503
+
2504
+ @staticmethod
2505
+ def _chat_follow_mode_mention_eligible(info: Dict[str, Any]) -> bool:
2506
+ value = info.get("followModeMentionEligible")
2507
+ if isinstance(value, bool):
2508
+ return value
2509
+ if value is None:
2510
+ return False
2511
+ if isinstance(value, int):
2512
+ return value == 1
2513
+ return str(value).strip().lower() in {"1", "true", "yes", "on"}
2514
+
2280
2515
  @staticmethod
2281
2516
  def _chat_title_from_info(info: Dict[str, Any]) -> Optional[str]:
2282
2517
  value = info.get("title")
@@ -2370,6 +2605,8 @@ class InlineAdapter(BasePlatformAdapter):
2370
2605
  if not await self._action_allowed(event):
2371
2606
  return True
2372
2607
  return await self._handle_slash_action(action_id, chat_id, interaction_id)
2608
+ if action_id.startswith(_INLINE_THREADS_ACTION_PREFIX):
2609
+ return await self._handle_thread_action(event)
2373
2610
  return False
2374
2611
 
2375
2612
  async def _action_allowed(self, event: Dict[str, Any]) -> bool:
@@ -2498,6 +2735,65 @@ class InlineAdapter(BasePlatformAdapter):
2498
2735
  logger.exception("[inline] slash confirm action failed")
2499
2736
  return True
2500
2737
 
2738
+ async def _handle_thread_action(self, event: Dict[str, Any]) -> bool:
2739
+ action_id = str(event.get("actionId") or "")
2740
+ chat_id = str(event.get("chatId") or "")
2741
+ interaction_id = str(event.get("interactionId") or "")
2742
+ parts = action_id.split(":", 2)
2743
+ if len(parts) != 3:
2744
+ await self._answer_action(interaction_id, "Thread controls expired")
2745
+ return True
2746
+ _, session_id, choice = parts
2747
+ if choice not in {"auto", "on", "off", "reset"}:
2748
+ await self._answer_action(interaction_id, "Thread controls expired")
2749
+ return True
2750
+ state = self._thread_action_state(session_id, chat_id)
2751
+ if not state:
2752
+ await self._answer_action(interaction_id, "Thread controls expired")
2753
+ return True
2754
+ target_chat_id = self._chat_key(state.get("target_chat_id"))
2755
+ if not target_chat_id:
2756
+ await self._answer_action(interaction_id, "Thread controls expired")
2757
+ return True
2758
+ if not await self._thread_action_allowed(event, state):
2759
+ return True
2760
+ try:
2761
+ if choice == "reset":
2762
+ self._set_reply_threads_for_chat(target_chat_id, None)
2763
+ else:
2764
+ self._set_reply_threads_for_chat(target_chat_id, choice)
2765
+ mode = self._reply_thread_mode_for_chat(target_chat_id)
2766
+ await self._edit_action_message(
2767
+ event,
2768
+ self._reply_thread_status_body(target_chat_id, controls=False, updated=True),
2769
+ {"rows": []},
2770
+ )
2771
+ self._thread_action_sessions.pop(session_id, None)
2772
+ await self._answer_action(interaction_id, f"Reply threads: {mode}")
2773
+ return True
2774
+ except Exception:
2775
+ logger.exception("[inline] thread action failed")
2776
+ await self._answer_action(interaction_id, "Thread setting failed")
2777
+ return True
2778
+
2779
+ async def _thread_action_allowed(self, event: Dict[str, Any], state: Dict[str, Any]) -> bool:
2780
+ actor_id = str(event.get("actorUserId") or "").strip()
2781
+ interaction_id = str(event.get("interactionId") or "")
2782
+ chat_type = await self._action_chat_type(event)
2783
+ if self._actor_authorized(chat_type, actor_id):
2784
+ return True
2785
+ display_chat_id = self._chat_key(state.get("display_chat_id"))
2786
+ target_chat_id = self._chat_key(state.get("target_chat_id"))
2787
+ if chat_type and actor_id and self._allowed(chat_type, actor_id):
2788
+ if chat_type == "dm":
2789
+ return True
2790
+ thread_id = display_chat_id if target_chat_id and display_chat_id != target_chat_id else None
2791
+ if self._chat_allowed(display_chat_id or str(event.get("chatId") or ""), thread_id, target_chat_id):
2792
+ return True
2793
+ await self._answer_action(interaction_id, "Not authorized")
2794
+ logger.info("[inline] blocked thread action actor=%s chat_type=%s action=%s", actor_id or "unknown", chat_type or "unknown", event.get("actionId") or "")
2795
+ return False
2796
+
2501
2797
  @staticmethod
2502
2798
  def _is_model_picker_action(action_id: str) -> bool:
2503
2799
  return action_id.startswith(("mp:", "mpg:", "mm:", "mc:", "mg:", "mb:", "mx:"))
@@ -2699,7 +2995,14 @@ class InlineAdapter(BasePlatformAdapter):
2699
2995
  except Exception:
2700
2996
  logger.debug("[inline] answer action failed", exc_info=True)
2701
2997
 
2702
- async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
2998
+ async def send(
2999
+ self,
3000
+ chat_id: str,
3001
+ content: str,
3002
+ reply_to: Optional[str] = None,
3003
+ metadata: Optional[Dict[str, Any]] = None,
3004
+ actions: Optional[Dict[str, Any]] = None,
3005
+ ) -> SendResult:
2703
3006
  target = self._target_for(chat_id, metadata)
2704
3007
  reply_to = self._reply_to_for_target(reply_to, target)
2705
3008
  parse_markdown = self._parse_markdown and not self._expects_edits(metadata)
@@ -2707,6 +3010,7 @@ class InlineAdapter(BasePlatformAdapter):
2707
3010
  message_ids: List[str] = []
2708
3011
  raw_responses: List[Any] = []
2709
3012
  last_result: Optional[SendResult] = None
3013
+ marked_visible = False
2710
3014
  for index, chunk in enumerate(chunks):
2711
3015
  body: Dict[str, Any] = {
2712
3016
  "target": target,
@@ -2715,9 +3019,14 @@ class InlineAdapter(BasePlatformAdapter):
2715
3019
  }
2716
3020
  if reply_to and index == 0:
2717
3021
  body["replyToMsgId"] = str(reply_to)
3022
+ if actions is not None and index == 0:
3023
+ body["actions"] = actions
2718
3024
  last_result = await self._send_sidecar("/send", body)
2719
3025
  if not last_result.success:
2720
3026
  return last_result
3027
+ if not marked_visible:
3028
+ self._mark_reply_thread_visible(target)
3029
+ marked_visible = True
2721
3030
  if last_result.message_id:
2722
3031
  message_ids.append(str(last_result.message_id))
2723
3032
  raw_responses.append(last_result.raw_response)
@@ -2752,13 +3061,17 @@ class InlineAdapter(BasePlatformAdapter):
2752
3061
  if finalize:
2753
3062
  return await self._edit_overflow_split(chat_id, message_id, content, metadata=metadata)
2754
3063
  text = self.truncate_message(text, self.MAX_MESSAGE_LENGTH)[0]
3064
+ target = self._target_for(chat_id, metadata)
2755
3065
  body = {
2756
- "target": self._target_for(chat_id, metadata),
3066
+ "target": target,
2757
3067
  "messageId": str(message_id),
2758
3068
  "text": text,
2759
3069
  "parseMarkdown": parse_markdown,
2760
3070
  }
2761
- return await self._send_sidecar("/edit", body)
3071
+ result = await self._send_sidecar("/edit", body)
3072
+ if result.success:
3073
+ self._mark_reply_thread_visible(target)
3074
+ return result
2762
3075
 
2763
3076
  async def _edit_overflow_split(
2764
3077
  self,
@@ -2781,6 +3094,7 @@ class InlineAdapter(BasePlatformAdapter):
2781
3094
  })
2782
3095
  if not first_result.success:
2783
3096
  return first_result
3097
+ self._mark_reply_thread_visible(target)
2784
3098
 
2785
3099
  continuation_ids: List[str] = []
2786
3100
  raw_responses: List[Any] = [first_result.raw_response]
@@ -2834,13 +3148,17 @@ class InlineAdapter(BasePlatformAdapter):
2834
3148
 
2835
3149
  async def send_typing(self, chat_id: str, metadata=None) -> None:
2836
3150
  try:
2837
- await self._sidecar_call("/typing", {"target": self._target_for(chat_id, metadata), "state": "start"})
3151
+ target = self._typing_target_for(chat_id, metadata)
3152
+ await self._sidecar_call("/typing", {"target": target, "state": "start"})
3153
+ self._active_typing_targets[self._typing_target_key(chat_id, metadata)] = target
2838
3154
  except Exception as exc:
2839
3155
  logger.debug("[inline] typing failed: %s", exc)
2840
3156
 
2841
3157
  async def stop_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
2842
3158
  try:
2843
- await self._sidecar_call("/typing", {"target": self._target_for(chat_id, metadata), "state": "stop"})
3159
+ key = self._typing_target_key(chat_id, metadata)
3160
+ target = self._active_typing_targets.pop(key, None) or self._typing_target_for(chat_id, metadata)
3161
+ await self._sidecar_call("/typing", {"target": target, "state": "stop"})
2844
3162
  except Exception:
2845
3163
  pass
2846
3164
 
@@ -2902,7 +3220,10 @@ class InlineAdapter(BasePlatformAdapter):
2902
3220
  }
2903
3221
  if reply_to:
2904
3222
  body["replyToMsgId"] = reply_to
2905
- return await self._send_sidecar("/send-attachment", body)
3223
+ result = await self._send_sidecar("/send-attachment", body)
3224
+ if result.success:
3225
+ self._mark_reply_thread_visible(target)
3226
+ return result
2906
3227
 
2907
3228
  async def create_handoff_thread(self, parent_chat_id: str, name: str) -> Optional[str]:
2908
3229
  try:
@@ -3173,6 +3494,21 @@ class InlineAdapter(BasePlatformAdapter):
3173
3494
  return _target_from_chat_id(str(thread_id))
3174
3495
  return _target_from_chat_id(chat_id)
3175
3496
 
3497
+ def _typing_target_key(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> str:
3498
+ return f"{self._chat_key(chat_id)}:{self._chat_key((metadata or {}).get('thread_id'))}"
3499
+
3500
+ # First auto-created reply-thread turns need parent-chat typing. When Hermes
3501
+ # is replying as the first message in a brand-new Inline reply thread, there
3502
+ # is not yet a thread view for users to watch, so typing should remain on
3503
+ # the parent message's chat until the assistant's first child-thread reply lands.
3504
+ def _typing_target_for(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
3505
+ thread_id = self._chat_key((metadata or {}).get("thread_id"))
3506
+ chat_key = self._chat_key(chat_id)
3507
+ parent_chat_id = self._reply_thread_parent_typing_targets.get(thread_id) if thread_id else None
3508
+ if thread_id and parent_chat_id and chat_key == self._chat_key(parent_chat_id):
3509
+ return _target_from_chat_id(parent_chat_id)
3510
+ return self._target_for(chat_id, metadata)
3511
+
3176
3512
  @staticmethod
3177
3513
  def _remember(mapping: OrderedDict, key: str, value: Any, limit: int = 512) -> None:
3178
3514
  if key in mapping:
@@ -3312,7 +3648,7 @@ def _standalone_attachment_kind(path: str, is_voice: bool, force_document: bool)
3312
3648
  def _inline_threads_command_handler(raw_args: str) -> str:
3313
3649
  text = f"/threads {str(raw_args or '').strip()}".strip()
3314
3650
  action = InlineAdapter._thread_command_action(text) or "help"
3315
- usage = "Usage: /threads status, /threads on, /threads off, or /threads auto."
3651
+ usage = "Usage: /threads status, /threads auto, /threads on, /threads off, or /threads reset."
3316
3652
  if action == "help":
3317
3653
  return usage
3318
3654
  return (
@@ -3347,7 +3683,7 @@ def register(ctx) -> None:
3347
3683
  allowed_users_env="INLINE_ALLOWED_USERS",
3348
3684
  allow_all_env="INLINE_ALLOW_ALL_USERS",
3349
3685
  max_message_length=_MAX_MESSAGE_LENGTH,
3350
- emoji="I",
3686
+ emoji="💬",
3351
3687
  pii_safe=False,
3352
3688
  allow_update_command=True,
3353
3689
  platform_hint=(
@@ -1,7 +1,7 @@
1
1
  name: inline-platform
2
2
  label: Inline
3
3
  kind: platform
4
- version: 0.0.2
4
+ version: 0.0.4
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
@@ -63,11 +63,11 @@ optional_env:
63
63
  prompt: "Allowed group sender ids"
64
64
  password: false
65
65
  - name: INLINE_REQUIRE_MENTION
66
- description: "Require a mention/wake word in group chats (default true)"
66
+ description: "Require a mention/wake word in group chats. Replies to the bot and followed eligible reply/fresh threads can continue without one unless strict mention is enabled (default true)."
67
67
  prompt: "Require group mention? (true/false)"
68
68
  password: false
69
69
  - name: INLINE_STRICT_MENTION
70
- description: "Require an explicit mention/wake word on every group-chat turn, including replies to the bot (true/false, default false)"
70
+ description: "Require an explicit mention/wake word on every group-chat turn, including replies to the bot and followed threads (true/false, default false)"
71
71
  prompt: "Require every group turn to mention Hermes? (true/false)"
72
72
  password: false
73
73
  - name: INLINE_ALLOWED_CHATS
@@ -79,8 +79,8 @@ optional_env:
79
79
  prompt: "Free-response Inline chat ids"
80
80
  password: false
81
81
  - name: INLINE_REPLY_THREADS
82
- description: "Create/use Inline reply threads for top-level DM and group replies by default (true/false, default true)"
83
- prompt: "Reply in Inline threads by default? (true/false)"
82
+ description: "Reply-thread mode: auto, on, or off. Auto creates child reply threads only for large parent chats or explicit thread requests (default auto)."
83
+ prompt: "Reply-thread mode (auto/on/off)"
84
84
  password: false
85
85
  - name: INLINE_CONTEXT_BACKFILL
86
86
  description: "Automatic context mode: selective, off, or always (default selective)"
@@ -22160,6 +22160,7 @@ class WebSocketTransport {
22160
22160
  lastReconnectDelayMs = null;
22161
22161
  lastReconnectCause = null;
22162
22162
  reconnectCount = 0;
22163
+ reconnectScheduling = false;
22163
22164
  constructor(options) {
22164
22165
  this.url = options.url;
22165
22166
  this.log = options.logger ?? {};
@@ -22187,26 +22188,33 @@ class WebSocketTransport {
22187
22188
  }
22188
22189
  async reconnect(options) {
22189
22190
  const skipDelay = options?.skipDelay ?? false;
22190
- if (this.state === "connecting" && this.reconnectionTimer) {
22191
+ if (this.state === "connecting" && (this.reconnectionTimer || this.reconnectScheduling)) {
22191
22192
  return;
22192
22193
  }
22193
- await this.setConnecting();
22194
- this.cleanUpPreviousConnection();
22195
- this.connectionAttemptNo = this.connectionAttemptNo + 1 >>> 0;
22196
- const delaySeconds = this.getReconnectionDelaySeconds(this.connectionAttemptNo);
22197
- const delayMs = Math.round(delaySeconds * 1000);
22198
- const cause = options?.cause?.trim() || "unspecified";
22199
- this.lastReconnectScheduledAt = Date.now();
22200
- this.lastReconnectDelayMs = delayMs;
22201
- this.lastReconnectCause = cause;
22202
- this.reconnectCount += 1;
22203
- this.log.warn?.(`WebSocket reconnect scheduled (attempt=${this.connectionAttemptNo}, delayMs=${delayMs}, cause=${cause})`);
22204
- this.reconnectionTimer = setTimeout(() => {
22205
- this.reconnectionTimer = null;
22206
- if (this.state === "idle" || this.state === "connected")
22194
+ this.reconnectScheduling = true;
22195
+ try {
22196
+ await this.setConnecting();
22197
+ if (this.state === "idle" || this.reconnectionTimer)
22207
22198
  return;
22208
- this.openConnection();
22209
- }, skipDelay ? 0 : delaySeconds * 1000);
22199
+ this.cleanUpPreviousConnection();
22200
+ this.connectionAttemptNo = this.connectionAttemptNo + 1 >>> 0;
22201
+ const delaySeconds = this.getReconnectionDelaySeconds(this.connectionAttemptNo);
22202
+ const delayMs = Math.round(delaySeconds * 1000);
22203
+ const cause = options?.cause?.trim() || "unspecified";
22204
+ this.lastReconnectScheduledAt = Date.now();
22205
+ this.lastReconnectDelayMs = delayMs;
22206
+ this.lastReconnectCause = cause;
22207
+ this.reconnectCount += 1;
22208
+ this.log.warn?.(`WebSocket reconnect scheduled (attempt=${this.connectionAttemptNo}, delayMs=${delayMs}, cause=${cause})`);
22209
+ this.reconnectionTimer = setTimeout(() => {
22210
+ this.reconnectionTimer = null;
22211
+ if (this.state === "idle" || this.state === "connected")
22212
+ return;
22213
+ this.openConnection();
22214
+ }, skipDelay ? 0 : delaySeconds * 1000);
22215
+ } finally {
22216
+ this.reconnectScheduling = false;
22217
+ }
22210
22218
  }
22211
22219
  cleanUpPreviousConnection() {
22212
22220
  if (this.reconnectionTimer) {
@@ -22676,6 +22684,7 @@ class InlineSdkClient {
22676
22684
  const chat = result.getChat.chat;
22677
22685
  if (!chat)
22678
22686
  throw new Error("getChat: missing chat");
22687
+ const dialogFollowMode = result.getChat.dialog?.followMode;
22679
22688
  return {
22680
22689
  chatId: chat.id,
22681
22690
  title: chat.title,
@@ -22683,6 +22692,8 @@ class InlineSdkClient {
22683
22692
  ...chat.spaceId != null ? { spaceId: chat.spaceId } : {},
22684
22693
  ...chat.parentChatId != null ? { parentChatId: chat.parentChatId } : {},
22685
22694
  ...chat.parentMessageId != null ? { parentMessageId: chat.parentMessageId } : {},
22695
+ ...chat.lastMsgId != null ? { lastMsgId: chat.lastMsgId } : {},
22696
+ ...dialogFollowMode != null ? { dialogFollowMode } : {},
22686
22697
  ...chat.isPublic != null ? { isPublic: chat.isPublic } : {},
22687
22698
  ...chat.untitled != null ? { untitled: chat.untitled } : {},
22688
22699
  ...chat.number != null ? { number: chat.number } : {}
@@ -23799,6 +23810,36 @@ var resolveUploadFileUrl = (baseUrl) => {
23799
23810
  return url;
23800
23811
  };
23801
23812
  var hasMethodMapping = (method) => Object.prototype.hasOwnProperty.call(rpcInputKindByMethod, method) && Object.prototype.hasOwnProperty.call(rpcResultKindByMethod, method);
23813
+ // ../sdk/dist/sdk/thread-mention-gating.js
23814
+ var INLINE_FOLLOW_MODE_MENTION_FRESH_LAST_MESSAGE_ID_LIMIT = 50;
23815
+ function isInlineReplyThreadForMentionGate(chat) {
23816
+ return parsePositiveInteger(chat.parentMessageId) != null;
23817
+ }
23818
+ function isInlineFreshThreadForMentionGate(lastMsgId, limit = INLINE_FOLLOW_MODE_MENTION_FRESH_LAST_MESSAGE_ID_LIMIT) {
23819
+ const normalized = parsePositiveInteger(lastMsgId);
23820
+ return normalized != null && normalized < BigInt(limit);
23821
+ }
23822
+ function isInlineFollowModeMentionGateEligible(chat) {
23823
+ if (isInlineReplyThreadForMentionGate(chat)) {
23824
+ return true;
23825
+ }
23826
+ return isInlineFreshThreadForMentionGate(chat.lastMsgId);
23827
+ }
23828
+ function parsePositiveInteger(value) {
23829
+ if (value == null)
23830
+ return null;
23831
+ if (typeof value === "bigint")
23832
+ return value > 0n ? value : null;
23833
+ if (typeof value === "number") {
23834
+ if (!Number.isSafeInteger(value) || value <= 0)
23835
+ return null;
23836
+ return BigInt(value);
23837
+ }
23838
+ const text = value.trim();
23839
+ if (!/^[1-9]\d*$/.test(text))
23840
+ return null;
23841
+ return BigInt(text);
23842
+ }
23802
23843
  // ../sdk/dist/state/json-file-state-store.js
23803
23844
  import { readFile, rename, writeFile } from "node:fs/promises";
23804
23845
 
@@ -24458,6 +24499,7 @@ async function endpointChat(res, body) {
24458
24499
  }
24459
24500
  const snapshot = await getRawChatSnapshot(target.chatId);
24460
24501
  const chat = snapshot.chat;
24502
+ const followModeMentionEligible = isInlineFollowModeMentionGateEligible(chat);
24461
24503
  writeJson(res, 200, {
24462
24504
  ok: true,
24463
24505
  result: {
@@ -24475,6 +24517,9 @@ async function endpointChat(res, body) {
24475
24517
  ...chat.createdBy != null ? { createdBy: chat.createdBy.toString() } : {},
24476
24518
  ...chat.untitled != null ? { untitled: chat.untitled } : {},
24477
24519
  ...chat.number != null ? { number: chat.number } : {},
24520
+ ...snapshot.dialog != null ? { dialog: safeJson(snapshot.dialog) } : {},
24521
+ ...snapshot.dialogFollowMode != null ? { dialogFollowMode: String(snapshot.dialogFollowMode) } : {},
24522
+ followModeMentionEligible,
24478
24523
  pinnedMessageIds: safeJson(snapshot.pinnedMessageIds),
24479
24524
  ...snapshot.anchorMessage != null ? { anchorMessage: safeJson(snapshot.anchorMessage) } : {},
24480
24525
  chat: safeJson(chat)
@@ -24636,8 +24681,12 @@ async function getRawChatSnapshot(chatId) {
24636
24681
  const chat = typed.getChat?.chat;
24637
24682
  if (!chat)
24638
24683
  throw new SidecarError("chat not found", "not_found");
24684
+ const dialog = typed.getChat?.dialog;
24685
+ const dialogFollowMode = dialog?.followMode ?? dialog?.follow_mode ?? undefined;
24639
24686
  return {
24640
24687
  chat,
24688
+ ...dialog != null ? { dialog } : {},
24689
+ ...dialogFollowMode != null ? { dialogFollowMode } : {},
24641
24690
  pinnedMessageIds: typed.getChat?.pinnedMessageIds ?? [],
24642
24691
  ...typed.getChat?.anchorMessage != null ? { anchorMessage: typed.getChat.anchorMessage } : {}
24643
24692
  };
@@ -24767,6 +24816,8 @@ class MockInlineClient {
24767
24816
  title: "Mock reply thread 456",
24768
24817
  parentChatId: 123n,
24769
24818
  parentMessageId: 9001n,
24819
+ lastMsgId: 9002n,
24820
+ dialogFollowMode: 1,
24770
24821
  untitled: true
24771
24822
  };
24772
24823
  }
@@ -24834,6 +24885,7 @@ class MockInlineClient {
24834
24885
  title: "Mock reply thread 456",
24835
24886
  parentChatId: 123n,
24836
24887
  parentMessageId: 9001n,
24888
+ lastMsgId: 9002n,
24837
24889
  untitled: true
24838
24890
  } : {
24839
24891
  id: chatId,
@@ -24842,6 +24894,7 @@ class MockInlineClient {
24842
24894
  return {
24843
24895
  getChat: {
24844
24896
  chat,
24897
+ dialog: { chatId, followMode: chatId === 456n ? 1 : 0 },
24845
24898
  pinnedMessageIds: [8801n],
24846
24899
  anchorMessage: {
24847
24900
  id: 8801n,
@@ -53,7 +53,6 @@ _ACTION_MANIFEST = [
53
53
  ("get_messages", "(chat_id?, message_ids)", "Fetch specific Inline messages by ID."),
54
54
  ("get_history", "(chat_id?, limit?, anchor_id?)", "Fetch recent or anchored Inline history."),
55
55
  ("search_messages", "(chat_id?|user_id?, query, limit?, offset_id?)", "Search Inline message text in a chat, thread, or DM."),
56
- ("send_message", "(chat_id?|user_id?, text, reply_to_msg_id?)", "Send a Markdown message."),
57
56
  ("edit_message", "(chat_id?|user_id?, message_id, text)", "Edit a bot-owned message."),
58
57
  ("delete_message", "(chat_id?|user_id?, message_id)", "Delete a bot-owned message."),
59
58
  ("add_reaction", "(chat_id?|user_id?, message_id?, emoji)", "Add a reaction to an Inline message."),
@@ -63,8 +62,7 @@ _ACTION_MANIFEST = [
63
62
  ("unpin_message", "(chat_id?|user_id?, message_id?)", "Unpin an Inline message for the conversation."),
64
63
  ("list_pins", "(chat_id?)", "List pinned Inline message IDs for a chat or reply thread."),
65
64
  ("create_thread", "(parent_chat_id?, parent_message_id?, title?)", "Create an Inline reply thread."),
66
- ("set_typing", "(chat_id?, state)", "Start or stop the typing indicator."),
67
- ("set_presence", "(chat_id?|user_id?, kind, comment?)", "Set the bot presence state."),
65
+ ("set_presence", "(chat_id?|user_id?, kind, comment?)", "Set the bot avatar presence/status message."),
68
66
  ]
69
67
  _ACTIONS = [name for name, _, _ in _ACTION_MANIFEST]
70
68
  _PRESENCE_KINDS = ["idle", "happy", "waving", "jumping", "failed", "waiting", "running", "review"]
@@ -95,10 +93,11 @@ def tool_context_prompt(
95
93
  if not check_inline_tool_requirements():
96
94
  return None
97
95
  lines = [
98
- "- The `inline` tool is available for Inline chat/thread/message actions. Prefer it when you need history, search, exact message lookup, reactions, pins, or thread creation.",
96
+ "- The `inline` tool is available for Inline history, search, exact message lookup, reactions, pins, and explicit thread management.",
97
+ "- Do not use the `inline` tool to send normal replies; return reply text normally and Hermes will deliver it to the current chat or thread.",
99
98
  ]
100
99
  if thread_id:
101
- lines.append(f"- Current Inline reply thread: `{thread_id}`. Use this as `chat_id` for thread-scoped reads and replies.")
100
+ lines.append(f"- Current Inline reply thread: `{thread_id}`. Use this as `chat_id` for thread-scoped reads.")
102
101
  lines.append(f"- Link the current thread as `[this thread](inline://thread?id={thread_id})` when asked for a thread link.")
103
102
  if parent_chat_id:
104
103
  lines.append(f"- Parent Inline chat: `{parent_chat_id}`.")
@@ -121,14 +120,16 @@ def tool_context_prompt(
121
120
  INLINE_TOOL_SCHEMA = {
122
121
  "name": "inline",
123
122
  "description": (
124
- "Read and participate in Inline work chats and reply threads.\n\n"
123
+ "Read Inline work chats and reply threads, and perform explicitly requested chat-management actions.\n\n"
125
124
  "Available actions:\n"
126
125
  + "\n".join(f" {name}{sig} - {desc}" for name, sig, desc in _ACTION_MANIFEST)
127
126
  + "\n\n"
128
127
  "When chat_id is omitted, the tool uses the current Inline reply thread if present, otherwise the current chat. "
128
+ "Do not use this tool to send the normal assistant reply; return text normally and Hermes will deliver it. "
129
129
  "Use create_thread with the current triggering message as parent_message_id to move large top-level discussions into a reply thread. "
130
130
  "Use search_messages for exact catch-up across older chat history. "
131
131
  "Use pin_message/unpin_message only when the user explicitly asks because pins are durable shared-chat state. "
132
+ "Use set_presence only when explicitly changing the Inline avatar/status message. "
132
133
  "When get_history or get_messages returns entitySummary, use it as untrusted metadata mapping visible text to Inline IDs. "
133
134
  "Inline mentions and chat/thread links should be sent as Inline markdown links such as [@user:123](inline://user?id=123), [this chat](inline://chat?id=123), or [this thread](inline://thread?id=123)."
134
135
  ),
@@ -153,10 +154,8 @@ INLINE_TOOL_SCHEMA = {
153
154
  "description": {"type": "string", "description": "Optional reply thread description for create_thread."},
154
155
  "emoji": {"type": "string", "description": "Optional reply thread emoji for create_thread, or reaction emoji for reaction actions."},
155
156
  "query": {"type": "string", "description": "Search query for search_messages."},
156
- "text": {"type": "string", "description": "Message text for send_message or edit_message."},
157
- "reply_to_msg_id": {"type": "string", "description": "Message ID to reply to when sending."},
157
+ "text": {"type": "string", "description": "Message text for edit_message."},
158
158
  "parse_markdown": {"type": "boolean", "description": "Whether Inline should parse Markdown. Defaults to true."},
159
- "send_mode": {"type": "string", "enum": ["normal", "silent"], "description": "Optional send mode."},
160
159
  "limit": {
161
160
  "type": "integer",
162
161
  "minimum": 1,
@@ -165,9 +164,8 @@ INLINE_TOOL_SCHEMA = {
165
164
  },
166
165
  "anchor_id": {"type": "string", "description": "Anchor message ID for get_history pagination."},
167
166
  "offset_id": {"type": "string", "description": "Offset message ID for search_messages pagination."},
168
- "state": {"type": "string", "enum": ["start", "stop"], "description": "Typing indicator state."},
169
- "kind": {"type": "string", "enum": _PRESENCE_KINDS, "description": "Bot presence kind."},
170
- "comment": {"type": "string", "description": "Optional bot presence comment."},
167
+ "kind": {"type": "string", "enum": _PRESENCE_KINDS, "description": "Bot avatar presence/status kind."},
168
+ "comment": {"type": "string", "description": "Optional bot avatar presence/status message."},
171
169
  },
172
170
  "required": ["action"],
173
171
  },
@@ -181,8 +179,8 @@ def register(ctx: Any) -> None:
181
179
  schema=INLINE_TOOL_SCHEMA,
182
180
  handler=_handle_inline_tool,
183
181
  check_fn=check_inline_tool_requirements,
184
- description="Read and participate in Inline chats, messages, and reply threads.",
185
- emoji="I",
182
+ description="Read Inline chats, messages, reply threads, and explicit chat-management state.",
183
+ emoji="💬",
186
184
  )
187
185
 
188
186
 
@@ -234,20 +232,6 @@ def _request_for_action(action: str, args: Dict[str, Any]) -> tuple[str, Dict[st
234
232
  body["offsetId"] = offset_id
235
233
  return "/search", body
236
234
 
237
- if action == "send_message":
238
- text = _required_str(args, "text", max_chars=_MAX_TEXT_CHARS)
239
- body = {
240
- "target": _target(args),
241
- "text": text,
242
- "parseMarkdown": _bool(args.get("parse_markdown"), True),
243
- }
244
- reply_to = _inline_id(args.get("reply_to_msg_id"))
245
- if reply_to:
246
- body["replyToMsgId"] = reply_to
247
- if _str(args.get("send_mode")) == "silent":
248
- body["sendMode"] = "silent"
249
- return "/send", body
250
-
251
235
  if action == "edit_message":
252
236
  return "/edit", {
253
237
  "target": _target(args),
@@ -304,12 +288,6 @@ def _request_for_action(action: str, args: Dict[str, Any]) -> tuple[str, Dict[st
304
288
  body[key] = value
305
289
  return "/create-subthread", body
306
290
 
307
- if action == "set_typing":
308
- return "/typing", {
309
- "target": _target(args),
310
- "state": _str(args.get("state")) or "start",
311
- }
312
-
313
291
  if action == "set_presence":
314
292
  kind = _str(args.get("kind"))
315
293
  if kind not in _PRESENCE_KINDS: