@inline-chat/hermes-agent-adapter 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inline-chat/hermes-agent-adapter",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
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",
@@ -60,7 +60,7 @@
60
60
  "check": "bun run typecheck && bun run lint && bun run test"
61
61
  },
62
62
  "dependencies": {
63
- "@inline-chat/realtime-sdk": "0.0.14",
63
+ "@inline-chat/realtime-sdk": "0.0.15",
64
64
  "yaml": "2.9.0"
65
65
  },
66
66
  "devDependencies": {
@@ -75,9 +75,12 @@
75
75
  "inlineHermes": {
76
76
  "pluginId": "inline",
77
77
  "pluginPath": "plugin/inline",
78
+ "install": {
79
+ "npmSpec": "@inline-chat/hermes-agent-adapter"
80
+ },
78
81
  "machineSetupProtocol": 1,
79
82
  "minHermesVersion": "0.17.0",
80
- "testedHermesVersion": "0.19.1",
81
- "testedHermesCommit": "cc4cab2"
83
+ "testedHermesVersion": "0.20.0",
84
+ "testedHermesCommit": "3c27eb6"
82
85
  }
83
86
  }
@@ -69,6 +69,8 @@ _DEFAULT_OBSERVED_CONTEXT_LIMIT = 20
69
69
  _MAX_OBSERVED_CONTEXT_LIMIT = 100
70
70
  _MAX_CONTEXT_HISTORY_LIMIT = 20
71
71
  _MAX_CONTEXT_REQUEST_LIMIT = 100
72
+ _JOIN_MENTION_LOOKBACK_SECONDS = 60
73
+ _JOIN_HISTORY_PAGE_LIMIT = 100
72
74
  _CONTEXT_MESSAGE_TEXT_LIMIT = 360
73
75
  _OBSERVED_CONTEXT_CACHE_MAX_SIZE = 512
74
76
  _STATE_DIR = Path.home() / ".hermes" / "inline"
@@ -855,6 +857,7 @@ class InlineAdapter(BasePlatformAdapter):
855
857
  self._me_id: Optional[str] = None
856
858
  self._me_username: Optional[str] = None
857
859
  self._seen_messages: Dict[str, float] = {}
860
+ self._seen_message_instances: Dict[str, float] = {}
858
861
  self._clarify_choices: "OrderedDict[str, List[str]]" = OrderedDict()
859
862
  self._clarify_sessions: "OrderedDict[str, str]" = OrderedDict()
860
863
  self._approval_sessions: "OrderedDict[str, str]" = OrderedDict()
@@ -2048,7 +2051,12 @@ class InlineAdapter(BasePlatformAdapter):
2048
2051
  if self._system_events:
2049
2052
  await self._dispatch_message(event, edit=True)
2050
2053
  return
2051
- if kind in {"message.delete", "message.history.clear", "chat.participant.add", "chat.participant.delete"}:
2054
+ if kind == "chat.participant.add":
2055
+ if await self._recover_self_join_mentions(event):
2056
+ return
2057
+ await self._dispatch_system_event(event)
2058
+ return
2059
+ if kind in {"message.delete", "message.history.clear", "chat.participant.delete"}:
2052
2060
  await self._dispatch_system_event(event)
2053
2061
  return
2054
2062
  if kind != "message.new":
@@ -2068,6 +2076,28 @@ class InlineAdapter(BasePlatformAdapter):
2068
2076
  del self._seen_messages[stale]
2069
2077
  return False
2070
2078
 
2079
+ def _is_duplicate_message_instance(self, chat_id: str, msg: Dict[str, Any], event: Dict[str, Any]) -> bool:
2080
+ message_date = str(msg.get("date") or event.get("date") or "").strip()
2081
+ content = json.dumps({
2082
+ "fromId": msg.get("fromId"),
2083
+ "message": msg.get("message"),
2084
+ "entities": msg.get("entities"),
2085
+ "media": msg.get("media"),
2086
+ }, sort_keys=True, default=str, separators=(",", ":"))
2087
+ fingerprint = hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
2088
+ key = f"{chat_id}:{str(msg.get('id') or '')}:{message_date}:{fingerprint}"
2089
+ now = time.time()
2090
+ old = self._seen_message_instances.get(key)
2091
+ if old is not None and now - old < _DEDUP_WINDOW_SECONDS:
2092
+ return True
2093
+ if key in self._seen_message_instances:
2094
+ del self._seen_message_instances[key]
2095
+ self._seen_message_instances[key] = now
2096
+ if len(self._seen_message_instances) > _DEDUP_MAX_SIZE:
2097
+ for stale in list(self._seen_message_instances.keys())[: len(self._seen_message_instances) - _DEDUP_MAX_SIZE]:
2098
+ del self._seen_message_instances[stale]
2099
+ return False
2100
+
2071
2101
  async def _dispatch_message(self, event: Dict[str, Any], *, edit: bool = False) -> None:
2072
2102
  msg = event.get("message") or {}
2073
2103
  msg_id = str(msg.get("id") or "")
@@ -2089,6 +2119,8 @@ class InlineAdapter(BasePlatformAdapter):
2089
2119
  dedup_key = f"new:{chat_id}:msg:{msg_id}"
2090
2120
  if self._is_duplicate(dedup_key):
2091
2121
  return
2122
+ if not edit and self._is_duplicate_message_instance(chat_id, msg, event):
2123
+ return
2092
2124
  from_id = str(msg.get("fromId") or "")
2093
2125
  if msg.get("out") or (self._me_id and from_id == self._me_id):
2094
2126
  return
@@ -2209,6 +2241,21 @@ class InlineAdapter(BasePlatformAdapter):
2209
2241
  parent_message_id = msg_id
2210
2242
 
2211
2243
  channel_prompt, auto_skill = self._resolve_thread_bindings(chat_id, thread_id, parent_chat_id)
2244
+ mentioned_agent_id = self._mentioned_agent_id(msg)
2245
+ if mentioned_agent_id:
2246
+ try:
2247
+ resolved_agent = await self._sidecar_call("/get-agent", {"agentId": mentioned_agent_id})
2248
+ agent = resolved_agent.get("agent") if isinstance(resolved_agent.get("agent"), dict) else {}
2249
+ agent_name = str(agent.get("name") or "").strip()
2250
+ agent_instructions = str(agent.get("instructions") or "").strip()
2251
+ agent_skill = str(agent.get("skillKey") or agent.get("skill_key") or "").strip()
2252
+ if agent_name:
2253
+ specialization = agent_instructions or f'You are a specialized agent named "{agent_name}".'
2254
+ channel_prompt = self._merge_channel_prompt(channel_prompt, specialization)
2255
+ if agent_skill:
2256
+ auto_skill = [agent_skill]
2257
+ except Exception as exc:
2258
+ logger.warning("[inline] failed to resolve mentioned Agent %s: %s", mentioned_agent_id, exc)
2212
2259
  entity_text = self._inline_entity_text(msg, str(msg.get("message") or ""))
2213
2260
  parent_chat_info: Dict[str, Any] = {}
2214
2261
  if parent_chat_id:
@@ -2298,6 +2345,92 @@ class InlineAdapter(BasePlatformAdapter):
2298
2345
  timestamp=self._timestamp(event.get("date") or msg.get("date")),
2299
2346
  ))
2300
2347
 
2348
+ def _message_explicitly_mentions_me(self, msg: Dict[str, Any]) -> bool:
2349
+ if not self._me_id:
2350
+ return False
2351
+ if bool(msg.get("mentioned")):
2352
+ return True
2353
+ for entity in self._message_entities(msg):
2354
+ if self._entity_kind(entity) != "mention":
2355
+ continue
2356
+ payload = self._entity_payload(entity, "mention")
2357
+ if self._entity_id(payload, "userId") == self._me_id:
2358
+ return True
2359
+ username = str(self._me_username or "").strip().lstrip("@")
2360
+ text = str(msg.get("message") or "")
2361
+ if not username or not text:
2362
+ return False
2363
+ return bool(re.search(rf"(^|\s)@{re.escape(username)}(?=$|[\s,.:;!?])", text, re.IGNORECASE))
2364
+
2365
+ async def _recover_self_join_mentions(self, event: Dict[str, Any]) -> bool:
2366
+ participant = event.get("participant") if isinstance(event.get("participant"), dict) else {}
2367
+ if not self._me_id or str(participant.get("userId") or "").strip() != self._me_id:
2368
+ return False
2369
+
2370
+ chat_id = str(event.get("chatId") or "").strip()
2371
+ joined_at = _to_int(participant.get("date") or event.get("date"))
2372
+ if not chat_id or joined_at is None or joined_at <= 0:
2373
+ logger.warning("[inline] cannot recover join mentions without chat and participant date")
2374
+ return True
2375
+
2376
+ cutoff = joined_at - _JOIN_MENTION_LOOKBACK_SECONDS
2377
+ messages: Dict[str, Dict[str, Any]] = {}
2378
+ anchor_id: Optional[str] = None
2379
+ while True:
2380
+ body: Dict[str, Any] = {
2381
+ "target": _target_from_chat_id(chat_id),
2382
+ "limit": _JOIN_HISTORY_PAGE_LIMIT,
2383
+ }
2384
+ if anchor_id:
2385
+ body["anchorId"] = anchor_id
2386
+ try:
2387
+ data = await self._sidecar_call("/history", body)
2388
+ except Exception as exc:
2389
+ logger.warning("[inline] join mention history unavailable for chat %s: %s", chat_id, exc)
2390
+ return True
2391
+ page = (data.get("result") or {}).get("messages") or []
2392
+ page = [message for message in page if isinstance(message, dict)]
2393
+ if not page:
2394
+ break
2395
+
2396
+ oldest: Optional[Dict[str, Any]] = None
2397
+ added = 0
2398
+ for message in page:
2399
+ message_id = str(message.get("id") or "").strip()
2400
+ message_date = _to_int(message.get("date"))
2401
+ if not message_id or message_date is None:
2402
+ continue
2403
+ key = f"{message_id}:{message_date}"
2404
+ if key not in messages:
2405
+ messages[key] = message
2406
+ added += 1
2407
+ if oldest is None or message_date < int(oldest["date"]):
2408
+ oldest = {**message, "date": message_date}
2409
+ if oldest is None or int(oldest["date"]) < cutoff or len(page) < _JOIN_HISTORY_PAGE_LIMIT or added == 0:
2410
+ break
2411
+ anchor_id = str(oldest.get("id") or "").strip() or None
2412
+ if not anchor_id:
2413
+ break
2414
+
2415
+ candidates = []
2416
+ for message in messages.values():
2417
+ message_date = _to_int(message.get("date"))
2418
+ if message_date is None or message_date < cutoff or message_date > joined_at:
2419
+ continue
2420
+ if str(message.get("fromId") or "") == self._me_id:
2421
+ continue
2422
+ if self._message_explicitly_mentions_me(message):
2423
+ candidates.append(message)
2424
+ candidates.sort(key=lambda message: (_to_int(message.get("date")) or 0, str(message.get("id") or "")))
2425
+
2426
+ for message in candidates:
2427
+ await self._dispatch_message({
2428
+ "kind": "message.new",
2429
+ "chatId": chat_id,
2430
+ "message": {**message, "mentioned": True},
2431
+ })
2432
+ return True
2433
+
2301
2434
  async def _dispatch_reaction(self, event: Dict[str, Any], *, added: bool) -> None:
2302
2435
  reaction = event.get("reaction") if isinstance(event.get("reaction"), dict) else {}
2303
2436
  chat_id = str(event.get("chatId") or reaction.get("chatId") or "")
@@ -2770,6 +2903,19 @@ class InlineAdapter(BasePlatformAdapter):
2770
2903
  parts.append(f"+{len(entities) - _INLINE_ENTITY_LIMIT} more")
2771
2904
  return " | ".join(parts) if parts else None
2772
2905
 
2906
+ def _mentioned_agent_id(self, msg: Dict[str, Any]) -> Optional[str]:
2907
+ for entity in self._message_entities(msg):
2908
+ if self._entity_kind(entity) != "mention":
2909
+ continue
2910
+ payload = self._entity_payload(entity, "mention")
2911
+ user_id = self._entity_id(payload, "userId")
2912
+ if self._me_id and user_id != self._me_id:
2913
+ continue
2914
+ agent_id = self._entity_id(payload, "agentId")
2915
+ if agent_id:
2916
+ return agent_id
2917
+ return None
2918
+
2773
2919
  @staticmethod
2774
2920
  def _merge_channel_prompt(*parts: Optional[str]) -> Optional[str]:
2775
2921
  merged = [str(part).strip() for part in parts if str(part or "").strip()]
@@ -335,16 +335,16 @@ def _run_inline_json(inline_bin: str, args: list[str]) -> tuple[dict | None, str
335
335
 
336
336
  def register_cli(parser: argparse.ArgumentParser) -> None:
337
337
  subs = parser.add_subparsers(dest="inline_command", required=False)
338
- setup = subs.add_parser("setup", help="Configure Inline")
339
- setup.add_argument("--non-interactive", action="store_true")
340
- setup.add_argument("--token-stdin", action="store_true")
341
- setup.add_argument("--owner-user-id")
342
- setup.add_argument("--access", choices=["owner", "allowlist", "open", "disabled"], default="owner")
343
- setup.add_argument("--allow-user", action="append", default=[], type=_positive_user_id)
344
- setup.add_argument("--json", action="store_true")
345
- status = subs.add_parser("status", help="Show Inline adapter status")
346
- status.add_argument("--json", action="store_true")
347
- status.add_argument("--probe", action="store_true")
338
+ setup = subs.add_parser("setup", help="Configure Inline", description="Configure the Inline platform and its access policy.")
339
+ setup.add_argument("--non-interactive", action="store_true", help="Run prompt-free machine setup; requires the token on stdin.")
340
+ setup.add_argument("--token-stdin", action="store_true", help="Read one bounded Inline token from stdin instead of argv.")
341
+ setup.add_argument("--owner-user-id", help="Positive Inline user ID that owns the configured bot.")
342
+ setup.add_argument("--access", choices=["owner", "allowlist", "open", "disabled"], default="owner", help="Who may invoke Hermes through Inline (default: owner).")
343
+ setup.add_argument("--allow-user", action="append", default=[], type=_positive_user_id, help="Additional positive Inline user ID to allow; repeatable.")
344
+ setup.add_argument("--json", action="store_true", help="Print compact machine-readable setup output.")
345
+ status = subs.add_parser("status", help="Show Inline adapter status", description="Check Inline configuration, sidecar, Node runtime, and optional credential identity.")
346
+ status.add_argument("--json", action="store_true", help="Print compact machine-readable status output.")
347
+ status.add_argument("--probe", action="store_true", help="Verify the configured Inline credential and bot identity.")
348
348
  parser.set_defaults(func=dispatch)
349
349
 
350
350
 
@@ -418,15 +418,27 @@ def _status(args) -> int:
418
418
  configured = bool(token)
419
419
  probe_requested = bool(getattr(args, "probe", False))
420
420
  probe = _probe_inline_token(token) if configured and probe_requested else None
421
- ready = configured and (not probe_requested or bool(probe and probe.get("ok")))
421
+ node = _node_status()
422
+ sidecar = _sidecar_status(node)
423
+ sidecar_bundled = bool(
424
+ sidecar["exists"]
425
+ and sidecar["regularFile"]
426
+ and sidecar["readable"]
427
+ and sidecar["size"] > 0
428
+ )
429
+ runtime_usable = bool(sidecar["ok"] and node["ok"])
430
+ ready = runtime_usable and configured and (not probe_requested or bool(probe and probe.get("ok")))
422
431
  result = {
423
432
  "ok": ready,
433
+ "ready": ready,
424
434
  "action": "inline.status",
425
435
  "setupProtocolVersion": _MACHINE_SETUP_PROTOCOL_VERSION,
426
436
  "pluginVersion": _plugin_version(),
427
437
  "configured": configured,
428
- "sidecarBundled": _SIDECAR_ENTRY.exists(),
429
- "node": _node_status(),
438
+ "runtimeUsable": runtime_usable,
439
+ "sidecarBundled": sidecar_bundled,
440
+ "sidecar": sidecar,
441
+ "node": node,
430
442
  "probeRequested": probe_requested,
431
443
  **({"probe": probe} if probe is not None else {}),
432
444
  }
@@ -434,14 +446,15 @@ def _status(args) -> int:
434
446
  print(json.dumps(result, separators=(",", ":")))
435
447
  else:
436
448
  print(f"Inline configured: {'yes' if configured else 'no'}")
437
- print(f"Inline sidecar bundled: {'yes' if _SIDECAR_ENTRY.exists() else 'no'}")
438
- print(f"Node available: {_node_status()}")
449
+ print(f"Inline sidecar usable: {'yes' if sidecar['ok'] else 'no'}")
450
+ print(f"Node available: {_node_status_text(node)}")
451
+ print(f"Inline runtime ready: {'yes' if ready else 'no'}")
439
452
  if not configured:
440
453
  print("Next: run `hermes inline setup` for guided bot setup.")
441
454
  elif probe_requested:
442
455
  print(f"Inline credential probe: {'ready' if ready else 'failed'}")
443
456
  print("Advanced diagnostics: inline-hermes doctor --json")
444
- return 0 if not probe_requested or ready else 1
457
+ return 0 if runtime_usable and (not probe_requested or ready) else 1
445
458
 
446
459
 
447
460
  def _plugin_version() -> str:
@@ -514,10 +527,17 @@ def _find_node_bin() -> str | None:
514
527
  return shutil.which("node")
515
528
 
516
529
 
517
- def _node_status() -> str:
530
+ def _node_status() -> dict:
518
531
  node_bin = _find_node_bin()
519
532
  if not node_bin:
520
- return "no"
533
+ return {
534
+ "ok": False,
535
+ "path": None,
536
+ "version": None,
537
+ "major": None,
538
+ "minimumMajor": _MIN_NODE_MAJOR,
539
+ "error": "Node.js was not found.",
540
+ }
521
541
  try:
522
542
  result = subprocess.run(
523
543
  [node_bin, "--version"],
@@ -528,12 +548,88 @@ def _node_status() -> str:
528
548
  check=False,
529
549
  )
530
550
  except Exception as exc:
531
- return f"no ({exc})"
551
+ return {
552
+ "ok": False,
553
+ "path": node_bin,
554
+ "version": None,
555
+ "major": None,
556
+ "minimumMajor": _MIN_NODE_MAJOR,
557
+ "error": f"Node.js could not run: {exc}",
558
+ }
532
559
  version = (result.stdout or result.stderr or "").strip()
533
560
  if result.returncode != 0:
534
- return f"no ({version or f'exited with status {result.returncode}'})"
561
+ return {
562
+ "ok": False,
563
+ "path": node_bin,
564
+ "version": version or None,
565
+ "major": None,
566
+ "minimumMajor": _MIN_NODE_MAJOR,
567
+ "error": f"Node.js exited with status {result.returncode}.",
568
+ }
535
569
  match = re.search(r"\bv?(\d+)(?:\.\d+){0,2}\b", version)
536
- major = int(match.group(1)) if match else 0
537
- if major < _MIN_NODE_MAJOR:
538
- return f"no ({version or 'unknown version'}, requires >=20)"
539
- return f"yes ({version})"
570
+ major = int(match.group(1)) if match else None
571
+ ok = major is not None and major >= _MIN_NODE_MAJOR
572
+ return {
573
+ "ok": ok,
574
+ "path": node_bin,
575
+ "version": version or None,
576
+ "major": major,
577
+ "minimumMajor": _MIN_NODE_MAJOR,
578
+ "error": None if ok else f"Node.js {version or 'version'} is incompatible; requires >= {_MIN_NODE_MAJOR}.",
579
+ }
580
+
581
+
582
+ def _sidecar_status(node: dict) -> dict:
583
+ try:
584
+ info = _SIDECAR_ENTRY.stat()
585
+ exists = True
586
+ regular_file = _SIDECAR_ENTRY.is_file()
587
+ readable = os.access(_SIDECAR_ENTRY, os.R_OK)
588
+ size = info.st_size
589
+ except OSError:
590
+ exists = False
591
+ regular_file = False
592
+ readable = False
593
+ size = 0
594
+
595
+ syntax_checked = False
596
+ syntax_ok = False
597
+ error = None
598
+ if not exists:
599
+ error = "The packaged Inline sidecar is missing."
600
+ elif not regular_file or not readable or size <= 0:
601
+ error = "The packaged Inline sidecar is not a readable non-empty file."
602
+ elif node.get("ok"):
603
+ syntax_checked = True
604
+ try:
605
+ checked = subprocess.run(
606
+ [str(node["path"]), "--check", str(_SIDECAR_ENTRY)],
607
+ stdout=subprocess.DEVNULL,
608
+ stderr=subprocess.DEVNULL,
609
+ timeout=10,
610
+ check=False,
611
+ )
612
+ syntax_ok = checked.returncode == 0
613
+ if not syntax_ok:
614
+ error = "The packaged Inline sidecar failed Node.js syntax validation."
615
+ except (OSError, subprocess.TimeoutExpired):
616
+ error = "The packaged Inline sidecar could not be validated by Node.js."
617
+ else:
618
+ error = "The packaged Inline sidecar cannot run without compatible Node.js."
619
+
620
+ return {
621
+ "ok": bool(exists and regular_file and readable and size > 0 and syntax_checked and syntax_ok),
622
+ "exists": exists,
623
+ "regularFile": regular_file,
624
+ "readable": readable,
625
+ "size": size,
626
+ "syntaxChecked": syntax_checked,
627
+ "syntaxOk": syntax_ok,
628
+ "error": error,
629
+ }
630
+
631
+
632
+ def _node_status_text(node: dict) -> str:
633
+ if node.get("ok"):
634
+ return f"yes ({node.get('version') or 'unknown version'})"
635
+ return f"no ({node.get('error') or 'unknown error'})"
@@ -1,7 +1,7 @@
1
1
  name: inline-platform
2
2
  label: Inline
3
3
  kind: platform
4
- version: 0.0.7
4
+ version: 0.0.8
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