@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/README.md +8 -8
- package/dist/install.js +214 -128
- package/package.json +7 -4
- package/plugin/inline/adapter.py +147 -1
- package/plugin/inline/cli.py +120 -24
- package/plugin/inline/plugin.yaml +1 -1
- package/plugin/inline/sidecar/index.mjs +23891 -9700
- package/plugin/inline/tools.py +47 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inline-chat/hermes-agent-adapter",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.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.
|
|
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.
|
|
81
|
-
"testedHermesCommit": "
|
|
83
|
+
"testedHermesVersion": "0.20.0",
|
|
84
|
+
"testedHermesCommit": "3c27eb6"
|
|
82
85
|
}
|
|
83
86
|
}
|
package/plugin/inline/adapter.py
CHANGED
|
@@ -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
|
|
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()]
|
package/plugin/inline/cli.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
"
|
|
429
|
-
"
|
|
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
|
|
438
|
-
print(f"Node available: {
|
|
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() ->
|
|
530
|
+
def _node_status() -> dict:
|
|
518
531
|
node_bin = _find_node_bin()
|
|
519
532
|
if not node_bin:
|
|
520
|
-
return
|
|
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
|
|
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
|
|
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
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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'})"
|