calvyn-code 0.14.0

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.
Files changed (1718) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -0
  3. package/README.zh-CN.md +180 -0
  4. package/acp_adapter/__init__.py +1 -0
  5. package/acp_adapter/__main__.py +5 -0
  6. package/acp_adapter/auth.py +68 -0
  7. package/acp_adapter/bootstrap/__init__.py +0 -0
  8. package/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 +288 -0
  9. package/acp_adapter/bootstrap/bootstrap_browser_tools.sh +399 -0
  10. package/acp_adapter/entry.py +292 -0
  11. package/acp_adapter/events.py +265 -0
  12. package/acp_adapter/permissions.py +148 -0
  13. package/acp_adapter/server.py +1713 -0
  14. package/acp_adapter/session.py +629 -0
  15. package/acp_adapter/tools.py +1180 -0
  16. package/agent/__init__.py +6 -0
  17. package/agent/__pycache__/__init__.cpython-312.pyc +0 -0
  18. package/agent/__pycache__/account_usage.cpython-312.pyc +0 -0
  19. package/agent/__pycache__/anthropic_adapter.cpython-312.pyc +0 -0
  20. package/agent/__pycache__/async_utils.cpython-312.pyc +0 -0
  21. package/agent/__pycache__/auxiliary_client.cpython-312.pyc +0 -0
  22. package/agent/__pycache__/codex_responses_adapter.cpython-312.pyc +0 -0
  23. package/agent/__pycache__/context_compressor.cpython-312.pyc +0 -0
  24. package/agent/__pycache__/context_engine.cpython-312.pyc +0 -0
  25. package/agent/__pycache__/context_references.cpython-312.pyc +0 -0
  26. package/agent/__pycache__/credential_pool.cpython-312.pyc +0 -0
  27. package/agent/__pycache__/curator.cpython-312.pyc +0 -0
  28. package/agent/__pycache__/display.cpython-312.pyc +0 -0
  29. package/agent/__pycache__/error_classifier.cpython-312.pyc +0 -0
  30. package/agent/__pycache__/file_safety.cpython-312.pyc +0 -0
  31. package/agent/__pycache__/google_code_assist.cpython-312.pyc +0 -0
  32. package/agent/__pycache__/google_oauth.cpython-312.pyc +0 -0
  33. package/agent/__pycache__/i18n.cpython-312.pyc +0 -0
  34. package/agent/__pycache__/image_gen_provider.cpython-312.pyc +0 -0
  35. package/agent/__pycache__/image_gen_registry.cpython-312.pyc +0 -0
  36. package/agent/__pycache__/insights.cpython-312.pyc +0 -0
  37. package/agent/__pycache__/lmstudio_reasoning.cpython-312.pyc +0 -0
  38. package/agent/__pycache__/manual_compression_feedback.cpython-312.pyc +0 -0
  39. package/agent/__pycache__/markdown_tables.cpython-312.pyc +0 -0
  40. package/agent/__pycache__/memory_manager.cpython-312.pyc +0 -0
  41. package/agent/__pycache__/memory_provider.cpython-312.pyc +0 -0
  42. package/agent/__pycache__/model_metadata.cpython-312.pyc +0 -0
  43. package/agent/__pycache__/models_dev.cpython-312.pyc +0 -0
  44. package/agent/__pycache__/moonshot_schema.cpython-312.pyc +0 -0
  45. package/agent/__pycache__/onboarding.cpython-312.pyc +0 -0
  46. package/agent/__pycache__/portal_tags.cpython-312.pyc +0 -0
  47. package/agent/__pycache__/prompt_builder.cpython-312.pyc +0 -0
  48. package/agent/__pycache__/prompt_caching.cpython-312.pyc +0 -0
  49. package/agent/__pycache__/redact.cpython-312.pyc +0 -0
  50. package/agent/__pycache__/retry_utils.cpython-312.pyc +0 -0
  51. package/agent/__pycache__/shell_hooks.cpython-312.pyc +0 -0
  52. package/agent/__pycache__/skill_commands.cpython-312.pyc +0 -0
  53. package/agent/__pycache__/skill_preprocessing.cpython-312.pyc +0 -0
  54. package/agent/__pycache__/skill_utils.cpython-312.pyc +0 -0
  55. package/agent/__pycache__/subdirectory_hints.cpython-312.pyc +0 -0
  56. package/agent/__pycache__/think_scrubber.cpython-312.pyc +0 -0
  57. package/agent/__pycache__/title_generator.cpython-312.pyc +0 -0
  58. package/agent/__pycache__/tool_guardrails.cpython-312.pyc +0 -0
  59. package/agent/__pycache__/tool_result_classification.cpython-312.pyc +0 -0
  60. package/agent/__pycache__/trajectory.cpython-312.pyc +0 -0
  61. package/agent/__pycache__/usage_pricing.cpython-312.pyc +0 -0
  62. package/agent/__pycache__/video_gen_provider.cpython-312.pyc +0 -0
  63. package/agent/__pycache__/video_gen_registry.cpython-312.pyc +0 -0
  64. package/agent/__pycache__/web_search_provider.cpython-312.pyc +0 -0
  65. package/agent/__pycache__/web_search_registry.cpython-312.pyc +0 -0
  66. package/agent/account_usage.py +326 -0
  67. package/agent/anthropic_adapter.py +2087 -0
  68. package/agent/async_utils.py +68 -0
  69. package/agent/auxiliary_client.py +4893 -0
  70. package/agent/bedrock_adapter.py +1276 -0
  71. package/agent/codex_responses_adapter.py +1084 -0
  72. package/agent/context_compressor.py +1583 -0
  73. package/agent/context_engine.py +211 -0
  74. package/agent/context_references.py +519 -0
  75. package/agent/copilot_acp_client.py +684 -0
  76. package/agent/credential_pool.py +1780 -0
  77. package/agent/credential_sources.py +449 -0
  78. package/agent/curator.py +1782 -0
  79. package/agent/curator_backup.py +694 -0
  80. package/agent/display.py +987 -0
  81. package/agent/error_classifier.py +1058 -0
  82. package/agent/file_safety.py +112 -0
  83. package/agent/gemini_cloudcode_adapter.py +909 -0
  84. package/agent/gemini_native_adapter.py +971 -0
  85. package/agent/gemini_schema.py +99 -0
  86. package/agent/google_code_assist.py +452 -0
  87. package/agent/google_oauth.py +1062 -0
  88. package/agent/i18n.py +258 -0
  89. package/agent/image_gen_provider.py +243 -0
  90. package/agent/image_gen_registry.py +145 -0
  91. package/agent/image_routing.py +301 -0
  92. package/agent/insights.py +931 -0
  93. package/agent/lmstudio_reasoning.py +48 -0
  94. package/agent/lsp/__init__.py +106 -0
  95. package/agent/lsp/__pycache__/__init__.cpython-312.pyc +0 -0
  96. package/agent/lsp/__pycache__/cli.cpython-312.pyc +0 -0
  97. package/agent/lsp/__pycache__/client.cpython-312.pyc +0 -0
  98. package/agent/lsp/__pycache__/eventlog.cpython-312.pyc +0 -0
  99. package/agent/lsp/__pycache__/manager.cpython-312.pyc +0 -0
  100. package/agent/lsp/__pycache__/protocol.cpython-312.pyc +0 -0
  101. package/agent/lsp/__pycache__/servers.cpython-312.pyc +0 -0
  102. package/agent/lsp/__pycache__/workspace.cpython-312.pyc +0 -0
  103. package/agent/lsp/cli.py +308 -0
  104. package/agent/lsp/client.py +930 -0
  105. package/agent/lsp/eventlog.py +213 -0
  106. package/agent/lsp/install.py +376 -0
  107. package/agent/lsp/manager.py +644 -0
  108. package/agent/lsp/protocol.py +196 -0
  109. package/agent/lsp/range_shift.py +149 -0
  110. package/agent/lsp/reporter.py +78 -0
  111. package/agent/lsp/servers.py +1040 -0
  112. package/agent/lsp/workspace.py +223 -0
  113. package/agent/manual_compression_feedback.py +49 -0
  114. package/agent/markdown_tables.py +309 -0
  115. package/agent/memory_manager.py +556 -0
  116. package/agent/memory_provider.py +279 -0
  117. package/agent/model_metadata.py +1827 -0
  118. package/agent/models_dev.py +724 -0
  119. package/agent/moonshot_schema.py +231 -0
  120. package/agent/nous_rate_guard.py +326 -0
  121. package/agent/onboarding.py +193 -0
  122. package/agent/plugin_llm.py +1046 -0
  123. package/agent/portal_tags.py +64 -0
  124. package/agent/prompt_builder.py +1457 -0
  125. package/agent/prompt_caching.py +79 -0
  126. package/agent/rate_limit_tracker.py +246 -0
  127. package/agent/redact.py +403 -0
  128. package/agent/retry_utils.py +57 -0
  129. package/agent/shell_hooks.py +837 -0
  130. package/agent/skill_commands.py +502 -0
  131. package/agent/skill_preprocessing.py +131 -0
  132. package/agent/skill_utils.py +512 -0
  133. package/agent/subdirectory_hints.py +224 -0
  134. package/agent/think_scrubber.py +386 -0
  135. package/agent/title_generator.py +171 -0
  136. package/agent/tool_guardrails.py +458 -0
  137. package/agent/tool_result_classification.py +26 -0
  138. package/agent/trajectory.py +56 -0
  139. package/agent/transports/__init__.py +68 -0
  140. package/agent/transports/__pycache__/__init__.cpython-312.pyc +0 -0
  141. package/agent/transports/__pycache__/anthropic.cpython-312.pyc +0 -0
  142. package/agent/transports/__pycache__/base.cpython-312.pyc +0 -0
  143. package/agent/transports/__pycache__/bedrock.cpython-312.pyc +0 -0
  144. package/agent/transports/__pycache__/chat_completions.cpython-312.pyc +0 -0
  145. package/agent/transports/__pycache__/codex.cpython-312.pyc +0 -0
  146. package/agent/transports/__pycache__/types.cpython-312.pyc +0 -0
  147. package/agent/transports/anthropic.py +179 -0
  148. package/agent/transports/base.py +89 -0
  149. package/agent/transports/bedrock.py +154 -0
  150. package/agent/transports/chat_completions.py +614 -0
  151. package/agent/transports/codex.py +283 -0
  152. package/agent/transports/codex_app_server.py +368 -0
  153. package/agent/transports/codex_app_server_session.py +810 -0
  154. package/agent/transports/codex_event_projector.py +312 -0
  155. package/agent/transports/hermes_tools_mcp_server.py +233 -0
  156. package/agent/transports/types.py +162 -0
  157. package/agent/usage_pricing.py +877 -0
  158. package/agent/video_gen_provider.py +300 -0
  159. package/agent/video_gen_registry.py +117 -0
  160. package/agent/web_search_provider.py +221 -0
  161. package/agent/web_search_registry.py +262 -0
  162. package/assets/banner.png +0 -0
  163. package/batch_runner.py +1303 -0
  164. package/bin/calvyn.js +67 -0
  165. package/calvyn_bootstrap.py +130 -0
  166. package/calvyn_constants.py +346 -0
  167. package/calvyn_logging.py +390 -0
  168. package/calvyn_state.py +2967 -0
  169. package/calvyn_time.py +105 -0
  170. package/cli.py +14160 -0
  171. package/cron/__init__.py +42 -0
  172. package/cron/__pycache__/__init__.cpython-312.pyc +0 -0
  173. package/cron/__pycache__/jobs.cpython-312.pyc +0 -0
  174. package/cron/__pycache__/scheduler.cpython-312.pyc +0 -0
  175. package/cron/jobs.py +1160 -0
  176. package/cron/scheduler.py +1832 -0
  177. package/gateway/__init__.py +35 -0
  178. package/gateway/__pycache__/__init__.cpython-312.pyc +0 -0
  179. package/gateway/__pycache__/channel_directory.cpython-312.pyc +0 -0
  180. package/gateway/__pycache__/config.cpython-312.pyc +0 -0
  181. package/gateway/__pycache__/delivery.cpython-312.pyc +0 -0
  182. package/gateway/__pycache__/display_config.cpython-312.pyc +0 -0
  183. package/gateway/__pycache__/hooks.cpython-312.pyc +0 -0
  184. package/gateway/__pycache__/pairing.cpython-312.pyc +0 -0
  185. package/gateway/__pycache__/platform_registry.cpython-312.pyc +0 -0
  186. package/gateway/__pycache__/restart.cpython-312.pyc +0 -0
  187. package/gateway/__pycache__/run.cpython-312.pyc +0 -0
  188. package/gateway/__pycache__/runtime_footer.cpython-312.pyc +0 -0
  189. package/gateway/__pycache__/session.cpython-312.pyc +0 -0
  190. package/gateway/__pycache__/session_context.cpython-312.pyc +0 -0
  191. package/gateway/__pycache__/shutdown_forensics.cpython-312.pyc +0 -0
  192. package/gateway/__pycache__/slash_access.cpython-312.pyc +0 -0
  193. package/gateway/__pycache__/status.cpython-312.pyc +0 -0
  194. package/gateway/__pycache__/stream_consumer.cpython-312.pyc +0 -0
  195. package/gateway/__pycache__/whatsapp_identity.cpython-312.pyc +0 -0
  196. package/gateway/assets/telegram-botfather-threads-settings.jpg +0 -0
  197. package/gateway/builtin_hooks/__init__.py +1 -0
  198. package/gateway/channel_directory.py +357 -0
  199. package/gateway/config.py +1873 -0
  200. package/gateway/delivery.py +258 -0
  201. package/gateway/display_config.py +206 -0
  202. package/gateway/hooks.py +210 -0
  203. package/gateway/mirror.py +179 -0
  204. package/gateway/pairing.py +322 -0
  205. package/gateway/platform_registry.py +260 -0
  206. package/gateway/platforms/ADDING_A_PLATFORM.md +374 -0
  207. package/gateway/platforms/__init__.py +45 -0
  208. package/gateway/platforms/__pycache__/__init__.cpython-312.pyc +0 -0
  209. package/gateway/platforms/__pycache__/base.cpython-312.pyc +0 -0
  210. package/gateway/platforms/__pycache__/helpers.cpython-312.pyc +0 -0
  211. package/gateway/platforms/__pycache__/telegram.cpython-312.pyc +0 -0
  212. package/gateway/platforms/__pycache__/telegram_network.cpython-312.pyc +0 -0
  213. package/gateway/platforms/__pycache__/yuanbao.cpython-312.pyc +0 -0
  214. package/gateway/platforms/__pycache__/yuanbao_media.cpython-312.pyc +0 -0
  215. package/gateway/platforms/__pycache__/yuanbao_proto.cpython-312.pyc +0 -0
  216. package/gateway/platforms/_http_client_limits.py +84 -0
  217. package/gateway/platforms/api_server.py +3488 -0
  218. package/gateway/platforms/base.py +3747 -0
  219. package/gateway/platforms/bluebubbles.py +937 -0
  220. package/gateway/platforms/dingtalk.py +1473 -0
  221. package/gateway/platforms/discord.py +5584 -0
  222. package/gateway/platforms/email.py +773 -0
  223. package/gateway/platforms/feishu.py +5059 -0
  224. package/gateway/platforms/feishu_comment.py +1382 -0
  225. package/gateway/platforms/feishu_comment_rules.py +430 -0
  226. package/gateway/platforms/helpers.py +279 -0
  227. package/gateway/platforms/homeassistant.py +449 -0
  228. package/gateway/platforms/matrix.py +2777 -0
  229. package/gateway/platforms/mattermost.py +852 -0
  230. package/gateway/platforms/msgraph_webhook.py +397 -0
  231. package/gateway/platforms/qqbot/__init__.py +91 -0
  232. package/gateway/platforms/qqbot/adapter.py +3072 -0
  233. package/gateway/platforms/qqbot/chunked_upload.py +602 -0
  234. package/gateway/platforms/qqbot/constants.py +74 -0
  235. package/gateway/platforms/qqbot/crypto.py +45 -0
  236. package/gateway/platforms/qqbot/keyboards.py +473 -0
  237. package/gateway/platforms/qqbot/onboard.py +220 -0
  238. package/gateway/platforms/qqbot/utils.py +71 -0
  239. package/gateway/platforms/signal.py +1518 -0
  240. package/gateway/platforms/signal_rate_limit.py +369 -0
  241. package/gateway/platforms/slack.py +3028 -0
  242. package/gateway/platforms/sms.py +377 -0
  243. package/gateway/platforms/telegram.py +4836 -0
  244. package/gateway/platforms/telegram_network.py +249 -0
  245. package/gateway/platforms/webhook.py +806 -0
  246. package/gateway/platforms/wecom.py +1610 -0
  247. package/gateway/platforms/wecom_callback.py +403 -0
  248. package/gateway/platforms/wecom_crypto.py +142 -0
  249. package/gateway/platforms/weixin.py +2170 -0
  250. package/gateway/platforms/whatsapp.py +1283 -0
  251. package/gateway/platforms/yuanbao.py +4873 -0
  252. package/gateway/platforms/yuanbao_media.py +645 -0
  253. package/gateway/platforms/yuanbao_proto.py +1209 -0
  254. package/gateway/platforms/yuanbao_sticker.py +558 -0
  255. package/gateway/restart.py +20 -0
  256. package/gateway/run.py +17074 -0
  257. package/gateway/runtime_footer.py +150 -0
  258. package/gateway/session.py +1399 -0
  259. package/gateway/session_context.py +156 -0
  260. package/gateway/shutdown_forensics.py +462 -0
  261. package/gateway/slash_access.py +229 -0
  262. package/gateway/status.py +972 -0
  263. package/gateway/sticker_cache.py +111 -0
  264. package/gateway/stream_consumer.py +1286 -0
  265. package/gateway/whatsapp_identity.py +156 -0
  266. package/hermes_cli/__init__.py +47 -0
  267. package/hermes_cli/__pycache__/__init__.cpython-312.pyc +0 -0
  268. package/hermes_cli/__pycache__/_parser.cpython-312.pyc +0 -0
  269. package/hermes_cli/__pycache__/auth.cpython-312.pyc +0 -0
  270. package/hermes_cli/__pycache__/banner.cpython-312.pyc +0 -0
  271. package/hermes_cli/__pycache__/browser_connect.cpython-312.pyc +0 -0
  272. package/hermes_cli/__pycache__/callbacks.cpython-312.pyc +0 -0
  273. package/hermes_cli/__pycache__/checkpoints.cpython-312.pyc +0 -0
  274. package/hermes_cli/__pycache__/cli_output.cpython-312.pyc +0 -0
  275. package/hermes_cli/__pycache__/codex_models.cpython-312.pyc +0 -0
  276. package/hermes_cli/__pycache__/codex_runtime_switch.cpython-312.pyc +0 -0
  277. package/hermes_cli/__pycache__/colors.cpython-312.pyc +0 -0
  278. package/hermes_cli/__pycache__/commands.cpython-312.pyc +0 -0
  279. package/hermes_cli/__pycache__/config.cpython-312.pyc +0 -0
  280. package/hermes_cli/__pycache__/copilot_auth.cpython-312.pyc +0 -0
  281. package/hermes_cli/__pycache__/curator.cpython-312.pyc +0 -0
  282. package/hermes_cli/__pycache__/curses_ui.cpython-312.pyc +0 -0
  283. package/hermes_cli/__pycache__/debug.cpython-312.pyc +0 -0
  284. package/hermes_cli/__pycache__/default_soul.cpython-312.pyc +0 -0
  285. package/hermes_cli/__pycache__/env_loader.cpython-312.pyc +0 -0
  286. package/hermes_cli/__pycache__/fallback_cmd.cpython-312.pyc +0 -0
  287. package/hermes_cli/__pycache__/gateway.cpython-312.pyc +0 -0
  288. package/hermes_cli/__pycache__/gateway_windows.cpython-312.pyc +0 -0
  289. package/hermes_cli/__pycache__/goals.cpython-312.pyc +0 -0
  290. package/hermes_cli/__pycache__/inventory.cpython-312.pyc +0 -0
  291. package/hermes_cli/__pycache__/kanban.cpython-312.pyc +0 -0
  292. package/hermes_cli/__pycache__/kanban_db.cpython-312.pyc +0 -0
  293. package/hermes_cli/__pycache__/main.cpython-312.pyc +0 -0
  294. package/hermes_cli/__pycache__/model_catalog.cpython-312.pyc +0 -0
  295. package/hermes_cli/__pycache__/model_normalize.cpython-312.pyc +0 -0
  296. package/hermes_cli/__pycache__/model_switch.cpython-312.pyc +0 -0
  297. package/hermes_cli/__pycache__/models.cpython-312.pyc +0 -0
  298. package/hermes_cli/__pycache__/nous_subscription.cpython-312.pyc +0 -0
  299. package/hermes_cli/__pycache__/pairing.cpython-312.pyc +0 -0
  300. package/hermes_cli/__pycache__/platforms.cpython-312.pyc +0 -0
  301. package/hermes_cli/__pycache__/plugins.cpython-312.pyc +0 -0
  302. package/hermes_cli/__pycache__/profiles.cpython-312.pyc +0 -0
  303. package/hermes_cli/__pycache__/providers.cpython-312.pyc +0 -0
  304. package/hermes_cli/__pycache__/pt_input_extras.cpython-312.pyc +0 -0
  305. package/hermes_cli/__pycache__/runtime_provider.cpython-312.pyc +0 -0
  306. package/hermes_cli/__pycache__/security_advisories.cpython-312.pyc +0 -0
  307. package/hermes_cli/__pycache__/setup.cpython-312.pyc +0 -0
  308. package/hermes_cli/__pycache__/skills_hub.cpython-312.pyc +0 -0
  309. package/hermes_cli/__pycache__/skin_engine.cpython-312.pyc +0 -0
  310. package/hermes_cli/__pycache__/stdio.cpython-312.pyc +0 -0
  311. package/hermes_cli/__pycache__/timeouts.cpython-312.pyc +0 -0
  312. package/hermes_cli/__pycache__/tips.cpython-312.pyc +0 -0
  313. package/hermes_cli/__pycache__/tools_config.cpython-312.pyc +0 -0
  314. package/hermes_cli/__pycache__/voice.cpython-312.pyc +0 -0
  315. package/hermes_cli/_parser.py +365 -0
  316. package/hermes_cli/_subprocess_compat.py +175 -0
  317. package/hermes_cli/auth.py +6299 -0
  318. package/hermes_cli/auth_commands.py +749 -0
  319. package/hermes_cli/azure_detect.py +300 -0
  320. package/hermes_cli/backup.py +938 -0
  321. package/hermes_cli/banner.py +703 -0
  322. package/hermes_cli/browser_connect.py +139 -0
  323. package/hermes_cli/callbacks.py +243 -0
  324. package/hermes_cli/checkpoints.py +244 -0
  325. package/hermes_cli/claw.py +810 -0
  326. package/hermes_cli/cli_output.py +78 -0
  327. package/hermes_cli/clipboard.py +495 -0
  328. package/hermes_cli/codex_models.py +198 -0
  329. package/hermes_cli/codex_runtime_plugin_migration.py +757 -0
  330. package/hermes_cli/codex_runtime_switch.py +266 -0
  331. package/hermes_cli/colors.py +38 -0
  332. package/hermes_cli/commands.py +1728 -0
  333. package/hermes_cli/completion.py +315 -0
  334. package/hermes_cli/config.py +5382 -0
  335. package/hermes_cli/copilot_auth.py +392 -0
  336. package/hermes_cli/cron.py +313 -0
  337. package/hermes_cli/curator.py +598 -0
  338. package/hermes_cli/curses_ui.py +472 -0
  339. package/hermes_cli/debug.py +747 -0
  340. package/hermes_cli/default_soul.py +11 -0
  341. package/hermes_cli/dep_ensure.py +107 -0
  342. package/hermes_cli/dingtalk_auth.py +293 -0
  343. package/hermes_cli/doctor.py +1863 -0
  344. package/hermes_cli/dump.py +326 -0
  345. package/hermes_cli/env_loader.py +175 -0
  346. package/hermes_cli/fallback_cmd.py +361 -0
  347. package/hermes_cli/gateway.py +5422 -0
  348. package/hermes_cli/gateway_windows.py +692 -0
  349. package/hermes_cli/goals.py +757 -0
  350. package/hermes_cli/hooks.py +385 -0
  351. package/hermes_cli/inventory.py +240 -0
  352. package/hermes_cli/kanban.py +2252 -0
  353. package/hermes_cli/kanban_db.py +4840 -0
  354. package/hermes_cli/kanban_diagnostics.py +776 -0
  355. package/hermes_cli/kanban_specify.py +266 -0
  356. package/hermes_cli/logs.py +391 -0
  357. package/hermes_cli/main.py +12396 -0
  358. package/hermes_cli/mcp_config.py +781 -0
  359. package/hermes_cli/memory_setup.py +465 -0
  360. package/hermes_cli/model_catalog.py +330 -0
  361. package/hermes_cli/model_normalize.py +473 -0
  362. package/hermes_cli/model_switch.py +1777 -0
  363. package/hermes_cli/models.py +3789 -0
  364. package/hermes_cli/nous_subscription.py +799 -0
  365. package/hermes_cli/oneshot.py +351 -0
  366. package/hermes_cli/pairing.py +115 -0
  367. package/hermes_cli/platforms.py +83 -0
  368. package/hermes_cli/plugins.py +1562 -0
  369. package/hermes_cli/plugins_cmd.py +1587 -0
  370. package/hermes_cli/profile_distribution.py +703 -0
  371. package/hermes_cli/profiles.py +1319 -0
  372. package/hermes_cli/providers.py +720 -0
  373. package/hermes_cli/proxy/__init__.py +20 -0
  374. package/hermes_cli/proxy/adapters/__init__.py +35 -0
  375. package/hermes_cli/proxy/adapters/base.py +94 -0
  376. package/hermes_cli/proxy/adapters/nous_portal.py +137 -0
  377. package/hermes_cli/proxy/cli.py +141 -0
  378. package/hermes_cli/proxy/server.py +265 -0
  379. package/hermes_cli/pt_input_extras.py +83 -0
  380. package/hermes_cli/pty_bridge.py +237 -0
  381. package/hermes_cli/relaunch.py +205 -0
  382. package/hermes_cli/runtime_provider.py +1428 -0
  383. package/hermes_cli/security_advisories.py +452 -0
  384. package/hermes_cli/setup.py +3559 -0
  385. package/hermes_cli/skills_config.py +177 -0
  386. package/hermes_cli/skills_hub.py +1595 -0
  387. package/hermes_cli/skin_engine.py +929 -0
  388. package/hermes_cli/slack_cli.py +160 -0
  389. package/hermes_cli/status.py +550 -0
  390. package/hermes_cli/stdio.py +252 -0
  391. package/hermes_cli/timeouts.py +82 -0
  392. package/hermes_cli/tips.py +487 -0
  393. package/hermes_cli/tools_config.py +3151 -0
  394. package/hermes_cli/uninstall.py +681 -0
  395. package/hermes_cli/vercel_auth.py +70 -0
  396. package/hermes_cli/voice.py +846 -0
  397. package/hermes_cli/web_server.py +4438 -0
  398. package/hermes_cli/webhook.py +275 -0
  399. package/locales/af.yaml +350 -0
  400. package/locales/de.yaml +350 -0
  401. package/locales/en.yaml +365 -0
  402. package/locales/es.yaml +350 -0
  403. package/locales/fr.yaml +350 -0
  404. package/locales/ga.yaml +354 -0
  405. package/locales/hu.yaml +350 -0
  406. package/locales/it.yaml +350 -0
  407. package/locales/ja.yaml +350 -0
  408. package/locales/ko.yaml +350 -0
  409. package/locales/pt.yaml +350 -0
  410. package/locales/ru.yaml +350 -0
  411. package/locales/tr.yaml +350 -0
  412. package/locales/uk.yaml +350 -0
  413. package/locales/zh-hant.yaml +350 -0
  414. package/locales/zh.yaml +350 -0
  415. package/mcp_serve.py +898 -0
  416. package/model_tools.py +899 -0
  417. package/optional-skills/DESCRIPTION.md +24 -0
  418. package/optional-skills/autonomous-ai-agents/DESCRIPTION.md +2 -0
  419. package/optional-skills/autonomous-ai-agents/blackbox/SKILL.md +144 -0
  420. package/optional-skills/autonomous-ai-agents/honcho/SKILL.md +431 -0
  421. package/optional-skills/blockchain/evm/SKILL.md +211 -0
  422. package/optional-skills/blockchain/evm/scripts/evm_client.py +1508 -0
  423. package/optional-skills/blockchain/hyperliquid/SKILL.md +211 -0
  424. package/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py +1660 -0
  425. package/optional-skills/blockchain/solana/SKILL.md +208 -0
  426. package/optional-skills/blockchain/solana/scripts/solana_client.py +698 -0
  427. package/optional-skills/communication/DESCRIPTION.md +1 -0
  428. package/optional-skills/communication/one-three-one-rule/SKILL.md +104 -0
  429. package/optional-skills/creative/blender-mcp/SKILL.md +117 -0
  430. package/optional-skills/creative/concept-diagrams/SKILL.md +362 -0
  431. package/optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md +244 -0
  432. package/optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md +276 -0
  433. package/optional-skills/creative/concept-diagrams/examples/autonomous-llm-research-agent-flow.md +240 -0
  434. package/optional-skills/creative/concept-diagrams/examples/banana-journey-tree-to-smoothie.md +161 -0
  435. package/optional-skills/creative/concept-diagrams/examples/commercial-aircraft-structure.md +209 -0
  436. package/optional-skills/creative/concept-diagrams/examples/cpu-ooo-microarchitecture.md +236 -0
  437. package/optional-skills/creative/concept-diagrams/examples/electricity-grid-flow.md +182 -0
  438. package/optional-skills/creative/concept-diagrams/examples/feature-film-production-pipeline.md +172 -0
  439. package/optional-skills/creative/concept-diagrams/examples/hospital-emergency-department-flow.md +165 -0
  440. package/optional-skills/creative/concept-diagrams/examples/ml-benchmark-grouped-bar-chart.md +114 -0
  441. package/optional-skills/creative/concept-diagrams/examples/place-order-uml-sequence.md +325 -0
  442. package/optional-skills/creative/concept-diagrams/examples/smart-city-infrastructure.md +173 -0
  443. package/optional-skills/creative/concept-diagrams/examples/smartphone-layer-anatomy.md +154 -0
  444. package/optional-skills/creative/concept-diagrams/examples/sn2-reaction-mechanism.md +247 -0
  445. package/optional-skills/creative/concept-diagrams/examples/wind-turbine-structure.md +338 -0
  446. package/optional-skills/creative/concept-diagrams/references/dashboard-patterns.md +43 -0
  447. package/optional-skills/creative/concept-diagrams/references/infrastructure-patterns.md +144 -0
  448. package/optional-skills/creative/concept-diagrams/references/physical-shape-cookbook.md +42 -0
  449. package/optional-skills/creative/concept-diagrams/templates/template.html +174 -0
  450. package/optional-skills/creative/hyperframes/SKILL.md +191 -0
  451. package/optional-skills/creative/hyperframes/references/cli.md +185 -0
  452. package/optional-skills/creative/hyperframes/references/composition.md +129 -0
  453. package/optional-skills/creative/hyperframes/references/features.md +289 -0
  454. package/optional-skills/creative/hyperframes/references/gsap.md +136 -0
  455. package/optional-skills/creative/hyperframes/references/troubleshooting.md +137 -0
  456. package/optional-skills/creative/hyperframes/references/website-to-video.md +145 -0
  457. package/optional-skills/creative/hyperframes/scripts/setup.sh +135 -0
  458. package/optional-skills/creative/kanban-video-orchestrator/SKILL.md +207 -0
  459. package/optional-skills/creative/kanban-video-orchestrator/assets/brief.md.tmpl +79 -0
  460. package/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl +185 -0
  461. package/optional-skills/creative/kanban-video-orchestrator/assets/soul.md.tmpl +38 -0
  462. package/optional-skills/creative/kanban-video-orchestrator/references/examples.md +227 -0
  463. package/optional-skills/creative/kanban-video-orchestrator/references/intake.md +166 -0
  464. package/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md +276 -0
  465. package/optional-skills/creative/kanban-video-orchestrator/references/monitoring.md +180 -0
  466. package/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md +298 -0
  467. package/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md +317 -0
  468. package/optional-skills/creative/kanban-video-orchestrator/scripts/bootstrap_pipeline.py +501 -0
  469. package/optional-skills/creative/kanban-video-orchestrator/scripts/monitor.py +195 -0
  470. package/optional-skills/creative/meme-generation/EXAMPLES.md +46 -0
  471. package/optional-skills/creative/meme-generation/SKILL.md +130 -0
  472. package/optional-skills/creative/meme-generation/scripts/generate_meme.py +471 -0
  473. package/optional-skills/creative/meme-generation/scripts/templates.json +97 -0
  474. package/optional-skills/devops/cli/SKILL.md +156 -0
  475. package/optional-skills/devops/cli/references/app-discovery.md +112 -0
  476. package/optional-skills/devops/cli/references/authentication.md +59 -0
  477. package/optional-skills/devops/cli/references/cli-reference.md +104 -0
  478. package/optional-skills/devops/cli/references/running-apps.md +171 -0
  479. package/optional-skills/devops/docker-management/SKILL.md +281 -0
  480. package/optional-skills/devops/pinggy-tunnel/SKILL.md +309 -0
  481. package/optional-skills/devops/watchers/SKILL.md +112 -0
  482. package/optional-skills/devops/watchers/scripts/_watermark.py +148 -0
  483. package/optional-skills/devops/watchers/scripts/watch_github.py +168 -0
  484. package/optional-skills/devops/watchers/scripts/watch_http_json.py +131 -0
  485. package/optional-skills/devops/watchers/scripts/watch_rss.py +121 -0
  486. package/optional-skills/dogfood/DESCRIPTION.md +3 -0
  487. package/optional-skills/dogfood/adversarial-ux-test/SKILL.md +191 -0
  488. package/optional-skills/email/agentmail/SKILL.md +126 -0
  489. package/optional-skills/finance/3-statement-model/SKILL.md +433 -0
  490. package/optional-skills/finance/3-statement-model/references/formatting.md +118 -0
  491. package/optional-skills/finance/3-statement-model/references/formulas.md +292 -0
  492. package/optional-skills/finance/3-statement-model/references/sec-filings.md +125 -0
  493. package/optional-skills/finance/comps-analysis/SKILL.md +662 -0
  494. package/optional-skills/finance/dcf-model/SKILL.md +1270 -0
  495. package/optional-skills/finance/dcf-model/TROUBLESHOOTING.md +40 -0
  496. package/optional-skills/finance/dcf-model/requirements.txt +7 -0
  497. package/optional-skills/finance/dcf-model/scripts/validate_dcf.py +292 -0
  498. package/optional-skills/finance/excel-author/SKILL.md +244 -0
  499. package/optional-skills/finance/excel-author/scripts/recalc.py +88 -0
  500. package/optional-skills/finance/lbo-model/SKILL.md +291 -0
  501. package/optional-skills/finance/merger-model/SKILL.md +144 -0
  502. package/optional-skills/finance/pptx-author/SKILL.md +173 -0
  503. package/optional-skills/finance/stocks/SKILL.md +95 -0
  504. package/optional-skills/finance/stocks/scripts/stocks_client.py +755 -0
  505. package/optional-skills/health/DESCRIPTION.md +1 -0
  506. package/optional-skills/health/fitness-nutrition/SKILL.md +256 -0
  507. package/optional-skills/health/fitness-nutrition/references/FORMULAS.md +100 -0
  508. package/optional-skills/health/fitness-nutrition/scripts/body_calc.py +210 -0
  509. package/optional-skills/health/fitness-nutrition/scripts/nutrition_search.py +86 -0
  510. package/optional-skills/health/neuroskill-bci/SKILL.md +459 -0
  511. package/optional-skills/health/neuroskill-bci/references/api.md +286 -0
  512. package/optional-skills/health/neuroskill-bci/references/metrics.md +220 -0
  513. package/optional-skills/health/neuroskill-bci/references/protocols.md +452 -0
  514. package/optional-skills/mcp/DESCRIPTION.md +3 -0
  515. package/optional-skills/mcp/fastmcp/SKILL.md +300 -0
  516. package/optional-skills/mcp/fastmcp/references/fastmcp-cli.md +110 -0
  517. package/optional-skills/mcp/fastmcp/scripts/scaffold_fastmcp.py +56 -0
  518. package/optional-skills/mcp/fastmcp/templates/api_wrapper.py +54 -0
  519. package/optional-skills/mcp/fastmcp/templates/database_server.py +77 -0
  520. package/optional-skills/mcp/fastmcp/templates/file_processor.py +55 -0
  521. package/optional-skills/mcp/mcporter/SKILL.md +123 -0
  522. package/optional-skills/migration/DESCRIPTION.md +2 -0
  523. package/optional-skills/migration/openclaw-migration/SKILL.md +298 -0
  524. package/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +3136 -0
  525. package/optional-skills/mlops/accelerate/SKILL.md +336 -0
  526. package/optional-skills/mlops/accelerate/references/custom-plugins.md +453 -0
  527. package/optional-skills/mlops/accelerate/references/megatron-integration.md +489 -0
  528. package/optional-skills/mlops/accelerate/references/performance.md +525 -0
  529. package/optional-skills/mlops/chroma/SKILL.md +410 -0
  530. package/optional-skills/mlops/chroma/references/integration.md +38 -0
  531. package/optional-skills/mlops/clip/SKILL.md +257 -0
  532. package/optional-skills/mlops/clip/references/applications.md +207 -0
  533. package/optional-skills/mlops/faiss/SKILL.md +225 -0
  534. package/optional-skills/mlops/faiss/references/index_types.md +280 -0
  535. package/optional-skills/mlops/flash-attention/SKILL.md +367 -0
  536. package/optional-skills/mlops/flash-attention/references/benchmarks.md +215 -0
  537. package/optional-skills/mlops/flash-attention/references/transformers-integration.md +293 -0
  538. package/optional-skills/mlops/guidance/SKILL.md +576 -0
  539. package/optional-skills/mlops/guidance/references/backends.md +554 -0
  540. package/optional-skills/mlops/guidance/references/constraints.md +674 -0
  541. package/optional-skills/mlops/guidance/references/examples.md +767 -0
  542. package/optional-skills/mlops/huggingface-tokenizers/SKILL.md +520 -0
  543. package/optional-skills/mlops/huggingface-tokenizers/references/algorithms.md +653 -0
  544. package/optional-skills/mlops/huggingface-tokenizers/references/integration.md +637 -0
  545. package/optional-skills/mlops/huggingface-tokenizers/references/pipeline.md +723 -0
  546. package/optional-skills/mlops/huggingface-tokenizers/references/training.md +565 -0
  547. package/optional-skills/mlops/inference/outlines/SKILL.md +656 -0
  548. package/optional-skills/mlops/inference/outlines/references/backends.md +615 -0
  549. package/optional-skills/mlops/inference/outlines/references/examples.md +773 -0
  550. package/optional-skills/mlops/inference/outlines/references/json_generation.md +652 -0
  551. package/optional-skills/mlops/instructor/SKILL.md +744 -0
  552. package/optional-skills/mlops/instructor/references/examples.md +107 -0
  553. package/optional-skills/mlops/instructor/references/providers.md +70 -0
  554. package/optional-skills/mlops/instructor/references/validation.md +606 -0
  555. package/optional-skills/mlops/lambda-labs/SKILL.md +549 -0
  556. package/optional-skills/mlops/lambda-labs/references/advanced-usage.md +611 -0
  557. package/optional-skills/mlops/lambda-labs/references/troubleshooting.md +530 -0
  558. package/optional-skills/mlops/llava/SKILL.md +308 -0
  559. package/optional-skills/mlops/llava/references/training.md +197 -0
  560. package/optional-skills/mlops/modal/SKILL.md +345 -0
  561. package/optional-skills/mlops/modal/references/advanced-usage.md +503 -0
  562. package/optional-skills/mlops/modal/references/troubleshooting.md +494 -0
  563. package/optional-skills/mlops/nemo-curator/SKILL.md +387 -0
  564. package/optional-skills/mlops/nemo-curator/references/deduplication.md +87 -0
  565. package/optional-skills/mlops/nemo-curator/references/filtering.md +102 -0
  566. package/optional-skills/mlops/peft/SKILL.md +435 -0
  567. package/optional-skills/mlops/peft/references/advanced-usage.md +514 -0
  568. package/optional-skills/mlops/peft/references/troubleshooting.md +480 -0
  569. package/optional-skills/mlops/pinecone/SKILL.md +362 -0
  570. package/optional-skills/mlops/pinecone/references/deployment.md +181 -0
  571. package/optional-skills/mlops/pytorch-fsdp/SKILL.md +130 -0
  572. package/optional-skills/mlops/pytorch-fsdp/references/index.md +7 -0
  573. package/optional-skills/mlops/pytorch-fsdp/references/other.md +4261 -0
  574. package/optional-skills/mlops/pytorch-lightning/SKILL.md +350 -0
  575. package/optional-skills/mlops/pytorch-lightning/references/callbacks.md +436 -0
  576. package/optional-skills/mlops/pytorch-lightning/references/distributed.md +490 -0
  577. package/optional-skills/mlops/pytorch-lightning/references/hyperparameter-tuning.md +556 -0
  578. package/optional-skills/mlops/qdrant/SKILL.md +497 -0
  579. package/optional-skills/mlops/qdrant/references/advanced-usage.md +648 -0
  580. package/optional-skills/mlops/qdrant/references/troubleshooting.md +631 -0
  581. package/optional-skills/mlops/saelens/SKILL.md +390 -0
  582. package/optional-skills/mlops/saelens/references/README.md +69 -0
  583. package/optional-skills/mlops/saelens/references/api.md +333 -0
  584. package/optional-skills/mlops/saelens/references/tutorials.md +318 -0
  585. package/optional-skills/mlops/simpo/SKILL.md +223 -0
  586. package/optional-skills/mlops/simpo/references/datasets.md +478 -0
  587. package/optional-skills/mlops/simpo/references/hyperparameters.md +452 -0
  588. package/optional-skills/mlops/simpo/references/loss-functions.md +350 -0
  589. package/optional-skills/mlops/slime/SKILL.md +468 -0
  590. package/optional-skills/mlops/slime/references/api-reference.md +392 -0
  591. package/optional-skills/mlops/slime/references/troubleshooting.md +386 -0
  592. package/optional-skills/mlops/stable-diffusion/SKILL.md +523 -0
  593. package/optional-skills/mlops/stable-diffusion/references/advanced-usage.md +716 -0
  594. package/optional-skills/mlops/stable-diffusion/references/troubleshooting.md +555 -0
  595. package/optional-skills/mlops/tensorrt-llm/SKILL.md +191 -0
  596. package/optional-skills/mlops/tensorrt-llm/references/multi-gpu.md +298 -0
  597. package/optional-skills/mlops/tensorrt-llm/references/optimization.md +242 -0
  598. package/optional-skills/mlops/tensorrt-llm/references/serving.md +470 -0
  599. package/optional-skills/mlops/torchtitan/SKILL.md +362 -0
  600. package/optional-skills/mlops/torchtitan/references/checkpoint.md +181 -0
  601. package/optional-skills/mlops/torchtitan/references/custom-models.md +258 -0
  602. package/optional-skills/mlops/torchtitan/references/float8.md +133 -0
  603. package/optional-skills/mlops/torchtitan/references/fsdp.md +126 -0
  604. package/optional-skills/mlops/training/axolotl/SKILL.md +166 -0
  605. package/optional-skills/mlops/training/axolotl/references/api.md +5548 -0
  606. package/optional-skills/mlops/training/axolotl/references/dataset-formats.md +1029 -0
  607. package/optional-skills/mlops/training/axolotl/references/index.md +15 -0
  608. package/optional-skills/mlops/training/axolotl/references/other.md +3563 -0
  609. package/optional-skills/mlops/training/trl-fine-tuning/SKILL.md +463 -0
  610. package/optional-skills/mlops/training/trl-fine-tuning/references/dpo-variants.md +227 -0
  611. package/optional-skills/mlops/training/trl-fine-tuning/references/grpo-training.md +504 -0
  612. package/optional-skills/mlops/training/trl-fine-tuning/references/online-rl.md +82 -0
  613. package/optional-skills/mlops/training/trl-fine-tuning/references/reward-modeling.md +122 -0
  614. package/optional-skills/mlops/training/trl-fine-tuning/references/sft-training.md +168 -0
  615. package/optional-skills/mlops/training/trl-fine-tuning/templates/basic_grpo_training.py +228 -0
  616. package/optional-skills/mlops/training/unsloth/SKILL.md +84 -0
  617. package/optional-skills/mlops/training/unsloth/references/index.md +7 -0
  618. package/optional-skills/mlops/training/unsloth/references/llms-full.md +16799 -0
  619. package/optional-skills/mlops/training/unsloth/references/llms-txt.md +12044 -0
  620. package/optional-skills/mlops/training/unsloth/references/llms.md +82 -0
  621. package/optional-skills/mlops/whisper/SKILL.md +321 -0
  622. package/optional-skills/mlops/whisper/references/languages.md +189 -0
  623. package/optional-skills/productivity/canvas/SKILL.md +98 -0
  624. package/optional-skills/productivity/canvas/scripts/canvas_api.py +157 -0
  625. package/optional-skills/productivity/here-now/SKILL.md +217 -0
  626. package/optional-skills/productivity/here-now/scripts/drive.sh +406 -0
  627. package/optional-skills/productivity/here-now/scripts/publish.sh +445 -0
  628. package/optional-skills/productivity/memento-flashcards/SKILL.md +324 -0
  629. package/optional-skills/productivity/memento-flashcards/scripts/memento_cards.py +353 -0
  630. package/optional-skills/productivity/memento-flashcards/scripts/youtube_quiz.py +88 -0
  631. package/optional-skills/productivity/shop-app/SKILL.md +340 -0
  632. package/optional-skills/productivity/shopify/SKILL.md +373 -0
  633. package/optional-skills/productivity/siyuan/SKILL.md +298 -0
  634. package/optional-skills/productivity/telephony/SKILL.md +418 -0
  635. package/optional-skills/productivity/telephony/scripts/telephony.py +1343 -0
  636. package/optional-skills/research/bioinformatics/SKILL.md +235 -0
  637. package/optional-skills/research/darwinian-evolver/SKILL.md +199 -0
  638. package/optional-skills/research/darwinian-evolver/scripts/parrot_openrouter.py +218 -0
  639. package/optional-skills/research/darwinian-evolver/scripts/show_snapshot.py +69 -0
  640. package/optional-skills/research/darwinian-evolver/templates/custom_problem_template.py +240 -0
  641. package/optional-skills/research/domain-intel/SKILL.md +97 -0
  642. package/optional-skills/research/domain-intel/scripts/domain_intel.py +397 -0
  643. package/optional-skills/research/drug-discovery/SKILL.md +227 -0
  644. package/optional-skills/research/drug-discovery/references/ADMET_REFERENCE.md +66 -0
  645. package/optional-skills/research/drug-discovery/scripts/chembl_target.py +53 -0
  646. package/optional-skills/research/drug-discovery/scripts/ro5_screen.py +44 -0
  647. package/optional-skills/research/duckduckgo-search/SKILL.md +238 -0
  648. package/optional-skills/research/duckduckgo-search/scripts/duckduckgo.sh +28 -0
  649. package/optional-skills/research/gitnexus-explorer/SKILL.md +214 -0
  650. package/optional-skills/research/gitnexus-explorer/scripts/proxy.mjs +92 -0
  651. package/optional-skills/research/osint-investigation/SKILL.md +277 -0
  652. package/optional-skills/research/osint-investigation/references/sources/courtlistener.md +98 -0
  653. package/optional-skills/research/osint-investigation/references/sources/gdelt.md +104 -0
  654. package/optional-skills/research/osint-investigation/references/sources/icij-offshore.md +104 -0
  655. package/optional-skills/research/osint-investigation/references/sources/nyc-acris.md +90 -0
  656. package/optional-skills/research/osint-investigation/references/sources/ofac-sdn.md +92 -0
  657. package/optional-skills/research/osint-investigation/references/sources/opencorporates.md +103 -0
  658. package/optional-skills/research/osint-investigation/references/sources/sec-edgar.md +83 -0
  659. package/optional-skills/research/osint-investigation/references/sources/senate-ld.md +89 -0
  660. package/optional-skills/research/osint-investigation/references/sources/usaspending.md +97 -0
  661. package/optional-skills/research/osint-investigation/references/sources/wayback.md +93 -0
  662. package/optional-skills/research/osint-investigation/references/sources/wikipedia.md +107 -0
  663. package/optional-skills/research/osint-investigation/scripts/_http.py +82 -0
  664. package/optional-skills/research/osint-investigation/scripts/_normalize.py +67 -0
  665. package/optional-skills/research/osint-investigation/scripts/build_findings.py +221 -0
  666. package/optional-skills/research/osint-investigation/scripts/entity_resolution.py +228 -0
  667. package/optional-skills/research/osint-investigation/scripts/fetch_courtlistener.py +149 -0
  668. package/optional-skills/research/osint-investigation/scripts/fetch_gdelt.py +162 -0
  669. package/optional-skills/research/osint-investigation/scripts/fetch_icij_offshore.py +234 -0
  670. package/optional-skills/research/osint-investigation/scripts/fetch_nyc_acris.py +203 -0
  671. package/optional-skills/research/osint-investigation/scripts/fetch_ofac_sdn.py +175 -0
  672. package/optional-skills/research/osint-investigation/scripts/fetch_opencorporates.py +192 -0
  673. package/optional-skills/research/osint-investigation/scripts/fetch_sec_edgar.py +184 -0
  674. package/optional-skills/research/osint-investigation/scripts/fetch_senate_ld.py +146 -0
  675. package/optional-skills/research/osint-investigation/scripts/fetch_usaspending.py +170 -0
  676. package/optional-skills/research/osint-investigation/scripts/fetch_wayback.py +142 -0
  677. package/optional-skills/research/osint-investigation/scripts/fetch_wikipedia.py +267 -0
  678. package/optional-skills/research/osint-investigation/scripts/timing_analysis.py +253 -0
  679. package/optional-skills/research/osint-investigation/templates/source-template.md +59 -0
  680. package/optional-skills/research/parallel-cli/SKILL.md +391 -0
  681. package/optional-skills/research/qmd/SKILL.md +441 -0
  682. package/optional-skills/research/scrapling/SKILL.md +336 -0
  683. package/optional-skills/research/searxng-search/SKILL.md +212 -0
  684. package/optional-skills/research/searxng-search/scripts/searxng.sh +22 -0
  685. package/optional-skills/security/1password/SKILL.md +163 -0
  686. package/optional-skills/security/1password/references/cli-examples.md +31 -0
  687. package/optional-skills/security/1password/references/get-started.md +21 -0
  688. package/optional-skills/security/DESCRIPTION.md +3 -0
  689. package/optional-skills/security/oss-forensics/SKILL.md +423 -0
  690. package/optional-skills/security/oss-forensics/references/evidence-types.md +89 -0
  691. package/optional-skills/security/oss-forensics/references/github-archive-guide.md +184 -0
  692. package/optional-skills/security/oss-forensics/references/investigation-templates.md +131 -0
  693. package/optional-skills/security/oss-forensics/references/recovery-techniques.md +164 -0
  694. package/optional-skills/security/oss-forensics/scripts/evidence-store.py +313 -0
  695. package/optional-skills/security/oss-forensics/templates/forensic-report.md +151 -0
  696. package/optional-skills/security/oss-forensics/templates/malicious-package-report.md +43 -0
  697. package/optional-skills/security/sherlock/SKILL.md +193 -0
  698. package/optional-skills/software-development/rest-graphql-debug/SKILL.md +514 -0
  699. package/optional-skills/web-development/DESCRIPTION.md +5 -0
  700. package/optional-skills/web-development/page-agent/SKILL.md +190 -0
  701. package/package.json +78 -0
  702. package/plugins/__init__.py +1 -0
  703. package/plugins/__pycache__/__init__.cpython-312.pyc +0 -0
  704. package/plugins/context_engine/__init__.py +219 -0
  705. package/plugins/disk-cleanup/README.md +51 -0
  706. package/plugins/disk-cleanup/__init__.py +316 -0
  707. package/plugins/disk-cleanup/disk_cleanup.py +497 -0
  708. package/plugins/disk-cleanup/plugin.yaml +7 -0
  709. package/plugins/example-dashboard/dashboard/manifest.json +14 -0
  710. package/plugins/example-dashboard/dashboard/plugin_api.py +17 -0
  711. package/plugins/google_meet/README.md +131 -0
  712. package/plugins/google_meet/SKILL.md +148 -0
  713. package/plugins/google_meet/__init__.py +103 -0
  714. package/plugins/google_meet/audio_bridge.py +244 -0
  715. package/plugins/google_meet/cli.py +479 -0
  716. package/plugins/google_meet/meet_bot.py +852 -0
  717. package/plugins/google_meet/node/__init__.py +54 -0
  718. package/plugins/google_meet/node/cli.py +125 -0
  719. package/plugins/google_meet/node/client.py +107 -0
  720. package/plugins/google_meet/node/protocol.py +124 -0
  721. package/plugins/google_meet/node/registry.py +113 -0
  722. package/plugins/google_meet/node/server.py +201 -0
  723. package/plugins/google_meet/plugin.yaml +16 -0
  724. package/plugins/google_meet/process_manager.py +324 -0
  725. package/plugins/google_meet/realtime/__init__.py +10 -0
  726. package/plugins/google_meet/realtime/openai_client.py +332 -0
  727. package/plugins/google_meet/tools.py +348 -0
  728. package/plugins/hermes-achievements/LICENSE +21 -0
  729. package/plugins/hermes-achievements/README.md +150 -0
  730. package/plugins/hermes-achievements/dashboard/dist/index.js +732 -0
  731. package/plugins/hermes-achievements/dashboard/dist/style.css +146 -0
  732. package/plugins/hermes-achievements/dashboard/manifest.json +11 -0
  733. package/plugins/hermes-achievements/dashboard/plugin_api.py +1062 -0
  734. package/plugins/hermes-achievements/docs/achievements-performance-implementation-plan.md +157 -0
  735. package/plugins/hermes-achievements/docs/achievements-performance-implementation-spec.md +219 -0
  736. package/plugins/hermes-achievements/docs/achievements-performance-spec.md +174 -0
  737. package/plugins/hermes-achievements/docs/assets/achievements-dashboard-hd.png +0 -0
  738. package/plugins/hermes-achievements/docs/assets/achievements-tier-showcase-hd.png +0 -0
  739. package/plugins/hermes-achievements/tests/test_achievement_engine.py +156 -0
  740. package/plugins/image_gen/openai/__init__.py +303 -0
  741. package/plugins/image_gen/openai/__pycache__/__init__.cpython-312.pyc +0 -0
  742. package/plugins/image_gen/openai/plugin.yaml +7 -0
  743. package/plugins/image_gen/openai-codex/__init__.py +378 -0
  744. package/plugins/image_gen/openai-codex/__pycache__/__init__.cpython-312.pyc +0 -0
  745. package/plugins/image_gen/openai-codex/plugin.yaml +5 -0
  746. package/plugins/image_gen/xai/__init__.py +316 -0
  747. package/plugins/image_gen/xai/__pycache__/__init__.cpython-312.pyc +0 -0
  748. package/plugins/image_gen/xai/plugin.yaml +7 -0
  749. package/plugins/kanban/dashboard/dist/index.js +3143 -0
  750. package/plugins/kanban/dashboard/dist/style.css +1500 -0
  751. package/plugins/kanban/dashboard/manifest.json +14 -0
  752. package/plugins/kanban/dashboard/plugin_api.py +1612 -0
  753. package/plugins/kanban/systemd/hermes-kanban-dispatcher.service +32 -0
  754. package/plugins/memory/__init__.py +408 -0
  755. package/plugins/memory/byterover/README.md +41 -0
  756. package/plugins/memory/byterover/__init__.py +384 -0
  757. package/plugins/memory/byterover/plugin.yaml +9 -0
  758. package/plugins/memory/hindsight/README.md +138 -0
  759. package/plugins/memory/hindsight/__init__.py +1758 -0
  760. package/plugins/memory/hindsight/plugin.yaml +8 -0
  761. package/plugins/memory/holographic/README.md +36 -0
  762. package/plugins/memory/holographic/__init__.py +409 -0
  763. package/plugins/memory/holographic/holographic.py +203 -0
  764. package/plugins/memory/holographic/plugin.yaml +5 -0
  765. package/plugins/memory/holographic/retrieval.py +593 -0
  766. package/plugins/memory/holographic/store.py +579 -0
  767. package/plugins/memory/honcho/README.md +328 -0
  768. package/plugins/memory/honcho/__init__.py +1329 -0
  769. package/plugins/memory/honcho/cli.py +1452 -0
  770. package/plugins/memory/honcho/client.py +784 -0
  771. package/plugins/memory/honcho/plugin.yaml +7 -0
  772. package/plugins/memory/honcho/session.py +1255 -0
  773. package/plugins/memory/mem0/README.md +38 -0
  774. package/plugins/memory/mem0/__init__.py +374 -0
  775. package/plugins/memory/mem0/plugin.yaml +5 -0
  776. package/plugins/memory/openviking/README.md +40 -0
  777. package/plugins/memory/openviking/__init__.py +945 -0
  778. package/plugins/memory/openviking/plugin.yaml +9 -0
  779. package/plugins/memory/retaindb/README.md +40 -0
  780. package/plugins/memory/retaindb/__init__.py +767 -0
  781. package/plugins/memory/retaindb/plugin.yaml +7 -0
  782. package/plugins/memory/supermemory/README.md +99 -0
  783. package/plugins/memory/supermemory/__init__.py +792 -0
  784. package/plugins/memory/supermemory/plugin.yaml +5 -0
  785. package/plugins/model-providers/README.md +70 -0
  786. package/plugins/model-providers/ai-gateway/__init__.py +43 -0
  787. package/plugins/model-providers/ai-gateway/__pycache__/__init__.cpython-312.pyc +0 -0
  788. package/plugins/model-providers/ai-gateway/plugin.yaml +5 -0
  789. package/plugins/model-providers/alibaba/__init__.py +13 -0
  790. package/plugins/model-providers/alibaba/__pycache__/__init__.cpython-312.pyc +0 -0
  791. package/plugins/model-providers/alibaba/plugin.yaml +5 -0
  792. package/plugins/model-providers/alibaba-coding-plan/__init__.py +21 -0
  793. package/plugins/model-providers/alibaba-coding-plan/__pycache__/__init__.cpython-312.pyc +0 -0
  794. package/plugins/model-providers/alibaba-coding-plan/plugin.yaml +5 -0
  795. package/plugins/model-providers/anthropic/__init__.py +52 -0
  796. package/plugins/model-providers/anthropic/__pycache__/__init__.cpython-312.pyc +0 -0
  797. package/plugins/model-providers/anthropic/plugin.yaml +5 -0
  798. package/plugins/model-providers/arcee/__init__.py +13 -0
  799. package/plugins/model-providers/arcee/__pycache__/__init__.cpython-312.pyc +0 -0
  800. package/plugins/model-providers/arcee/plugin.yaml +5 -0
  801. package/plugins/model-providers/azure-foundry/__init__.py +21 -0
  802. package/plugins/model-providers/azure-foundry/__pycache__/__init__.cpython-312.pyc +0 -0
  803. package/plugins/model-providers/azure-foundry/plugin.yaml +5 -0
  804. package/plugins/model-providers/bedrock/__init__.py +29 -0
  805. package/plugins/model-providers/bedrock/__pycache__/__init__.cpython-312.pyc +0 -0
  806. package/plugins/model-providers/bedrock/plugin.yaml +5 -0
  807. package/plugins/model-providers/copilot/__init__.py +58 -0
  808. package/plugins/model-providers/copilot/__pycache__/__init__.cpython-312.pyc +0 -0
  809. package/plugins/model-providers/copilot/plugin.yaml +5 -0
  810. package/plugins/model-providers/copilot-acp/__init__.py +34 -0
  811. package/plugins/model-providers/copilot-acp/__pycache__/__init__.cpython-312.pyc +0 -0
  812. package/plugins/model-providers/copilot-acp/plugin.yaml +5 -0
  813. package/plugins/model-providers/custom/__init__.py +68 -0
  814. package/plugins/model-providers/custom/__pycache__/__init__.cpython-312.pyc +0 -0
  815. package/plugins/model-providers/custom/plugin.yaml +5 -0
  816. package/plugins/model-providers/deepseek/__init__.py +99 -0
  817. package/plugins/model-providers/deepseek/__pycache__/__init__.cpython-312.pyc +0 -0
  818. package/plugins/model-providers/deepseek/plugin.yaml +5 -0
  819. package/plugins/model-providers/gemini/__init__.py +72 -0
  820. package/plugins/model-providers/gemini/__pycache__/__init__.cpython-312.pyc +0 -0
  821. package/plugins/model-providers/gemini/plugin.yaml +5 -0
  822. package/plugins/model-providers/gmi/__init__.py +31 -0
  823. package/plugins/model-providers/gmi/__pycache__/__init__.cpython-312.pyc +0 -0
  824. package/plugins/model-providers/gmi/plugin.yaml +5 -0
  825. package/plugins/model-providers/huggingface/__init__.py +20 -0
  826. package/plugins/model-providers/huggingface/__pycache__/__init__.cpython-312.pyc +0 -0
  827. package/plugins/model-providers/huggingface/plugin.yaml +5 -0
  828. package/plugins/model-providers/kilocode/__init__.py +14 -0
  829. package/plugins/model-providers/kilocode/__pycache__/__init__.cpython-312.pyc +0 -0
  830. package/plugins/model-providers/kilocode/plugin.yaml +5 -0
  831. package/plugins/model-providers/kimi-coding/__init__.py +71 -0
  832. package/plugins/model-providers/kimi-coding/__pycache__/__init__.cpython-312.pyc +0 -0
  833. package/plugins/model-providers/kimi-coding/plugin.yaml +5 -0
  834. package/plugins/model-providers/minimax/__init__.py +45 -0
  835. package/plugins/model-providers/minimax/__pycache__/__init__.cpython-312.pyc +0 -0
  836. package/plugins/model-providers/minimax/plugin.yaml +5 -0
  837. package/plugins/model-providers/nous/__init__.py +54 -0
  838. package/plugins/model-providers/nous/__pycache__/__init__.cpython-312.pyc +0 -0
  839. package/plugins/model-providers/nous/plugin.yaml +5 -0
  840. package/plugins/model-providers/novita/__init__.py +27 -0
  841. package/plugins/model-providers/novita/__pycache__/__init__.cpython-312.pyc +0 -0
  842. package/plugins/model-providers/novita/plugin.yaml +5 -0
  843. package/plugins/model-providers/nvidia/__init__.py +21 -0
  844. package/plugins/model-providers/nvidia/__pycache__/__init__.cpython-312.pyc +0 -0
  845. package/plugins/model-providers/nvidia/plugin.yaml +5 -0
  846. package/plugins/model-providers/ollama-cloud/__init__.py +14 -0
  847. package/plugins/model-providers/ollama-cloud/__pycache__/__init__.cpython-312.pyc +0 -0
  848. package/plugins/model-providers/ollama-cloud/plugin.yaml +5 -0
  849. package/plugins/model-providers/openai-codex/__init__.py +15 -0
  850. package/plugins/model-providers/openai-codex/__pycache__/__init__.cpython-312.pyc +0 -0
  851. package/plugins/model-providers/openai-codex/plugin.yaml +5 -0
  852. package/plugins/model-providers/opencode-zen/__init__.py +30 -0
  853. package/plugins/model-providers/opencode-zen/__pycache__/__init__.cpython-312.pyc +0 -0
  854. package/plugins/model-providers/opencode-zen/plugin.yaml +5 -0
  855. package/plugins/model-providers/openrouter/__init__.py +115 -0
  856. package/plugins/model-providers/openrouter/__pycache__/__init__.cpython-312.pyc +0 -0
  857. package/plugins/model-providers/openrouter/plugin.yaml +5 -0
  858. package/plugins/model-providers/qwen-oauth/__init__.py +82 -0
  859. package/plugins/model-providers/qwen-oauth/__pycache__/__init__.cpython-312.pyc +0 -0
  860. package/plugins/model-providers/qwen-oauth/plugin.yaml +5 -0
  861. package/plugins/model-providers/stepfun/__init__.py +14 -0
  862. package/plugins/model-providers/stepfun/__pycache__/__init__.cpython-312.pyc +0 -0
  863. package/plugins/model-providers/stepfun/plugin.yaml +5 -0
  864. package/plugins/model-providers/xai/__init__.py +15 -0
  865. package/plugins/model-providers/xai/__pycache__/__init__.cpython-312.pyc +0 -0
  866. package/plugins/model-providers/xai/plugin.yaml +5 -0
  867. package/plugins/model-providers/xiaomi/__init__.py +14 -0
  868. package/plugins/model-providers/xiaomi/__pycache__/__init__.cpython-312.pyc +0 -0
  869. package/plugins/model-providers/xiaomi/plugin.yaml +5 -0
  870. package/plugins/model-providers/zai/__init__.py +21 -0
  871. package/plugins/model-providers/zai/__pycache__/__init__.cpython-312.pyc +0 -0
  872. package/plugins/model-providers/zai/plugin.yaml +5 -0
  873. package/plugins/observability/langfuse/README.md +53 -0
  874. package/plugins/observability/langfuse/__init__.py +1004 -0
  875. package/plugins/observability/langfuse/plugin.yaml +14 -0
  876. package/plugins/platforms/google_chat/__init__.py +3 -0
  877. package/plugins/platforms/google_chat/__pycache__/__init__.cpython-312.pyc +0 -0
  878. package/plugins/platforms/google_chat/__pycache__/adapter.cpython-312.pyc +0 -0
  879. package/plugins/platforms/google_chat/adapter.py +3343 -0
  880. package/plugins/platforms/google_chat/oauth.py +639 -0
  881. package/plugins/platforms/google_chat/plugin.yaml +39 -0
  882. package/plugins/platforms/irc/__init__.py +3 -0
  883. package/plugins/platforms/irc/__pycache__/__init__.cpython-312.pyc +0 -0
  884. package/plugins/platforms/irc/__pycache__/adapter.cpython-312.pyc +0 -0
  885. package/plugins/platforms/irc/adapter.py +969 -0
  886. package/plugins/platforms/irc/plugin.yaml +54 -0
  887. package/plugins/platforms/line/__init__.py +3 -0
  888. package/plugins/platforms/line/__pycache__/__init__.cpython-312.pyc +0 -0
  889. package/plugins/platforms/line/__pycache__/adapter.cpython-312.pyc +0 -0
  890. package/plugins/platforms/line/adapter.py +1639 -0
  891. package/plugins/platforms/line/plugin.yaml +65 -0
  892. package/plugins/platforms/simplex/__init__.py +3 -0
  893. package/plugins/platforms/simplex/__pycache__/__init__.cpython-312.pyc +0 -0
  894. package/plugins/platforms/simplex/__pycache__/adapter.cpython-312.pyc +0 -0
  895. package/plugins/platforms/simplex/adapter.py +746 -0
  896. package/plugins/platforms/simplex/plugin.yaml +37 -0
  897. package/plugins/platforms/teams/__init__.py +3 -0
  898. package/plugins/platforms/teams/__pycache__/__init__.cpython-312.pyc +0 -0
  899. package/plugins/platforms/teams/__pycache__/adapter.cpython-312.pyc +0 -0
  900. package/plugins/platforms/teams/adapter.py +1188 -0
  901. package/plugins/platforms/teams/plugin.yaml +48 -0
  902. package/plugins/spotify/__init__.py +66 -0
  903. package/plugins/spotify/__pycache__/__init__.cpython-312.pyc +0 -0
  904. package/plugins/spotify/__pycache__/client.cpython-312.pyc +0 -0
  905. package/plugins/spotify/__pycache__/tools.cpython-312.pyc +0 -0
  906. package/plugins/spotify/client.py +435 -0
  907. package/plugins/spotify/plugin.yaml +13 -0
  908. package/plugins/spotify/tools.py +454 -0
  909. package/plugins/teams_pipeline/__init__.py +23 -0
  910. package/plugins/teams_pipeline/cli.py +463 -0
  911. package/plugins/teams_pipeline/meetings.py +333 -0
  912. package/plugins/teams_pipeline/models.py +350 -0
  913. package/plugins/teams_pipeline/pipeline.py +692 -0
  914. package/plugins/teams_pipeline/plugin.yaml +9 -0
  915. package/plugins/teams_pipeline/runtime.py +135 -0
  916. package/plugins/teams_pipeline/store.py +194 -0
  917. package/plugins/teams_pipeline/subscriptions.py +249 -0
  918. package/plugins/video_gen/fal/__init__.py +523 -0
  919. package/plugins/video_gen/fal/__pycache__/__init__.cpython-312.pyc +0 -0
  920. package/plugins/video_gen/fal/plugin.yaml +7 -0
  921. package/plugins/video_gen/xai/__init__.py +441 -0
  922. package/plugins/video_gen/xai/__pycache__/__init__.cpython-312.pyc +0 -0
  923. package/plugins/video_gen/xai/plugin.yaml +7 -0
  924. package/plugins/web/__init__.py +7 -0
  925. package/plugins/web/__pycache__/__init__.cpython-312.pyc +0 -0
  926. package/plugins/web/brave_free/__init__.py +14 -0
  927. package/plugins/web/brave_free/__pycache__/__init__.cpython-312.pyc +0 -0
  928. package/plugins/web/brave_free/__pycache__/provider.cpython-312.pyc +0 -0
  929. package/plugins/web/brave_free/plugin.yaml +7 -0
  930. package/plugins/web/brave_free/provider.py +137 -0
  931. package/plugins/web/ddgs/__init__.py +15 -0
  932. package/plugins/web/ddgs/__pycache__/__init__.cpython-312.pyc +0 -0
  933. package/plugins/web/ddgs/__pycache__/provider.cpython-312.pyc +0 -0
  934. package/plugins/web/ddgs/plugin.yaml +7 -0
  935. package/plugins/web/ddgs/provider.py +104 -0
  936. package/plugins/web/exa/__init__.py +15 -0
  937. package/plugins/web/exa/__pycache__/__init__.cpython-312.pyc +0 -0
  938. package/plugins/web/exa/__pycache__/provider.cpython-312.pyc +0 -0
  939. package/plugins/web/exa/plugin.yaml +7 -0
  940. package/plugins/web/exa/provider.py +212 -0
  941. package/plugins/web/firecrawl/__init__.py +28 -0
  942. package/plugins/web/firecrawl/__pycache__/__init__.cpython-312.pyc +0 -0
  943. package/plugins/web/firecrawl/__pycache__/provider.cpython-312.pyc +0 -0
  944. package/plugins/web/firecrawl/plugin.yaml +7 -0
  945. package/plugins/web/firecrawl/provider.py +773 -0
  946. package/plugins/web/parallel/__init__.py +16 -0
  947. package/plugins/web/parallel/__pycache__/__init__.cpython-312.pyc +0 -0
  948. package/plugins/web/parallel/__pycache__/provider.cpython-312.pyc +0 -0
  949. package/plugins/web/parallel/plugin.yaml +7 -0
  950. package/plugins/web/parallel/provider.py +291 -0
  951. package/plugins/web/searxng/__init__.py +15 -0
  952. package/plugins/web/searxng/__pycache__/__init__.cpython-312.pyc +0 -0
  953. package/plugins/web/searxng/__pycache__/provider.cpython-312.pyc +0 -0
  954. package/plugins/web/searxng/plugin.yaml +7 -0
  955. package/plugins/web/searxng/provider.py +140 -0
  956. package/plugins/web/tavily/__init__.py +15 -0
  957. package/plugins/web/tavily/__pycache__/__init__.cpython-312.pyc +0 -0
  958. package/plugins/web/tavily/__pycache__/provider.cpython-312.pyc +0 -0
  959. package/plugins/web/tavily/plugin.yaml +7 -0
  960. package/plugins/web/tavily/provider.py +285 -0
  961. package/providers/README.md +78 -0
  962. package/providers/__init__.py +192 -0
  963. package/providers/__pycache__/__init__.cpython-312.pyc +0 -0
  964. package/providers/__pycache__/base.cpython-312.pyc +0 -0
  965. package/providers/base.py +184 -0
  966. package/pyproject.toml +255 -0
  967. package/run_agent.py +16409 -0
  968. package/scripts/benchmark_browser_eval.py +138 -0
  969. package/scripts/build_model_catalog.py +95 -0
  970. package/scripts/build_skills_index.py +325 -0
  971. package/scripts/check-windows-footguns.py +624 -0
  972. package/scripts/contributor_audit.py +473 -0
  973. package/scripts/discord-voice-doctor.py +396 -0
  974. package/scripts/hermes-gateway +416 -0
  975. package/scripts/install.cmd +28 -0
  976. package/scripts/install.ps1 +1611 -0
  977. package/scripts/install.sh +2007 -0
  978. package/scripts/install_psutil_android.py +117 -0
  979. package/scripts/keystroke_diagnostic.py +81 -0
  980. package/scripts/kill_modal.sh +34 -0
  981. package/scripts/lib/node-bootstrap.sh +238 -0
  982. package/scripts/lint_diff.py +207 -0
  983. package/scripts/postinstall.js +150 -0
  984. package/scripts/profile-tui.py +626 -0
  985. package/scripts/release.py +1680 -0
  986. package/scripts/run_tests.sh +129 -0
  987. package/scripts/sample_and_compress.py +409 -0
  988. package/scripts/setup_open_webui.sh +349 -0
  989. package/scripts/whatsapp-bridge/allowlist.js +88 -0
  990. package/scripts/whatsapp-bridge/allowlist.test.mjs +80 -0
  991. package/scripts/whatsapp-bridge/bridge.js +729 -0
  992. package/scripts/whatsapp-bridge/package-lock.json +2141 -0
  993. package/scripts/whatsapp-bridge/package.json +19 -0
  994. package/skills/apple/DESCRIPTION.md +2 -0
  995. package/skills/apple/apple-notes/SKILL.md +90 -0
  996. package/skills/apple/apple-reminders/SKILL.md +98 -0
  997. package/skills/apple/findmy/SKILL.md +131 -0
  998. package/skills/apple/imessage/SKILL.md +102 -0
  999. package/skills/apple/macos-computer-use/SKILL.md +201 -0
  1000. package/skills/autonomous-ai-agents/DESCRIPTION.md +3 -0
  1001. package/skills/autonomous-ai-agents/claude-code/SKILL.md +745 -0
  1002. package/skills/autonomous-ai-agents/codex/SKILL.md +130 -0
  1003. package/skills/autonomous-ai-agents/hermes-agent/SKILL.md +1014 -0
  1004. package/skills/autonomous-ai-agents/opencode/SKILL.md +219 -0
  1005. package/skills/creative/DESCRIPTION.md +3 -0
  1006. package/skills/creative/architecture-diagram/SKILL.md +148 -0
  1007. package/skills/creative/architecture-diagram/templates/template.html +319 -0
  1008. package/skills/creative/ascii-art/SKILL.md +322 -0
  1009. package/skills/creative/ascii-video/README.md +290 -0
  1010. package/skills/creative/ascii-video/SKILL.md +241 -0
  1011. package/skills/creative/ascii-video/references/architecture.md +802 -0
  1012. package/skills/creative/ascii-video/references/composition.md +892 -0
  1013. package/skills/creative/ascii-video/references/effects.md +1865 -0
  1014. package/skills/creative/ascii-video/references/inputs.md +685 -0
  1015. package/skills/creative/ascii-video/references/optimization.md +688 -0
  1016. package/skills/creative/ascii-video/references/scenes.md +1011 -0
  1017. package/skills/creative/ascii-video/references/shaders.md +1385 -0
  1018. package/skills/creative/ascii-video/references/troubleshooting.md +367 -0
  1019. package/skills/creative/baoyu-comic/PORT_NOTES.md +77 -0
  1020. package/skills/creative/baoyu-comic/SKILL.md +247 -0
  1021. package/skills/creative/baoyu-comic/references/analysis-framework.md +176 -0
  1022. package/skills/creative/baoyu-comic/references/art-styles/chalk.md +101 -0
  1023. package/skills/creative/baoyu-comic/references/art-styles/ink-brush.md +97 -0
  1024. package/skills/creative/baoyu-comic/references/art-styles/ligne-claire.md +75 -0
  1025. package/skills/creative/baoyu-comic/references/art-styles/manga.md +93 -0
  1026. package/skills/creative/baoyu-comic/references/art-styles/minimalist.md +84 -0
  1027. package/skills/creative/baoyu-comic/references/art-styles/realistic.md +89 -0
  1028. package/skills/creative/baoyu-comic/references/auto-selection.md +71 -0
  1029. package/skills/creative/baoyu-comic/references/base-prompt.md +98 -0
  1030. package/skills/creative/baoyu-comic/references/character-template.md +180 -0
  1031. package/skills/creative/baoyu-comic/references/layouts/cinematic.md +23 -0
  1032. package/skills/creative/baoyu-comic/references/layouts/dense.md +23 -0
  1033. package/skills/creative/baoyu-comic/references/layouts/four-panel.md +40 -0
  1034. package/skills/creative/baoyu-comic/references/layouts/mixed.md +23 -0
  1035. package/skills/creative/baoyu-comic/references/layouts/splash.md +23 -0
  1036. package/skills/creative/baoyu-comic/references/layouts/standard.md +23 -0
  1037. package/skills/creative/baoyu-comic/references/layouts/webtoon.md +30 -0
  1038. package/skills/creative/baoyu-comic/references/ohmsha-guide.md +85 -0
  1039. package/skills/creative/baoyu-comic/references/partial-workflows.md +106 -0
  1040. package/skills/creative/baoyu-comic/references/presets/concept-story.md +121 -0
  1041. package/skills/creative/baoyu-comic/references/presets/four-panel.md +107 -0
  1042. package/skills/creative/baoyu-comic/references/presets/ohmsha.md +114 -0
  1043. package/skills/creative/baoyu-comic/references/presets/shoujo.md +116 -0
  1044. package/skills/creative/baoyu-comic/references/presets/wuxia.md +110 -0
  1045. package/skills/creative/baoyu-comic/references/storyboard-template.md +143 -0
  1046. package/skills/creative/baoyu-comic/references/tones/action.md +110 -0
  1047. package/skills/creative/baoyu-comic/references/tones/dramatic.md +95 -0
  1048. package/skills/creative/baoyu-comic/references/tones/energetic.md +105 -0
  1049. package/skills/creative/baoyu-comic/references/tones/neutral.md +63 -0
  1050. package/skills/creative/baoyu-comic/references/tones/romantic.md +100 -0
  1051. package/skills/creative/baoyu-comic/references/tones/vintage.md +104 -0
  1052. package/skills/creative/baoyu-comic/references/tones/warm.md +94 -0
  1053. package/skills/creative/baoyu-comic/references/workflow.md +401 -0
  1054. package/skills/creative/baoyu-infographic/PORT_NOTES.md +43 -0
  1055. package/skills/creative/baoyu-infographic/SKILL.md +237 -0
  1056. package/skills/creative/baoyu-infographic/references/analysis-framework.md +182 -0
  1057. package/skills/creative/baoyu-infographic/references/base-prompt.md +43 -0
  1058. package/skills/creative/baoyu-infographic/references/layouts/bento-grid.md +41 -0
  1059. package/skills/creative/baoyu-infographic/references/layouts/binary-comparison.md +48 -0
  1060. package/skills/creative/baoyu-infographic/references/layouts/bridge.md +41 -0
  1061. package/skills/creative/baoyu-infographic/references/layouts/circular-flow.md +41 -0
  1062. package/skills/creative/baoyu-infographic/references/layouts/comic-strip.md +41 -0
  1063. package/skills/creative/baoyu-infographic/references/layouts/comparison-matrix.md +41 -0
  1064. package/skills/creative/baoyu-infographic/references/layouts/dashboard.md +41 -0
  1065. package/skills/creative/baoyu-infographic/references/layouts/dense-modules.md +72 -0
  1066. package/skills/creative/baoyu-infographic/references/layouts/funnel.md +41 -0
  1067. package/skills/creative/baoyu-infographic/references/layouts/hierarchical-layers.md +48 -0
  1068. package/skills/creative/baoyu-infographic/references/layouts/hub-spoke.md +41 -0
  1069. package/skills/creative/baoyu-infographic/references/layouts/iceberg.md +41 -0
  1070. package/skills/creative/baoyu-infographic/references/layouts/isometric-map.md +41 -0
  1071. package/skills/creative/baoyu-infographic/references/layouts/jigsaw.md +41 -0
  1072. package/skills/creative/baoyu-infographic/references/layouts/linear-progression.md +48 -0
  1073. package/skills/creative/baoyu-infographic/references/layouts/periodic-table.md +41 -0
  1074. package/skills/creative/baoyu-infographic/references/layouts/story-mountain.md +41 -0
  1075. package/skills/creative/baoyu-infographic/references/layouts/structural-breakdown.md +48 -0
  1076. package/skills/creative/baoyu-infographic/references/layouts/tree-branching.md +41 -0
  1077. package/skills/creative/baoyu-infographic/references/layouts/venn-diagram.md +41 -0
  1078. package/skills/creative/baoyu-infographic/references/layouts/winding-roadmap.md +41 -0
  1079. package/skills/creative/baoyu-infographic/references/structured-content-template.md +244 -0
  1080. package/skills/creative/baoyu-infographic/references/styles/aged-academia.md +36 -0
  1081. package/skills/creative/baoyu-infographic/references/styles/bold-graphic.md +36 -0
  1082. package/skills/creative/baoyu-infographic/references/styles/chalkboard.md +61 -0
  1083. package/skills/creative/baoyu-infographic/references/styles/claymation.md +29 -0
  1084. package/skills/creative/baoyu-infographic/references/styles/corporate-memphis.md +29 -0
  1085. package/skills/creative/baoyu-infographic/references/styles/craft-handmade.md +44 -0
  1086. package/skills/creative/baoyu-infographic/references/styles/cyberpunk-neon.md +29 -0
  1087. package/skills/creative/baoyu-infographic/references/styles/hand-drawn-edu.md +63 -0
  1088. package/skills/creative/baoyu-infographic/references/styles/ikea-manual.md +29 -0
  1089. package/skills/creative/baoyu-infographic/references/styles/kawaii.md +29 -0
  1090. package/skills/creative/baoyu-infographic/references/styles/knolling.md +29 -0
  1091. package/skills/creative/baoyu-infographic/references/styles/lego-brick.md +29 -0
  1092. package/skills/creative/baoyu-infographic/references/styles/morandi-journal.md +60 -0
  1093. package/skills/creative/baoyu-infographic/references/styles/origami.md +29 -0
  1094. package/skills/creative/baoyu-infographic/references/styles/pixel-art.md +29 -0
  1095. package/skills/creative/baoyu-infographic/references/styles/pop-laboratory.md +48 -0
  1096. package/skills/creative/baoyu-infographic/references/styles/retro-pop-grid.md +47 -0
  1097. package/skills/creative/baoyu-infographic/references/styles/storybook-watercolor.md +29 -0
  1098. package/skills/creative/baoyu-infographic/references/styles/subway-map.md +29 -0
  1099. package/skills/creative/baoyu-infographic/references/styles/technical-schematic.md +36 -0
  1100. package/skills/creative/baoyu-infographic/references/styles/ui-wireframe.md +29 -0
  1101. package/skills/creative/claude-design/SKILL.md +591 -0
  1102. package/skills/creative/comfyui/SKILL.md +612 -0
  1103. package/skills/creative/comfyui/references/official-cli.md +255 -0
  1104. package/skills/creative/comfyui/references/rest-api.md +312 -0
  1105. package/skills/creative/comfyui/references/template-integrity.md +243 -0
  1106. package/skills/creative/comfyui/references/workflow-format.md +226 -0
  1107. package/skills/creative/comfyui/scripts/_common.py +835 -0
  1108. package/skills/creative/comfyui/scripts/auto_fix_deps.py +225 -0
  1109. package/skills/creative/comfyui/scripts/check_deps.py +437 -0
  1110. package/skills/creative/comfyui/scripts/comfyui_setup.sh +286 -0
  1111. package/skills/creative/comfyui/scripts/extract_schema.py +315 -0
  1112. package/skills/creative/comfyui/scripts/fetch_logs.py +158 -0
  1113. package/skills/creative/comfyui/scripts/hardware_check.py +497 -0
  1114. package/skills/creative/comfyui/scripts/health_check.py +223 -0
  1115. package/skills/creative/comfyui/scripts/run_batch.py +243 -0
  1116. package/skills/creative/comfyui/scripts/run_workflow.py +796 -0
  1117. package/skills/creative/comfyui/scripts/ws_monitor.py +267 -0
  1118. package/skills/creative/comfyui/tests/README.md +50 -0
  1119. package/skills/creative/comfyui/tests/conftest.py +64 -0
  1120. package/skills/creative/comfyui/tests/pytest.ini +5 -0
  1121. package/skills/creative/comfyui/tests/test_check_deps.py +68 -0
  1122. package/skills/creative/comfyui/tests/test_cloud_integration.py +95 -0
  1123. package/skills/creative/comfyui/tests/test_common.py +447 -0
  1124. package/skills/creative/comfyui/tests/test_extract_schema.py +185 -0
  1125. package/skills/creative/comfyui/tests/test_run_workflow.py +213 -0
  1126. package/skills/creative/comfyui/workflows/README.md +86 -0
  1127. package/skills/creative/comfyui/workflows/animatediff_video.json +64 -0
  1128. package/skills/creative/comfyui/workflows/flux_dev_txt2img.json +78 -0
  1129. package/skills/creative/comfyui/workflows/sd15_txt2img.json +49 -0
  1130. package/skills/creative/comfyui/workflows/sdxl_img2img.json +54 -0
  1131. package/skills/creative/comfyui/workflows/sdxl_inpaint.json +59 -0
  1132. package/skills/creative/comfyui/workflows/sdxl_txt2img.json +49 -0
  1133. package/skills/creative/comfyui/workflows/upscale_4x.json +27 -0
  1134. package/skills/creative/comfyui/workflows/wan_video_t2v.json +69 -0
  1135. package/skills/creative/creative-ideation/SKILL.md +152 -0
  1136. package/skills/creative/creative-ideation/references/full-prompt-library.md +110 -0
  1137. package/skills/creative/design-md/SKILL.md +199 -0
  1138. package/skills/creative/design-md/templates/starter.md +99 -0
  1139. package/skills/creative/excalidraw/SKILL.md +199 -0
  1140. package/skills/creative/excalidraw/references/colors.md +44 -0
  1141. package/skills/creative/excalidraw/references/dark-mode.md +68 -0
  1142. package/skills/creative/excalidraw/references/examples.md +141 -0
  1143. package/skills/creative/excalidraw/scripts/upload.py +133 -0
  1144. package/skills/creative/humanizer/LICENSE +21 -0
  1145. package/skills/creative/humanizer/SKILL.md +578 -0
  1146. package/skills/creative/manim-video/README.md +23 -0
  1147. package/skills/creative/manim-video/SKILL.md +269 -0
  1148. package/skills/creative/manim-video/references/animation-design-thinking.md +161 -0
  1149. package/skills/creative/manim-video/references/animations.md +282 -0
  1150. package/skills/creative/manim-video/references/camera-and-3d.md +135 -0
  1151. package/skills/creative/manim-video/references/decorations.md +202 -0
  1152. package/skills/creative/manim-video/references/equations.md +216 -0
  1153. package/skills/creative/manim-video/references/graphs-and-data.md +163 -0
  1154. package/skills/creative/manim-video/references/mobjects.md +333 -0
  1155. package/skills/creative/manim-video/references/paper-explainer.md +255 -0
  1156. package/skills/creative/manim-video/references/production-quality.md +190 -0
  1157. package/skills/creative/manim-video/references/rendering.md +185 -0
  1158. package/skills/creative/manim-video/references/scene-planning.md +118 -0
  1159. package/skills/creative/manim-video/references/troubleshooting.md +135 -0
  1160. package/skills/creative/manim-video/references/updaters-and-trackers.md +260 -0
  1161. package/skills/creative/manim-video/references/visual-design.md +124 -0
  1162. package/skills/creative/manim-video/scripts/setup.sh +14 -0
  1163. package/skills/creative/p5js/README.md +64 -0
  1164. package/skills/creative/p5js/SKILL.md +556 -0
  1165. package/skills/creative/p5js/references/animation.md +439 -0
  1166. package/skills/creative/p5js/references/color-systems.md +352 -0
  1167. package/skills/creative/p5js/references/core-api.md +410 -0
  1168. package/skills/creative/p5js/references/export-pipeline.md +566 -0
  1169. package/skills/creative/p5js/references/interaction.md +398 -0
  1170. package/skills/creative/p5js/references/shapes-and-geometry.md +300 -0
  1171. package/skills/creative/p5js/references/troubleshooting.md +532 -0
  1172. package/skills/creative/p5js/references/typography.md +302 -0
  1173. package/skills/creative/p5js/references/visual-effects.md +895 -0
  1174. package/skills/creative/p5js/references/webgl-and-3d.md +423 -0
  1175. package/skills/creative/p5js/scripts/export-frames.js +179 -0
  1176. package/skills/creative/p5js/scripts/render.sh +108 -0
  1177. package/skills/creative/p5js/scripts/serve.sh +28 -0
  1178. package/skills/creative/p5js/scripts/setup.sh +87 -0
  1179. package/skills/creative/p5js/templates/viewer.html +395 -0
  1180. package/skills/creative/pixel-art/ATTRIBUTION.md +54 -0
  1181. package/skills/creative/pixel-art/SKILL.md +218 -0
  1182. package/skills/creative/pixel-art/references/palettes.md +49 -0
  1183. package/skills/creative/pixel-art/scripts/__init__.py +0 -0
  1184. package/skills/creative/pixel-art/scripts/palettes.py +167 -0
  1185. package/skills/creative/pixel-art/scripts/pixel_art.py +162 -0
  1186. package/skills/creative/pixel-art/scripts/pixel_art_video.py +345 -0
  1187. package/skills/creative/popular-web-designs/SKILL.md +214 -0
  1188. package/skills/creative/popular-web-designs/templates/airbnb.md +259 -0
  1189. package/skills/creative/popular-web-designs/templates/airtable.md +102 -0
  1190. package/skills/creative/popular-web-designs/templates/apple.md +326 -0
  1191. package/skills/creative/popular-web-designs/templates/bmw.md +193 -0
  1192. package/skills/creative/popular-web-designs/templates/cal.md +272 -0
  1193. package/skills/creative/popular-web-designs/templates/claude.md +325 -0
  1194. package/skills/creative/popular-web-designs/templates/clay.md +317 -0
  1195. package/skills/creative/popular-web-designs/templates/clickhouse.md +294 -0
  1196. package/skills/creative/popular-web-designs/templates/cohere.md +279 -0
  1197. package/skills/creative/popular-web-designs/templates/coinbase.md +142 -0
  1198. package/skills/creative/popular-web-designs/templates/composio.md +320 -0
  1199. package/skills/creative/popular-web-designs/templates/cursor.md +322 -0
  1200. package/skills/creative/popular-web-designs/templates/elevenlabs.md +278 -0
  1201. package/skills/creative/popular-web-designs/templates/expo.md +294 -0
  1202. package/skills/creative/popular-web-designs/templates/figma.md +233 -0
  1203. package/skills/creative/popular-web-designs/templates/framer.md +259 -0
  1204. package/skills/creative/popular-web-designs/templates/hashicorp.md +291 -0
  1205. package/skills/creative/popular-web-designs/templates/ibm.md +345 -0
  1206. package/skills/creative/popular-web-designs/templates/intercom.md +159 -0
  1207. package/skills/creative/popular-web-designs/templates/kraken.md +138 -0
  1208. package/skills/creative/popular-web-designs/templates/linear.app.md +380 -0
  1209. package/skills/creative/popular-web-designs/templates/lovable.md +311 -0
  1210. package/skills/creative/popular-web-designs/templates/minimax.md +270 -0
  1211. package/skills/creative/popular-web-designs/templates/mintlify.md +339 -0
  1212. package/skills/creative/popular-web-designs/templates/miro.md +121 -0
  1213. package/skills/creative/popular-web-designs/templates/mistral.ai.md +274 -0
  1214. package/skills/creative/popular-web-designs/templates/mongodb.md +279 -0
  1215. package/skills/creative/popular-web-designs/templates/notion.md +322 -0
  1216. package/skills/creative/popular-web-designs/templates/nvidia.md +306 -0
  1217. package/skills/creative/popular-web-designs/templates/ollama.md +280 -0
  1218. package/skills/creative/popular-web-designs/templates/opencode.ai.md +294 -0
  1219. package/skills/creative/popular-web-designs/templates/pinterest.md +243 -0
  1220. package/skills/creative/popular-web-designs/templates/posthog.md +269 -0
  1221. package/skills/creative/popular-web-designs/templates/raycast.md +281 -0
  1222. package/skills/creative/popular-web-designs/templates/replicate.md +274 -0
  1223. package/skills/creative/popular-web-designs/templates/resend.md +316 -0
  1224. package/skills/creative/popular-web-designs/templates/revolut.md +198 -0
  1225. package/skills/creative/popular-web-designs/templates/runwayml.md +257 -0
  1226. package/skills/creative/popular-web-designs/templates/sanity.md +370 -0
  1227. package/skills/creative/popular-web-designs/templates/sentry.md +275 -0
  1228. package/skills/creative/popular-web-designs/templates/spacex.md +207 -0
  1229. package/skills/creative/popular-web-designs/templates/spotify.md +259 -0
  1230. package/skills/creative/popular-web-designs/templates/stripe.md +335 -0
  1231. package/skills/creative/popular-web-designs/templates/supabase.md +268 -0
  1232. package/skills/creative/popular-web-designs/templates/superhuman.md +265 -0
  1233. package/skills/creative/popular-web-designs/templates/together.ai.md +276 -0
  1234. package/skills/creative/popular-web-designs/templates/uber.md +308 -0
  1235. package/skills/creative/popular-web-designs/templates/vercel.md +323 -0
  1236. package/skills/creative/popular-web-designs/templates/voltagent.md +336 -0
  1237. package/skills/creative/popular-web-designs/templates/warp.md +266 -0
  1238. package/skills/creative/popular-web-designs/templates/webflow.md +105 -0
  1239. package/skills/creative/popular-web-designs/templates/wise.md +186 -0
  1240. package/skills/creative/popular-web-designs/templates/x.ai.md +270 -0
  1241. package/skills/creative/popular-web-designs/templates/zapier.md +341 -0
  1242. package/skills/creative/pretext/SKILL.md +220 -0
  1243. package/skills/creative/pretext/references/patterns.md +258 -0
  1244. package/skills/creative/pretext/templates/donut-orbit.html +1468 -0
  1245. package/skills/creative/pretext/templates/hello-orb-flow.html +95 -0
  1246. package/skills/creative/sketch/SKILL.md +218 -0
  1247. package/skills/creative/songwriting-and-ai-music/SKILL.md +287 -0
  1248. package/skills/creative/touchdesigner-mcp/SKILL.md +356 -0
  1249. package/skills/creative/touchdesigner-mcp/references/3d-scene.md +275 -0
  1250. package/skills/creative/touchdesigner-mcp/references/animation.md +221 -0
  1251. package/skills/creative/touchdesigner-mcp/references/audio-reactive.md +175 -0
  1252. package/skills/creative/touchdesigner-mcp/references/dat-scripting.md +352 -0
  1253. package/skills/creative/touchdesigner-mcp/references/external-data.md +322 -0
  1254. package/skills/creative/touchdesigner-mcp/references/geometry-comp.md +121 -0
  1255. package/skills/creative/touchdesigner-mcp/references/glsl.md +151 -0
  1256. package/skills/creative/touchdesigner-mcp/references/layout-compositor.md +131 -0
  1257. package/skills/creative/touchdesigner-mcp/references/mcp-tools.md +382 -0
  1258. package/skills/creative/touchdesigner-mcp/references/midi-osc.md +211 -0
  1259. package/skills/creative/touchdesigner-mcp/references/network-patterns.md +966 -0
  1260. package/skills/creative/touchdesigner-mcp/references/operator-tips.md +106 -0
  1261. package/skills/creative/touchdesigner-mcp/references/operators.md +239 -0
  1262. package/skills/creative/touchdesigner-mcp/references/panel-ui.md +281 -0
  1263. package/skills/creative/touchdesigner-mcp/references/particles.md +245 -0
  1264. package/skills/creative/touchdesigner-mcp/references/pitfalls.md +704 -0
  1265. package/skills/creative/touchdesigner-mcp/references/postfx.md +183 -0
  1266. package/skills/creative/touchdesigner-mcp/references/projection-mapping.md +211 -0
  1267. package/skills/creative/touchdesigner-mcp/references/python-api.md +463 -0
  1268. package/skills/creative/touchdesigner-mcp/references/replicator.md +198 -0
  1269. package/skills/creative/touchdesigner-mcp/references/troubleshooting.md +244 -0
  1270. package/skills/creative/touchdesigner-mcp/scripts/setup.sh +115 -0
  1271. package/skills/data-science/DESCRIPTION.md +3 -0
  1272. package/skills/data-science/jupyter-live-kernel/SKILL.md +167 -0
  1273. package/skills/devops/kanban-orchestrator/SKILL.md +189 -0
  1274. package/skills/devops/kanban-worker/SKILL.md +184 -0
  1275. package/skills/devops/webhook-subscriptions/SKILL.md +204 -0
  1276. package/skills/diagramming/DESCRIPTION.md +3 -0
  1277. package/skills/dogfood/SKILL.md +162 -0
  1278. package/skills/dogfood/references/issue-taxonomy.md +109 -0
  1279. package/skills/dogfood/templates/dogfood-report-template.md +86 -0
  1280. package/skills/domain/DESCRIPTION.md +24 -0
  1281. package/skills/email/DESCRIPTION.md +3 -0
  1282. package/skills/email/himalaya/SKILL.md +299 -0
  1283. package/skills/email/himalaya/references/configuration.md +227 -0
  1284. package/skills/email/himalaya/references/message-composition.md +199 -0
  1285. package/skills/gaming/DESCRIPTION.md +3 -0
  1286. package/skills/gaming/minecraft-modpack-server/SKILL.md +187 -0
  1287. package/skills/gaming/pokemon-player/SKILL.md +216 -0
  1288. package/skills/gifs/DESCRIPTION.md +3 -0
  1289. package/skills/github/DESCRIPTION.md +3 -0
  1290. package/skills/github/codebase-inspection/SKILL.md +116 -0
  1291. package/skills/github/github-auth/SKILL.md +247 -0
  1292. package/skills/github/github-auth/scripts/gh-env.sh +66 -0
  1293. package/skills/github/github-code-review/SKILL.md +481 -0
  1294. package/skills/github/github-code-review/references/review-output-template.md +74 -0
  1295. package/skills/github/github-issues/SKILL.md +370 -0
  1296. package/skills/github/github-issues/templates/bug-report.md +35 -0
  1297. package/skills/github/github-issues/templates/feature-request.md +31 -0
  1298. package/skills/github/github-pr-workflow/SKILL.md +367 -0
  1299. package/skills/github/github-pr-workflow/references/ci-troubleshooting.md +183 -0
  1300. package/skills/github/github-pr-workflow/references/conventional-commits.md +71 -0
  1301. package/skills/github/github-pr-workflow/templates/pr-body-bugfix.md +35 -0
  1302. package/skills/github/github-pr-workflow/templates/pr-body-feature.md +33 -0
  1303. package/skills/github/github-repo-management/SKILL.md +516 -0
  1304. package/skills/github/github-repo-management/references/github-api-cheatsheet.md +161 -0
  1305. package/skills/index-cache/anthropics_skills_skills_.json +1 -0
  1306. package/skills/index-cache/claude_marketplace_anthropics_skills.json +1 -0
  1307. package/skills/index-cache/lobehub_index.json +1 -0
  1308. package/skills/index-cache/openai_skills_skills_.json +1 -0
  1309. package/skills/inference-sh/DESCRIPTION.md +19 -0
  1310. package/skills/mcp/DESCRIPTION.md +3 -0
  1311. package/skills/mcp/native-mcp/SKILL.md +357 -0
  1312. package/skills/media/DESCRIPTION.md +3 -0
  1313. package/skills/media/gif-search/SKILL.md +91 -0
  1314. package/skills/media/heartmula/SKILL.md +171 -0
  1315. package/skills/media/songsee/SKILL.md +83 -0
  1316. package/skills/media/spotify/SKILL.md +135 -0
  1317. package/skills/media/youtube-content/SKILL.md +73 -0
  1318. package/skills/media/youtube-content/references/output-formats.md +56 -0
  1319. package/skills/media/youtube-content/scripts/fetch_transcript.py +124 -0
  1320. package/skills/mlops/DESCRIPTION.md +3 -0
  1321. package/skills/mlops/evaluation/DESCRIPTION.md +3 -0
  1322. package/skills/mlops/evaluation/lm-evaluation-harness/SKILL.md +498 -0
  1323. package/skills/mlops/evaluation/lm-evaluation-harness/references/api-evaluation.md +490 -0
  1324. package/skills/mlops/evaluation/lm-evaluation-harness/references/benchmark-guide.md +488 -0
  1325. package/skills/mlops/evaluation/lm-evaluation-harness/references/custom-tasks.md +602 -0
  1326. package/skills/mlops/evaluation/lm-evaluation-harness/references/distributed-eval.md +519 -0
  1327. package/skills/mlops/evaluation/weights-and-biases/SKILL.md +594 -0
  1328. package/skills/mlops/evaluation/weights-and-biases/references/artifacts.md +584 -0
  1329. package/skills/mlops/evaluation/weights-and-biases/references/integrations.md +700 -0
  1330. package/skills/mlops/evaluation/weights-and-biases/references/sweeps.md +847 -0
  1331. package/skills/mlops/huggingface-hub/SKILL.md +81 -0
  1332. package/skills/mlops/inference/DESCRIPTION.md +3 -0
  1333. package/skills/mlops/inference/llama-cpp/SKILL.md +249 -0
  1334. package/skills/mlops/inference/llama-cpp/references/advanced-usage.md +504 -0
  1335. package/skills/mlops/inference/llama-cpp/references/hub-discovery.md +168 -0
  1336. package/skills/mlops/inference/llama-cpp/references/optimization.md +89 -0
  1337. package/skills/mlops/inference/llama-cpp/references/quantization.md +243 -0
  1338. package/skills/mlops/inference/llama-cpp/references/server.md +150 -0
  1339. package/skills/mlops/inference/llama-cpp/references/troubleshooting.md +442 -0
  1340. package/skills/mlops/inference/obliteratus/SKILL.md +342 -0
  1341. package/skills/mlops/inference/obliteratus/references/analysis-modules.md +166 -0
  1342. package/skills/mlops/inference/obliteratus/references/methods-guide.md +141 -0
  1343. package/skills/mlops/inference/obliteratus/templates/abliteration-config.yaml +33 -0
  1344. package/skills/mlops/inference/obliteratus/templates/analysis-study.yaml +40 -0
  1345. package/skills/mlops/inference/obliteratus/templates/batch-abliteration.yaml +41 -0
  1346. package/skills/mlops/inference/vllm/SKILL.md +372 -0
  1347. package/skills/mlops/inference/vllm/references/optimization.md +226 -0
  1348. package/skills/mlops/inference/vllm/references/quantization.md +284 -0
  1349. package/skills/mlops/inference/vllm/references/server-deployment.md +255 -0
  1350. package/skills/mlops/inference/vllm/references/troubleshooting.md +447 -0
  1351. package/skills/mlops/models/DESCRIPTION.md +3 -0
  1352. package/skills/mlops/models/audiocraft/SKILL.md +568 -0
  1353. package/skills/mlops/models/audiocraft/references/advanced-usage.md +666 -0
  1354. package/skills/mlops/models/audiocraft/references/troubleshooting.md +504 -0
  1355. package/skills/mlops/models/segment-anything/SKILL.md +506 -0
  1356. package/skills/mlops/models/segment-anything/references/advanced-usage.md +589 -0
  1357. package/skills/mlops/models/segment-anything/references/troubleshooting.md +484 -0
  1358. package/skills/mlops/research/DESCRIPTION.md +3 -0
  1359. package/skills/mlops/research/dspy/SKILL.md +594 -0
  1360. package/skills/mlops/research/dspy/references/examples.md +663 -0
  1361. package/skills/mlops/research/dspy/references/modules.md +475 -0
  1362. package/skills/mlops/research/dspy/references/optimizers.md +566 -0
  1363. package/skills/mlops/training/DESCRIPTION.md +3 -0
  1364. package/skills/mlops/vector-databases/DESCRIPTION.md +3 -0
  1365. package/skills/note-taking/DESCRIPTION.md +3 -0
  1366. package/skills/note-taking/obsidian/SKILL.md +61 -0
  1367. package/skills/productivity/DESCRIPTION.md +3 -0
  1368. package/skills/productivity/airtable/SKILL.md +229 -0
  1369. package/skills/productivity/google-workspace/SKILL.md +335 -0
  1370. package/skills/productivity/google-workspace/references/gmail-search-syntax.md +63 -0
  1371. package/skills/productivity/google-workspace/scripts/_hermes_home.py +43 -0
  1372. package/skills/productivity/google-workspace/scripts/google_api.py +1221 -0
  1373. package/skills/productivity/google-workspace/scripts/gws_bridge.py +108 -0
  1374. package/skills/productivity/google-workspace/scripts/setup.py +454 -0
  1375. package/skills/productivity/linear/SKILL.md +380 -0
  1376. package/skills/productivity/linear/scripts/linear_api.py +445 -0
  1377. package/skills/productivity/maps/SKILL.md +195 -0
  1378. package/skills/productivity/maps/scripts/maps_client.py +1298 -0
  1379. package/skills/productivity/nano-pdf/SKILL.md +52 -0
  1380. package/skills/productivity/notion/SKILL.md +448 -0
  1381. package/skills/productivity/notion/references/block-types.md +112 -0
  1382. package/skills/productivity/ocr-and-documents/DESCRIPTION.md +3 -0
  1383. package/skills/productivity/ocr-and-documents/SKILL.md +172 -0
  1384. package/skills/productivity/ocr-and-documents/scripts/extract_marker.py +87 -0
  1385. package/skills/productivity/ocr-and-documents/scripts/extract_pymupdf.py +98 -0
  1386. package/skills/productivity/powerpoint/LICENSE.txt +30 -0
  1387. package/skills/productivity/powerpoint/SKILL.md +237 -0
  1388. package/skills/productivity/powerpoint/editing.md +205 -0
  1389. package/skills/productivity/powerpoint/pptxgenjs.md +420 -0
  1390. package/skills/productivity/powerpoint/scripts/__init__.py +0 -0
  1391. package/skills/productivity/powerpoint/scripts/add_slide.py +195 -0
  1392. package/skills/productivity/powerpoint/scripts/clean.py +286 -0
  1393. package/skills/productivity/powerpoint/scripts/office/helpers/__init__.py +0 -0
  1394. package/skills/productivity/powerpoint/scripts/office/helpers/merge_runs.py +199 -0
  1395. package/skills/productivity/powerpoint/scripts/office/helpers/simplify_redlines.py +197 -0
  1396. package/skills/productivity/powerpoint/scripts/office/pack.py +159 -0
  1397. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
  1398. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
  1399. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
  1400. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
  1401. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
  1402. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
  1403. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
  1404. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
  1405. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
  1406. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
  1407. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
  1408. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
  1409. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
  1410. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
  1411. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
  1412. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
  1413. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
  1414. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
  1415. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
  1416. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
  1417. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
  1418. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
  1419. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
  1420. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
  1421. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
  1422. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
  1423. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
  1424. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-contentTypes.xsd +42 -0
  1425. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-coreProperties.xsd +50 -0
  1426. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-digSig.xsd +49 -0
  1427. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-relationships.xsd +33 -0
  1428. package/skills/productivity/powerpoint/scripts/office/schemas/mce/mc.xsd +75 -0
  1429. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
  1430. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
  1431. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
  1432. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
  1433. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
  1434. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
  1435. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
  1436. package/skills/productivity/teams-meeting-pipeline/SKILL.md +116 -0
  1437. package/skills/red-teaming/godmode/SKILL.md +404 -0
  1438. package/skills/red-teaming/godmode/references/jailbreak-templates.md +128 -0
  1439. package/skills/red-teaming/godmode/references/refusal-detection.md +142 -0
  1440. package/skills/red-teaming/godmode/scripts/auto_jailbreak.py +769 -0
  1441. package/skills/red-teaming/godmode/scripts/godmode_race.py +530 -0
  1442. package/skills/red-teaming/godmode/scripts/load_godmode.py +45 -0
  1443. package/skills/red-teaming/godmode/scripts/parseltongue.py +550 -0
  1444. package/skills/red-teaming/godmode/templates/prefill-subtle.json +10 -0
  1445. package/skills/red-teaming/godmode/templates/prefill.json +18 -0
  1446. package/skills/research/DESCRIPTION.md +3 -0
  1447. package/skills/research/arxiv/SKILL.md +282 -0
  1448. package/skills/research/arxiv/scripts/search_arxiv.py +114 -0
  1449. package/skills/research/blogwatcher/SKILL.md +137 -0
  1450. package/skills/research/llm-wiki/SKILL.md +507 -0
  1451. package/skills/research/polymarket/SKILL.md +77 -0
  1452. package/skills/research/polymarket/references/api-endpoints.md +220 -0
  1453. package/skills/research/polymarket/scripts/polymarket.py +284 -0
  1454. package/skills/research/research-paper-writing/SKILL.md +2377 -0
  1455. package/skills/research/research-paper-writing/references/autoreason-methodology.md +394 -0
  1456. package/skills/research/research-paper-writing/references/checklists.md +434 -0
  1457. package/skills/research/research-paper-writing/references/citation-workflow.md +564 -0
  1458. package/skills/research/research-paper-writing/references/experiment-patterns.md +728 -0
  1459. package/skills/research/research-paper-writing/references/human-evaluation.md +476 -0
  1460. package/skills/research/research-paper-writing/references/paper-types.md +481 -0
  1461. package/skills/research/research-paper-writing/references/reviewer-guidelines.md +433 -0
  1462. package/skills/research/research-paper-writing/references/sources.md +191 -0
  1463. package/skills/research/research-paper-writing/references/writing-guide.md +474 -0
  1464. package/skills/research/research-paper-writing/templates/README.md +251 -0
  1465. package/skills/research/research-paper-writing/templates/aaai2026/README.md +534 -0
  1466. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026-unified-supp.tex +144 -0
  1467. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026-unified-template.tex +952 -0
  1468. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.bib +111 -0
  1469. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.bst +1493 -0
  1470. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.sty +315 -0
  1471. package/skills/research/research-paper-writing/templates/acl/README.md +50 -0
  1472. package/skills/research/research-paper-writing/templates/acl/acl.sty +312 -0
  1473. package/skills/research/research-paper-writing/templates/acl/acl_latex.tex +377 -0
  1474. package/skills/research/research-paper-writing/templates/acl/acl_lualatex.tex +101 -0
  1475. package/skills/research/research-paper-writing/templates/acl/acl_natbib.bst +1940 -0
  1476. package/skills/research/research-paper-writing/templates/acl/anthology.bib.txt +26 -0
  1477. package/skills/research/research-paper-writing/templates/acl/custom.bib +70 -0
  1478. package/skills/research/research-paper-writing/templates/acl/formatting.md +326 -0
  1479. package/skills/research/research-paper-writing/templates/colm2025/README.md +3 -0
  1480. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.bib +11 -0
  1481. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.bst +1440 -0
  1482. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.pdf +0 -0
  1483. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.sty +218 -0
  1484. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.tex +305 -0
  1485. package/skills/research/research-paper-writing/templates/colm2025/fancyhdr.sty +485 -0
  1486. package/skills/research/research-paper-writing/templates/colm2025/math_commands.tex +508 -0
  1487. package/skills/research/research-paper-writing/templates/colm2025/natbib.sty +1246 -0
  1488. package/skills/research/research-paper-writing/templates/iclr2026/fancyhdr.sty +485 -0
  1489. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.bib +24 -0
  1490. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.bst +1440 -0
  1491. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.pdf +0 -0
  1492. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.sty +246 -0
  1493. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.tex +414 -0
  1494. package/skills/research/research-paper-writing/templates/iclr2026/math_commands.tex +508 -0
  1495. package/skills/research/research-paper-writing/templates/iclr2026/natbib.sty +1246 -0
  1496. package/skills/research/research-paper-writing/templates/icml2026/algorithm.sty +79 -0
  1497. package/skills/research/research-paper-writing/templates/icml2026/algorithmic.sty +201 -0
  1498. package/skills/research/research-paper-writing/templates/icml2026/example_paper.bib +75 -0
  1499. package/skills/research/research-paper-writing/templates/icml2026/example_paper.pdf +0 -0
  1500. package/skills/research/research-paper-writing/templates/icml2026/example_paper.tex +662 -0
  1501. package/skills/research/research-paper-writing/templates/icml2026/fancyhdr.sty +864 -0
  1502. package/skills/research/research-paper-writing/templates/icml2026/icml2026.bst +1443 -0
  1503. package/skills/research/research-paper-writing/templates/icml2026/icml2026.sty +767 -0
  1504. package/skills/research/research-paper-writing/templates/icml2026/icml_numpapers.pdf +0 -0
  1505. package/skills/research/research-paper-writing/templates/neurips2025/Makefile +36 -0
  1506. package/skills/research/research-paper-writing/templates/neurips2025/extra_pkgs.tex +53 -0
  1507. package/skills/research/research-paper-writing/templates/neurips2025/main.tex +38 -0
  1508. package/skills/research/research-paper-writing/templates/neurips2025/neurips.sty +382 -0
  1509. package/skills/smart-home/DESCRIPTION.md +3 -0
  1510. package/skills/smart-home/openhue/SKILL.md +109 -0
  1511. package/skills/social-media/DESCRIPTION.md +3 -0
  1512. package/skills/social-media/xurl/SKILL.md +414 -0
  1513. package/skills/software-development/debugging-hermes-tui-commands/SKILL.md +152 -0
  1514. package/skills/software-development/hermes-agent-skill-authoring/SKILL.md +165 -0
  1515. package/skills/software-development/node-inspect-debugger/SKILL.md +319 -0
  1516. package/skills/software-development/plan/SKILL.md +58 -0
  1517. package/skills/software-development/python-debugpy/SKILL.md +375 -0
  1518. package/skills/software-development/requesting-code-review/SKILL.md +280 -0
  1519. package/skills/software-development/spike/SKILL.md +197 -0
  1520. package/skills/software-development/subagent-driven-development/SKILL.md +352 -0
  1521. package/skills/software-development/subagent-driven-development/references/context-budget-discipline.md +53 -0
  1522. package/skills/software-development/subagent-driven-development/references/gates-taxonomy.md +93 -0
  1523. package/skills/software-development/systematic-debugging/SKILL.md +367 -0
  1524. package/skills/software-development/test-driven-development/SKILL.md +343 -0
  1525. package/skills/software-development/writing-plans/SKILL.md +297 -0
  1526. package/skills/yuanbao/SKILL.md +108 -0
  1527. package/tools/__init__.py +25 -0
  1528. package/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  1529. package/tools/__pycache__/approval.cpython-312.pyc +0 -0
  1530. package/tools/__pycache__/binary_extensions.cpython-312.pyc +0 -0
  1531. package/tools/__pycache__/browser_camofox.cpython-312.pyc +0 -0
  1532. package/tools/__pycache__/browser_camofox_state.cpython-312.pyc +0 -0
  1533. package/tools/__pycache__/browser_cdp_tool.cpython-312.pyc +0 -0
  1534. package/tools/__pycache__/browser_dialog_tool.cpython-312.pyc +0 -0
  1535. package/tools/__pycache__/browser_supervisor.cpython-312.pyc +0 -0
  1536. package/tools/__pycache__/browser_tool.cpython-312.pyc +0 -0
  1537. package/tools/__pycache__/budget_config.cpython-312.pyc +0 -0
  1538. package/tools/__pycache__/checkpoint_manager.cpython-312.pyc +0 -0
  1539. package/tools/__pycache__/clarify_gateway.cpython-312.pyc +0 -0
  1540. package/tools/__pycache__/clarify_tool.cpython-312.pyc +0 -0
  1541. package/tools/__pycache__/code_execution_tool.cpython-312.pyc +0 -0
  1542. package/tools/__pycache__/computer_use_tool.cpython-312.pyc +0 -0
  1543. package/tools/__pycache__/cronjob_tools.cpython-312.pyc +0 -0
  1544. package/tools/__pycache__/debug_helpers.cpython-312.pyc +0 -0
  1545. package/tools/__pycache__/delegate_tool.cpython-312.pyc +0 -0
  1546. package/tools/__pycache__/discord_tool.cpython-312.pyc +0 -0
  1547. package/tools/__pycache__/feishu_doc_tool.cpython-312.pyc +0 -0
  1548. package/tools/__pycache__/feishu_drive_tool.cpython-312.pyc +0 -0
  1549. package/tools/__pycache__/file_operations.cpython-312.pyc +0 -0
  1550. package/tools/__pycache__/file_state.cpython-312.pyc +0 -0
  1551. package/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
  1552. package/tools/__pycache__/homeassistant_tool.cpython-312.pyc +0 -0
  1553. package/tools/__pycache__/image_generation_tool.cpython-312.pyc +0 -0
  1554. package/tools/__pycache__/interrupt.cpython-312.pyc +0 -0
  1555. package/tools/__pycache__/kanban_tools.cpython-312.pyc +0 -0
  1556. package/tools/__pycache__/lazy_deps.cpython-312.pyc +0 -0
  1557. package/tools/__pycache__/managed_tool_gateway.cpython-312.pyc +0 -0
  1558. package/tools/__pycache__/mcp_tool.cpython-312.pyc +0 -0
  1559. package/tools/__pycache__/memory_tool.cpython-312.pyc +0 -0
  1560. package/tools/__pycache__/mixture_of_agents_tool.cpython-312.pyc +0 -0
  1561. package/tools/__pycache__/openrouter_client.cpython-312.pyc +0 -0
  1562. package/tools/__pycache__/process_registry.cpython-312.pyc +0 -0
  1563. package/tools/__pycache__/registry.cpython-312.pyc +0 -0
  1564. package/tools/__pycache__/schema_sanitizer.cpython-312.pyc +0 -0
  1565. package/tools/__pycache__/send_message_tool.cpython-312.pyc +0 -0
  1566. package/tools/__pycache__/session_search_tool.cpython-312.pyc +0 -0
  1567. package/tools/__pycache__/skill_manager_tool.cpython-312.pyc +0 -0
  1568. package/tools/__pycache__/skill_provenance.cpython-312.pyc +0 -0
  1569. package/tools/__pycache__/skill_usage.cpython-312.pyc +0 -0
  1570. package/tools/__pycache__/skills_guard.cpython-312.pyc +0 -0
  1571. package/tools/__pycache__/skills_sync.cpython-312.pyc +0 -0
  1572. package/tools/__pycache__/skills_tool.cpython-312.pyc +0 -0
  1573. package/tools/__pycache__/slash_confirm.cpython-312.pyc +0 -0
  1574. package/tools/__pycache__/terminal_tool.cpython-312.pyc +0 -0
  1575. package/tools/__pycache__/tirith_security.cpython-312.pyc +0 -0
  1576. package/tools/__pycache__/todo_tool.cpython-312.pyc +0 -0
  1577. package/tools/__pycache__/tool_backend_helpers.cpython-312.pyc +0 -0
  1578. package/tools/__pycache__/tool_result_storage.cpython-312.pyc +0 -0
  1579. package/tools/__pycache__/tts_tool.cpython-312.pyc +0 -0
  1580. package/tools/__pycache__/url_safety.cpython-312.pyc +0 -0
  1581. package/tools/__pycache__/video_generation_tool.cpython-312.pyc +0 -0
  1582. package/tools/__pycache__/vision_tools.cpython-312.pyc +0 -0
  1583. package/tools/__pycache__/voice_mode.cpython-312.pyc +0 -0
  1584. package/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
  1585. package/tools/__pycache__/website_policy.cpython-312.pyc +0 -0
  1586. package/tools/__pycache__/x_search_tool.cpython-312.pyc +0 -0
  1587. package/tools/__pycache__/xai_http.cpython-312.pyc +0 -0
  1588. package/tools/__pycache__/yuanbao_tools.cpython-312.pyc +0 -0
  1589. package/tools/ansi_strip.py +44 -0
  1590. package/tools/approval.py +1392 -0
  1591. package/tools/binary_extensions.py +42 -0
  1592. package/tools/browser_camofox.py +700 -0
  1593. package/tools/browser_camofox_state.py +48 -0
  1594. package/tools/browser_cdp_tool.py +569 -0
  1595. package/tools/browser_dialog_tool.py +148 -0
  1596. package/tools/browser_providers/__init__.py +10 -0
  1597. package/tools/browser_providers/__pycache__/__init__.cpython-312.pyc +0 -0
  1598. package/tools/browser_providers/__pycache__/base.cpython-312.pyc +0 -0
  1599. package/tools/browser_providers/__pycache__/browser_use.cpython-312.pyc +0 -0
  1600. package/tools/browser_providers/__pycache__/browserbase.cpython-312.pyc +0 -0
  1601. package/tools/browser_providers/__pycache__/firecrawl.cpython-312.pyc +0 -0
  1602. package/tools/browser_providers/base.py +59 -0
  1603. package/tools/browser_providers/browser_use.py +225 -0
  1604. package/tools/browser_providers/browserbase.py +222 -0
  1605. package/tools/browser_providers/firecrawl.py +112 -0
  1606. package/tools/browser_supervisor.py +1457 -0
  1607. package/tools/browser_tool.py +3676 -0
  1608. package/tools/budget_config.py +51 -0
  1609. package/tools/checkpoint_manager.py +1639 -0
  1610. package/tools/clarify_gateway.py +278 -0
  1611. package/tools/clarify_tool.py +141 -0
  1612. package/tools/code_execution_tool.py +1782 -0
  1613. package/tools/computer_use/__init__.py +43 -0
  1614. package/tools/computer_use/__pycache__/__init__.cpython-312.pyc +0 -0
  1615. package/tools/computer_use/__pycache__/backend.cpython-312.pyc +0 -0
  1616. package/tools/computer_use/__pycache__/schema.cpython-312.pyc +0 -0
  1617. package/tools/computer_use/__pycache__/tool.cpython-312.pyc +0 -0
  1618. package/tools/computer_use/backend.py +150 -0
  1619. package/tools/computer_use/cua_backend.py +682 -0
  1620. package/tools/computer_use/schema.py +191 -0
  1621. package/tools/computer_use/tool.py +521 -0
  1622. package/tools/computer_use_tool.py +39 -0
  1623. package/tools/credential_files.py +437 -0
  1624. package/tools/cronjob_tools.py +719 -0
  1625. package/tools/debug_helpers.py +106 -0
  1626. package/tools/delegate_tool.py +2797 -0
  1627. package/tools/discord_tool.py +959 -0
  1628. package/tools/env_passthrough.py +145 -0
  1629. package/tools/environments/__init__.py +14 -0
  1630. package/tools/environments/__pycache__/__init__.cpython-312.pyc +0 -0
  1631. package/tools/environments/__pycache__/base.cpython-312.pyc +0 -0
  1632. package/tools/environments/__pycache__/docker.cpython-312.pyc +0 -0
  1633. package/tools/environments/__pycache__/file_sync.cpython-312.pyc +0 -0
  1634. package/tools/environments/__pycache__/local.cpython-312.pyc +0 -0
  1635. package/tools/environments/__pycache__/managed_modal.cpython-312.pyc +0 -0
  1636. package/tools/environments/__pycache__/modal.cpython-312.pyc +0 -0
  1637. package/tools/environments/__pycache__/modal_utils.cpython-312.pyc +0 -0
  1638. package/tools/environments/__pycache__/singularity.cpython-312.pyc +0 -0
  1639. package/tools/environments/__pycache__/ssh.cpython-312.pyc +0 -0
  1640. package/tools/environments/base.py +844 -0
  1641. package/tools/environments/daytona.py +270 -0
  1642. package/tools/environments/docker.py +656 -0
  1643. package/tools/environments/file_sync.py +400 -0
  1644. package/tools/environments/local.py +658 -0
  1645. package/tools/environments/managed_modal.py +282 -0
  1646. package/tools/environments/modal.py +479 -0
  1647. package/tools/environments/modal_utils.py +199 -0
  1648. package/tools/environments/singularity.py +263 -0
  1649. package/tools/environments/ssh.py +295 -0
  1650. package/tools/environments/vercel_sandbox.py +655 -0
  1651. package/tools/feishu_doc_tool.py +138 -0
  1652. package/tools/feishu_drive_tool.py +431 -0
  1653. package/tools/file_operations.py +1825 -0
  1654. package/tools/file_state.py +332 -0
  1655. package/tools/file_tools.py +1172 -0
  1656. package/tools/fuzzy_match.py +703 -0
  1657. package/tools/homeassistant_tool.py +513 -0
  1658. package/tools/image_generation_tool.py +1098 -0
  1659. package/tools/interrupt.py +98 -0
  1660. package/tools/kanban_tools.py +1139 -0
  1661. package/tools/lazy_deps.py +608 -0
  1662. package/tools/managed_tool_gateway.py +168 -0
  1663. package/tools/mcp_oauth.py +633 -0
  1664. package/tools/mcp_oauth_manager.py +607 -0
  1665. package/tools/mcp_tool.py +3483 -0
  1666. package/tools/memory_tool.py +584 -0
  1667. package/tools/microsoft_graph_auth.py +245 -0
  1668. package/tools/microsoft_graph_client.py +408 -0
  1669. package/tools/mixture_of_agents_tool.py +542 -0
  1670. package/tools/neutts_samples/jo.txt +1 -0
  1671. package/tools/neutts_samples/jo.wav +0 -0
  1672. package/tools/neutts_synth.py +104 -0
  1673. package/tools/openrouter_client.py +33 -0
  1674. package/tools/osv_check.py +155 -0
  1675. package/tools/patch_parser.py +592 -0
  1676. package/tools/path_security.py +43 -0
  1677. package/tools/process_registry.py +1534 -0
  1678. package/tools/registry.py +589 -0
  1679. package/tools/schema_sanitizer.py +370 -0
  1680. package/tools/send_message_tool.py +1900 -0
  1681. package/tools/session_search_tool.py +613 -0
  1682. package/tools/skill_manager_tool.py +932 -0
  1683. package/tools/skill_provenance.py +78 -0
  1684. package/tools/skill_usage.py +610 -0
  1685. package/tools/skills_guard.py +932 -0
  1686. package/tools/skills_hub.py +3263 -0
  1687. package/tools/skills_sync.py +432 -0
  1688. package/tools/skills_tool.py +1569 -0
  1689. package/tools/slash_confirm.py +167 -0
  1690. package/tools/terminal_tool.py +2376 -0
  1691. package/tools/tirith_security.py +775 -0
  1692. package/tools/todo_tool.py +277 -0
  1693. package/tools/tool_backend_helpers.py +144 -0
  1694. package/tools/tool_output_limits.py +92 -0
  1695. package/tools/tool_result_storage.py +232 -0
  1696. package/tools/transcription_tools.py +936 -0
  1697. package/tools/tts_tool.py +2285 -0
  1698. package/tools/url_safety.py +330 -0
  1699. package/tools/video_generation_tool.py +561 -0
  1700. package/tools/vision_tools.py +1422 -0
  1701. package/tools/voice_mode.py +1019 -0
  1702. package/tools/web_tools.py +1551 -0
  1703. package/tools/website_policy.py +283 -0
  1704. package/tools/x_search_tool.py +424 -0
  1705. package/tools/xai_http.py +83 -0
  1706. package/tools/yuanbao_tools.py +736 -0
  1707. package/toolset_distributions.py +364 -0
  1708. package/toolsets.py +866 -0
  1709. package/trajectory_compressor.py +1509 -0
  1710. package/tui_gateway/__init__.py +0 -0
  1711. package/tui_gateway/entry.py +251 -0
  1712. package/tui_gateway/event_publisher.py +126 -0
  1713. package/tui_gateway/render.py +49 -0
  1714. package/tui_gateway/server.py +6623 -0
  1715. package/tui_gateway/slash_worker.py +76 -0
  1716. package/tui_gateway/transport.py +219 -0
  1717. package/tui_gateway/ws.py +178 -0
  1718. package/utils.py +361 -0
@@ -0,0 +1,4438 @@
1
+ """
2
+ Hermes Agent — Web UI server.
3
+
4
+ Provides a FastAPI backend serving the Vite/React frontend and REST API
5
+ endpoints for managing configuration, environment variables, and sessions.
6
+
7
+ Usage:
8
+ python -m hermes_cli.main web # Start on http://127.0.0.1:9119
9
+ python -m hermes_cli.main web --port 8080
10
+ """
11
+
12
+ import asyncio
13
+ import hmac
14
+ import importlib.util
15
+ import json
16
+ import logging
17
+ import os
18
+ import secrets
19
+ import subprocess
20
+ import sys
21
+ import threading
22
+ import time
23
+ import urllib.parse
24
+ import urllib.request
25
+ from pathlib import Path
26
+ from typing import Any, Dict, List, Optional, Tuple
27
+
28
+ import yaml
29
+
30
+ PROJECT_ROOT = Path(__file__).parent.parent.resolve()
31
+ if str(PROJECT_ROOT) not in sys.path:
32
+ sys.path.insert(0, str(PROJECT_ROOT))
33
+
34
+ from hermes_cli import __version__, __release_date__
35
+ from hermes_cli.config import (
36
+ cfg_get,
37
+ DEFAULT_CONFIG,
38
+ OPTIONAL_ENV_VARS,
39
+ get_config_path,
40
+ get_env_path,
41
+ get_hermes_home,
42
+ load_config,
43
+ load_env,
44
+ save_config,
45
+ save_env_value,
46
+ remove_env_value,
47
+ check_config_version,
48
+ redact_key,
49
+ )
50
+ from gateway.status import get_running_pid, read_runtime_status
51
+
52
+ try:
53
+ from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
54
+ from fastapi.middleware.cors import CORSMiddleware
55
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
56
+ from fastapi.staticfiles import StaticFiles
57
+ from pydantic import BaseModel
58
+ except ImportError:
59
+ # First try lazy-installing the dashboard extras. Only the user actually
60
+ # running `hermes dashboard` needs fastapi+uvicorn; lazy install keeps
61
+ # them out of every other install path. After install, re-import.
62
+ try:
63
+ from tools.lazy_deps import ensure as _lazy_ensure
64
+ _lazy_ensure("tool.dashboard", prompt=False)
65
+ from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
66
+ from fastapi.middleware.cors import CORSMiddleware
67
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
68
+ from fastapi.staticfiles import StaticFiles
69
+ from pydantic import BaseModel
70
+ except Exception:
71
+ raise SystemExit(
72
+ "Web UI requires fastapi and uvicorn.\n"
73
+ f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'"
74
+ )
75
+
76
+ WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
77
+ _log = logging.getLogger(__name__)
78
+
79
+ app = FastAPI(title="Hermes Agent", version=__version__)
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Session token for protecting sensitive endpoints (reveal).
83
+ # Generated fresh on every server start — dies when the process exits.
84
+ # Injected into the SPA HTML so only the legitimate web UI can use it.
85
+ # ---------------------------------------------------------------------------
86
+ _SESSION_TOKEN = secrets.token_urlsafe(32)
87
+ _SESSION_HEADER_NAME = "X-Hermes-Session-Token"
88
+
89
+ # In-browser Chat tab (/chat, /api/pty, …). Off unless ``hermes dashboard --tui``
90
+ # or HERMES_DASHBOARD_TUI=1. Set from :func:`start_server`.
91
+ _DASHBOARD_EMBEDDED_CHAT_ENABLED = False
92
+
93
+ # Simple rate limiter for the reveal endpoint
94
+ _reveal_timestamps: List[float] = []
95
+ _REVEAL_MAX_PER_WINDOW = 5
96
+ _REVEAL_WINDOW_SECONDS = 30
97
+
98
+ # CORS: restrict to localhost origins only. The web UI is intended to run
99
+ # locally; binding to 0.0.0.0 with allow_origins=["*"] would let any website
100
+ # read/modify config and secrets.
101
+
102
+ app.add_middleware(
103
+ CORSMiddleware,
104
+ allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$",
105
+ allow_methods=["*"],
106
+ allow_headers=["*"],
107
+ )
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Endpoints that do NOT require the session token. Everything else under
111
+ # /api/ is gated by the auth middleware below. Keep this list minimal —
112
+ # only truly non-sensitive, read-only endpoints belong here.
113
+ # ---------------------------------------------------------------------------
114
+ _PUBLIC_API_PATHS: frozenset = frozenset({
115
+ "/api/status",
116
+ "/api/config/defaults",
117
+ "/api/config/schema",
118
+ "/api/model/info",
119
+ "/api/dashboard/themes",
120
+ "/api/dashboard/plugins",
121
+ "/api/dashboard/plugins/rescan",
122
+ })
123
+
124
+
125
+ def _has_valid_session_token(request: Request) -> bool:
126
+ """True if the request carries a valid dashboard session token.
127
+
128
+ The dedicated session header avoids collisions with reverse proxies that
129
+ already use ``Authorization`` (for example Caddy ``basic_auth``). We still
130
+ accept the legacy Bearer path for backward compatibility with older
131
+ dashboard bundles.
132
+ """
133
+ session_header = request.headers.get(_SESSION_HEADER_NAME, "")
134
+ if session_header and hmac.compare_digest(
135
+ session_header.encode(),
136
+ _SESSION_TOKEN.encode(),
137
+ ):
138
+ return True
139
+
140
+ auth = request.headers.get("authorization", "")
141
+ expected = f"Bearer {_SESSION_TOKEN}"
142
+ return hmac.compare_digest(auth.encode(), expected.encode())
143
+
144
+
145
+ def _require_token(request: Request) -> None:
146
+ """Validate the ephemeral session token. Raises 401 on mismatch."""
147
+ if not _has_valid_session_token(request):
148
+ raise HTTPException(status_code=401, detail="Unauthorized")
149
+
150
+
151
+ # Accepted Host header values for loopback binds. DNS rebinding attacks
152
+ # point a victim browser at an attacker-controlled hostname (evil.test)
153
+ # which resolves to 127.0.0.1 after a TTL flip — bypassing same-origin
154
+ # checks because the browser now considers evil.test and our dashboard
155
+ # "same origin". Validating the Host header at the app layer rejects any
156
+ # request whose Host isn't one we bound for. See GHSA-ppp5-vxwm-4cf7.
157
+ _LOOPBACK_HOST_VALUES: frozenset = frozenset({
158
+ "localhost", "127.0.0.1", "::1",
159
+ })
160
+
161
+
162
+ def _is_accepted_host(host_header: str, bound_host: str) -> bool:
163
+ """True if the Host header targets the interface we bound to.
164
+
165
+ Accepts:
166
+ - Exact bound host (with or without port suffix)
167
+ - Loopback aliases when bound to loopback
168
+ - Any host when bound to 0.0.0.0 (explicit opt-in to non-loopback,
169
+ no protection possible at this layer)
170
+ """
171
+ if not host_header:
172
+ return False
173
+ # Strip port suffix. IPv6 addresses use bracket notation:
174
+ # [::1] — no port
175
+ # [::1]:9119 — with port
176
+ # Plain hosts/v4:
177
+ # localhost:9119
178
+ # 127.0.0.1:9119
179
+ h = host_header.strip()
180
+ if h.startswith("["):
181
+ # IPv6 bracketed — port (if any) follows "]:"
182
+ close = h.find("]")
183
+ if close != -1:
184
+ host_only = h[1:close] # strip brackets
185
+ else:
186
+ host_only = h.strip("[]")
187
+ else:
188
+ host_only = h.rsplit(":", 1)[0] if ":" in h else h
189
+ host_only = host_only.lower()
190
+
191
+ # 0.0.0.0 bind means operator explicitly opted into all-interfaces
192
+ # (requires --insecure per web_server.start_server). No Host-layer
193
+ # defence can protect that mode; rely on operator network controls.
194
+ if bound_host in {"0.0.0.0", "::"}:
195
+ return True
196
+
197
+ # Loopback bind: accept the loopback names
198
+ bound_lc = bound_host.lower()
199
+ if bound_lc in _LOOPBACK_HOST_VALUES:
200
+ return host_only in _LOOPBACK_HOST_VALUES
201
+
202
+ # Explicit non-loopback bind: require exact host match
203
+ return host_only == bound_lc
204
+
205
+
206
+ @app.middleware("http")
207
+ async def host_header_middleware(request: Request, call_next):
208
+ """Reject requests whose Host header doesn't match the bound interface.
209
+
210
+ Defends against DNS rebinding: a victim browser on a localhost
211
+ dashboard is tricked into fetching from an attacker hostname that
212
+ TTL-flips to 127.0.0.1. CORS and same-origin checks don't help —
213
+ the browser now treats the attacker origin as same-origin with the
214
+ dashboard. Host-header validation at the app layer catches it.
215
+
216
+ See GHSA-ppp5-vxwm-4cf7.
217
+ """
218
+ # Store the bound host on app.state so this middleware can read it —
219
+ # set by start_server() at listen time.
220
+ bound_host = getattr(app.state, "bound_host", None)
221
+ if bound_host:
222
+ host_header = request.headers.get("host", "")
223
+ if not _is_accepted_host(host_header, bound_host):
224
+ return JSONResponse(
225
+ status_code=400,
226
+ content={
227
+ "detail": (
228
+ "Invalid Host header. Dashboard requests must use "
229
+ "the hostname the server was bound to."
230
+ ),
231
+ },
232
+ )
233
+ return await call_next(request)
234
+
235
+
236
+ @app.middleware("http")
237
+ async def auth_middleware(request: Request, call_next):
238
+ """Require the session token on all /api/ routes except the public list."""
239
+ path = request.url.path
240
+ if path.startswith("/api/") and path not in _PUBLIC_API_PATHS:
241
+ if not _has_valid_session_token(request):
242
+ return JSONResponse(
243
+ status_code=401,
244
+ content={"detail": "Unauthorized"},
245
+ )
246
+ return await call_next(request)
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Config schema — auto-generated from DEFAULT_CONFIG
251
+ # ---------------------------------------------------------------------------
252
+
253
+ # Manual overrides for fields that need select options or custom types
254
+ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
255
+ "model": {
256
+ "type": "string",
257
+ "description": "Default model (e.g. anthropic/claude-sonnet-4.6)",
258
+ "category": "general",
259
+ },
260
+ "model_context_length": {
261
+ "type": "number",
262
+ "description": "Context window override (0 = auto-detect from model metadata)",
263
+ "category": "general",
264
+ },
265
+ "terminal.backend": {
266
+ "type": "select",
267
+ "description": "Terminal execution backend",
268
+ "options": ["local", "docker", "ssh", "modal", "daytona", "vercel_sandbox", "singularity"],
269
+ },
270
+ "terminal.vercel_runtime": {
271
+ "type": "select",
272
+ "description": "Vercel Sandbox runtime",
273
+ "options": ["node24", "node22", "python3.13"], # sync with _SUPPORTED_VERCEL_RUNTIMES in terminal_tool.py
274
+ },
275
+ "terminal.modal_mode": {
276
+ "type": "select",
277
+ "description": "Modal sandbox mode",
278
+ "options": ["sandbox", "function"],
279
+ },
280
+ "tts.provider": {
281
+ "type": "select",
282
+ "description": "Text-to-speech provider",
283
+ "options": ["edge", "elevenlabs", "openai", "neutts"],
284
+ },
285
+ "stt.provider": {
286
+ "type": "select",
287
+ "description": "Speech-to-text provider",
288
+ # "mistral" temporarily removed — mistralai PyPI package quarantined
289
+ # (malicious 2.4.6 release on 2026-05-12). Restore once available.
290
+ "options": ["local", "openai"],
291
+ },
292
+ "display.skin": {
293
+ "type": "select",
294
+ "description": "CLI visual theme",
295
+ "options": ["default", "ares", "mono", "slate"],
296
+ },
297
+ "dashboard.theme": {
298
+ "type": "select",
299
+ "description": "Web dashboard visual theme",
300
+ "options": ["default", "midnight", "ember", "mono", "cyberpunk", "rose"],
301
+ },
302
+ "display.resume_display": {
303
+ "type": "select",
304
+ "description": "How resumed sessions display history",
305
+ "options": ["minimal", "full", "off"],
306
+ },
307
+ "display.busy_input_mode": {
308
+ "type": "select",
309
+ "description": "Input behavior while agent is running",
310
+ "options": ["interrupt", "queue", "steer"],
311
+ },
312
+ "memory.provider": {
313
+ "type": "select",
314
+ "description": "Memory provider plugin",
315
+ "options": ["builtin", "honcho"],
316
+ },
317
+ "approvals.mode": {
318
+ "type": "select",
319
+ "description": "Dangerous command approval mode",
320
+ "options": ["ask", "yolo", "deny"],
321
+ },
322
+ "context.engine": {
323
+ "type": "select",
324
+ "description": "Context management engine",
325
+ "options": ["default", "custom"],
326
+ },
327
+ "human_delay.mode": {
328
+ "type": "select",
329
+ "description": "Simulated typing delay mode",
330
+ "options": ["off", "typing", "fixed"],
331
+ },
332
+ "logging.level": {
333
+ "type": "select",
334
+ "description": "Log level for agent.log",
335
+ "options": ["DEBUG", "INFO", "WARNING", "ERROR"],
336
+ },
337
+ "agent.service_tier": {
338
+ "type": "select",
339
+ "description": "API service tier (OpenAI/Anthropic)",
340
+ "options": ["", "auto", "default", "flex"],
341
+ },
342
+ "delegation.reasoning_effort": {
343
+ "type": "select",
344
+ "description": "Reasoning effort for delegated subagents",
345
+ "options": ["", "low", "medium", "high"],
346
+ },
347
+ }
348
+
349
+ # Categories with fewer fields get merged into "general" to avoid tab sprawl.
350
+ _CATEGORY_MERGE: Dict[str, str] = {
351
+ "privacy": "security",
352
+ "context": "agent",
353
+ "skills": "agent",
354
+ "cron": "agent",
355
+ "network": "agent",
356
+ "checkpoints": "agent",
357
+ "approvals": "security",
358
+ "human_delay": "display",
359
+ "dashboard": "display",
360
+ "code_execution": "agent",
361
+ "prompt_caching": "agent",
362
+ "goals": "agent",
363
+ # Only `telegram.reactions` currently lives under telegram — fold it in
364
+ # with the other messaging-platform config (discord) so it isn't an
365
+ # orphan tab of one field.
366
+ "telegram": "discord",
367
+ }
368
+
369
+ # Display order for tabs — unlisted categories sort alphabetically after these.
370
+ _CATEGORY_ORDER = [
371
+ "general", "agent", "terminal", "display", "delegation",
372
+ "memory", "compression", "security", "browser", "voice",
373
+ "tts", "stt", "logging", "discord", "auxiliary",
374
+ ]
375
+
376
+
377
+ def _infer_type(value: Any) -> str:
378
+ """Infer a UI field type from a Python value."""
379
+ if isinstance(value, bool):
380
+ return "boolean"
381
+ if isinstance(value, int):
382
+ return "number"
383
+ if isinstance(value, float):
384
+ return "number"
385
+ if isinstance(value, list):
386
+ return "list"
387
+ if isinstance(value, dict):
388
+ return "object"
389
+ return "string"
390
+
391
+
392
+ def _build_schema_from_config(
393
+ config: Dict[str, Any],
394
+ prefix: str = "",
395
+ ) -> Dict[str, Dict[str, Any]]:
396
+ """Walk DEFAULT_CONFIG and produce a flat dot-path → field schema dict."""
397
+ schema: Dict[str, Dict[str, Any]] = {}
398
+ for key, value in config.items():
399
+ full_key = f"{prefix}.{key}" if prefix else key
400
+
401
+ # Skip internal / version keys
402
+ if full_key in {"_config_version",}:
403
+ continue
404
+
405
+ # Category is the first path component for nested keys, or "general"
406
+ # for top-level scalar fields (model, toolsets, timezone, etc.).
407
+ if prefix:
408
+ category = prefix.split(".")[0]
409
+ elif isinstance(value, dict):
410
+ category = key
411
+ else:
412
+ category = "general"
413
+
414
+ if isinstance(value, dict):
415
+ # Recurse into nested dicts
416
+ schema.update(_build_schema_from_config(value, full_key))
417
+ else:
418
+ entry: Dict[str, Any] = {
419
+ "type": _infer_type(value),
420
+ "description": full_key.replace(".", " → ").replace("_", " ").title(),
421
+ "category": category,
422
+ }
423
+ # Apply manual overrides
424
+ if full_key in _SCHEMA_OVERRIDES:
425
+ entry.update(_SCHEMA_OVERRIDES[full_key])
426
+ # Merge small categories
427
+ entry["category"] = _CATEGORY_MERGE.get(entry["category"], entry["category"])
428
+ schema[full_key] = entry
429
+ return schema
430
+
431
+
432
+ CONFIG_SCHEMA = _build_schema_from_config(DEFAULT_CONFIG)
433
+
434
+ # Inject virtual fields that don't live in DEFAULT_CONFIG but are surfaced
435
+ # by the normalize/denormalize cycle. Insert model_context_length right after
436
+ # the "model" key so it renders adjacent in the frontend.
437
+ _mcl_entry = _SCHEMA_OVERRIDES["model_context_length"]
438
+ _ordered_schema: Dict[str, Dict[str, Any]] = {}
439
+ for _k, _v in CONFIG_SCHEMA.items():
440
+ _ordered_schema[_k] = _v
441
+ if _k == "model":
442
+ _ordered_schema["model_context_length"] = _mcl_entry
443
+ CONFIG_SCHEMA = _ordered_schema
444
+
445
+
446
+ class ConfigUpdate(BaseModel):
447
+ config: dict
448
+
449
+
450
+ class EnvVarUpdate(BaseModel):
451
+ key: str
452
+ value: str
453
+
454
+
455
+ class EnvVarDelete(BaseModel):
456
+ key: str
457
+
458
+
459
+ class EnvVarReveal(BaseModel):
460
+ key: str
461
+
462
+
463
+ class ModelAssignment(BaseModel):
464
+ """Payload for POST /api/model/set — assign a provider/model to a slot.
465
+
466
+ scope="main" → writes model.provider + model.default
467
+ scope="auxiliary" → writes auxiliary.<task>.provider + auxiliary.<task>.model
468
+ scope="auxiliary" with task="" → applied to every auxiliary.* slot
469
+ scope="auxiliary" with task="__reset__" → resets every slot to provider="auto"
470
+ """
471
+ scope: str
472
+ provider: str
473
+ model: str
474
+ task: str = ""
475
+
476
+
477
+ _GATEWAY_HEALTH_URL = os.getenv("GATEWAY_HEALTH_URL")
478
+ try:
479
+ _GATEWAY_HEALTH_TIMEOUT = float(os.getenv("GATEWAY_HEALTH_TIMEOUT", "3"))
480
+ except (ValueError, TypeError):
481
+ _log.warning(
482
+ "Invalid GATEWAY_HEALTH_TIMEOUT value %r — using default 3.0s",
483
+ os.getenv("GATEWAY_HEALTH_TIMEOUT"),
484
+ )
485
+ _GATEWAY_HEALTH_TIMEOUT = 3.0
486
+
487
+ # DEPRECATED (scheduled for removal): GATEWAY_HEALTH_URL / GATEWAY_HEALTH_TIMEOUT.
488
+ # Cross-container / cross-host gateway liveness detection will be folded into a
489
+ # first-class dashboard config key so it's no longer Docker-adjacent lore buried
490
+ # in env vars. The env vars still work for now so existing Compose deployments
491
+ # don't break. Do not add new callers — wire new uses through the planned
492
+ # config surface.
493
+
494
+
495
+ def _probe_gateway_health() -> tuple[bool, dict | None]:
496
+ """Probe the gateway via its HTTP health endpoint (cross-container).
497
+
498
+ .. deprecated::
499
+ Driven by the deprecated ``GATEWAY_HEALTH_URL`` /
500
+ ``GATEWAY_HEALTH_TIMEOUT`` env vars. Scheduled for removal alongside
501
+ a move to a first-class dashboard config key. See
502
+ :data:`_GATEWAY_HEALTH_URL` for context.
503
+
504
+ Uses ``/health/detailed`` first (returns full state), falling back to
505
+ the simpler ``/health`` endpoint. Returns ``(is_alive, body_dict)``.
506
+
507
+ Accepts any of these as ``GATEWAY_HEALTH_URL``:
508
+ - ``http://gateway:8642`` (base URL — recommended)
509
+ - ``http://gateway:8642/health`` (explicit health path)
510
+ - ``http://gateway:8642/health/detailed`` (explicit detailed path)
511
+
512
+ This is a **blocking** call — run via ``run_in_executor`` from async code.
513
+ """
514
+ if not _GATEWAY_HEALTH_URL:
515
+ return False, None
516
+
517
+ # Normalise to base URL so we always probe the right paths regardless of
518
+ # whether the user included /health or /health/detailed in the env var.
519
+ base = _GATEWAY_HEALTH_URL.rstrip("/")
520
+ if base.endswith("/health/detailed"):
521
+ base = base[: -len("/health/detailed")]
522
+ elif base.endswith("/health"):
523
+ base = base[: -len("/health")]
524
+
525
+ for path in (f"{base}/health/detailed", f"{base}/health"):
526
+ try:
527
+ req = urllib.request.Request(path, method="GET")
528
+ with urllib.request.urlopen(req, timeout=_GATEWAY_HEALTH_TIMEOUT) as resp:
529
+ if resp.status == 200:
530
+ body = json.loads(resp.read())
531
+ return True, body
532
+ except Exception:
533
+ continue
534
+ return False, None
535
+
536
+
537
+ @app.get("/api/status")
538
+ async def get_status():
539
+ current_ver, latest_ver = check_config_version()
540
+
541
+ # --- Gateway liveness detection ---
542
+ # Try local PID check first (same-host). If that fails and a remote
543
+ # GATEWAY_HEALTH_URL is configured, probe the gateway over HTTP so the
544
+ # dashboard works when the gateway runs in a separate container.
545
+ gateway_pid = get_running_pid()
546
+ gateway_running = gateway_pid is not None
547
+ remote_health_body: dict | None = None
548
+
549
+ if not gateway_running and _GATEWAY_HEALTH_URL:
550
+ loop = asyncio.get_running_loop()
551
+ alive, remote_health_body = await loop.run_in_executor(
552
+ None, _probe_gateway_health
553
+ )
554
+ if alive:
555
+ gateway_running = True
556
+ # PID from the remote container (display only — not locally valid)
557
+ if remote_health_body:
558
+ gateway_pid = remote_health_body.get("pid")
559
+
560
+ gateway_state = None
561
+ gateway_platforms: dict = {}
562
+ gateway_exit_reason = None
563
+ gateway_updated_at = None
564
+ configured_gateway_platforms: set[str] | None = None
565
+ try:
566
+ from gateway.config import load_gateway_config
567
+
568
+ gateway_config = load_gateway_config()
569
+ configured_gateway_platforms = {
570
+ platform.value for platform in gateway_config.get_connected_platforms()
571
+ }
572
+ except Exception:
573
+ configured_gateway_platforms = None
574
+
575
+ # Prefer the detailed health endpoint response (has full state) when the
576
+ # local runtime status file is absent or stale (cross-container).
577
+ runtime = read_runtime_status()
578
+ if runtime is None and remote_health_body and remote_health_body.get("gateway_state"):
579
+ runtime = remote_health_body
580
+
581
+ if runtime:
582
+ gateway_state = runtime.get("gateway_state")
583
+ gateway_platforms = runtime.get("platforms") or {}
584
+ if configured_gateway_platforms is not None:
585
+ gateway_platforms = {
586
+ key: value
587
+ for key, value in gateway_platforms.items()
588
+ if key in configured_gateway_platforms
589
+ }
590
+ gateway_exit_reason = runtime.get("exit_reason")
591
+ gateway_updated_at = runtime.get("updated_at")
592
+ if not gateway_running:
593
+ gateway_state = gateway_state if gateway_state in {"stopped", "startup_failed"} else "stopped"
594
+ gateway_platforms = {}
595
+ elif gateway_running and remote_health_body is not None:
596
+ # The health probe confirmed the gateway is alive, but the local
597
+ # runtime status file may be stale (cross-container). Override
598
+ # stopped/None state so the dashboard shows the correct badge.
599
+ if gateway_state in {None, "stopped"}:
600
+ gateway_state = "running"
601
+
602
+ # If there was no runtime info at all but the health probe confirmed alive,
603
+ # ensure we still report the gateway as running (no shared volume scenario).
604
+ if gateway_running and gateway_state is None and remote_health_body is not None:
605
+ gateway_state = "running"
606
+
607
+ active_sessions = 0
608
+ try:
609
+ from calvyn_state import SessionDB
610
+ db = SessionDB()
611
+ try:
612
+ sessions = db.list_sessions_rich(limit=50)
613
+ now = time.time()
614
+ active_sessions = sum(
615
+ 1 for s in sessions
616
+ if s.get("ended_at") is None
617
+ and (now - s.get("last_active", s.get("started_at", 0))) < 300
618
+ )
619
+ finally:
620
+ db.close()
621
+ except Exception:
622
+ pass
623
+
624
+ return {
625
+ "version": __version__,
626
+ "release_date": __release_date__,
627
+ "hermes_home": str(get_hermes_home()),
628
+ "config_path": str(get_config_path()),
629
+ "env_path": str(get_env_path()),
630
+ "config_version": current_ver,
631
+ "latest_config_version": latest_ver,
632
+ "gateway_running": gateway_running,
633
+ "gateway_pid": gateway_pid,
634
+ "gateway_health_url": _GATEWAY_HEALTH_URL,
635
+ "gateway_state": gateway_state,
636
+ "gateway_platforms": gateway_platforms,
637
+ "gateway_exit_reason": gateway_exit_reason,
638
+ "gateway_updated_at": gateway_updated_at,
639
+ "active_sessions": active_sessions,
640
+ }
641
+
642
+
643
+ # ---------------------------------------------------------------------------
644
+ # Gateway + update actions (invoked from the Status page).
645
+ #
646
+ # Both commands are spawned as detached subprocesses so the HTTP request
647
+ # returns immediately. stdin is closed (``DEVNULL``) so any stray ``input()``
648
+ # calls fail fast with EOF rather than hanging forever. stdout/stderr are
649
+ # streamed to a per-action log file under ``~/.hermes/logs/<action>.log`` so
650
+ # the dashboard can tail them back to the user.
651
+ # ---------------------------------------------------------------------------
652
+
653
+ _ACTION_LOG_DIR: Path = get_hermes_home() / "logs"
654
+
655
+ # Short ``name`` (from the URL) → absolute log file path.
656
+ _ACTION_LOG_FILES: Dict[str, str] = {
657
+ "gateway-restart": "gateway-restart.log",
658
+ "hermes-update": "hermes-update.log",
659
+ }
660
+
661
+ # ``name`` → most recently spawned Popen handle. Used so ``status`` can
662
+ # report liveness and exit code without shelling out to ``ps``.
663
+ _ACTION_PROCS: Dict[str, subprocess.Popen] = {}
664
+
665
+
666
+ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen:
667
+ """Spawn ``hermes <subcommand>`` detached and record the Popen handle.
668
+
669
+ Uses the running interpreter's ``hermes_cli.main`` module so the action
670
+ inherits the same venv/PYTHONPATH the web server is using.
671
+ """
672
+ log_file_name = _ACTION_LOG_FILES[name]
673
+ _ACTION_LOG_DIR.mkdir(parents=True, exist_ok=True)
674
+ log_path = _ACTION_LOG_DIR / log_file_name
675
+ log_file = open(log_path, "ab", buffering=0)
676
+ log_file.write(
677
+ f"\n=== {name} started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode()
678
+ )
679
+
680
+ cmd = [sys.executable, "-m", "hermes_cli.main", *subcommand]
681
+
682
+ popen_kwargs: Dict[str, Any] = {
683
+ "cwd": str(PROJECT_ROOT),
684
+ "stdin": subprocess.DEVNULL,
685
+ "stdout": log_file,
686
+ "stderr": subprocess.STDOUT,
687
+ "env": {**os.environ, "HERMES_NONINTERACTIVE": "1"},
688
+ }
689
+ if sys.platform == "win32":
690
+ popen_kwargs["creationflags"] = (
691
+ subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
692
+ | getattr(subprocess, "DETACHED_PROCESS", 0)
693
+ )
694
+ else:
695
+ popen_kwargs["start_new_session"] = True
696
+
697
+ proc = subprocess.Popen(cmd, **popen_kwargs)
698
+ _ACTION_PROCS[name] = proc
699
+ return proc
700
+
701
+
702
+ def _tail_lines(path: Path, n: int) -> List[str]:
703
+ """Return the last ``n`` lines of ``path``. Reads the whole file — fine
704
+ for our small per-action logs. Binary-decoded with ``errors='replace'``
705
+ so log corruption doesn't 500 the endpoint."""
706
+ if not path.exists():
707
+ return []
708
+ try:
709
+ text = path.read_text(encoding="utf-8", errors="replace")
710
+ except OSError:
711
+ return []
712
+ lines = text.splitlines()
713
+ return lines[-n:] if n > 0 else lines
714
+
715
+
716
+ @app.post("/api/gateway/restart")
717
+ async def restart_gateway():
718
+ """Kick off a ``hermes gateway restart`` in the background."""
719
+ try:
720
+ proc = _spawn_hermes_action(["gateway", "restart"], "gateway-restart")
721
+ except Exception as exc:
722
+ _log.exception("Failed to spawn gateway restart")
723
+ raise HTTPException(status_code=500, detail=f"Failed to restart gateway: {exc}")
724
+ return {
725
+ "ok": True,
726
+ "pid": proc.pid,
727
+ "name": "gateway-restart",
728
+ }
729
+
730
+
731
+ @app.post("/api/hermes/update")
732
+ async def update_hermes():
733
+ """Kick off ``hermes update`` in the background."""
734
+ try:
735
+ proc = _spawn_hermes_action(["update"], "hermes-update")
736
+ except Exception as exc:
737
+ _log.exception("Failed to spawn hermes update")
738
+ raise HTTPException(status_code=500, detail=f"Failed to start update: {exc}")
739
+ return {
740
+ "ok": True,
741
+ "pid": proc.pid,
742
+ "name": "hermes-update",
743
+ }
744
+
745
+
746
+ @app.get("/api/actions/{name}/status")
747
+ async def get_action_status(name: str, lines: int = 200):
748
+ """Tail an action log and report whether the process is still running."""
749
+ log_file_name = _ACTION_LOG_FILES.get(name)
750
+ if log_file_name is None:
751
+ raise HTTPException(status_code=404, detail=f"Unknown action: {name}")
752
+
753
+ log_path = _ACTION_LOG_DIR / log_file_name
754
+ tail = _tail_lines(log_path, min(max(lines, 1), 2000))
755
+
756
+ proc = _ACTION_PROCS.get(name)
757
+ if proc is None:
758
+ running = False
759
+ exit_code: Optional[int] = None
760
+ pid: Optional[int] = None
761
+ else:
762
+ exit_code = proc.poll()
763
+ running = exit_code is None
764
+ pid = proc.pid
765
+
766
+ return {
767
+ "name": name,
768
+ "running": running,
769
+ "exit_code": exit_code,
770
+ "pid": pid,
771
+ "lines": tail,
772
+ }
773
+
774
+
775
+ @app.get("/api/sessions")
776
+ async def get_sessions(limit: int = 20, offset: int = 0):
777
+ try:
778
+ from calvyn_state import SessionDB
779
+ db = SessionDB()
780
+ try:
781
+ sessions = db.list_sessions_rich(limit=limit, offset=offset)
782
+ total = db.session_count()
783
+ now = time.time()
784
+ for s in sessions:
785
+ s["is_active"] = (
786
+ s.get("ended_at") is None
787
+ and (now - s.get("last_active", s.get("started_at", 0))) < 300
788
+ )
789
+ return {"sessions": sessions, "total": total, "limit": limit, "offset": offset}
790
+ finally:
791
+ db.close()
792
+ except Exception:
793
+ _log.exception("GET /api/sessions failed")
794
+ raise HTTPException(status_code=500, detail="Internal server error")
795
+
796
+
797
+ @app.get("/api/sessions/search")
798
+ async def search_sessions(q: str = "", limit: int = 20):
799
+ """Full-text search across session message content using FTS5."""
800
+ if not q or not q.strip():
801
+ return {"results": []}
802
+ try:
803
+ from calvyn_state import SessionDB
804
+ db = SessionDB()
805
+ try:
806
+ # Auto-add prefix wildcards so partial words match
807
+ # e.g. "nimb" → "nimb*" matches "nimby"
808
+ # Preserve quoted phrases and existing wildcards as-is
809
+ import re
810
+ terms = []
811
+ for token in re.findall(r'"[^"]*"|\S+', q.strip()):
812
+ if token.startswith('"') or token.endswith("*"):
813
+ terms.append(token)
814
+ else:
815
+ terms.append(token + "*")
816
+ prefix_query = " ".join(terms)
817
+ matches = db.search_messages(query=prefix_query, limit=limit)
818
+ # Group by session_id — return unique sessions with their best snippet
819
+ seen: dict = {}
820
+ for m in matches:
821
+ sid = m["session_id"]
822
+ if sid not in seen:
823
+ seen[sid] = {
824
+ "session_id": sid,
825
+ "snippet": m.get("snippet", ""),
826
+ "role": m.get("role"),
827
+ "source": m.get("source"),
828
+ "model": m.get("model"),
829
+ "session_started": m.get("session_started"),
830
+ }
831
+ return {"results": list(seen.values())}
832
+ finally:
833
+ db.close()
834
+ except Exception:
835
+ _log.exception("GET /api/sessions/search failed")
836
+ raise HTTPException(status_code=500, detail="Search failed")
837
+
838
+
839
+ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]:
840
+ """Normalize config for the web UI.
841
+
842
+ Hermes supports ``model`` as either a bare string (``"anthropic/claude-sonnet-4"``)
843
+ or a dict (``{default: ..., provider: ..., base_url: ...}``). The schema is built
844
+ from DEFAULT_CONFIG where ``model`` is a string, but user configs often have the
845
+ dict form. Normalize to the string form so the frontend schema matches.
846
+
847
+ Also surfaces ``model_context_length`` as a top-level field so the web UI can
848
+ display and edit it. A value of 0 means "auto-detect".
849
+ """
850
+ config = dict(config) # shallow copy
851
+ model_val = config.get("model")
852
+ if isinstance(model_val, dict):
853
+ # Extract context_length before flattening the dict
854
+ ctx_len = model_val.get("context_length", 0)
855
+ config["model"] = model_val.get("default", model_val.get("name", ""))
856
+ config["model_context_length"] = ctx_len if isinstance(ctx_len, int) else 0
857
+ else:
858
+ config["model_context_length"] = 0
859
+ return config
860
+
861
+
862
+ @app.get("/api/config")
863
+ async def get_config():
864
+ config = _normalize_config_for_web(load_config())
865
+ # Strip internal keys that the frontend shouldn't see or send back
866
+ return {k: v for k, v in config.items() if not k.startswith("_")}
867
+
868
+
869
+ @app.get("/api/config/defaults")
870
+ async def get_defaults():
871
+ return DEFAULT_CONFIG
872
+
873
+
874
+ @app.get("/api/config/schema")
875
+ async def get_schema():
876
+ return {"fields": CONFIG_SCHEMA, "category_order": _CATEGORY_ORDER}
877
+
878
+
879
+ _EMPTY_MODEL_INFO: dict = {
880
+ "model": "",
881
+ "provider": "",
882
+ "auto_context_length": 0,
883
+ "config_context_length": 0,
884
+ "effective_context_length": 0,
885
+ "capabilities": {},
886
+ }
887
+
888
+
889
+ @app.get("/api/model/info")
890
+ def get_model_info():
891
+ """Return resolved model metadata for the currently configured model.
892
+
893
+ Calls the same context-length resolution chain the agent uses, so the
894
+ frontend can display "Auto-detected: 200K" alongside the override field.
895
+ Also returns model capabilities (vision, reasoning, tools) when available.
896
+ """
897
+ try:
898
+ cfg = load_config()
899
+ model_cfg = cfg.get("model", "")
900
+
901
+ # Extract model name and provider from the config
902
+ if isinstance(model_cfg, dict):
903
+ model_name = model_cfg.get("default", model_cfg.get("name", ""))
904
+ provider = model_cfg.get("provider", "")
905
+ base_url = model_cfg.get("base_url", "")
906
+ config_ctx = model_cfg.get("context_length")
907
+ else:
908
+ model_name = str(model_cfg) if model_cfg else ""
909
+ provider = ""
910
+ base_url = ""
911
+ config_ctx = None
912
+
913
+ if not model_name:
914
+ return dict(_EMPTY_MODEL_INFO, provider=provider)
915
+
916
+ # Resolve auto-detected context length (pass config_ctx=None to get
917
+ # purely auto-detected value, then separately report the override)
918
+ try:
919
+ from agent.model_metadata import get_model_context_length
920
+ auto_ctx = get_model_context_length(
921
+ model=model_name,
922
+ base_url=base_url,
923
+ provider=provider,
924
+ config_context_length=None, # ignore override — we want auto value
925
+ )
926
+ except Exception:
927
+ auto_ctx = 0
928
+
929
+ config_ctx_int = 0
930
+ if isinstance(config_ctx, int) and config_ctx > 0:
931
+ config_ctx_int = config_ctx
932
+
933
+ # Effective is what the agent actually uses
934
+ effective_ctx = config_ctx_int if config_ctx_int > 0 else auto_ctx
935
+
936
+ # Try to get model capabilities from models.dev
937
+ caps = {}
938
+ try:
939
+ from agent.models_dev import get_model_capabilities
940
+ mc = get_model_capabilities(provider=provider, model=model_name)
941
+ if mc is not None:
942
+ caps = {
943
+ "supports_tools": mc.supports_tools,
944
+ "supports_vision": mc.supports_vision,
945
+ "supports_reasoning": mc.supports_reasoning,
946
+ "context_window": mc.context_window,
947
+ "max_output_tokens": mc.max_output_tokens,
948
+ "model_family": mc.model_family,
949
+ }
950
+ except Exception:
951
+ pass
952
+
953
+ return {
954
+ "model": model_name,
955
+ "provider": provider,
956
+ "auto_context_length": auto_ctx,
957
+ "config_context_length": config_ctx_int,
958
+ "effective_context_length": effective_ctx,
959
+ "capabilities": caps,
960
+ }
961
+ except Exception:
962
+ _log.exception("GET /api/model/info failed")
963
+ return dict(_EMPTY_MODEL_INFO)
964
+
965
+
966
+ # ---------------------------------------------------------------------------
967
+ # Model assignment — pick provider+model for main slot or auxiliary slots.
968
+ # Mirrors the model.options JSON-RPC from tui_gateway but uses REST so the
969
+ # Models page (which has no chat PTY open) can drive it.
970
+ # ---------------------------------------------------------------------------
971
+
972
+ # Canonical auxiliary task slots. Keep in sync with DEFAULT_CONFIG["auxiliary"]
973
+ # in hermes_cli/config.py — listed here for deterministic ordering in the UI.
974
+ _AUX_TASK_SLOTS: Tuple[str, ...] = (
975
+ "vision",
976
+ "web_extract",
977
+ "compression",
978
+ "session_search",
979
+ "skills_hub",
980
+ "approval",
981
+ "mcp",
982
+ "title_generation",
983
+ "curator",
984
+ )
985
+
986
+
987
+ @app.get("/api/model/options")
988
+ def get_model_options():
989
+ """Return authenticated providers + their curated model lists.
990
+
991
+ REST equivalent of the ``model.options`` JSON-RPC on tui_gateway, so the
992
+ dashboard Models page can render the picker without a live chat session.
993
+ The response shape matches ``model.options`` 1:1 so ``ModelPickerDialog``
994
+ can share the same types.
995
+ """
996
+ try:
997
+ from hermes_cli.inventory import build_models_payload, load_picker_context
998
+
999
+ return build_models_payload(load_picker_context(), max_models=50)
1000
+ except Exception:
1001
+ _log.exception("GET /api/model/options failed")
1002
+ raise HTTPException(status_code=500, detail="Failed to list model options")
1003
+
1004
+
1005
+ @app.get("/api/model/auxiliary")
1006
+ def get_auxiliary_models():
1007
+ """Return current auxiliary task assignments.
1008
+
1009
+ Shape:
1010
+ {
1011
+ "tasks": [
1012
+ {"task": "vision", "provider": "auto", "model": "", "base_url": ""},
1013
+ ...
1014
+ ],
1015
+ "main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7"},
1016
+ }
1017
+ """
1018
+ try:
1019
+ cfg = load_config()
1020
+ aux_cfg = cfg.get("auxiliary", {})
1021
+ if not isinstance(aux_cfg, dict):
1022
+ aux_cfg = {}
1023
+
1024
+ tasks = []
1025
+ for slot in _AUX_TASK_SLOTS:
1026
+ slot_cfg = aux_cfg.get(slot, {}) if isinstance(aux_cfg.get(slot), dict) else {}
1027
+ tasks.append({
1028
+ "task": slot,
1029
+ "provider": str(slot_cfg.get("provider", "auto") or "auto"),
1030
+ "model": str(slot_cfg.get("model", "") or ""),
1031
+ "base_url": str(slot_cfg.get("base_url", "") or ""),
1032
+ })
1033
+
1034
+ model_cfg = cfg.get("model", {})
1035
+ if isinstance(model_cfg, dict):
1036
+ main = {
1037
+ "provider": str(model_cfg.get("provider", "") or ""),
1038
+ "model": str(model_cfg.get("default", model_cfg.get("name", "")) or ""),
1039
+ }
1040
+ else:
1041
+ main = {"provider": "", "model": str(model_cfg) if model_cfg else ""}
1042
+
1043
+ return {"tasks": tasks, "main": main}
1044
+ except Exception:
1045
+ _log.exception("GET /api/model/auxiliary failed")
1046
+ raise HTTPException(status_code=500, detail="Failed to read auxiliary config")
1047
+
1048
+
1049
+ @app.post("/api/model/set")
1050
+ async def set_model_assignment(body: ModelAssignment):
1051
+ """Assign a model to the main slot or an auxiliary task slot.
1052
+
1053
+ Writes to ``~/.hermes/config.yaml`` — applies to **new** sessions only.
1054
+ The currently running chat PTY (if any) is not affected; use the
1055
+ ``/model`` slash command inside a chat to hot-swap that specific session.
1056
+ """
1057
+ scope = (body.scope or "").strip().lower()
1058
+ provider = (body.provider or "").strip()
1059
+ model = (body.model or "").strip()
1060
+ task = (body.task or "").strip().lower()
1061
+
1062
+ if scope not in {"main", "auxiliary"}:
1063
+ raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'")
1064
+
1065
+ try:
1066
+ cfg = load_config()
1067
+
1068
+ if scope == "main":
1069
+ if not provider or not model:
1070
+ raise HTTPException(status_code=400, detail="provider and model required for main")
1071
+ model_cfg = cfg.get("model", {})
1072
+ if not isinstance(model_cfg, dict):
1073
+ model_cfg = {}
1074
+ model_cfg["provider"] = provider
1075
+ model_cfg["default"] = model
1076
+ # Clear stale base_url so the resolver picks the provider's own default.
1077
+ if "base_url" in model_cfg and model_cfg.get("base_url"):
1078
+ model_cfg["base_url"] = ""
1079
+ # Also clear hardcoded context_length override — new model may have
1080
+ # a different context window.
1081
+ if "context_length" in model_cfg:
1082
+ model_cfg.pop("context_length", None)
1083
+ cfg["model"] = model_cfg
1084
+ save_config(cfg)
1085
+ return {"ok": True, "scope": "main", "provider": provider, "model": model}
1086
+
1087
+ # scope == "auxiliary"
1088
+ aux = cfg.get("auxiliary")
1089
+ if not isinstance(aux, dict):
1090
+ aux = {}
1091
+
1092
+ if task == "__reset__":
1093
+ # Reset every slot to provider="auto", model="" — keeps other fields intact.
1094
+ for slot in _AUX_TASK_SLOTS:
1095
+ slot_cfg = aux.get(slot)
1096
+ if not isinstance(slot_cfg, dict):
1097
+ slot_cfg = {}
1098
+ slot_cfg["provider"] = "auto"
1099
+ slot_cfg["model"] = ""
1100
+ aux[slot] = slot_cfg
1101
+ cfg["auxiliary"] = aux
1102
+ save_config(cfg)
1103
+ return {"ok": True, "scope": "auxiliary", "reset": True}
1104
+
1105
+ if not provider:
1106
+ raise HTTPException(status_code=400, detail="provider required for auxiliary")
1107
+
1108
+ targets = [task] if task else list(_AUX_TASK_SLOTS)
1109
+ for slot in targets:
1110
+ if slot not in _AUX_TASK_SLOTS:
1111
+ raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}")
1112
+ slot_cfg = aux.get(slot)
1113
+ if not isinstance(slot_cfg, dict):
1114
+ slot_cfg = {}
1115
+ slot_cfg["provider"] = provider
1116
+ slot_cfg["model"] = model
1117
+ aux[slot] = slot_cfg
1118
+
1119
+ cfg["auxiliary"] = aux
1120
+ save_config(cfg)
1121
+ return {
1122
+ "ok": True,
1123
+ "scope": "auxiliary",
1124
+ "tasks": targets,
1125
+ "provider": provider,
1126
+ "model": model,
1127
+ }
1128
+ except HTTPException:
1129
+ raise
1130
+ except Exception:
1131
+ _log.exception("POST /api/model/set failed")
1132
+ raise HTTPException(status_code=500, detail="Failed to save model assignment")
1133
+
1134
+
1135
+
1136
+
1137
+ def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
1138
+ """Reverse _normalize_config_for_web before saving.
1139
+
1140
+ Reconstructs ``model`` as a dict by reading the current on-disk config
1141
+ to recover model subkeys (provider, base_url, api_mode, etc.) that were
1142
+ stripped from the GET response. The frontend only sees model as a flat
1143
+ string; the rest is preserved transparently.
1144
+
1145
+ Also handles ``model_context_length`` — writes it back into the model dict
1146
+ as ``context_length``. A value of 0 or absent means "auto-detect" (omitted
1147
+ from the dict so get_model_context_length() uses its normal resolution).
1148
+ """
1149
+ config = dict(config)
1150
+ # Remove any _model_meta that might have leaked in (shouldn't happen
1151
+ # with the stripped GET response, but be defensive)
1152
+ config.pop("_model_meta", None)
1153
+
1154
+ # Extract and remove model_context_length before processing model
1155
+ ctx_override = config.pop("model_context_length", 0)
1156
+ if not isinstance(ctx_override, int):
1157
+ try:
1158
+ ctx_override = int(ctx_override)
1159
+ except (TypeError, ValueError):
1160
+ ctx_override = 0
1161
+
1162
+ model_val = config.get("model")
1163
+ if isinstance(model_val, str) and model_val:
1164
+ # Read the current disk config to recover model subkeys
1165
+ try:
1166
+ disk_config = load_config()
1167
+ disk_model = disk_config.get("model")
1168
+ if isinstance(disk_model, dict):
1169
+ # Preserve all subkeys, update default with the new value
1170
+ disk_model["default"] = model_val
1171
+ # Write context_length into the model dict (0 = remove/auto)
1172
+ if ctx_override > 0:
1173
+ disk_model["context_length"] = ctx_override
1174
+ else:
1175
+ disk_model.pop("context_length", None)
1176
+ config["model"] = disk_model
1177
+ # Model was previously a bare string — upgrade to dict if
1178
+ # user is setting a context_length override
1179
+ elif ctx_override > 0:
1180
+ config["model"] = {
1181
+ "default": model_val,
1182
+ "context_length": ctx_override,
1183
+ }
1184
+ except Exception:
1185
+ pass # can't read disk config — just use the string form
1186
+ return config
1187
+
1188
+
1189
+ @app.put("/api/config")
1190
+ async def update_config(body: ConfigUpdate):
1191
+ try:
1192
+ save_config(_denormalize_config_from_web(body.config))
1193
+ return {"ok": True}
1194
+ except Exception:
1195
+ _log.exception("PUT /api/config failed")
1196
+ raise HTTPException(status_code=500, detail="Internal server error")
1197
+
1198
+
1199
+ @app.get("/api/env")
1200
+ async def get_env_vars():
1201
+ env_on_disk = load_env()
1202
+ result = {}
1203
+ for var_name, info in OPTIONAL_ENV_VARS.items():
1204
+ value = env_on_disk.get(var_name)
1205
+ result[var_name] = {
1206
+ "is_set": bool(value),
1207
+ "redacted_value": redact_key(value) if value else None,
1208
+ "description": info.get("description", ""),
1209
+ "url": info.get("url"),
1210
+ "category": info.get("category", ""),
1211
+ "is_password": info.get("password", False),
1212
+ "tools": info.get("tools", []),
1213
+ "advanced": info.get("advanced", False),
1214
+ }
1215
+ return result
1216
+
1217
+
1218
+ @app.put("/api/env")
1219
+ async def set_env_var(body: EnvVarUpdate):
1220
+ try:
1221
+ save_env_value(body.key, body.value)
1222
+ return {"ok": True, "key": body.key}
1223
+ except Exception:
1224
+ _log.exception("PUT /api/env failed")
1225
+ raise HTTPException(status_code=500, detail="Internal server error")
1226
+
1227
+
1228
+ @app.delete("/api/env")
1229
+ async def remove_env_var(body: EnvVarDelete):
1230
+ try:
1231
+ removed = remove_env_value(body.key)
1232
+ if not removed:
1233
+ raise HTTPException(status_code=404, detail=f"{body.key} not found in .env")
1234
+ return {"ok": True, "key": body.key}
1235
+ except HTTPException:
1236
+ raise
1237
+ except Exception:
1238
+ _log.exception("DELETE /api/env failed")
1239
+ raise HTTPException(status_code=500, detail="Internal server error")
1240
+
1241
+
1242
+ @app.post("/api/env/reveal")
1243
+ async def reveal_env_var(body: EnvVarReveal, request: Request):
1244
+ """Return the real (unredacted) value of a single env var.
1245
+
1246
+ Protected by:
1247
+ - Ephemeral session token (generated per server start, injected into SPA)
1248
+ - Rate limiting (max 5 reveals per 30s window)
1249
+ - Audit logging
1250
+ """
1251
+ # --- Token check ---
1252
+ _require_token(request)
1253
+
1254
+ # --- Rate limit ---
1255
+ now = time.time()
1256
+ cutoff = now - _REVEAL_WINDOW_SECONDS
1257
+ _reveal_timestamps[:] = [t for t in _reveal_timestamps if t > cutoff]
1258
+ if len(_reveal_timestamps) >= _REVEAL_MAX_PER_WINDOW:
1259
+ raise HTTPException(status_code=429, detail="Too many reveal requests. Try again shortly.")
1260
+ _reveal_timestamps.append(now)
1261
+
1262
+ # --- Reveal ---
1263
+ env_on_disk = load_env()
1264
+ value = env_on_disk.get(body.key)
1265
+ if value is None:
1266
+ raise HTTPException(status_code=404, detail=f"{body.key} not found in .env")
1267
+
1268
+ _log.info("env/reveal: %s", body.key)
1269
+ return {"key": body.key, "value": value}
1270
+
1271
+
1272
+ # ---------------------------------------------------------------------------
1273
+ # OAuth provider endpoints — status + disconnect (Phase 1)
1274
+ # ---------------------------------------------------------------------------
1275
+ #
1276
+ # Phase 1 surfaces *which OAuth providers exist* and whether each is
1277
+ # connected, plus a disconnect button. The actual login flow (PKCE for
1278
+ # Anthropic, device-code for Nous/Codex) still runs in the CLI for now;
1279
+ # Phase 2 will add in-browser flows. For unconnected providers we return
1280
+ # the canonical ``hermes auth add <provider>`` command so the dashboard
1281
+ # can surface a one-click copy.
1282
+
1283
+
1284
+ def _truncate_token(value: Optional[str], visible: int = 6) -> str:
1285
+ """Return ``...XXXXXX`` (last N chars) for safe display in the UI.
1286
+
1287
+ We never expose more than the trailing ``visible`` characters of an
1288
+ OAuth access token. JWT prefixes (the part before the first dot) are
1289
+ stripped first when present so the visible suffix is always part of
1290
+ the signing region rather than a meaningless header chunk.
1291
+ """
1292
+ if not value:
1293
+ return ""
1294
+ s = str(value)
1295
+ if "." in s and s.count(".") >= 2:
1296
+ # Looks like a JWT — show the trailing piece of the signature only.
1297
+ s = s.rsplit(".", 1)[-1]
1298
+ if len(s) <= visible:
1299
+ return s
1300
+ return f"…{s[-visible:]}"
1301
+
1302
+
1303
+ def _anthropic_oauth_status() -> Dict[str, Any]:
1304
+ """Combined status across the three Anthropic credential sources we read.
1305
+
1306
+ Hermes resolves Anthropic creds in this order at runtime:
1307
+ 1. ``~/.hermes/.anthropic_oauth.json`` — Hermes-managed PKCE flow
1308
+ 2. ``~/.claude/.credentials.json`` — Claude Code CLI credentials (auto)
1309
+ 3. ``ANTHROPIC_TOKEN`` / ``ANTHROPIC_API_KEY`` env vars
1310
+ The dashboard reports the highest-priority source that's actually present.
1311
+ """
1312
+ try:
1313
+ from agent.anthropic_adapter import (
1314
+ read_hermes_oauth_credentials,
1315
+ read_claude_code_credentials,
1316
+ _HERMES_OAUTH_FILE,
1317
+ )
1318
+ except ImportError:
1319
+ read_claude_code_credentials = None # type: ignore
1320
+ read_hermes_oauth_credentials = None # type: ignore
1321
+ _HERMES_OAUTH_FILE = None # type: ignore
1322
+
1323
+ hermes_creds = None
1324
+ if read_hermes_oauth_credentials:
1325
+ try:
1326
+ hermes_creds = read_hermes_oauth_credentials()
1327
+ except Exception:
1328
+ hermes_creds = None
1329
+ if hermes_creds and hermes_creds.get("accessToken"):
1330
+ return {
1331
+ "logged_in": True,
1332
+ "source": "hermes_pkce",
1333
+ "source_label": f"Hermes PKCE ({_HERMES_OAUTH_FILE})",
1334
+ "token_preview": _truncate_token(hermes_creds.get("accessToken")),
1335
+ "expires_at": hermes_creds.get("expiresAt"),
1336
+ "has_refresh_token": bool(hermes_creds.get("refreshToken")),
1337
+ }
1338
+
1339
+ cc_creds = None
1340
+ if read_claude_code_credentials:
1341
+ try:
1342
+ cc_creds = read_claude_code_credentials()
1343
+ except Exception:
1344
+ cc_creds = None
1345
+ if cc_creds and cc_creds.get("accessToken"):
1346
+ return {
1347
+ "logged_in": True,
1348
+ "source": "claude_code",
1349
+ "source_label": "Claude Code (~/.claude/.credentials.json)",
1350
+ "token_preview": _truncate_token(cc_creds.get("accessToken")),
1351
+ "expires_at": cc_creds.get("expiresAt"),
1352
+ "has_refresh_token": bool(cc_creds.get("refreshToken")),
1353
+ }
1354
+
1355
+ env_token = os.getenv("ANTHROPIC_TOKEN") or os.getenv("CLAUDE_CODE_OAUTH_TOKEN")
1356
+ if env_token:
1357
+ return {
1358
+ "logged_in": True,
1359
+ "source": "env_var",
1360
+ "source_label": "ANTHROPIC_TOKEN environment variable",
1361
+ "token_preview": _truncate_token(env_token),
1362
+ "expires_at": None,
1363
+ "has_refresh_token": False,
1364
+ }
1365
+ return {"logged_in": False, "source": None}
1366
+
1367
+
1368
+ def _claude_code_only_status() -> Dict[str, Any]:
1369
+ """Surface Claude Code CLI credentials as their own provider entry.
1370
+
1371
+ Independent of the Anthropic entry above so users can see whether their
1372
+ Claude Code subscription tokens are actively flowing into Hermes even
1373
+ when they also have a separate Hermes-managed PKCE login.
1374
+ """
1375
+ try:
1376
+ from agent.anthropic_adapter import read_claude_code_credentials
1377
+ creds = read_claude_code_credentials()
1378
+ except Exception:
1379
+ creds = None
1380
+ if creds and creds.get("accessToken"):
1381
+ return {
1382
+ "logged_in": True,
1383
+ "source": "claude_code_cli",
1384
+ "source_label": "~/.claude/.credentials.json",
1385
+ "token_preview": _truncate_token(creds.get("accessToken")),
1386
+ "expires_at": creds.get("expiresAt"),
1387
+ "has_refresh_token": bool(creds.get("refreshToken")),
1388
+ }
1389
+ return {"logged_in": False, "source": None}
1390
+
1391
+
1392
+ # Provider catalog. The order matters — it's how we render the UI list.
1393
+ # ``cli_command`` is what the dashboard surfaces as the copy-to-clipboard
1394
+ # fallback while Phase 2 (in-browser flows) isn't built yet.
1395
+ # ``flow`` describes the OAuth shape so the future modal can pick the
1396
+ # right UI: ``pkce`` = open URL + paste callback code, ``device_code`` =
1397
+ # show code + verification URL + poll, ``external`` = read-only (delegated
1398
+ # to a third-party CLI like Claude Code or Qwen).
1399
+ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
1400
+ {
1401
+ "id": "anthropic",
1402
+ "name": "Anthropic (Claude API)",
1403
+ "flow": "pkce",
1404
+ "cli_command": "hermes auth add anthropic",
1405
+ "docs_url": "https://docs.claude.com/en/api/getting-started",
1406
+ "status_fn": _anthropic_oauth_status,
1407
+ },
1408
+ {
1409
+ "id": "claude-code",
1410
+ "name": "Claude Code (subscription)",
1411
+ "flow": "external",
1412
+ "cli_command": "claude setup-token",
1413
+ "docs_url": "https://docs.claude.com/en/docs/claude-code",
1414
+ "status_fn": _claude_code_only_status,
1415
+ },
1416
+ {
1417
+ "id": "nous",
1418
+ "name": "Nous Portal",
1419
+ "flow": "device_code",
1420
+ "cli_command": "hermes auth add nous",
1421
+ "docs_url": "https://portal.nousresearch.com",
1422
+ "status_fn": None, # dispatched via auth.get_nous_auth_status
1423
+ },
1424
+ {
1425
+ "id": "openai-codex",
1426
+ "name": "OpenAI Codex (ChatGPT)",
1427
+ "flow": "device_code",
1428
+ "cli_command": "hermes auth add openai-codex",
1429
+ "docs_url": "https://platform.openai.com/docs",
1430
+ "status_fn": None, # dispatched via auth.get_codex_auth_status
1431
+ },
1432
+ {
1433
+ "id": "qwen-oauth",
1434
+ "name": "Qwen (via Qwen CLI)",
1435
+ "flow": "external",
1436
+ "cli_command": "hermes auth add qwen-oauth",
1437
+ "docs_url": "https://github.com/QwenLM/qwen-code",
1438
+ "status_fn": None, # dispatched via auth.get_qwen_auth_status
1439
+ },
1440
+ {
1441
+ "id": "minimax-oauth",
1442
+ "name": "MiniMax (OAuth)",
1443
+ # MiniMax's flow is structurally device-code (verification URI +
1444
+ # user code, backend polls the token endpoint) with a PKCE
1445
+ # extension for code-binding. The dashboard renders the same UX
1446
+ # as Nous's device-code flow; the PKCE bit is a security
1447
+ # extension that doesn't change the operator experience.
1448
+ "flow": "device_code",
1449
+ "cli_command": "hermes auth add minimax-oauth",
1450
+ "docs_url": "https://www.minimax.io",
1451
+ "status_fn": None, # dispatched via auth.get_minimax_oauth_auth_status
1452
+ },
1453
+ )
1454
+
1455
+
1456
+ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
1457
+ """Dispatch to the right status helper for an OAuth provider entry."""
1458
+ if status_fn is not None:
1459
+ try:
1460
+ return status_fn()
1461
+ except Exception as e:
1462
+ return {"logged_in": False, "error": str(e)}
1463
+ try:
1464
+ from hermes_cli import auth as hauth
1465
+ if provider_id == "nous":
1466
+ raw = hauth.get_nous_auth_status()
1467
+ return {
1468
+ "logged_in": bool(raw.get("logged_in")),
1469
+ "source": "nous_portal",
1470
+ "source_label": raw.get("portal_base_url") or "Nous Portal",
1471
+ "token_preview": _truncate_token(raw.get("access_token")),
1472
+ "expires_at": raw.get("access_expires_at"),
1473
+ "has_refresh_token": bool(raw.get("has_refresh_token")),
1474
+ }
1475
+ if provider_id == "openai-codex":
1476
+ raw = hauth.get_codex_auth_status()
1477
+ return {
1478
+ "logged_in": bool(raw.get("logged_in")),
1479
+ "source": raw.get("source") or "openai_codex",
1480
+ "source_label": raw.get("auth_mode") or "OpenAI Codex",
1481
+ "token_preview": _truncate_token(raw.get("api_key")),
1482
+ "expires_at": None,
1483
+ "has_refresh_token": False,
1484
+ "last_refresh": raw.get("last_refresh"),
1485
+ }
1486
+ if provider_id == "qwen-oauth":
1487
+ raw = hauth.get_qwen_auth_status()
1488
+ return {
1489
+ "logged_in": bool(raw.get("logged_in")),
1490
+ "source": "qwen_cli",
1491
+ "source_label": raw.get("auth_store_path") or "Qwen CLI",
1492
+ "token_preview": _truncate_token(raw.get("access_token")),
1493
+ "expires_at": raw.get("expires_at"),
1494
+ "has_refresh_token": bool(raw.get("has_refresh_token")),
1495
+ }
1496
+ if provider_id == "minimax-oauth":
1497
+ raw = hauth.get_minimax_oauth_auth_status()
1498
+ return {
1499
+ "logged_in": bool(raw.get("logged_in")),
1500
+ "source": "minimax_oauth",
1501
+ "source_label": f"MiniMax ({raw.get('region', 'global')})",
1502
+ "token_preview": None,
1503
+ "expires_at": raw.get("expires_at"),
1504
+ "has_refresh_token": True,
1505
+ }
1506
+ except Exception as e:
1507
+ return {"logged_in": False, "error": str(e)}
1508
+ return {"logged_in": False}
1509
+
1510
+
1511
+ @app.get("/api/providers/oauth")
1512
+ async def list_oauth_providers():
1513
+ """Enumerate every OAuth-capable LLM provider with current status.
1514
+
1515
+ Response shape (per provider):
1516
+ id stable identifier (used in DELETE path)
1517
+ name human label
1518
+ flow "pkce" | "device_code" | "external"
1519
+ cli_command fallback CLI command for users to run manually
1520
+ docs_url external docs/portal link for the "Learn more" link
1521
+ status:
1522
+ logged_in bool — currently has usable creds
1523
+ source short slug ("hermes_pkce", "claude_code", ...)
1524
+ source_label human-readable origin (file path, env var name)
1525
+ token_preview last N chars of the token, never the full token
1526
+ expires_at ISO timestamp string or null
1527
+ has_refresh_token bool
1528
+ """
1529
+ providers = []
1530
+ for p in _OAUTH_PROVIDER_CATALOG:
1531
+ status = _resolve_provider_status(p["id"], p.get("status_fn"))
1532
+ providers.append({
1533
+ "id": p["id"],
1534
+ "name": p["name"],
1535
+ "flow": p["flow"],
1536
+ "cli_command": p["cli_command"],
1537
+ "docs_url": p["docs_url"],
1538
+ "status": status,
1539
+ })
1540
+ return {"providers": providers}
1541
+
1542
+
1543
+ @app.delete("/api/providers/oauth/{provider_id}")
1544
+ async def disconnect_oauth_provider(provider_id: str, request: Request):
1545
+ """Disconnect an OAuth provider. Token-protected (matches /env/reveal)."""
1546
+ _require_token(request)
1547
+
1548
+ valid_ids = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
1549
+ if provider_id not in valid_ids:
1550
+ raise HTTPException(
1551
+ status_code=400,
1552
+ detail=f"Unknown provider: {provider_id}. "
1553
+ f"Available: {', '.join(sorted(valid_ids))}",
1554
+ )
1555
+
1556
+ # Anthropic and claude-code clear the same Hermes-managed PKCE file
1557
+ # AND forget the Claude Code import. We don't touch ~/.claude/* directly
1558
+ # — that's owned by the Claude Code CLI; users can re-auth there if they
1559
+ # want to undo a disconnect.
1560
+ if provider_id in {"anthropic", "claude-code"}:
1561
+ try:
1562
+ from agent.anthropic_adapter import _HERMES_OAUTH_FILE
1563
+ if _HERMES_OAUTH_FILE.exists():
1564
+ _HERMES_OAUTH_FILE.unlink()
1565
+ except Exception:
1566
+ pass
1567
+ # Also clear the credential pool entry if present.
1568
+ try:
1569
+ from hermes_cli.auth import clear_provider_auth
1570
+ clear_provider_auth("anthropic")
1571
+ except Exception:
1572
+ pass
1573
+ _log.info("oauth/disconnect: %s", provider_id)
1574
+ return {"ok": True, "provider": provider_id}
1575
+
1576
+ try:
1577
+ from hermes_cli.auth import clear_provider_auth
1578
+ cleared = clear_provider_auth(provider_id)
1579
+ _log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared)
1580
+ return {"ok": bool(cleared), "provider": provider_id}
1581
+ except Exception as e:
1582
+ _log.exception("disconnect %s failed", provider_id)
1583
+ raise HTTPException(status_code=500, detail=str(e))
1584
+
1585
+
1586
+ # ---------------------------------------------------------------------------
1587
+ # OAuth Phase 2 — in-browser PKCE & device-code flows
1588
+ # ---------------------------------------------------------------------------
1589
+ #
1590
+ # Two flow shapes are supported:
1591
+ #
1592
+ # PKCE (Anthropic):
1593
+ # 1. POST /api/providers/oauth/anthropic/start
1594
+ # → server generates code_verifier + challenge, builds claude.ai
1595
+ # authorize URL, stashes verifier in _oauth_sessions[session_id]
1596
+ # → returns { session_id, flow: "pkce", auth_url }
1597
+ # 2. UI opens auth_url in a new tab. User authorizes, copies code.
1598
+ # 3. POST /api/providers/oauth/anthropic/submit { session_id, code }
1599
+ # → server exchanges (code + verifier) → tokens at console.anthropic.com
1600
+ # → persists to ~/.hermes/.anthropic_oauth.json AND credential pool
1601
+ # → returns { ok: true, status: "approved" }
1602
+ #
1603
+ # Device code (Nous, OpenAI Codex):
1604
+ # 1. POST /api/providers/oauth/{nous|openai-codex}/start
1605
+ # → server hits provider's device-auth endpoint
1606
+ # → gets { user_code, verification_url, device_code, interval, expires_in }
1607
+ # → spawns background poller thread that polls the token endpoint
1608
+ # every `interval` seconds until approved/expired
1609
+ # → stores poll status in _oauth_sessions[session_id]
1610
+ # → returns { session_id, flow: "device_code", user_code,
1611
+ # verification_url, expires_in, poll_interval }
1612
+ # 2. UI opens verification_url in a new tab and shows user_code.
1613
+ # 3. UI polls GET /api/providers/oauth/{provider}/poll/{session_id}
1614
+ # every 2s until status != "pending".
1615
+ # 4. On "approved" the background thread has already saved creds; UI
1616
+ # refreshes the providers list.
1617
+ #
1618
+ # Sessions are kept in-memory only (single-process FastAPI) and time out
1619
+ # after 15 minutes. A periodic cleanup runs on each /start call to GC
1620
+ # expired sessions so the dict doesn't grow without bound.
1621
+
1622
+ _OAUTH_SESSION_TTL_SECONDS = 15 * 60
1623
+ _oauth_sessions: Dict[str, Dict[str, Any]] = {}
1624
+ _oauth_sessions_lock = threading.Lock()
1625
+
1626
+ # Import OAuth constants from canonical source instead of duplicating.
1627
+ # Guarded so hermes web still starts if anthropic_adapter is unavailable;
1628
+ # Phase 2 endpoints will return 501 in that case.
1629
+ try:
1630
+ from agent.anthropic_adapter import (
1631
+ _OAUTH_CLIENT_ID as _ANTHROPIC_OAUTH_CLIENT_ID,
1632
+ _OAUTH_TOKEN_URL as _ANTHROPIC_OAUTH_TOKEN_URL,
1633
+ _OAUTH_REDIRECT_URI as _ANTHROPIC_OAUTH_REDIRECT_URI,
1634
+ _OAUTH_SCOPES as _ANTHROPIC_OAUTH_SCOPES,
1635
+ _generate_pkce as _generate_pkce_pair,
1636
+ )
1637
+ _ANTHROPIC_OAUTH_AVAILABLE = True
1638
+ except ImportError:
1639
+ _ANTHROPIC_OAUTH_AVAILABLE = False
1640
+ _ANTHROPIC_OAUTH_AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
1641
+
1642
+
1643
+ def _gc_oauth_sessions() -> None:
1644
+ """Drop expired sessions. Called opportunistically on /start."""
1645
+ cutoff = time.time() - _OAUTH_SESSION_TTL_SECONDS
1646
+ with _oauth_sessions_lock:
1647
+ stale = [sid for sid, sess in _oauth_sessions.items() if sess["created_at"] < cutoff]
1648
+ for sid in stale:
1649
+ _oauth_sessions.pop(sid, None)
1650
+
1651
+
1652
+ def _new_oauth_session(provider_id: str, flow: str) -> tuple[str, Dict[str, Any]]:
1653
+ """Create + register a new OAuth session, return (session_id, session_dict)."""
1654
+ sid = secrets.token_urlsafe(16)
1655
+ sess = {
1656
+ "session_id": sid,
1657
+ "provider": provider_id,
1658
+ "flow": flow,
1659
+ "created_at": time.time(),
1660
+ "status": "pending", # pending | approved | denied | expired | error
1661
+ "error_message": None,
1662
+ }
1663
+ with _oauth_sessions_lock:
1664
+ _oauth_sessions[sid] = sess
1665
+ return sid, sess
1666
+
1667
+
1668
+ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_at_ms: int) -> None:
1669
+ """Persist Anthropic PKCE creds to both Hermes file AND credential pool.
1670
+
1671
+ Mirrors what auth_commands.add_command does so the dashboard flow leaves
1672
+ the system in the same state as ``hermes auth add anthropic``.
1673
+ """
1674
+ from agent.anthropic_adapter import _HERMES_OAUTH_FILE
1675
+ payload = {
1676
+ "accessToken": access_token,
1677
+ "refreshToken": refresh_token,
1678
+ "expiresAt": expires_at_ms,
1679
+ }
1680
+ _HERMES_OAUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
1681
+ _HERMES_OAUTH_FILE.write_text(json.dumps(payload, indent=2), encoding="utf-8")
1682
+ # Best-effort credential-pool insert. Failure here doesn't invalidate
1683
+ # the file write — pool registration only matters for the rotation
1684
+ # strategy, not for runtime credential resolution.
1685
+ try:
1686
+ from agent.credential_pool import (
1687
+ PooledCredential,
1688
+ load_pool,
1689
+ AUTH_TYPE_OAUTH,
1690
+ SOURCE_MANUAL,
1691
+ )
1692
+ import uuid
1693
+ pool = load_pool("anthropic")
1694
+ # Avoid duplicate entries: delete any prior dashboard-issued OAuth entry
1695
+ existing = [e for e in pool.entries() if getattr(e, "source", "").startswith(f"{SOURCE_MANUAL}:dashboard_pkce")]
1696
+ for e in existing:
1697
+ try:
1698
+ pool.remove_entry(getattr(e, "id", ""))
1699
+ except Exception:
1700
+ pass
1701
+ entry = PooledCredential(
1702
+ provider="anthropic",
1703
+ id=uuid.uuid4().hex[:6],
1704
+ label="dashboard PKCE",
1705
+ auth_type=AUTH_TYPE_OAUTH,
1706
+ priority=0,
1707
+ source=f"{SOURCE_MANUAL}:dashboard_pkce",
1708
+ access_token=access_token,
1709
+ refresh_token=refresh_token,
1710
+ expires_at_ms=expires_at_ms,
1711
+ )
1712
+ pool.add_entry(entry)
1713
+ except Exception as e:
1714
+ _log.warning("anthropic pool add (dashboard) failed: %s", e)
1715
+
1716
+
1717
+ def _start_anthropic_pkce() -> Dict[str, Any]:
1718
+ """Begin PKCE flow. Returns the auth URL the UI should open."""
1719
+ if not _ANTHROPIC_OAUTH_AVAILABLE:
1720
+ raise HTTPException(status_code=501, detail="Anthropic OAuth not available (missing adapter)")
1721
+ verifier, challenge = _generate_pkce_pair()
1722
+ sid, sess = _new_oauth_session("anthropic", "pkce")
1723
+ sess["verifier"] = verifier
1724
+ sess["state"] = verifier # Anthropic round-trips verifier as state
1725
+ params = {
1726
+ "code": "true",
1727
+ "client_id": _ANTHROPIC_OAUTH_CLIENT_ID,
1728
+ "response_type": "code",
1729
+ "redirect_uri": _ANTHROPIC_OAUTH_REDIRECT_URI,
1730
+ "scope": _ANTHROPIC_OAUTH_SCOPES,
1731
+ "code_challenge": challenge,
1732
+ "code_challenge_method": "S256",
1733
+ "state": verifier,
1734
+ }
1735
+ auth_url = f"{_ANTHROPIC_OAUTH_AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
1736
+ return {
1737
+ "session_id": sid,
1738
+ "flow": "pkce",
1739
+ "auth_url": auth_url,
1740
+ "expires_in": _OAUTH_SESSION_TTL_SECONDS,
1741
+ }
1742
+
1743
+
1744
+ def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]:
1745
+ """Exchange authorization code for tokens. Persists on success."""
1746
+ with _oauth_sessions_lock:
1747
+ sess = _oauth_sessions.get(session_id)
1748
+ if not sess or sess["provider"] != "anthropic" or sess["flow"] != "pkce":
1749
+ raise HTTPException(status_code=404, detail="Unknown or expired session")
1750
+ if sess["status"] != "pending":
1751
+ return {"ok": False, "status": sess["status"], "message": sess.get("error_message")}
1752
+
1753
+ # Anthropic's redirect callback page formats the code as `<code>#<state>`.
1754
+ # Strip the state suffix if present (we already have the verifier server-side).
1755
+ parts = code_input.strip().split("#", 1)
1756
+ code = parts[0].strip()
1757
+ if not code:
1758
+ return {"ok": False, "status": "error", "message": "No code provided"}
1759
+ state_from_callback = parts[1] if len(parts) > 1 else ""
1760
+
1761
+ exchange_data = json.dumps({
1762
+ "grant_type": "authorization_code",
1763
+ "client_id": _ANTHROPIC_OAUTH_CLIENT_ID,
1764
+ "code": code,
1765
+ "state": state_from_callback or sess["state"],
1766
+ "redirect_uri": _ANTHROPIC_OAUTH_REDIRECT_URI,
1767
+ "code_verifier": sess["verifier"],
1768
+ }).encode()
1769
+ req = urllib.request.Request(
1770
+ _ANTHROPIC_OAUTH_TOKEN_URL,
1771
+ data=exchange_data,
1772
+ headers={
1773
+ "Content-Type": "application/json",
1774
+ "User-Agent": "hermes-dashboard/1.0",
1775
+ },
1776
+ method="POST",
1777
+ )
1778
+ try:
1779
+ with urllib.request.urlopen(req, timeout=20) as resp:
1780
+ result = json.loads(resp.read().decode())
1781
+ except Exception as e:
1782
+ with _oauth_sessions_lock:
1783
+ sess["status"] = "error"
1784
+ sess["error_message"] = f"Token exchange failed: {e}"
1785
+ return {"ok": False, "status": "error", "message": sess["error_message"]}
1786
+
1787
+ access_token = result.get("access_token", "")
1788
+ refresh_token = result.get("refresh_token", "")
1789
+ expires_in = int(result.get("expires_in") or 3600)
1790
+ if not access_token:
1791
+ with _oauth_sessions_lock:
1792
+ sess["status"] = "error"
1793
+ sess["error_message"] = "No access token returned"
1794
+ return {"ok": False, "status": "error", "message": sess["error_message"]}
1795
+
1796
+ expires_at_ms = int(time.time() * 1000) + (expires_in * 1000)
1797
+ try:
1798
+ _save_anthropic_oauth_creds(access_token, refresh_token, expires_at_ms)
1799
+ except Exception as e:
1800
+ with _oauth_sessions_lock:
1801
+ sess["status"] = "error"
1802
+ sess["error_message"] = f"Save failed: {e}"
1803
+ return {"ok": False, "status": "error", "message": sess["error_message"]}
1804
+ with _oauth_sessions_lock:
1805
+ sess["status"] = "approved"
1806
+ _log.info("oauth/pkce: anthropic login completed (session=%s)", session_id)
1807
+ return {"ok": True, "status": "approved"}
1808
+
1809
+
1810
+ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
1811
+ """Initiate a device-code flow (Nous, OpenAI Codex, or MiniMax).
1812
+
1813
+ Calls the provider's device-auth endpoint via the existing CLI helpers,
1814
+ then spawns a background poller. Returns the user-facing display fields
1815
+ so the UI can render the verification page link + user code.
1816
+ """
1817
+ if provider_id == "nous":
1818
+ from hermes_cli.auth import _request_device_code, PROVIDER_REGISTRY
1819
+ import httpx
1820
+ pconfig = PROVIDER_REGISTRY["nous"]
1821
+ portal_base_url = (
1822
+ os.getenv("HERMES_PORTAL_BASE_URL")
1823
+ or os.getenv("NOUS_PORTAL_BASE_URL")
1824
+ or pconfig.portal_base_url
1825
+ ).rstrip("/")
1826
+ client_id = pconfig.client_id
1827
+ scope = pconfig.scope
1828
+ def _do_nous_device_request():
1829
+ with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client:
1830
+ return _request_device_code(
1831
+ client=client,
1832
+ portal_base_url=portal_base_url,
1833
+ client_id=client_id,
1834
+ scope=scope,
1835
+ )
1836
+ device_data = await asyncio.get_running_loop().run_in_executor(None, _do_nous_device_request)
1837
+ sid, sess = _new_oauth_session("nous", "device_code")
1838
+ sess["device_code"] = str(device_data["device_code"])
1839
+ sess["interval"] = int(device_data["interval"])
1840
+ sess["expires_at"] = time.time() + int(device_data["expires_in"])
1841
+ sess["portal_base_url"] = portal_base_url
1842
+ sess["client_id"] = client_id
1843
+ threading.Thread(
1844
+ target=_nous_poller, args=(sid,), daemon=True, name=f"oauth-poll-{sid[:6]}"
1845
+ ).start()
1846
+ return {
1847
+ "session_id": sid,
1848
+ "flow": "device_code",
1849
+ "user_code": str(device_data["user_code"]),
1850
+ "verification_url": str(device_data["verification_uri_complete"]),
1851
+ "expires_in": int(device_data["expires_in"]),
1852
+ "poll_interval": int(device_data["interval"]),
1853
+ }
1854
+
1855
+ if provider_id == "openai-codex":
1856
+ # Codex uses fixed OpenAI device-auth endpoints; reuse the helper.
1857
+ sid, _ = _new_oauth_session("openai-codex", "device_code")
1858
+ # Use the helper but in a thread because it polls inline.
1859
+ # We can't extract just the start step without refactoring auth.py,
1860
+ # so we run the full helper in a worker and proxy the user_code +
1861
+ # verification_url back via the session dict. The helper prints
1862
+ # to stdout — we capture nothing here, just status.
1863
+ threading.Thread(
1864
+ target=_codex_full_login_worker, args=(sid,), daemon=True,
1865
+ name=f"oauth-codex-{sid[:6]}",
1866
+ ).start()
1867
+ # Block briefly until the worker has populated the user_code, OR error.
1868
+ deadline = time.monotonic() + 10
1869
+ while time.monotonic() < deadline:
1870
+ with _oauth_sessions_lock:
1871
+ s = _oauth_sessions.get(sid)
1872
+ if s and (s.get("user_code") or s["status"] != "pending"):
1873
+ break
1874
+ await asyncio.sleep(0.1)
1875
+ with _oauth_sessions_lock:
1876
+ s = _oauth_sessions.get(sid, {})
1877
+ if s.get("status") == "error":
1878
+ raise HTTPException(status_code=500, detail=s.get("error_message") or "device-auth failed")
1879
+ if not s.get("user_code"):
1880
+ raise HTTPException(status_code=504, detail="device-auth timed out before returning a user code")
1881
+ return {
1882
+ "session_id": sid,
1883
+ "flow": "device_code",
1884
+ "user_code": s["user_code"],
1885
+ "verification_url": s["verification_url"],
1886
+ "expires_in": int(s.get("expires_in") or 900),
1887
+ "poll_interval": int(s.get("interval") or 5),
1888
+ }
1889
+
1890
+ if provider_id == "minimax-oauth":
1891
+ # MiniMax uses a device-code-style flow (verification URI + user
1892
+ # code + background poll) with a PKCE extension on top. From the
1893
+ # operator's perspective it's identical to Nous's device-code
1894
+ # flow; the PKCE bit (verifier + challenge from
1895
+ # _minimax_pkce_pair) is a security extension that binds the
1896
+ # token exchange to the original session.
1897
+ from hermes_cli.auth import (
1898
+ _minimax_pkce_pair,
1899
+ _minimax_request_user_code,
1900
+ MINIMAX_OAUTH_CLIENT_ID,
1901
+ MINIMAX_OAUTH_GLOBAL_BASE,
1902
+ )
1903
+ import httpx
1904
+ verifier, challenge, state = _minimax_pkce_pair()
1905
+ portal_base_url = (
1906
+ os.getenv("MINIMAX_PORTAL_BASE_URL") or MINIMAX_OAUTH_GLOBAL_BASE
1907
+ ).rstrip("/")
1908
+ def _do_minimax_request():
1909
+ with httpx.Client(
1910
+ timeout=httpx.Timeout(15.0),
1911
+ headers={"Accept": "application/json"},
1912
+ follow_redirects=True,
1913
+ ) as client:
1914
+ return _minimax_request_user_code(
1915
+ client=client,
1916
+ portal_base_url=portal_base_url,
1917
+ client_id=MINIMAX_OAUTH_CLIENT_ID,
1918
+ code_challenge=challenge,
1919
+ state=state,
1920
+ )
1921
+ device_data = await asyncio.get_event_loop().run_in_executor(
1922
+ None, _do_minimax_request
1923
+ )
1924
+ sid, sess = _new_oauth_session("minimax-oauth", "device_code")
1925
+ # The CLI flow names this `interval_ms` because MiniMax's
1926
+ # `interval` field is in milliseconds (defensive default 2000ms
1927
+ # in _minimax_poll_token).
1928
+ interval_raw = device_data.get("interval")
1929
+ sess["interval_ms"] = (
1930
+ int(interval_raw) if interval_raw is not None else None
1931
+ )
1932
+ sess["user_code"] = str(device_data["user_code"])
1933
+ sess["code_verifier"] = verifier
1934
+ sess["state"] = state
1935
+ sess["portal_base_url"] = portal_base_url
1936
+ sess["client_id"] = MINIMAX_OAUTH_CLIENT_ID
1937
+ sess["region"] = "global"
1938
+ # `expired_in` from MiniMax is overloaded — could be a unix-ms
1939
+ # timestamp OR a seconds-from-now duration. Mirror the heuristic
1940
+ # in _minimax_poll_token. Stash the raw value for the poller;
1941
+ # compute a derived expires_at + UI-friendly expires_in seconds.
1942
+ expired_in_raw = int(device_data["expired_in"])
1943
+ sess["expired_in_raw"] = expired_in_raw
1944
+ if expired_in_raw > 1_000_000_000_000: # likely unix-ms
1945
+ expires_at_ts = expired_in_raw / 1000.0
1946
+ expires_in_seconds = max(0, int(expires_at_ts - time.time()))
1947
+ else:
1948
+ expires_at_ts = time.time() + expired_in_raw
1949
+ expires_in_seconds = expired_in_raw
1950
+ sess["expires_at"] = expires_at_ts
1951
+ threading.Thread(
1952
+ target=_minimax_poller,
1953
+ args=(sid,),
1954
+ daemon=True,
1955
+ name=f"oauth-poll-{sid[:6]}",
1956
+ ).start()
1957
+ return {
1958
+ "session_id": sid,
1959
+ "flow": "device_code",
1960
+ "user_code": str(device_data["user_code"]),
1961
+ "verification_url": str(device_data["verification_uri"]),
1962
+ "expires_in": expires_in_seconds,
1963
+ "poll_interval": max(2, (sess["interval_ms"] or 2000) // 1000),
1964
+ }
1965
+
1966
+ raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow")
1967
+
1968
+
1969
+ def _nous_poller(session_id: str) -> None:
1970
+ """Background poller that drives a Nous device-code flow to completion."""
1971
+ from hermes_cli.auth import _poll_for_token, refresh_nous_oauth_from_state
1972
+ from datetime import datetime, timezone
1973
+ import httpx
1974
+ with _oauth_sessions_lock:
1975
+ sess = _oauth_sessions.get(session_id)
1976
+ if not sess:
1977
+ return
1978
+ portal_base_url = sess["portal_base_url"]
1979
+ client_id = sess["client_id"]
1980
+ device_code = sess["device_code"]
1981
+ interval = sess["interval"]
1982
+ expires_in = max(60, int(sess["expires_at"] - time.time()))
1983
+ try:
1984
+ with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client:
1985
+ token_data = _poll_for_token(
1986
+ client=client,
1987
+ portal_base_url=portal_base_url,
1988
+ client_id=client_id,
1989
+ device_code=device_code,
1990
+ expires_in=expires_in,
1991
+ poll_interval=interval,
1992
+ )
1993
+ # Same post-processing as _nous_device_code_login (mint agent key)
1994
+ now = datetime.now(timezone.utc)
1995
+ token_ttl = int(token_data.get("expires_in") or 0)
1996
+ auth_state = {
1997
+ "portal_base_url": portal_base_url,
1998
+ "inference_base_url": token_data.get("inference_base_url"),
1999
+ "client_id": client_id,
2000
+ "scope": token_data.get("scope"),
2001
+ "token_type": token_data.get("token_type", "Bearer"),
2002
+ "access_token": token_data["access_token"],
2003
+ "refresh_token": token_data.get("refresh_token"),
2004
+ "obtained_at": now.isoformat(),
2005
+ "expires_at": (
2006
+ datetime.fromtimestamp(now.timestamp() + token_ttl, tz=timezone.utc).isoformat()
2007
+ if token_ttl else None
2008
+ ),
2009
+ "expires_in": token_ttl,
2010
+ }
2011
+ full_state = refresh_nous_oauth_from_state(
2012
+ auth_state, min_key_ttl_seconds=300, timeout_seconds=15.0,
2013
+ force_refresh=False, force_mint=True,
2014
+ )
2015
+ from hermes_cli.auth import persist_nous_credentials
2016
+ persist_nous_credentials(full_state)
2017
+ with _oauth_sessions_lock:
2018
+ sess["status"] = "approved"
2019
+ _log.info("oauth/device: nous login completed (session=%s)", session_id)
2020
+ except Exception as e:
2021
+ _log.warning("nous device-code poll failed (session=%s): %s", session_id, e)
2022
+ with _oauth_sessions_lock:
2023
+ sess["status"] = "error"
2024
+ sess["error_message"] = str(e)
2025
+
2026
+
2027
+ def _minimax_poller(session_id: str) -> None:
2028
+ """Background poller that drives a MiniMax OAuth flow to completion.
2029
+
2030
+ Mirrors `_nous_poller` but calls the MiniMax-specific token endpoint,
2031
+ which uses a PKCE-style ``code_verifier`` + ``user_code`` rather than
2032
+ the ``device_code`` field used by Nous. On success, builds the same
2033
+ auth_state dict that ``_minimax_oauth_login`` (the CLI flow) builds
2034
+ and persists via ``_minimax_save_auth_state`` — so the dashboard
2035
+ path leaves the system in the same state as
2036
+ ``hermes auth add minimax-oauth``.
2037
+ """
2038
+ from hermes_cli.auth import (
2039
+ _minimax_poll_token,
2040
+ _minimax_resolve_token_expiry_unix,
2041
+ _minimax_save_auth_state,
2042
+ MINIMAX_OAUTH_GLOBAL_INFERENCE,
2043
+ MINIMAX_OAUTH_SCOPE,
2044
+ )
2045
+ from datetime import datetime, timezone
2046
+ import httpx
2047
+ with _oauth_sessions_lock:
2048
+ sess = _oauth_sessions.get(session_id)
2049
+ if not sess:
2050
+ return
2051
+ portal_base_url = sess["portal_base_url"]
2052
+ client_id = sess["client_id"]
2053
+ user_code = sess["user_code"]
2054
+ code_verifier = sess["code_verifier"]
2055
+ interval_ms = sess.get("interval_ms")
2056
+ expired_in_raw = sess["expired_in_raw"]
2057
+ try:
2058
+ with httpx.Client(
2059
+ timeout=httpx.Timeout(15.0),
2060
+ headers={"Accept": "application/json"},
2061
+ follow_redirects=True,
2062
+ ) as client:
2063
+ token_data = _minimax_poll_token(
2064
+ client=client,
2065
+ portal_base_url=portal_base_url,
2066
+ client_id=client_id,
2067
+ user_code=user_code,
2068
+ code_verifier=code_verifier,
2069
+ expired_in=expired_in_raw,
2070
+ interval_ms=interval_ms,
2071
+ )
2072
+ # Build the auth_state dict in the same shape as the CLI flow's
2073
+ # `_minimax_oauth_login` so `_minimax_save_auth_state` writes
2074
+ # the canonical record. Region is fixed to "global" for the
2075
+ # dashboard path; cn-region operators can still use the CLI
2076
+ # flow which supports `--region cn`.
2077
+ now = datetime.now(timezone.utc)
2078
+ expires_at_ts = _minimax_resolve_token_expiry_unix(
2079
+ int(token_data["expired_in"]), now=now,
2080
+ )
2081
+ expires_in_s = max(0, int(expires_at_ts - now.timestamp()))
2082
+ auth_state = {
2083
+ "provider": "minimax-oauth",
2084
+ "region": sess.get("region", "global"),
2085
+ "portal_base_url": portal_base_url,
2086
+ "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE,
2087
+ "client_id": client_id,
2088
+ "scope": MINIMAX_OAUTH_SCOPE,
2089
+ "token_type": token_data.get("token_type", "Bearer"),
2090
+ "access_token": token_data["access_token"],
2091
+ "refresh_token": token_data["refresh_token"],
2092
+ "resource_url": token_data.get("resource_url"),
2093
+ "obtained_at": now.isoformat(),
2094
+ "expires_at": datetime.fromtimestamp(
2095
+ expires_at_ts, tz=timezone.utc
2096
+ ).isoformat(),
2097
+ "expires_in": expires_in_s,
2098
+ }
2099
+ _minimax_save_auth_state(auth_state)
2100
+ with _oauth_sessions_lock:
2101
+ sess["status"] = "approved"
2102
+ _log.info("oauth/device: minimax login completed (session=%s)", session_id)
2103
+ except Exception as e:
2104
+ _log.warning("minimax device-code poll failed (session=%s): %s", session_id, e)
2105
+ with _oauth_sessions_lock:
2106
+ sess["status"] = "error"
2107
+ sess["error_message"] = str(e)
2108
+
2109
+
2110
+ def _codex_full_login_worker(session_id: str) -> None:
2111
+ """Run the complete OpenAI Codex device-code flow.
2112
+
2113
+ Codex doesn't use the standard OAuth device-code endpoints; it has its
2114
+ own ``/api/accounts/deviceauth/usercode`` (JSON body, returns
2115
+ ``device_auth_id``) and ``/api/accounts/deviceauth/token`` (JSON body
2116
+ polled until 200). On success the response carries an
2117
+ ``authorization_code`` + ``code_verifier`` that get exchanged at
2118
+ CODEX_OAUTH_TOKEN_URL with grant_type=authorization_code.
2119
+
2120
+ The flow is replicated inline (rather than calling
2121
+ _codex_device_code_login) because that helper prints/blocks/polls in a
2122
+ single function — we need to surface the user_code to the dashboard the
2123
+ moment we receive it, well before polling completes.
2124
+ """
2125
+ try:
2126
+ import httpx
2127
+ from hermes_cli.auth import (
2128
+ CODEX_OAUTH_CLIENT_ID,
2129
+ CODEX_OAUTH_TOKEN_URL,
2130
+ DEFAULT_CODEX_BASE_URL,
2131
+ )
2132
+ issuer = "https://auth.openai.com"
2133
+
2134
+ # Step 1: request device code
2135
+ with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
2136
+ resp = client.post(
2137
+ f"{issuer}/api/accounts/deviceauth/usercode",
2138
+ json={"client_id": CODEX_OAUTH_CLIENT_ID},
2139
+ headers={"Content-Type": "application/json"},
2140
+ )
2141
+ if resp.status_code != 200:
2142
+ raise RuntimeError(f"deviceauth/usercode returned {resp.status_code}")
2143
+ device_data = resp.json()
2144
+ user_code = device_data.get("user_code", "")
2145
+ device_auth_id = device_data.get("device_auth_id", "")
2146
+ poll_interval = max(3, int(device_data.get("interval", "5")))
2147
+ if not user_code or not device_auth_id:
2148
+ raise RuntimeError("device-code response missing user_code or device_auth_id")
2149
+ verification_url = f"{issuer}/codex/device"
2150
+ with _oauth_sessions_lock:
2151
+ sess = _oauth_sessions.get(session_id)
2152
+ if not sess:
2153
+ return
2154
+ sess["user_code"] = user_code
2155
+ sess["verification_url"] = verification_url
2156
+ sess["device_auth_id"] = device_auth_id
2157
+ sess["interval"] = poll_interval
2158
+ sess["expires_in"] = 15 * 60 # OpenAI's effective limit
2159
+ sess["expires_at"] = time.time() + sess["expires_in"]
2160
+
2161
+ # Step 2: poll until authorized
2162
+ deadline = time.monotonic() + sess["expires_in"]
2163
+ code_resp = None
2164
+ with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
2165
+ while time.monotonic() < deadline:
2166
+ time.sleep(poll_interval)
2167
+ poll = client.post(
2168
+ f"{issuer}/api/accounts/deviceauth/token",
2169
+ json={"device_auth_id": device_auth_id, "user_code": user_code},
2170
+ headers={"Content-Type": "application/json"},
2171
+ )
2172
+ if poll.status_code == 200:
2173
+ code_resp = poll.json()
2174
+ break
2175
+ if poll.status_code in {403, 404}:
2176
+ continue # user hasn't authorized yet
2177
+ raise RuntimeError(f"deviceauth/token poll returned {poll.status_code}")
2178
+
2179
+ if code_resp is None:
2180
+ with _oauth_sessions_lock:
2181
+ sess["status"] = "expired"
2182
+ sess["error_message"] = "Device code expired before approval"
2183
+ return
2184
+
2185
+ # Step 3: exchange authorization_code for tokens
2186
+ authorization_code = code_resp.get("authorization_code", "")
2187
+ code_verifier = code_resp.get("code_verifier", "")
2188
+ if not authorization_code or not code_verifier:
2189
+ raise RuntimeError("device-auth response missing authorization_code/code_verifier")
2190
+ with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
2191
+ token_resp = client.post(
2192
+ CODEX_OAUTH_TOKEN_URL,
2193
+ data={
2194
+ "grant_type": "authorization_code",
2195
+ "code": authorization_code,
2196
+ "redirect_uri": f"{issuer}/deviceauth/callback",
2197
+ "client_id": CODEX_OAUTH_CLIENT_ID,
2198
+ "code_verifier": code_verifier,
2199
+ },
2200
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
2201
+ )
2202
+ if token_resp.status_code != 200:
2203
+ raise RuntimeError(f"token exchange returned {token_resp.status_code}")
2204
+ tokens = token_resp.json()
2205
+ access_token = tokens.get("access_token", "")
2206
+ refresh_token = tokens.get("refresh_token", "")
2207
+ if not access_token:
2208
+ raise RuntimeError("token exchange did not return access_token")
2209
+
2210
+ # Persist via credential pool — same shape as auth_commands.add_command
2211
+ from agent.credential_pool import (
2212
+ PooledCredential,
2213
+ load_pool,
2214
+ AUTH_TYPE_OAUTH,
2215
+ SOURCE_MANUAL,
2216
+ )
2217
+ import uuid as _uuid
2218
+ pool = load_pool("openai-codex")
2219
+ base_url = (
2220
+ os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/")
2221
+ or DEFAULT_CODEX_BASE_URL
2222
+ )
2223
+ entry = PooledCredential(
2224
+ provider="openai-codex",
2225
+ id=_uuid.uuid4().hex[:6],
2226
+ label="dashboard device_code",
2227
+ auth_type=AUTH_TYPE_OAUTH,
2228
+ priority=0,
2229
+ source=f"{SOURCE_MANUAL}:dashboard_device_code",
2230
+ access_token=access_token,
2231
+ refresh_token=refresh_token,
2232
+ base_url=base_url,
2233
+ )
2234
+ pool.add_entry(entry)
2235
+ with _oauth_sessions_lock:
2236
+ sess["status"] = "approved"
2237
+ _log.info("oauth/device: openai-codex login completed (session=%s)", session_id)
2238
+ except Exception as e:
2239
+ _log.warning("codex device-code worker failed (session=%s): %s", session_id, e)
2240
+ with _oauth_sessions_lock:
2241
+ s = _oauth_sessions.get(session_id)
2242
+ if s:
2243
+ s["status"] = "error"
2244
+ s["error_message"] = str(e)
2245
+
2246
+
2247
+ @app.post("/api/providers/oauth/{provider_id}/start")
2248
+ async def start_oauth_login(provider_id: str, request: Request):
2249
+ """Initiate an OAuth login flow. Token-protected."""
2250
+ _require_token(request)
2251
+ _gc_oauth_sessions()
2252
+ valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
2253
+ if provider_id not in valid:
2254
+ raise HTTPException(status_code=400, detail=f"Unknown provider {provider_id}")
2255
+ catalog_entry = next(p for p in _OAUTH_PROVIDER_CATALOG if p["id"] == provider_id)
2256
+ if catalog_entry["flow"] == "external":
2257
+ raise HTTPException(
2258
+ status_code=400,
2259
+ detail=f"{provider_id} uses an external CLI; run `{catalog_entry['cli_command']}` manually",
2260
+ )
2261
+ try:
2262
+ # The pkce branch is gated on provider_id == "anthropic" because
2263
+ # `_start_anthropic_pkce()` is hardcoded to the Anthropic flow.
2264
+ # Routing any other future pkce-flagged provider through it would
2265
+ # silently launch the Anthropic OAuth flow (the bug fixed in this
2266
+ # change for MiniMax). New PKCE providers must add their own
2267
+ # start function and an explicit branch here.
2268
+ if catalog_entry["flow"] == "pkce" and provider_id == "anthropic":
2269
+ return _start_anthropic_pkce()
2270
+ if catalog_entry["flow"] == "device_code":
2271
+ return await _start_device_code_flow(provider_id)
2272
+ except HTTPException:
2273
+ raise
2274
+ except Exception as e:
2275
+ _log.exception("oauth/start %s failed", provider_id)
2276
+ raise HTTPException(status_code=500, detail=str(e))
2277
+ raise HTTPException(status_code=400, detail="Unsupported flow")
2278
+
2279
+
2280
+ class OAuthSubmitBody(BaseModel):
2281
+ session_id: str
2282
+ code: str
2283
+
2284
+
2285
+ @app.post("/api/providers/oauth/{provider_id}/submit")
2286
+ async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Request):
2287
+ """Submit the auth code for PKCE flows. Token-protected."""
2288
+ _require_token(request)
2289
+ if provider_id == "anthropic":
2290
+ return await asyncio.get_running_loop().run_in_executor(
2291
+ None, _submit_anthropic_pkce, body.session_id, body.code,
2292
+ )
2293
+ raise HTTPException(status_code=400, detail=f"submit not supported for {provider_id}")
2294
+
2295
+
2296
+ @app.get("/api/providers/oauth/{provider_id}/poll/{session_id}")
2297
+ async def poll_oauth_session(provider_id: str, session_id: str):
2298
+ """Poll a device-code session's status (no auth — read-only state)."""
2299
+ with _oauth_sessions_lock:
2300
+ sess = _oauth_sessions.get(session_id)
2301
+ if not sess:
2302
+ raise HTTPException(status_code=404, detail="Session not found or expired")
2303
+ if sess["provider"] != provider_id:
2304
+ raise HTTPException(status_code=400, detail="Provider mismatch for session")
2305
+ return {
2306
+ "session_id": session_id,
2307
+ "status": sess["status"],
2308
+ "error_message": sess.get("error_message"),
2309
+ "expires_at": sess.get("expires_at"),
2310
+ }
2311
+
2312
+
2313
+ @app.delete("/api/providers/oauth/sessions/{session_id}")
2314
+ async def cancel_oauth_session(session_id: str, request: Request):
2315
+ """Cancel a pending OAuth session. Token-protected."""
2316
+ _require_token(request)
2317
+ with _oauth_sessions_lock:
2318
+ sess = _oauth_sessions.pop(session_id, None)
2319
+ if sess is None:
2320
+ return {"ok": False, "message": "session not found"}
2321
+ return {"ok": True, "session_id": session_id}
2322
+
2323
+
2324
+ # ---------------------------------------------------------------------------
2325
+ # Session detail endpoints
2326
+ # ---------------------------------------------------------------------------
2327
+
2328
+
2329
+
2330
+ def _session_latest_descendant(session_id: str):
2331
+ """Resolve a session id to the newest child leaf session.
2332
+
2333
+ /model may create child sessions. Dashboard refresh should continue the
2334
+ newest child instead of reopening the old parent.
2335
+ """
2336
+ from calvyn_state import SessionDB
2337
+
2338
+ def row_get(row, key, index):
2339
+ if isinstance(row, dict):
2340
+ return row.get(key)
2341
+ try:
2342
+ return row[key]
2343
+ except Exception:
2344
+ try:
2345
+ return row[index]
2346
+ except Exception:
2347
+ return None
2348
+
2349
+ db = SessionDB()
2350
+ try:
2351
+ sid = db.resolve_session_id(session_id)
2352
+ if not sid or not db.get_session(sid):
2353
+ return None, []
2354
+
2355
+ conn = (
2356
+ getattr(db, "conn", None)
2357
+ or getattr(db, "_conn", None)
2358
+ or getattr(db, "connection", None)
2359
+ or getattr(db, "_connection", None)
2360
+ )
2361
+
2362
+ rows = []
2363
+ if conn is not None:
2364
+ raw_rows = conn.execute(
2365
+ "SELECT id, parent_session_id, started_at FROM sessions"
2366
+ ).fetchall()
2367
+ for row in raw_rows:
2368
+ rows.append({
2369
+ "id": row_get(row, "id", 0),
2370
+ "parent_session_id": row_get(row, "parent_session_id", 1),
2371
+ "started_at": row_get(row, "started_at", 2),
2372
+ })
2373
+ else:
2374
+ rows = db.list_sessions_rich(limit=10000, offset=0)
2375
+
2376
+ children = {}
2377
+ for row in rows:
2378
+ rid = row.get("id")
2379
+ parent = row.get("parent_session_id")
2380
+ if rid and parent:
2381
+ children.setdefault(parent, []).append(row)
2382
+
2383
+ def started(row):
2384
+ try:
2385
+ return float(row.get("started_at") or 0)
2386
+ except Exception:
2387
+ return 0.0
2388
+
2389
+ current = sid
2390
+ path = [sid]
2391
+ seen = {sid}
2392
+
2393
+ while children.get(current):
2394
+ candidates = [r for r in children[current] if r.get("id") not in seen]
2395
+ if not candidates:
2396
+ break
2397
+ candidates.sort(key=started, reverse=True)
2398
+ current = candidates[0]["id"]
2399
+ path.append(current)
2400
+ seen.add(current)
2401
+
2402
+ return current, path
2403
+ finally:
2404
+ db.close()
2405
+
2406
+ @app.get("/api/sessions/{session_id}")
2407
+ async def get_session_detail(session_id: str):
2408
+ from calvyn_state import SessionDB
2409
+ db = SessionDB()
2410
+ try:
2411
+ sid = db.resolve_session_id(session_id)
2412
+ session = db.get_session(sid) if sid else None
2413
+ if not session:
2414
+ raise HTTPException(status_code=404, detail="Session not found")
2415
+ return session
2416
+ finally:
2417
+ db.close()
2418
+
2419
+
2420
+
2421
+ @app.get("/api/sessions/{session_id}/latest-descendant")
2422
+ async def get_session_latest_descendant(session_id: str):
2423
+ latest, path = _session_latest_descendant(session_id)
2424
+ if not latest:
2425
+ raise HTTPException(status_code=404, detail="Session not found")
2426
+ return {
2427
+ "requested_session_id": path[0] if path else session_id,
2428
+ "session_id": latest,
2429
+ "path": path,
2430
+ "changed": bool(path and latest != path[0]),
2431
+ }
2432
+
2433
+ @app.get("/api/sessions/{session_id}/messages")
2434
+ async def get_session_messages(session_id: str):
2435
+ from calvyn_state import SessionDB
2436
+ db = SessionDB()
2437
+ try:
2438
+ sid = db.resolve_session_id(session_id)
2439
+ if not sid:
2440
+ raise HTTPException(status_code=404, detail="Session not found")
2441
+ messages = db.get_messages(sid)
2442
+ return {"session_id": sid, "messages": messages}
2443
+ finally:
2444
+ db.close()
2445
+
2446
+
2447
+ @app.delete("/api/sessions/{session_id}")
2448
+ async def delete_session_endpoint(session_id: str):
2449
+ from calvyn_state import SessionDB
2450
+ db = SessionDB()
2451
+ try:
2452
+ if not db.delete_session(session_id):
2453
+ raise HTTPException(status_code=404, detail="Session not found")
2454
+ return {"ok": True}
2455
+ finally:
2456
+ db.close()
2457
+
2458
+
2459
+ # ---------------------------------------------------------------------------
2460
+ # Log viewer endpoint
2461
+ # ---------------------------------------------------------------------------
2462
+
2463
+
2464
+ @app.get("/api/logs")
2465
+ async def get_logs(
2466
+ file: str = "agent",
2467
+ lines: int = 100,
2468
+ level: Optional[str] = None,
2469
+ component: Optional[str] = None,
2470
+ search: Optional[str] = None,
2471
+ ):
2472
+ from hermes_cli.logs import _read_tail, LOG_FILES
2473
+
2474
+ log_name = LOG_FILES.get(file)
2475
+ if not log_name:
2476
+ raise HTTPException(status_code=400, detail=f"Unknown log file: {file}")
2477
+ log_path = get_hermes_home() / "logs" / log_name
2478
+ if not log_path.exists():
2479
+ return {"file": file, "lines": []}
2480
+
2481
+ try:
2482
+ from calvyn_logging import COMPONENT_PREFIXES
2483
+ except ImportError:
2484
+ COMPONENT_PREFIXES = {}
2485
+
2486
+ # Normalize "ALL" / "all" / empty → no filter. _matches_filters treats an
2487
+ # empty tuple as "must match a prefix" (startswith(()) is always False),
2488
+ # so passing () instead of None silently drops every line.
2489
+ min_level = level if level and level.upper() != "ALL" else None
2490
+ if component and component.lower() != "all":
2491
+ comp_prefixes = COMPONENT_PREFIXES.get(component)
2492
+ if comp_prefixes is None:
2493
+ raise HTTPException(
2494
+ status_code=400,
2495
+ detail=f"Unknown component: {component}. "
2496
+ f"Available: {', '.join(sorted(COMPONENT_PREFIXES))}",
2497
+ )
2498
+ else:
2499
+ comp_prefixes = None
2500
+
2501
+ has_filters = bool(min_level or comp_prefixes or search)
2502
+ result = _read_tail(
2503
+ log_path, min(lines, 500) if not search else 2000,
2504
+ has_filters=has_filters,
2505
+ min_level=min_level,
2506
+ component_prefixes=comp_prefixes,
2507
+ )
2508
+ # Post-filter by search term (case-insensitive substring match).
2509
+ # _read_tail doesn't support free-text search, so we filter here and
2510
+ # trim to the requested line count afterward.
2511
+ if search:
2512
+ needle = search.lower()
2513
+ result = [l for l in result if needle in l.lower()][-min(lines, 500):]
2514
+ return {"file": file, "lines": result}
2515
+
2516
+
2517
+ # ---------------------------------------------------------------------------
2518
+ # Cron job management endpoints
2519
+ # ---------------------------------------------------------------------------
2520
+
2521
+
2522
+ class CronJobCreate(BaseModel):
2523
+ prompt: str
2524
+ schedule: str
2525
+ name: str = ""
2526
+ deliver: str = "local"
2527
+
2528
+
2529
+ class CronJobUpdate(BaseModel):
2530
+ updates: dict
2531
+
2532
+
2533
+ @app.get("/api/cron/jobs")
2534
+ async def list_cron_jobs():
2535
+ from cron.jobs import list_jobs
2536
+ return list_jobs(include_disabled=True)
2537
+
2538
+
2539
+ @app.get("/api/cron/jobs/{job_id}")
2540
+ async def get_cron_job(job_id: str):
2541
+ from cron.jobs import get_job
2542
+ job = get_job(job_id)
2543
+ if not job:
2544
+ raise HTTPException(status_code=404, detail="Job not found")
2545
+ return job
2546
+
2547
+
2548
+ @app.post("/api/cron/jobs")
2549
+ async def create_cron_job(body: CronJobCreate):
2550
+ from cron.jobs import create_job
2551
+ try:
2552
+ job = create_job(prompt=body.prompt, schedule=body.schedule,
2553
+ name=body.name, deliver=body.deliver)
2554
+ return job
2555
+ except Exception as e:
2556
+ _log.exception("POST /api/cron/jobs failed")
2557
+ raise HTTPException(status_code=400, detail=str(e))
2558
+
2559
+
2560
+ @app.put("/api/cron/jobs/{job_id}")
2561
+ async def update_cron_job(job_id: str, body: CronJobUpdate):
2562
+ from cron.jobs import update_job
2563
+ job = update_job(job_id, body.updates)
2564
+ if not job:
2565
+ raise HTTPException(status_code=404, detail="Job not found")
2566
+ return job
2567
+
2568
+
2569
+ @app.post("/api/cron/jobs/{job_id}/pause")
2570
+ async def pause_cron_job(job_id: str):
2571
+ from cron.jobs import pause_job
2572
+ job = pause_job(job_id)
2573
+ if not job:
2574
+ raise HTTPException(status_code=404, detail="Job not found")
2575
+ return job
2576
+
2577
+
2578
+ @app.post("/api/cron/jobs/{job_id}/resume")
2579
+ async def resume_cron_job(job_id: str):
2580
+ from cron.jobs import resume_job
2581
+ job = resume_job(job_id)
2582
+ if not job:
2583
+ raise HTTPException(status_code=404, detail="Job not found")
2584
+ return job
2585
+
2586
+
2587
+ @app.post("/api/cron/jobs/{job_id}/trigger")
2588
+ async def trigger_cron_job(job_id: str):
2589
+ from cron.jobs import trigger_job
2590
+ job = trigger_job(job_id)
2591
+ if not job:
2592
+ raise HTTPException(status_code=404, detail="Job not found")
2593
+ return job
2594
+
2595
+
2596
+ @app.delete("/api/cron/jobs/{job_id}")
2597
+ async def delete_cron_job(job_id: str):
2598
+ from cron.jobs import remove_job
2599
+ if not remove_job(job_id):
2600
+ raise HTTPException(status_code=404, detail="Job not found")
2601
+ return {"ok": True}
2602
+
2603
+
2604
+ # ---------------------------------------------------------------------------
2605
+ # Profile management endpoints (minimal — list/create/rename/delete + SOUL.md)
2606
+ # ---------------------------------------------------------------------------
2607
+
2608
+
2609
+ class ProfileCreate(BaseModel):
2610
+ name: str
2611
+ clone_from_default: bool = False
2612
+ no_skills: bool = False
2613
+
2614
+
2615
+ class ProfileRename(BaseModel):
2616
+ new_name: str
2617
+
2618
+
2619
+ class ProfileSoulUpdate(BaseModel):
2620
+ content: str
2621
+
2622
+
2623
+ def _profile_attr(info, name: str, default: Any = None) -> Any:
2624
+ try:
2625
+ return getattr(info, name)
2626
+ except Exception:
2627
+ return default
2628
+
2629
+
2630
+ def _profile_to_dict(info) -> Dict[str, Any]:
2631
+ return {
2632
+ "name": _profile_attr(info, "name", ""),
2633
+ "path": str(_profile_attr(info, "path", "")),
2634
+ "is_default": bool(_profile_attr(info, "is_default", False)),
2635
+ "model": _profile_attr(info, "model"),
2636
+ "provider": _profile_attr(info, "provider"),
2637
+ "has_env": bool(_profile_attr(info, "has_env", False)),
2638
+ "skill_count": int(_profile_attr(info, "skill_count", 0) or 0),
2639
+ }
2640
+
2641
+
2642
+ def _fallback_profile_dicts(profiles_mod) -> List[Dict[str, Any]]:
2643
+ def _safe(callable_, default):
2644
+ try:
2645
+ return callable_()
2646
+ except Exception:
2647
+ return default
2648
+
2649
+ profiles: List[Dict[str, Any]] = []
2650
+ default_home = profiles_mod._get_default_hermes_home()
2651
+ if default_home.is_dir():
2652
+ model, provider = _safe(lambda: profiles_mod._read_config_model(default_home), (None, None))
2653
+ profiles.append({
2654
+ "name": "default",
2655
+ "path": str(default_home),
2656
+ "is_default": True,
2657
+ "model": model,
2658
+ "provider": provider,
2659
+ "has_env": (default_home / ".env").exists(),
2660
+ "skill_count": _safe(lambda: profiles_mod._count_skills(default_home), 0),
2661
+ })
2662
+
2663
+ profiles_root = profiles_mod._get_profiles_root()
2664
+ if profiles_root.is_dir():
2665
+ for entry in sorted(profiles_root.iterdir()):
2666
+ if not entry.is_dir() or not profiles_mod._PROFILE_ID_RE.match(entry.name):
2667
+ continue
2668
+ model, provider = _safe(lambda entry=entry: profiles_mod._read_config_model(entry), (None, None))
2669
+ profiles.append({
2670
+ "name": entry.name,
2671
+ "path": str(entry),
2672
+ "is_default": False,
2673
+ "model": model,
2674
+ "provider": provider,
2675
+ "has_env": (entry / ".env").exists(),
2676
+ "skill_count": _safe(lambda entry=entry: profiles_mod._count_skills(entry), 0),
2677
+ })
2678
+
2679
+ return profiles
2680
+
2681
+
2682
+ def _resolve_profile_dir(name: str) -> Path:
2683
+ """Validate ``name`` and resolve to its directory or raise an HTTPException."""
2684
+ from hermes_cli import profiles as profiles_mod
2685
+ try:
2686
+ profiles_mod.validate_profile_name(name)
2687
+ except ValueError as e:
2688
+ raise HTTPException(status_code=400, detail=str(e))
2689
+ if not profiles_mod.profile_exists(name):
2690
+ raise HTTPException(status_code=404, detail=f"Profile '{name}' does not exist.")
2691
+ return profiles_mod.get_profile_dir(name)
2692
+
2693
+
2694
+ def _profile_setup_command(name: str) -> str:
2695
+ """Return the shell command used to configure a profile in the CLI."""
2696
+ _resolve_profile_dir(name)
2697
+ return "hermes setup" if name == "default" else f"{name} setup"
2698
+
2699
+
2700
+ @app.get("/api/profiles")
2701
+ async def list_profiles_endpoint():
2702
+ from hermes_cli import profiles as profiles_mod
2703
+ try:
2704
+ return {"profiles": [_profile_to_dict(p) for p in profiles_mod.list_profiles()]}
2705
+ except Exception:
2706
+ _log.exception("GET /api/profiles failed; falling back to profile directory scan")
2707
+ return {"profiles": _fallback_profile_dicts(profiles_mod)}
2708
+
2709
+
2710
+ @app.post("/api/profiles")
2711
+ async def create_profile_endpoint(body: ProfileCreate):
2712
+ from hermes_cli import profiles as profiles_mod
2713
+ try:
2714
+ path = profiles_mod.create_profile(
2715
+ name=body.name,
2716
+ clone_from="default" if body.clone_from_default else None,
2717
+ clone_config=body.clone_from_default,
2718
+ no_skills=body.no_skills,
2719
+ )
2720
+ # Match the CLI's profile-create flow: fresh named profiles get the
2721
+ # bundled skills installed. When cloning from default, create_profile()
2722
+ # has already copied the source profile's skills, including any
2723
+ # user-installed skills. When no_skills=True, create_profile() wrote
2724
+ # the opt-out marker and seed_profile_skills() will no-op.
2725
+ if not body.clone_from_default:
2726
+ profiles_mod.seed_profile_skills(path, quiet=True)
2727
+
2728
+ # Match the CLI's profile-create flow: named profiles should get a
2729
+ # wrapper in ~/.local/bin when the alias is safe to create.
2730
+ collision = profiles_mod.check_alias_collision(body.name)
2731
+ if not collision:
2732
+ profiles_mod.create_wrapper_script(body.name)
2733
+ except (ValueError, FileExistsError, FileNotFoundError) as e:
2734
+ raise HTTPException(status_code=400, detail=str(e))
2735
+ except Exception as e:
2736
+ _log.exception("POST /api/profiles failed")
2737
+ raise HTTPException(status_code=500, detail=str(e))
2738
+ return {"ok": True, "name": body.name, "path": str(path)}
2739
+
2740
+
2741
+ @app.get("/api/profiles/{name}/setup-command")
2742
+ async def get_profile_setup_command(name: str):
2743
+ return {"command": _profile_setup_command(name)}
2744
+
2745
+
2746
+ @app.post("/api/profiles/{name}/open-terminal")
2747
+ async def open_profile_terminal_endpoint(name: str):
2748
+ try:
2749
+ command = _profile_setup_command(name)
2750
+
2751
+ if sys.platform.startswith("win"):
2752
+ subprocess.Popen(["cmd.exe", "/c", "start", "", command])
2753
+ elif sys.platform == "darwin":
2754
+ escaped = command.replace("\\", "\\\\").replace('"', '\\"')
2755
+ applescript = (
2756
+ 'tell application "Terminal"\n'
2757
+ "activate\n"
2758
+ f'do script "{escaped}"\n'
2759
+ "end tell"
2760
+ )
2761
+ subprocess.Popen(["osascript", "-e", applescript])
2762
+ else:
2763
+ terminal_commands = [
2764
+ ("x-terminal-emulator", ["x-terminal-emulator", "-e", "sh", "-lc", command]),
2765
+ ("gnome-terminal", ["gnome-terminal", "--", "sh", "-lc", command]),
2766
+ ("konsole", ["konsole", "-e", "sh", "-lc", command]),
2767
+ ("xfce4-terminal", ["xfce4-terminal", "-e", f"sh -lc '{command}'"]),
2768
+ ("mate-terminal", ["mate-terminal", "-e", f"sh -lc '{command}'"]),
2769
+ ("lxterminal", ["lxterminal", "-e", f"sh -lc '{command}'"]),
2770
+ ("tilix", ["tilix", "-e", "sh", "-lc", command]),
2771
+ ("alacritty", ["alacritty", "-e", "sh", "-lc", command]),
2772
+ ("kitty", ["kitty", "sh", "-lc", command]),
2773
+ ("xterm", ["xterm", "-e", "sh", "-lc", command]),
2774
+ ]
2775
+ for executable, popen_args in terminal_commands:
2776
+ if subprocess.call(
2777
+ ["which", executable],
2778
+ stdout=subprocess.DEVNULL,
2779
+ stderr=subprocess.DEVNULL,
2780
+ ) == 0:
2781
+ subprocess.Popen(popen_args)
2782
+ break
2783
+ else:
2784
+ raise HTTPException(
2785
+ status_code=400,
2786
+ detail="No supported terminal emulator found",
2787
+ )
2788
+ except FileNotFoundError as e:
2789
+ raise HTTPException(status_code=404, detail=str(e))
2790
+ except ValueError as e:
2791
+ raise HTTPException(status_code=400, detail=str(e))
2792
+ except HTTPException:
2793
+ raise
2794
+ except Exception as e:
2795
+ _log.exception("POST /api/profiles/%s/open-terminal failed", name)
2796
+ raise HTTPException(status_code=500, detail=str(e))
2797
+ return {"ok": True, "command": command}
2798
+
2799
+
2800
+ @app.patch("/api/profiles/{name}")
2801
+ async def rename_profile_endpoint(name: str, body: ProfileRename):
2802
+ from hermes_cli import profiles as profiles_mod
2803
+ try:
2804
+ path = profiles_mod.rename_profile(name, body.new_name)
2805
+ except FileNotFoundError as e:
2806
+ raise HTTPException(status_code=404, detail=str(e))
2807
+ except (ValueError, FileExistsError) as e:
2808
+ raise HTTPException(status_code=400, detail=str(e))
2809
+ except Exception as e:
2810
+ _log.exception("PATCH /api/profiles/%s failed", name)
2811
+ raise HTTPException(status_code=500, detail=str(e))
2812
+ return {"ok": True, "name": body.new_name, "path": str(path)}
2813
+
2814
+
2815
+ @app.delete("/api/profiles/{name}")
2816
+ async def delete_profile_endpoint(name: str):
2817
+ """Delete a profile. The dashboard collects the user's confirmation in
2818
+ its own dialog before this request, so we always pass ``yes=True`` to
2819
+ skip the CLI's interactive prompt."""
2820
+ from hermes_cli import profiles as profiles_mod
2821
+ try:
2822
+ path = profiles_mod.delete_profile(name, yes=True)
2823
+ except FileNotFoundError as e:
2824
+ raise HTTPException(status_code=404, detail=str(e))
2825
+ except ValueError as e:
2826
+ raise HTTPException(status_code=400, detail=str(e))
2827
+ except Exception as e:
2828
+ _log.exception("DELETE /api/profiles/%s failed", name)
2829
+ raise HTTPException(status_code=500, detail=str(e))
2830
+ return {"ok": True, "path": str(path)}
2831
+
2832
+
2833
+ @app.get("/api/profiles/{name}/soul")
2834
+ async def get_profile_soul(name: str):
2835
+ soul_path = _resolve_profile_dir(name) / "SOUL.md"
2836
+ if soul_path.exists():
2837
+ try:
2838
+ return {"content": soul_path.read_text(encoding="utf-8"), "exists": True}
2839
+ except OSError as e:
2840
+ raise HTTPException(status_code=500, detail=f"Could not read SOUL.md: {e}")
2841
+ return {"content": "", "exists": False}
2842
+
2843
+
2844
+ @app.put("/api/profiles/{name}/soul")
2845
+ async def update_profile_soul(name: str, body: ProfileSoulUpdate):
2846
+ soul_path = _resolve_profile_dir(name) / "SOUL.md"
2847
+ try:
2848
+ soul_path.write_text(body.content, encoding="utf-8")
2849
+ except OSError as e:
2850
+ _log.exception("PUT /api/profiles/%s/soul failed", name)
2851
+ raise HTTPException(status_code=500, detail=f"Could not write SOUL.md: {e}")
2852
+ return {"ok": True}
2853
+
2854
+
2855
+ # ---------------------------------------------------------------------------
2856
+ # Skills & Tools endpoints
2857
+ # ---------------------------------------------------------------------------
2858
+
2859
+
2860
+ class SkillToggle(BaseModel):
2861
+ name: str
2862
+ enabled: bool
2863
+
2864
+
2865
+ @app.get("/api/skills")
2866
+ async def get_skills():
2867
+ from tools.skills_tool import _find_all_skills
2868
+ from hermes_cli.skills_config import get_disabled_skills
2869
+ config = load_config()
2870
+ disabled = get_disabled_skills(config)
2871
+ skills = _find_all_skills(skip_disabled=True)
2872
+ for s in skills:
2873
+ s["enabled"] = s["name"] not in disabled
2874
+ return skills
2875
+
2876
+
2877
+ @app.put("/api/skills/toggle")
2878
+ async def toggle_skill(body: SkillToggle):
2879
+ from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
2880
+ config = load_config()
2881
+ disabled = get_disabled_skills(config)
2882
+ if body.enabled:
2883
+ disabled.discard(body.name)
2884
+ else:
2885
+ disabled.add(body.name)
2886
+ save_disabled_skills(config, disabled)
2887
+ return {"ok": True, "name": body.name, "enabled": body.enabled}
2888
+
2889
+
2890
+ @app.get("/api/tools/toolsets")
2891
+ async def get_toolsets():
2892
+ from hermes_cli.tools_config import (
2893
+ _get_effective_configurable_toolsets,
2894
+ _get_platform_tools,
2895
+ _toolset_has_keys,
2896
+ )
2897
+ from toolsets import resolve_toolset
2898
+
2899
+ config = load_config()
2900
+ enabled_toolsets = _get_platform_tools(
2901
+ config,
2902
+ "cli",
2903
+ include_default_mcp_servers=False,
2904
+ )
2905
+ result = []
2906
+ for name, label, desc in _get_effective_configurable_toolsets():
2907
+ try:
2908
+ tools = sorted(set(resolve_toolset(name)))
2909
+ except Exception:
2910
+ tools = []
2911
+ is_enabled = name in enabled_toolsets
2912
+ result.append({
2913
+ "name": name, "label": label, "description": desc,
2914
+ "enabled": is_enabled,
2915
+ "available": is_enabled,
2916
+ "configured": _toolset_has_keys(name, config),
2917
+ "tools": tools,
2918
+ })
2919
+ return result
2920
+
2921
+
2922
+ # ---------------------------------------------------------------------------
2923
+ # Raw YAML config endpoint
2924
+ # ---------------------------------------------------------------------------
2925
+
2926
+
2927
+ class RawConfigUpdate(BaseModel):
2928
+ yaml_text: str
2929
+
2930
+
2931
+ @app.get("/api/config/raw")
2932
+ async def get_config_raw():
2933
+ path = get_config_path()
2934
+ if not path.exists():
2935
+ return {"yaml": ""}
2936
+ return {"yaml": path.read_text(encoding="utf-8")}
2937
+
2938
+
2939
+ @app.put("/api/config/raw")
2940
+ async def update_config_raw(body: RawConfigUpdate):
2941
+ try:
2942
+ parsed = yaml.safe_load(body.yaml_text)
2943
+ if not isinstance(parsed, dict):
2944
+ raise HTTPException(status_code=400, detail="YAML must be a mapping")
2945
+ save_config(parsed)
2946
+ return {"ok": True}
2947
+ except yaml.YAMLError as e:
2948
+ raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}")
2949
+
2950
+
2951
+ # ---------------------------------------------------------------------------
2952
+ # Token / cost analytics endpoint
2953
+ # ---------------------------------------------------------------------------
2954
+
2955
+
2956
+ @app.get("/api/analytics/usage")
2957
+ async def get_usage_analytics(days: int = 30):
2958
+ from calvyn_state import SessionDB
2959
+ from agent.insights import InsightsEngine
2960
+
2961
+ db = SessionDB()
2962
+ try:
2963
+ cutoff = time.time() - (days * 86400)
2964
+ cur = db._conn.execute("""
2965
+ SELECT date(started_at, 'unixepoch') as day,
2966
+ SUM(input_tokens) as input_tokens,
2967
+ SUM(output_tokens) as output_tokens,
2968
+ SUM(cache_read_tokens) as cache_read_tokens,
2969
+ SUM(reasoning_tokens) as reasoning_tokens,
2970
+ COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost,
2971
+ COALESCE(SUM(actual_cost_usd), 0) as actual_cost,
2972
+ COUNT(*) as sessions,
2973
+ SUM(COALESCE(api_call_count, 0)) as api_calls
2974
+ FROM sessions WHERE started_at > ?
2975
+ GROUP BY day ORDER BY day
2976
+ """, (cutoff,))
2977
+ daily = [dict(r) for r in cur.fetchall()]
2978
+
2979
+ cur2 = db._conn.execute("""
2980
+ SELECT model,
2981
+ SUM(input_tokens) as input_tokens,
2982
+ SUM(output_tokens) as output_tokens,
2983
+ COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost,
2984
+ COUNT(*) as sessions,
2985
+ SUM(COALESCE(api_call_count, 0)) as api_calls
2986
+ FROM sessions WHERE started_at > ? AND model IS NOT NULL
2987
+ GROUP BY model ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC
2988
+ """, (cutoff,))
2989
+ by_model = [dict(r) for r in cur2.fetchall()]
2990
+
2991
+ cur3 = db._conn.execute("""
2992
+ SELECT SUM(input_tokens) as total_input,
2993
+ SUM(output_tokens) as total_output,
2994
+ SUM(cache_read_tokens) as total_cache_read,
2995
+ SUM(reasoning_tokens) as total_reasoning,
2996
+ COALESCE(SUM(estimated_cost_usd), 0) as total_estimated_cost,
2997
+ COALESCE(SUM(actual_cost_usd), 0) as total_actual_cost,
2998
+ COUNT(*) as total_sessions,
2999
+ SUM(COALESCE(api_call_count, 0)) as total_api_calls
3000
+ FROM sessions WHERE started_at > ?
3001
+ """, (cutoff,))
3002
+ totals = dict(cur3.fetchone())
3003
+ insights_report = InsightsEngine(db).generate(days=days)
3004
+ skills = insights_report.get("skills", {
3005
+ "summary": {
3006
+ "total_skill_loads": 0,
3007
+ "total_skill_edits": 0,
3008
+ "total_skill_actions": 0,
3009
+ "distinct_skills_used": 0,
3010
+ },
3011
+ "top_skills": [],
3012
+ })
3013
+
3014
+ return {
3015
+ "daily": daily,
3016
+ "by_model": by_model,
3017
+ "totals": totals,
3018
+ "period_days": days,
3019
+ "skills": skills,
3020
+ }
3021
+ finally:
3022
+ db.close()
3023
+
3024
+
3025
+ @app.get("/api/analytics/models")
3026
+ async def get_models_analytics(days: int = 30):
3027
+ """Rich per-model analytics for the Models dashboard page.
3028
+
3029
+ Returns token/cost/session breakdown per model plus capability metadata
3030
+ from models.dev (context window, vision, tools, reasoning, etc.).
3031
+ """
3032
+ from calvyn_state import SessionDB
3033
+
3034
+ db = SessionDB()
3035
+ try:
3036
+ cutoff = time.time() - (days * 86400)
3037
+
3038
+ cur = db._conn.execute("""
3039
+ SELECT model,
3040
+ billing_provider,
3041
+ SUM(input_tokens) as input_tokens,
3042
+ SUM(output_tokens) as output_tokens,
3043
+ SUM(cache_read_tokens) as cache_read_tokens,
3044
+ SUM(reasoning_tokens) as reasoning_tokens,
3045
+ COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost,
3046
+ COALESCE(SUM(actual_cost_usd), 0) as actual_cost,
3047
+ COUNT(*) as sessions,
3048
+ SUM(COALESCE(api_call_count, 0)) as api_calls,
3049
+ SUM(tool_call_count) as tool_calls,
3050
+ MAX(started_at) as last_used_at,
3051
+ AVG(input_tokens + output_tokens) as avg_tokens_per_session
3052
+ FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != ''
3053
+ GROUP BY model, billing_provider
3054
+ ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC
3055
+ """, (cutoff,))
3056
+ rows = [dict(r) for r in cur.fetchall()]
3057
+
3058
+ models = []
3059
+ for row in rows:
3060
+ provider = row.get("billing_provider") or ""
3061
+ model_name = row["model"]
3062
+ caps = {}
3063
+ try:
3064
+ from agent.models_dev import get_model_capabilities
3065
+ mc = get_model_capabilities(provider=provider, model=model_name)
3066
+ if mc is not None:
3067
+ caps = {
3068
+ "supports_tools": mc.supports_tools,
3069
+ "supports_vision": mc.supports_vision,
3070
+ "supports_reasoning": mc.supports_reasoning,
3071
+ "context_window": mc.context_window,
3072
+ "max_output_tokens": mc.max_output_tokens,
3073
+ "model_family": mc.model_family,
3074
+ }
3075
+ except Exception:
3076
+ pass
3077
+
3078
+ models.append({
3079
+ "model": model_name,
3080
+ "provider": provider,
3081
+ "input_tokens": row["input_tokens"],
3082
+ "output_tokens": row["output_tokens"],
3083
+ "cache_read_tokens": row["cache_read_tokens"],
3084
+ "reasoning_tokens": row["reasoning_tokens"],
3085
+ "estimated_cost": row["estimated_cost"],
3086
+ "actual_cost": row["actual_cost"],
3087
+ "sessions": row["sessions"],
3088
+ "api_calls": row["api_calls"],
3089
+ "tool_calls": row["tool_calls"],
3090
+ "last_used_at": row["last_used_at"],
3091
+ "avg_tokens_per_session": row["avg_tokens_per_session"],
3092
+ "capabilities": caps,
3093
+ })
3094
+
3095
+ totals_cur = db._conn.execute("""
3096
+ SELECT COUNT(DISTINCT model) as distinct_models,
3097
+ SUM(input_tokens) as total_input,
3098
+ SUM(output_tokens) as total_output,
3099
+ SUM(cache_read_tokens) as total_cache_read,
3100
+ SUM(reasoning_tokens) as total_reasoning,
3101
+ COALESCE(SUM(estimated_cost_usd), 0) as total_estimated_cost,
3102
+ COALESCE(SUM(actual_cost_usd), 0) as total_actual_cost,
3103
+ COUNT(*) as total_sessions,
3104
+ SUM(COALESCE(api_call_count, 0)) as total_api_calls
3105
+ FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != ''
3106
+ """, (cutoff,))
3107
+ totals = dict(totals_cur.fetchone())
3108
+
3109
+ return {
3110
+ "models": models,
3111
+ "totals": totals,
3112
+ "period_days": days,
3113
+ }
3114
+ finally:
3115
+ db.close()
3116
+
3117
+
3118
+ # ---------------------------------------------------------------------------
3119
+ # /api/pty — PTY-over-WebSocket bridge for the dashboard "Chat" tab.
3120
+ #
3121
+ # The endpoint spawns the same ``hermes --tui`` binary the CLI uses, behind
3122
+ # a POSIX pseudo-terminal, and forwards bytes + resize escapes across a
3123
+ # WebSocket. The browser renders the ANSI through xterm.js (see
3124
+ # web/src/pages/ChatPage.tsx).
3125
+ #
3126
+ # Auth: ``?token=<session_token>`` query param (browsers can't set
3127
+ # Authorization on the WS upgrade). Same ephemeral ``_SESSION_TOKEN`` as
3128
+ # REST. Localhost-only — we defensively reject non-loopback clients even
3129
+ # though uvicorn binds to 127.0.0.1.
3130
+ # ---------------------------------------------------------------------------
3131
+
3132
+ import re
3133
+ import asyncio
3134
+
3135
+ # PTY bridge is POSIX-only (depends on fcntl/termios/ptyprocess). On native
3136
+ # Windows the import raises; catch and leave PtyBridge=None so the rest of
3137
+ # the dashboard (sessions, jobs, metrics, config editor) still loads and the
3138
+ # /api/pty endpoint cleanly refuses with a WSL-suggested message.
3139
+ try:
3140
+ from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError
3141
+ _PTY_BRIDGE_AVAILABLE = True
3142
+ except ImportError as _pty_import_err: # pragma: no cover - Windows-only path
3143
+ PtyBridge = None # type: ignore[assignment]
3144
+ _PTY_BRIDGE_AVAILABLE = False
3145
+
3146
+ class PtyUnavailableError(RuntimeError): # type: ignore[no-redef]
3147
+ """Stub on platforms where pty_bridge can't be imported."""
3148
+ pass
3149
+
3150
+ _RESIZE_RE = re.compile(rb"\x1b\[RESIZE:(\d+);(\d+)\]")
3151
+ _PTY_READ_CHUNK_TIMEOUT = 0.2
3152
+ _VALID_CHANNEL_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
3153
+ # Starlette's TestClient reports the peer as "testclient"; treat it as
3154
+ # loopback so tests don't need to rewrite request scope.
3155
+ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
3156
+
3157
+
3158
+ def _is_public_bind() -> bool:
3159
+ """True when bound to all-interfaces (operator used --insecure)."""
3160
+ return getattr(app.state, "bound_host", "") in {"0.0.0.0", "::"}
3161
+
3162
+
3163
+ def _ws_client_is_allowed(ws: "WebSocket") -> bool:
3164
+ """Check if the WebSocket client IP is acceptable.
3165
+
3166
+ Allows loopback always; allows any IP when bound to all-interfaces
3167
+ (--insecure mode, guarded by session token auth).
3168
+ """
3169
+ if _is_public_bind():
3170
+ return True
3171
+ client_host = ws.client.host if ws.client else ""
3172
+ if not client_host:
3173
+ return True
3174
+ return client_host in _LOOPBACK_HOSTS
3175
+
3176
+ # Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard)
3177
+ # and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
3178
+ # the chat tab generates on mount; entries auto-evict when the last subscriber
3179
+ # drops AND the publisher has disconnected.
3180
+ _event_channels: dict[str, set] = {}
3181
+ _event_lock = asyncio.Lock()
3182
+
3183
+
3184
+ def _resolve_chat_argv(
3185
+ resume: Optional[str] = None,
3186
+ sidecar_url: Optional[str] = None,
3187
+ ) -> tuple[list[str], Optional[str], Optional[dict]]:
3188
+ """Resolve the argv + cwd + env for the chat PTY.
3189
+
3190
+ Default: whatever ``hermes --tui`` would run. Tests monkeypatch this
3191
+ function to inject a tiny fake command (``cat``, ``sh -c 'printf …'``)
3192
+ so nothing has to build Node or the TUI bundle.
3193
+
3194
+ Session resume is propagated via the ``HERMES_TUI_RESUME`` env var —
3195
+ matching what ``hermes_cli.main._launch_tui`` does for the CLI path.
3196
+ Appending ``--resume <id>`` to argv doesn't work because ``ui-tui`` does
3197
+ not parse its argv.
3198
+
3199
+ `sidecar_url` (when set) is forwarded as ``HERMES_TUI_SIDECAR_URL`` so
3200
+ the spawned ``tui_gateway.entry`` can mirror dispatcher emits to the
3201
+ dashboard's ``/api/pub`` endpoint (see :func:`pub_ws`).
3202
+ """
3203
+ from hermes_cli.main import PROJECT_ROOT, _make_tui_argv
3204
+
3205
+ argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False)
3206
+ env = os.environ.copy()
3207
+ env.setdefault("NODE_ENV", "production")
3208
+ # Browser-embedded chat should prefer stable wheel-based scrollback over
3209
+ # native terminal mouse tracking. When mouse tracking is enabled, wheel
3210
+ # events are consumed by the TUI and forwarded as terminal input, which
3211
+ # makes browser-side transcript scrolling feel broken. Keep the terminal
3212
+ # build unchanged for native CLI usage; only disable mouse tracking for
3213
+ # the dashboard PTY path.
3214
+ env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1")
3215
+
3216
+ if resume:
3217
+ latest_resume, _latest_path = _session_latest_descendant(resume)
3218
+ if latest_resume:
3219
+ resume = latest_resume
3220
+ env["HERMES_TUI_RESUME"] = resume
3221
+
3222
+ if sidecar_url:
3223
+ env["HERMES_TUI_SIDECAR_URL"] = sidecar_url
3224
+
3225
+ return list(argv), str(cwd) if cwd else None, env
3226
+
3227
+
3228
+ def _build_sidecar_url(channel: str) -> Optional[str]:
3229
+ """ws:// URL the PTY child should publish events to, or None when unbound."""
3230
+ host = getattr(app.state, "bound_host", None)
3231
+ port = getattr(app.state, "bound_port", None)
3232
+
3233
+ if not host or not port:
3234
+ return None
3235
+
3236
+ netloc = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}"
3237
+ qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel})
3238
+
3239
+ return f"ws://{netloc}/api/pub?{qs}"
3240
+
3241
+
3242
+ async def _broadcast_event(channel: str, payload: str) -> None:
3243
+ """Fan out one publisher frame to every subscriber on `channel`."""
3244
+ async with _event_lock:
3245
+ subs = list(_event_channels.get(channel, ()))
3246
+
3247
+ for sub in subs:
3248
+ try:
3249
+ await sub.send_text(payload)
3250
+ except Exception:
3251
+ # Subscriber went away mid-send; the /api/events finally clause
3252
+ # will remove it from the registry on its next iteration.
3253
+ pass
3254
+
3255
+
3256
+ def _channel_or_close_code(ws: WebSocket) -> Optional[str]:
3257
+ """Return the channel id from the query string or None if invalid."""
3258
+ channel = ws.query_params.get("channel", "")
3259
+
3260
+ return channel if _VALID_CHANNEL_RE.match(channel) else None
3261
+
3262
+
3263
+ @app.websocket("/api/pty")
3264
+ async def pty_ws(ws: WebSocket) -> None:
3265
+ if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
3266
+ await ws.close(code=4403)
3267
+ return
3268
+
3269
+ # --- auth + loopback check (before accept so we can close cleanly) ---
3270
+ token = ws.query_params.get("token", "")
3271
+ expected = _SESSION_TOKEN
3272
+ if not hmac.compare_digest(token.encode(), expected.encode()):
3273
+ await ws.close(code=4401)
3274
+ return
3275
+
3276
+ if not _ws_client_is_allowed(ws):
3277
+ await ws.close(code=4403)
3278
+ return
3279
+
3280
+ await ws.accept()
3281
+
3282
+ # On native Windows, the POSIX PTY bridge can't be imported. Tell the
3283
+ # client and close cleanly rather than pretending the feature works.
3284
+ if not _PTY_BRIDGE_AVAILABLE:
3285
+ await ws.send_text(
3286
+ "\r\n\x1b[31mChat unavailable: the embedded terminal requires a "
3287
+ "POSIX PTY, which native Windows Python doesn't provide.\x1b[0m\r\n"
3288
+ "\x1b[33mInstall Hermes inside WSL2 to use the dashboard's /chat "
3289
+ "tab — the rest of the dashboard works here.\x1b[0m\r\n"
3290
+ )
3291
+ await ws.close(code=1011)
3292
+ return
3293
+
3294
+ # --- spawn PTY ------------------------------------------------------
3295
+ resume = ws.query_params.get("resume") or None
3296
+ channel = _channel_or_close_code(ws)
3297
+ sidecar_url = _build_sidecar_url(channel) if channel else None
3298
+
3299
+ try:
3300
+ argv, cwd, env = _resolve_chat_argv(resume=resume, sidecar_url=sidecar_url)
3301
+ except SystemExit as exc:
3302
+ # _make_tui_argv calls sys.exit(1) when node/npm is missing.
3303
+ await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n")
3304
+ await ws.close(code=1011)
3305
+ return
3306
+
3307
+
3308
+ try:
3309
+ bridge = PtyBridge.spawn(argv, cwd=cwd, env=env)
3310
+ except PtyUnavailableError as exc:
3311
+ await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n")
3312
+ await ws.close(code=1011)
3313
+ return
3314
+ except (FileNotFoundError, OSError) as exc:
3315
+ await ws.send_text(f"\r\n\x1b[31mChat failed to start: {exc}\x1b[0m\r\n")
3316
+ await ws.close(code=1011)
3317
+ return
3318
+
3319
+ loop = asyncio.get_running_loop()
3320
+
3321
+ # --- reader task: PTY master → WebSocket ----------------------------
3322
+ async def pump_pty_to_ws() -> None:
3323
+ while True:
3324
+ chunk = await loop.run_in_executor(
3325
+ None, bridge.read, _PTY_READ_CHUNK_TIMEOUT
3326
+ )
3327
+ if chunk is None: # EOF
3328
+ return
3329
+ if not chunk: # no data this tick; yield control and retry
3330
+ await asyncio.sleep(0)
3331
+ continue
3332
+ try:
3333
+ await ws.send_bytes(chunk)
3334
+ except Exception:
3335
+ return
3336
+
3337
+ reader_task = asyncio.create_task(pump_pty_to_ws())
3338
+
3339
+ # --- writer loop: WebSocket → PTY master ----------------------------
3340
+ try:
3341
+ while True:
3342
+ msg = await ws.receive()
3343
+ msg_type = msg.get("type")
3344
+ if msg_type == "websocket.disconnect":
3345
+ break
3346
+ raw = msg.get("bytes")
3347
+ if raw is None:
3348
+ text = msg.get("text")
3349
+ raw = text.encode("utf-8") if isinstance(text, str) else b""
3350
+ if not raw:
3351
+ continue
3352
+
3353
+ # Resize escape is consumed locally, never written to the PTY.
3354
+ match = _RESIZE_RE.match(raw)
3355
+ if match and match.end() == len(raw):
3356
+ cols = int(match.group(1))
3357
+ rows = int(match.group(2))
3358
+ bridge.resize(cols=cols, rows=rows)
3359
+ continue
3360
+
3361
+ bridge.write(raw)
3362
+ except WebSocketDisconnect:
3363
+ pass
3364
+ finally:
3365
+ reader_task.cancel()
3366
+ try:
3367
+ await reader_task
3368
+ except (asyncio.CancelledError, Exception):
3369
+ pass
3370
+ bridge.close()
3371
+
3372
+
3373
+ # ---------------------------------------------------------------------------
3374
+ # /api/ws — JSON-RPC WebSocket sidecar for the dashboard "Chat" tab.
3375
+ #
3376
+ # Drives the same `tui_gateway.dispatch` surface Ink uses over stdio, so the
3377
+ # dashboard can render structured metadata (model badge, tool-call sidebar,
3378
+ # slash launcher, session info) alongside the xterm.js terminal that PTY
3379
+ # already paints. Both transports bind to the same session id when one is
3380
+ # active, so a tool.start emitted by the agent fans out to both sinks.
3381
+ # ---------------------------------------------------------------------------
3382
+
3383
+
3384
+ @app.websocket("/api/ws")
3385
+ async def gateway_ws(ws: WebSocket) -> None:
3386
+ if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
3387
+ await ws.close(code=4403)
3388
+ return
3389
+
3390
+ token = ws.query_params.get("token", "")
3391
+ if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
3392
+ await ws.close(code=4401)
3393
+ return
3394
+
3395
+ if not _ws_client_is_allowed(ws):
3396
+ await ws.close(code=4403)
3397
+ return
3398
+
3399
+ from tui_gateway.ws import handle_ws
3400
+
3401
+ await handle_ws(ws)
3402
+
3403
+
3404
+ # ---------------------------------------------------------------------------
3405
+ # /api/pub + /api/events — chat-tab event broadcast.
3406
+ #
3407
+ # The PTY-side ``tui_gateway.entry`` opens /api/pub at startup (driven by
3408
+ # HERMES_TUI_SIDECAR_URL set in /api/pty's PTY env) and writes every
3409
+ # dispatcher emit through it. The dashboard fans those frames out to any
3410
+ # subscriber that opened /api/events on the same channel id. This is what
3411
+ # gives the React sidebar its tool-call feed without breaking the PTY
3412
+ # child's stdio handshake with Ink.
3413
+ # ---------------------------------------------------------------------------
3414
+
3415
+
3416
+ @app.websocket("/api/pub")
3417
+ async def pub_ws(ws: WebSocket) -> None:
3418
+ if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
3419
+ await ws.close(code=4403)
3420
+ return
3421
+
3422
+ token = ws.query_params.get("token", "")
3423
+ if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
3424
+ await ws.close(code=4401)
3425
+ return
3426
+
3427
+ if not _ws_client_is_allowed(ws):
3428
+ await ws.close(code=4403)
3429
+ return
3430
+
3431
+ channel = _channel_or_close_code(ws)
3432
+ if not channel:
3433
+ await ws.close(code=4400)
3434
+ return
3435
+
3436
+ await ws.accept()
3437
+
3438
+ try:
3439
+ while True:
3440
+ await _broadcast_event(channel, await ws.receive_text())
3441
+ except WebSocketDisconnect:
3442
+ pass
3443
+
3444
+
3445
+ @app.websocket("/api/events")
3446
+ async def events_ws(ws: WebSocket) -> None:
3447
+ if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
3448
+ await ws.close(code=4403)
3449
+ return
3450
+
3451
+ token = ws.query_params.get("token", "")
3452
+ if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
3453
+ await ws.close(code=4401)
3454
+ return
3455
+
3456
+ if not _ws_client_is_allowed(ws):
3457
+ await ws.close(code=4403)
3458
+ return
3459
+
3460
+ channel = _channel_or_close_code(ws)
3461
+ if not channel:
3462
+ await ws.close(code=4400)
3463
+ return
3464
+
3465
+ await ws.accept()
3466
+
3467
+ async with _event_lock:
3468
+ _event_channels.setdefault(channel, set()).add(ws)
3469
+
3470
+ try:
3471
+ while True:
3472
+ # Subscribers don't speak — the receive() just blocks until
3473
+ # disconnect so the connection stays open as long as the
3474
+ # browser holds it.
3475
+ await ws.receive_text()
3476
+ except WebSocketDisconnect:
3477
+ pass
3478
+ finally:
3479
+ async with _event_lock:
3480
+ subs = _event_channels.get(channel)
3481
+
3482
+ if subs is not None:
3483
+ subs.discard(ws)
3484
+
3485
+ if not subs:
3486
+ _event_channels.pop(channel, None)
3487
+
3488
+
3489
+ def _normalise_prefix(raw: Optional[str]) -> str:
3490
+ """Normalise an X-Forwarded-Prefix header value.
3491
+
3492
+ Returns a string like ``"/hermes"`` (no trailing slash) or ``""`` when
3493
+ no prefix is set / the header is malformed. We deliberately reject
3494
+ anything containing ``..`` or non-printable bytes so a hostile proxy
3495
+ can't inject HTML via the prefix.
3496
+ """
3497
+ if not raw:
3498
+ return ""
3499
+ p = raw.strip()
3500
+ if not p:
3501
+ return ""
3502
+ if not p.startswith("/"):
3503
+ p = "/" + p
3504
+ p = p.rstrip("/")
3505
+ if "//" in p or ".." in p or any(c in p for c in ('"', "'", "<", ">", " ", "\n", "\r", "\t")):
3506
+ return ""
3507
+ if len(p) > 64:
3508
+ return ""
3509
+ return p
3510
+
3511
+
3512
+ def mount_spa(application: FastAPI):
3513
+ """Mount the built SPA. Falls back to index.html for client-side routing.
3514
+
3515
+ The session token is injected into index.html via a ``<script>`` tag so
3516
+ the SPA can authenticate against protected API endpoints without a
3517
+ separate (unauthenticated) token-dispensing endpoint.
3518
+
3519
+ When served behind a path-prefix reverse proxy (e.g.
3520
+ ``mission-control.tilos.com/hermes/*`` -> local Caddy -> :9119), the
3521
+ proxy injects ``X-Forwarded-Prefix: /hermes`` on every request. We
3522
+ rewrite the served ``index.html`` so absolute asset URLs (``/assets/...``)
3523
+ and the SPA's runtime ``__HERMES_BASE_PATH__`` honour that prefix
3524
+ without rebuilding the bundle.
3525
+ """
3526
+ if not WEB_DIST.exists():
3527
+ @application.get("/{full_path:path}")
3528
+ async def no_frontend(full_path: str):
3529
+ return JSONResponse(
3530
+ {"error": "Frontend not built. Run: cd web && npm run build"},
3531
+ status_code=404,
3532
+ )
3533
+ return
3534
+
3535
+ _index_path = WEB_DIST / "index.html"
3536
+
3537
+ def _serve_index(prefix: str = ""):
3538
+ """Return index.html with the session token + base-path injected.
3539
+
3540
+ ``prefix`` is the normalised ``X-Forwarded-Prefix`` (e.g. ``/hermes``)
3541
+ or empty string when served at root.
3542
+ """
3543
+ html = _index_path.read_text()
3544
+ chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
3545
+ token_script = (
3546
+ f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";'
3547
+ f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
3548
+ f'window.__HERMES_BASE_PATH__="{prefix}";</script>'
3549
+ )
3550
+ if prefix:
3551
+ # Rewrite absolute asset URLs baked into the Vite build so the
3552
+ # browser fetches them through the same proxy prefix.
3553
+ html = html.replace('href="/assets/', f'href="{prefix}/assets/')
3554
+ html = html.replace('src="/assets/', f'src="{prefix}/assets/')
3555
+ html = html.replace('href="/favicon.ico"', f'href="{prefix}/favicon.ico"')
3556
+ html = html.replace('href="/fonts/', f'href="{prefix}/fonts/')
3557
+ html = html.replace('href="/ds-assets/', f'href="{prefix}/ds-assets/')
3558
+ html = html.replace('src="/ds-assets/', f'src="{prefix}/ds-assets/')
3559
+ html = html.replace("</head>", f"{token_script}</head>", 1)
3560
+ return HTMLResponse(
3561
+ html,
3562
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
3563
+ )
3564
+
3565
+ # When served behind a path-prefix proxy, the built CSS contains
3566
+ # absolute ``url(/fonts/...)`` and ``url(/ds-assets/...)`` references.
3567
+ # Browsers resolve those against the document origin, which means
3568
+ # under ``/hermes`` they'd hit ``mission-control.tilos.com/fonts/...``
3569
+ # (the MC Pages app), not the Hermes backend. Intercept CSS asset
3570
+ # requests BEFORE the StaticFiles mount and rewrite the absolute paths
3571
+ # when a prefix is in play.
3572
+ @application.get("/assets/{filename}.css")
3573
+ async def serve_css(filename: str, request: Request):
3574
+ css_path = WEB_DIST / "assets" / f"{filename}.css"
3575
+ if not css_path.is_file() or not css_path.resolve().is_relative_to(
3576
+ WEB_DIST.resolve()
3577
+ ):
3578
+ return JSONResponse({"error": "not found"}, status_code=404)
3579
+ prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
3580
+ css = css_path.read_text()
3581
+ if prefix:
3582
+ for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"):
3583
+ css = css.replace(f"url({asset_dir}", f"url({prefix}{asset_dir}")
3584
+ css = css.replace(f"url(\"{asset_dir}", f"url(\"{prefix}{asset_dir}")
3585
+ css = css.replace(f"url('{asset_dir}", f"url('{prefix}{asset_dir}")
3586
+ return Response(content=css, media_type="text/css")
3587
+
3588
+ application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
3589
+
3590
+ @application.get("/{full_path:path}")
3591
+ async def serve_spa(full_path: str, request: Request):
3592
+ prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
3593
+ file_path = WEB_DIST / full_path
3594
+ # Prevent path traversal via url-encoded sequences (%2e%2e/)
3595
+ if (
3596
+ full_path
3597
+ and file_path.resolve().is_relative_to(WEB_DIST.resolve())
3598
+ and file_path.exists()
3599
+ and file_path.is_file()
3600
+ ):
3601
+ return FileResponse(file_path)
3602
+ return _serve_index(prefix)
3603
+
3604
+
3605
+ # ---------------------------------------------------------------------------
3606
+ # Dashboard theme endpoints
3607
+ # ---------------------------------------------------------------------------
3608
+
3609
+ # Built-in dashboard themes — label + description only. The actual color
3610
+ # definitions live in the frontend (web/src/themes/presets.ts).
3611
+ _BUILTIN_DASHBOARD_THEMES = [
3612
+ {"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"},
3613
+ {"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"},
3614
+ {"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"},
3615
+ {"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"},
3616
+ {"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"},
3617
+ {"name": "cyberpunk", "label": "Cyberpunk", "description": "Neon green on black — matrix terminal"},
3618
+ {"name": "rose", "label": "Rosé", "description": "Soft pink and warm ivory — easy on the eyes"},
3619
+ ]
3620
+
3621
+
3622
+ def _parse_theme_layer(value: Any, default_hex: str, default_alpha: float = 1.0) -> Optional[Dict[str, Any]]:
3623
+ """Normalise a theme layer spec from YAML into `{hex, alpha}` form.
3624
+
3625
+ Accepts shorthand (a bare hex string) or full dict form. Returns
3626
+ ``None`` on garbage input so the caller can fall back to a built-in
3627
+ default rather than blowing up.
3628
+ """
3629
+ if value is None:
3630
+ return {"hex": default_hex, "alpha": default_alpha}
3631
+ if isinstance(value, str):
3632
+ return {"hex": value, "alpha": default_alpha}
3633
+ if isinstance(value, dict):
3634
+ hex_val = value.get("hex", default_hex)
3635
+ alpha_val = value.get("alpha", default_alpha)
3636
+ if not isinstance(hex_val, str):
3637
+ return None
3638
+ try:
3639
+ alpha_f = float(alpha_val)
3640
+ except (TypeError, ValueError):
3641
+ alpha_f = default_alpha
3642
+ return {"hex": hex_val, "alpha": max(0.0, min(1.0, alpha_f))}
3643
+ return None
3644
+
3645
+
3646
+ _THEME_DEFAULT_TYPOGRAPHY: Dict[str, str] = {
3647
+ "fontSans": 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
3648
+ "fontMono": 'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace',
3649
+ "baseSize": "15px",
3650
+ "lineHeight": "1.55",
3651
+ "letterSpacing": "0",
3652
+ }
3653
+
3654
+ _THEME_DEFAULT_LAYOUT: Dict[str, str] = {
3655
+ "radius": "0.5rem",
3656
+ "density": "comfortable",
3657
+ }
3658
+
3659
+ _THEME_OVERRIDE_KEYS = {
3660
+ "card", "cardForeground", "popover", "popoverForeground",
3661
+ "primary", "primaryForeground", "secondary", "secondaryForeground",
3662
+ "muted", "mutedForeground", "accent", "accentForeground",
3663
+ "destructive", "destructiveForeground", "success", "warning",
3664
+ "border", "input", "ring",
3665
+ }
3666
+
3667
+ # Well-known named asset slots themes can populate. Any other keys under
3668
+ # ``assets.custom`` are exposed as ``--theme-asset-custom-<key>`` CSS vars
3669
+ # for plugin/shell use.
3670
+ _THEME_NAMED_ASSET_KEYS = {"bg", "hero", "logo", "crest", "sidebar", "header"}
3671
+
3672
+ # Component-style buckets themes can override. The value under each bucket
3673
+ # is a mapping from camelCase property name to CSS string; each pair emits
3674
+ # ``--component-<bucket>-<kebab-property>`` on :root. The frontend's shell
3675
+ # components (Card, App header, Backdrop, etc.) consume these vars so themes
3676
+ # can restyle chrome (clip-path, border-image, segmented progress, etc.)
3677
+ # without shipping their own CSS.
3678
+ _THEME_COMPONENT_BUCKETS = {
3679
+ "card", "header", "footer", "sidebar", "tab",
3680
+ "progress", "badge", "backdrop", "page",
3681
+ }
3682
+
3683
+ _THEME_LAYOUT_VARIANTS = {"standard", "cockpit", "tiled"}
3684
+
3685
+ # Cap on customCSS length so a malformed/oversized theme YAML can't blow up
3686
+ # the response payload or the <style> tag. 32 KiB is plenty for every
3687
+ # practical reskin (the Strike Freedom demo is ~2 KiB).
3688
+ _THEME_CUSTOM_CSS_MAX = 32 * 1024
3689
+
3690
+
3691
+ def _normalise_theme_definition(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
3692
+ """Normalise a user theme YAML into the wire format `ThemeProvider`
3693
+ expects. Returns ``None`` if the theme is unusable.
3694
+
3695
+ Accepts both the full schema (palette/typography/layout) and a loose
3696
+ form with bare hex strings, so hand-written YAMLs stay friendly.
3697
+ """
3698
+ if not isinstance(data, dict):
3699
+ return None
3700
+ name = data.get("name")
3701
+ if not isinstance(name, str) or not name.strip():
3702
+ return None
3703
+
3704
+ # Palette
3705
+ palette_src = data.get("palette", {}) if isinstance(data.get("palette"), dict) else {}
3706
+ # Allow top-level `colors.background` as a shorthand too.
3707
+ colors_src = data.get("colors", {}) if isinstance(data.get("colors"), dict) else {}
3708
+
3709
+ def _layer(key: str, default_hex: str, default_alpha: float = 1.0) -> Dict[str, Any]:
3710
+ spec = palette_src.get(key, colors_src.get(key))
3711
+ parsed = _parse_theme_layer(spec, default_hex, default_alpha)
3712
+ return parsed if parsed is not None else {"hex": default_hex, "alpha": default_alpha}
3713
+
3714
+ palette = {
3715
+ "background": _layer("background", "#041c1c", 1.0),
3716
+ "midground": _layer("midground", "#ffe6cb", 1.0),
3717
+ "foreground": _layer("foreground", "#ffffff", 0.0),
3718
+ "warmGlow": palette_src.get("warmGlow") or data.get("warmGlow") or "rgba(255, 189, 56, 0.35)",
3719
+ "noiseOpacity": 1.0,
3720
+ }
3721
+ raw_noise = palette_src.get("noiseOpacity", data.get("noiseOpacity"))
3722
+ try:
3723
+ palette["noiseOpacity"] = float(raw_noise) if raw_noise is not None else 1.0
3724
+ except (TypeError, ValueError):
3725
+ palette["noiseOpacity"] = 1.0
3726
+
3727
+ # Typography
3728
+ typo_src = data.get("typography", {}) if isinstance(data.get("typography"), dict) else {}
3729
+ typography = dict(_THEME_DEFAULT_TYPOGRAPHY)
3730
+ for key in ("fontSans", "fontMono", "fontDisplay", "fontUrl", "baseSize", "lineHeight", "letterSpacing"):
3731
+ val = typo_src.get(key)
3732
+ if isinstance(val, str) and val.strip():
3733
+ typography[key] = val
3734
+
3735
+ # Layout
3736
+ layout_src = data.get("layout", {}) if isinstance(data.get("layout"), dict) else {}
3737
+ layout = dict(_THEME_DEFAULT_LAYOUT)
3738
+ radius = layout_src.get("radius")
3739
+ if isinstance(radius, str) and radius.strip():
3740
+ layout["radius"] = radius
3741
+ density = layout_src.get("density")
3742
+ if isinstance(density, str) and density in {"compact", "comfortable", "spacious"}:
3743
+ layout["density"] = density
3744
+
3745
+ # Color overrides — keep only valid keys with string values.
3746
+ overrides_src = data.get("colorOverrides", {})
3747
+ color_overrides: Dict[str, str] = {}
3748
+ if isinstance(overrides_src, dict):
3749
+ for key, val in overrides_src.items():
3750
+ if key in _THEME_OVERRIDE_KEYS and isinstance(val, str) and val.strip():
3751
+ color_overrides[key] = val
3752
+
3753
+ # Assets — named slots + arbitrary user-defined keys. Values must be
3754
+ # strings (URLs or CSS ``url(...)``/``linear-gradient(...)`` expressions).
3755
+ # We don't fetch remote assets here; the frontend just injects them as
3756
+ # CSS vars. Empty values are dropped so a theme can explicitly clear a
3757
+ # slot by setting ``hero: ""``.
3758
+ assets_out: Dict[str, Any] = {}
3759
+ assets_src = data.get("assets", {}) if isinstance(data.get("assets"), dict) else {}
3760
+ for key in _THEME_NAMED_ASSET_KEYS:
3761
+ val = assets_src.get(key)
3762
+ if isinstance(val, str) and val.strip():
3763
+ assets_out[key] = val
3764
+ custom_assets_src = assets_src.get("custom")
3765
+ if isinstance(custom_assets_src, dict):
3766
+ custom_assets: Dict[str, str] = {}
3767
+ for key, val in custom_assets_src.items():
3768
+ if (
3769
+ isinstance(key, str)
3770
+ and key.replace("-", "").replace("_", "").isalnum()
3771
+ and isinstance(val, str)
3772
+ and val.strip()
3773
+ ):
3774
+ custom_assets[key] = val
3775
+ if custom_assets:
3776
+ assets_out["custom"] = custom_assets
3777
+
3778
+ # Custom CSS — raw CSS text the frontend injects as a scoped <style>
3779
+ # tag on theme apply. Clipped to _THEME_CUSTOM_CSS_MAX to keep the
3780
+ # payload bounded. We intentionally do NOT parse/sanitise the CSS
3781
+ # here — the dashboard is localhost-only and themes are user-authored
3782
+ # YAML in ~/.hermes/, same trust level as the config file itself.
3783
+ custom_css_val = data.get("customCSS")
3784
+ custom_css: Optional[str] = None
3785
+ if isinstance(custom_css_val, str) and custom_css_val.strip():
3786
+ custom_css = custom_css_val[:_THEME_CUSTOM_CSS_MAX]
3787
+
3788
+ # Component style overrides — per-bucket dicts of camelCase CSS
3789
+ # property -> CSS string. The frontend converts these into CSS vars
3790
+ # that shell components (Card, App header, Backdrop) consume.
3791
+ component_styles_src = data.get("componentStyles", {})
3792
+ component_styles: Dict[str, Dict[str, str]] = {}
3793
+ if isinstance(component_styles_src, dict):
3794
+ for bucket, props in component_styles_src.items():
3795
+ if bucket not in _THEME_COMPONENT_BUCKETS or not isinstance(props, dict):
3796
+ continue
3797
+ clean: Dict[str, str] = {}
3798
+ for prop, value in props.items():
3799
+ if (
3800
+ isinstance(prop, str)
3801
+ and prop.replace("-", "").replace("_", "").isalnum()
3802
+ and isinstance(value, (str, int, float))
3803
+ and str(value).strip()
3804
+ ):
3805
+ clean[prop] = str(value)
3806
+ if clean:
3807
+ component_styles[bucket] = clean
3808
+
3809
+ layout_variant_src = data.get("layoutVariant")
3810
+ layout_variant = (
3811
+ layout_variant_src
3812
+ if isinstance(layout_variant_src, str) and layout_variant_src in _THEME_LAYOUT_VARIANTS
3813
+ else "standard"
3814
+ )
3815
+
3816
+ result: Dict[str, Any] = {
3817
+ "name": name,
3818
+ "label": data.get("label") or name,
3819
+ "description": data.get("description", ""),
3820
+ "palette": palette,
3821
+ "typography": typography,
3822
+ "layout": layout,
3823
+ "layoutVariant": layout_variant,
3824
+ }
3825
+ if color_overrides:
3826
+ result["colorOverrides"] = color_overrides
3827
+ if assets_out:
3828
+ result["assets"] = assets_out
3829
+ if custom_css is not None:
3830
+ result["customCSS"] = custom_css
3831
+ if component_styles:
3832
+ result["componentStyles"] = component_styles
3833
+ return result
3834
+
3835
+
3836
+ def _discover_user_themes() -> list:
3837
+ """Scan ~/.hermes/dashboard-themes/*.yaml for user-created themes.
3838
+
3839
+ Returns a list of fully-normalised theme definitions ready to ship
3840
+ to the frontend, so the client can apply them without a secondary
3841
+ round-trip or a built-in stub.
3842
+ """
3843
+ themes_dir = get_hermes_home() / "dashboard-themes"
3844
+ if not themes_dir.is_dir():
3845
+ return []
3846
+ result = []
3847
+ for f in sorted(themes_dir.glob("*.yaml")):
3848
+ try:
3849
+ data = yaml.safe_load(f.read_text(encoding="utf-8"))
3850
+ except Exception:
3851
+ continue
3852
+ normalised = _normalise_theme_definition(data)
3853
+ if normalised is not None:
3854
+ result.append(normalised)
3855
+ return result
3856
+
3857
+
3858
+ @app.get("/api/dashboard/themes")
3859
+ async def get_dashboard_themes():
3860
+ """Return available themes and the currently active one.
3861
+
3862
+ Built-in entries ship name/label/description only (the frontend owns
3863
+ their full definitions in `web/src/themes/presets.ts`). User themes
3864
+ from `~/.hermes/dashboard-themes/*.yaml` ship with their full
3865
+ normalised definition under `definition`, so the client can apply
3866
+ them without a stub.
3867
+ """
3868
+ config = load_config()
3869
+ active = cfg_get(config, "dashboard", "theme", default="default")
3870
+ user_themes = _discover_user_themes()
3871
+ seen = set()
3872
+ themes = []
3873
+ for t in _BUILTIN_DASHBOARD_THEMES:
3874
+ seen.add(t["name"])
3875
+ themes.append(t)
3876
+ for t in user_themes:
3877
+ if t["name"] in seen:
3878
+ continue
3879
+ themes.append({
3880
+ "name": t["name"],
3881
+ "label": t["label"],
3882
+ "description": t["description"],
3883
+ "definition": t,
3884
+ })
3885
+ seen.add(t["name"])
3886
+ return {"themes": themes, "active": active}
3887
+
3888
+
3889
+ class ThemeSetBody(BaseModel):
3890
+ name: str
3891
+
3892
+
3893
+ @app.put("/api/dashboard/theme")
3894
+ async def set_dashboard_theme(body: ThemeSetBody):
3895
+ """Set the active dashboard theme (persists to config.yaml)."""
3896
+ config = load_config()
3897
+ if "dashboard" not in config:
3898
+ config["dashboard"] = {}
3899
+ config["dashboard"]["theme"] = body.name
3900
+ save_config(config)
3901
+ return {"ok": True, "theme": body.name}
3902
+
3903
+
3904
+ # ---------------------------------------------------------------------------
3905
+ # Dashboard plugin system
3906
+ # ---------------------------------------------------------------------------
3907
+
3908
+ def _discover_dashboard_plugins() -> list:
3909
+ """Scan plugins/*/dashboard/manifest.json for dashboard extensions.
3910
+
3911
+ Checks three plugin sources (same as hermes_cli.plugins):
3912
+ 1. User plugins: ~/.hermes/plugins/<name>/dashboard/manifest.json
3913
+ 2. Bundled plugins: <repo>/plugins/<name>/dashboard/manifest.json (memory/, etc.)
3914
+ 3. Project plugins: ./.hermes/plugins/ (only if HERMES_ENABLE_PROJECT_PLUGINS)
3915
+ """
3916
+ plugins = []
3917
+ seen_names: set = set()
3918
+
3919
+ from hermes_cli.plugins import get_bundled_plugins_dir
3920
+ bundled_root = get_bundled_plugins_dir()
3921
+ search_dirs = [
3922
+ (get_hermes_home() / "plugins", "user"),
3923
+ (bundled_root / "memory", "bundled"),
3924
+ (bundled_root, "bundled"),
3925
+ ]
3926
+ if os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS"):
3927
+ search_dirs.append((Path.cwd() / ".hermes" / "plugins", "project"))
3928
+
3929
+ for plugins_root, source in search_dirs:
3930
+ if not plugins_root.is_dir():
3931
+ continue
3932
+ for child in sorted(plugins_root.iterdir()):
3933
+ if not child.is_dir():
3934
+ continue
3935
+ manifest_file = child / "dashboard" / "manifest.json"
3936
+ if not manifest_file.exists():
3937
+ continue
3938
+ try:
3939
+ data = json.loads(manifest_file.read_text(encoding="utf-8"))
3940
+ name = data.get("name", child.name)
3941
+ if name in seen_names:
3942
+ continue
3943
+ seen_names.add(name)
3944
+ # Tab options: ``path`` + ``position`` for a new tab, optional
3945
+ # ``override`` to replace a built-in route, and ``hidden`` to
3946
+ # register the plugin component/slots without adding a tab
3947
+ # (useful for slot-only plugins like a header-crest injector).
3948
+ raw_tab = data.get("tab", {}) if isinstance(data.get("tab"), dict) else {}
3949
+ tab_info = {
3950
+ "path": raw_tab.get("path", f"/{name}"),
3951
+ "position": raw_tab.get("position", "end"),
3952
+ }
3953
+ override_path = raw_tab.get("override")
3954
+ if isinstance(override_path, str) and override_path.startswith("/"):
3955
+ tab_info["override"] = override_path
3956
+ if bool(raw_tab.get("hidden")):
3957
+ tab_info["hidden"] = True
3958
+ # Slots: list of named slot locations this plugin populates.
3959
+ # The frontend exposes ``registerSlot(pluginName, slotName, Component)``
3960
+ # on window; plugins with non-empty slots call it from their JS bundle.
3961
+ slots_src = data.get("slots")
3962
+ slots: List[str] = []
3963
+ if isinstance(slots_src, list):
3964
+ slots = [s for s in slots_src if isinstance(s, str) and s]
3965
+ plugins.append({
3966
+ "name": name,
3967
+ "label": data.get("label", name),
3968
+ "description": data.get("description", ""),
3969
+ "icon": data.get("icon", "Puzzle"),
3970
+ "version": data.get("version", "0.0.0"),
3971
+ "tab": tab_info,
3972
+ "slots": slots,
3973
+ "entry": data.get("entry", "dist/index.js"),
3974
+ "css": data.get("css"),
3975
+ "has_api": bool(data.get("api")),
3976
+ "source": source,
3977
+ "_dir": str(child / "dashboard"),
3978
+ "_api_file": data.get("api"),
3979
+ })
3980
+ except Exception as exc:
3981
+ _log.warning("Bad dashboard plugin manifest %s: %s", manifest_file, exc)
3982
+ continue
3983
+ return plugins
3984
+
3985
+
3986
+ # Cache discovered plugins per-process (refresh on explicit re-scan).
3987
+ _dashboard_plugins_cache: Optional[list] = None
3988
+
3989
+
3990
+ def _get_dashboard_plugins(force_rescan: bool = False) -> list:
3991
+ global _dashboard_plugins_cache
3992
+ if _dashboard_plugins_cache is None or force_rescan:
3993
+ _dashboard_plugins_cache = _discover_dashboard_plugins()
3994
+ elif _dashboard_plugins_cache:
3995
+ if any(not Path(p["_dir"]).is_dir() for p in _dashboard_plugins_cache):
3996
+ _dashboard_plugins_cache = _discover_dashboard_plugins()
3997
+ return _dashboard_plugins_cache
3998
+
3999
+
4000
+ @app.get("/api/dashboard/plugins")
4001
+ async def get_dashboard_plugins():
4002
+ """Return discovered dashboard plugins (excludes user-hidden ones)."""
4003
+ plugins = _get_dashboard_plugins()
4004
+ # Read user's hidden plugins list from config.
4005
+ config = load_config()
4006
+ hidden: list = cfg_get(config, "dashboard", "hidden_plugins", default=[]) or []
4007
+ # Strip internal fields before sending to frontend and filter out hidden.
4008
+ return [
4009
+ {k: v for k, v in p.items() if not k.startswith("_")}
4010
+ for p in plugins
4011
+ if p["name"] not in hidden
4012
+ ]
4013
+
4014
+
4015
+ @app.get("/api/dashboard/plugins/rescan")
4016
+ async def rescan_dashboard_plugins():
4017
+ """Force re-scan of dashboard plugins."""
4018
+ plugins = _get_dashboard_plugins(force_rescan=True)
4019
+ return {"ok": True, "count": len(plugins)}
4020
+
4021
+
4022
+ class _AgentPluginInstallBody(BaseModel):
4023
+ identifier: str
4024
+ force: bool = False
4025
+ enable: bool = True
4026
+
4027
+
4028
+ def _strip_dashboard_manifest(p: Dict[str, Any]) -> Dict[str, Any]:
4029
+ return {k: v for k, v in p.items() if not k.startswith("_")}
4030
+
4031
+
4032
+ def _merged_plugins_hub() -> Dict[str, Any]:
4033
+ """Agent discovery + dashboard manifests + optional provider picker metadata."""
4034
+ from hermes_cli.plugins_cmd import (
4035
+ _discover_all_plugins,
4036
+ _get_current_context_engine,
4037
+ _get_current_memory_provider,
4038
+ _discover_context_engines,
4039
+ _discover_memory_providers,
4040
+ _get_disabled_set,
4041
+ _get_enabled_set,
4042
+ _read_manifest as _read_plugin_manifest_at,
4043
+ )
4044
+
4045
+ dashboard_list = _get_dashboard_plugins()
4046
+ dash_by_name = {str(p["name"]): p for p in dashboard_list}
4047
+
4048
+ disabled_set = _get_disabled_set()
4049
+ enabled_set = _get_enabled_set()
4050
+
4051
+ # Read user-hidden plugins from config for the user_hidden field.
4052
+ config = load_config()
4053
+ hidden_plugins: list = cfg_get(config, "dashboard", "hidden_plugins", default=[]) or []
4054
+
4055
+ plugins_root_resolved = (get_hermes_home() / "plugins").resolve()
4056
+ rows: List[Dict[str, Any]] = []
4057
+
4058
+ for name, version, description, source, dir_str in _discover_all_plugins():
4059
+ if name in disabled_set:
4060
+ runtime_status = "disabled"
4061
+ elif name in enabled_set:
4062
+ runtime_status = "enabled"
4063
+ else:
4064
+ runtime_status = "inactive"
4065
+
4066
+ dir_path = Path(dir_str)
4067
+ dm = dash_by_name.get(name)
4068
+ has_dash_manifest = dm is not None or (dir_path / "dashboard" / "manifest.json").exists()
4069
+
4070
+ under_user_tree = False
4071
+ try:
4072
+ dir_path.resolve().relative_to(plugins_root_resolved)
4073
+ under_user_tree = True
4074
+ except ValueError:
4075
+ pass
4076
+
4077
+ can_remove_update = (
4078
+ source in {"user", "git"} and under_user_tree and Path(dir_str).is_dir()
4079
+ )
4080
+
4081
+ # Check if this plugin provides tools that require auth
4082
+ auth_required = False
4083
+ auth_command = ""
4084
+ manifest_data = _read_plugin_manifest_at(dir_path)
4085
+ provides_tools = manifest_data.get("provides_tools") or []
4086
+ if provides_tools:
4087
+ try:
4088
+ from tools.registry import registry
4089
+ for tname in provides_tools:
4090
+ entry = registry.get_entry(tname)
4091
+ if entry and entry.check_fn and not entry.check_fn():
4092
+ auth_required = True
4093
+ auth_command = f"hermes auth {name}"
4094
+ break
4095
+ except Exception:
4096
+ pass
4097
+
4098
+ rows.append({
4099
+ "name": name,
4100
+ "version": version or "",
4101
+ "description": description or "",
4102
+ "source": source,
4103
+ "runtime_status": runtime_status,
4104
+ "has_dashboard_manifest": has_dash_manifest,
4105
+ "dashboard_manifest": _strip_dashboard_manifest(dm) if dm else None,
4106
+ "path": dir_str,
4107
+ "can_remove": can_remove_update,
4108
+ "can_update_git": can_remove_update and (Path(dir_str) / ".git").exists(),
4109
+ "auth_required": auth_required,
4110
+ "auth_command": auth_command,
4111
+ "user_hidden": name in hidden_plugins,
4112
+ })
4113
+
4114
+ agent_names = {r["name"] for r in rows}
4115
+ orphan_dashboard = [
4116
+ _strip_dashboard_manifest(p)
4117
+ for p in dashboard_list
4118
+ if str(p["name"]) not in agent_names
4119
+ ]
4120
+
4121
+ memory_providers: List[Dict[str, str]] = []
4122
+ try:
4123
+ for n, desc in _discover_memory_providers():
4124
+ memory_providers.append({"name": n, "description": desc})
4125
+ except Exception:
4126
+ memory_providers = []
4127
+
4128
+ context_engines: List[Dict[str, str]] = []
4129
+ try:
4130
+ for n, desc in _discover_context_engines():
4131
+ context_engines.append({"name": n, "description": desc})
4132
+ except Exception:
4133
+ context_engines = []
4134
+
4135
+ return {
4136
+ "plugins": rows,
4137
+ "orphan_dashboard_plugins": orphan_dashboard,
4138
+ "providers": {
4139
+ "memory_provider": _get_current_memory_provider() or "",
4140
+ "memory_options": memory_providers,
4141
+ "context_engine": _get_current_context_engine(),
4142
+ "context_options": context_engines,
4143
+ },
4144
+ }
4145
+
4146
+
4147
+ @app.get("/api/dashboard/plugins/hub")
4148
+ async def get_plugins_hub(request: Request):
4149
+ """Unified agent plugins + dashboard extension metadata (session protected)."""
4150
+ _require_token(request)
4151
+ try:
4152
+ return _merged_plugins_hub()
4153
+ except Exception as exc:
4154
+ _log.warning("plugins/hub failed: %s", exc)
4155
+ raise HTTPException(status_code=500, detail="Failed to build plugins hub.") from exc
4156
+
4157
+
4158
+ @app.post("/api/dashboard/agent-plugins/install")
4159
+ async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallBody):
4160
+ _require_token(request)
4161
+ from hermes_cli.plugins_cmd import dashboard_install_plugin
4162
+
4163
+ result = dashboard_install_plugin(
4164
+ body.identifier.strip(),
4165
+ force=body.force,
4166
+ enable=body.enable,
4167
+ )
4168
+ if not result.get("ok"):
4169
+ raise HTTPException(
4170
+ status_code=400,
4171
+ detail=result.get("error") or "Install failed.",
4172
+ )
4173
+ _get_dashboard_plugins(force_rescan=True)
4174
+ # Strip internal paths from the response
4175
+ result.pop("after_install_path", None)
4176
+ return result
4177
+
4178
+
4179
+ def _validate_plugin_name(name: str) -> str:
4180
+ """Reject path-traversal attempts in plugin name URL parameters."""
4181
+ if not name or "/" in name or "\\" in name or ".." in name:
4182
+ raise HTTPException(status_code=400, detail="Invalid plugin name.")
4183
+ return name
4184
+
4185
+
4186
+ @app.post("/api/dashboard/agent-plugins/{name}/enable")
4187
+ async def post_agent_plugin_enable(request: Request, name: str):
4188
+ _require_token(request)
4189
+ name = _validate_plugin_name(name)
4190
+ from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled
4191
+
4192
+ result = dashboard_set_agent_plugin_enabled(name, enabled=True)
4193
+ if not result.get("ok"):
4194
+ raise HTTPException(status_code=400, detail=result.get("error") or "Enable failed.")
4195
+ return result
4196
+
4197
+
4198
+ @app.post("/api/dashboard/agent-plugins/{name}/disable")
4199
+ async def post_agent_plugin_disable(request: Request, name: str):
4200
+ _require_token(request)
4201
+ name = _validate_plugin_name(name)
4202
+ from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled
4203
+
4204
+ result = dashboard_set_agent_plugin_enabled(name, enabled=False)
4205
+ if not result.get("ok"):
4206
+ raise HTTPException(status_code=400, detail=result.get("error") or "Disable failed.")
4207
+ return result
4208
+
4209
+
4210
+ @app.post("/api/dashboard/agent-plugins/{name}/update")
4211
+ async def post_agent_plugin_update(request: Request, name: str):
4212
+ _require_token(request)
4213
+ name = _validate_plugin_name(name)
4214
+ from hermes_cli.plugins_cmd import dashboard_update_user_plugin
4215
+
4216
+ result = dashboard_update_user_plugin(name)
4217
+ if not result.get("ok"):
4218
+ raise HTTPException(status_code=400, detail=result.get("error") or "Update failed.")
4219
+ _get_dashboard_plugins(force_rescan=True)
4220
+ return result
4221
+
4222
+
4223
+ @app.delete("/api/dashboard/agent-plugins/{name}")
4224
+ async def delete_agent_plugin(request: Request, name: str):
4225
+ _require_token(request)
4226
+ name = _validate_plugin_name(name)
4227
+ from hermes_cli.plugins_cmd import dashboard_remove_user_plugin
4228
+
4229
+ result = dashboard_remove_user_plugin(name)
4230
+ if not result.get("ok"):
4231
+ raise HTTPException(status_code=400, detail=result.get("error") or "Remove failed.")
4232
+ _get_dashboard_plugins(force_rescan=True)
4233
+ return result
4234
+
4235
+
4236
+ class _PluginProvidersPutBody(BaseModel):
4237
+ memory_provider: Optional[str] = None
4238
+ context_engine: Optional[str] = None
4239
+
4240
+
4241
+ @app.put("/api/dashboard/plugin-providers")
4242
+ async def put_plugin_providers(request: Request, body: _PluginProvidersPutBody):
4243
+ """Persist memory provider / context engine selection (writes config.yaml)."""
4244
+ _require_token(request)
4245
+ from hermes_cli.plugins_cmd import (
4246
+ _save_context_engine,
4247
+ _save_memory_provider,
4248
+ )
4249
+
4250
+ if body.memory_provider is not None:
4251
+ _save_memory_provider(body.memory_provider)
4252
+ if body.context_engine is not None:
4253
+ _save_context_engine(body.context_engine)
4254
+ return {"ok": True}
4255
+
4256
+
4257
+ class _PluginVisibilityBody(BaseModel):
4258
+ hidden: bool
4259
+
4260
+
4261
+ @app.post("/api/dashboard/plugins/{name}/visibility")
4262
+ async def post_plugin_visibility(request: Request, name: str, body: _PluginVisibilityBody):
4263
+ """Toggle a plugin's sidebar visibility (persists to config.yaml dashboard.hidden_plugins)."""
4264
+ _require_token(request)
4265
+ name = _validate_plugin_name(name)
4266
+
4267
+ config = load_config()
4268
+ if "dashboard" not in config or not isinstance(config.get("dashboard"), dict):
4269
+ config["dashboard"] = {}
4270
+ hidden_list: list = config["dashboard"].get("hidden_plugins") or []
4271
+ if not isinstance(hidden_list, list):
4272
+ hidden_list = []
4273
+
4274
+ if body.hidden and name not in hidden_list:
4275
+ hidden_list.append(name)
4276
+ elif not body.hidden and name in hidden_list:
4277
+ hidden_list.remove(name)
4278
+
4279
+ config["dashboard"]["hidden_plugins"] = hidden_list
4280
+ save_config(config)
4281
+ return {"ok": True, "name": name, "hidden": body.hidden}
4282
+
4283
+
4284
+ @app.get("/dashboard-plugins/{plugin_name}/{file_path:path}")
4285
+ async def serve_plugin_asset(plugin_name: str, file_path: str):
4286
+ """Serve static assets from a dashboard plugin directory.
4287
+
4288
+ Only serves files from the plugin's ``dashboard/`` subdirectory.
4289
+ Path traversal is blocked by checking ``resolve().is_relative_to()``.
4290
+ """
4291
+ plugins = _get_dashboard_plugins()
4292
+ plugin = next((p for p in plugins if p["name"] == plugin_name), None)
4293
+ if not plugin:
4294
+ raise HTTPException(status_code=404, detail="Plugin not found")
4295
+
4296
+ base = Path(plugin["_dir"])
4297
+ target = (base / file_path).resolve()
4298
+
4299
+ if not target.is_relative_to(base.resolve()):
4300
+ raise HTTPException(status_code=403, detail="Path traversal blocked")
4301
+ if not target.exists() or not target.is_file():
4302
+ raise HTTPException(status_code=404, detail="File not found")
4303
+
4304
+ # Guess content type
4305
+ suffix = target.suffix.lower()
4306
+ content_types = {
4307
+ ".js": "application/javascript",
4308
+ ".mjs": "application/javascript",
4309
+ ".css": "text/css",
4310
+ ".json": "application/json",
4311
+ ".html": "text/html",
4312
+ ".svg": "image/svg+xml",
4313
+ ".png": "image/png",
4314
+ ".jpg": "image/jpeg",
4315
+ ".woff2": "font/woff2",
4316
+ ".woff": "font/woff",
4317
+ }
4318
+ media_type = content_types.get(suffix, "application/octet-stream")
4319
+ return FileResponse(target, media_type=media_type)
4320
+
4321
+
4322
+ def _mount_plugin_api_routes():
4323
+ """Import and mount backend API routes from plugins that declare them.
4324
+
4325
+ Each plugin's ``api`` field points to a Python file that must expose
4326
+ a ``router`` (FastAPI APIRouter). Routes are mounted under
4327
+ ``/api/plugins/<name>/``.
4328
+ """
4329
+ for plugin in _get_dashboard_plugins():
4330
+ api_file_name = plugin.get("_api_file")
4331
+ if not api_file_name:
4332
+ continue
4333
+ api_path = Path(plugin["_dir"]) / api_file_name
4334
+ if not api_path.exists():
4335
+ _log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name)
4336
+ continue
4337
+ try:
4338
+ module_name = f"hermes_dashboard_plugin_{plugin['name']}"
4339
+ spec = importlib.util.spec_from_file_location(module_name, api_path)
4340
+ if spec is None or spec.loader is None:
4341
+ continue
4342
+ mod = importlib.util.module_from_spec(spec)
4343
+ # Register in sys.modules BEFORE exec_module so pydantic/FastAPI
4344
+ # can resolve forward references (e.g. models defined in a file
4345
+ # that uses `from __future__ import annotations`). Without this,
4346
+ # TypeAdapter lazy-build fails at first request with
4347
+ # "is not fully defined" because the module namespace isn't
4348
+ # reachable by name for string-annotation resolution.
4349
+ sys.modules[module_name] = mod
4350
+ try:
4351
+ spec.loader.exec_module(mod)
4352
+ except Exception:
4353
+ sys.modules.pop(module_name, None)
4354
+ raise
4355
+ router = getattr(mod, "router", None)
4356
+ if router is None:
4357
+ _log.warning("Plugin %s api file has no 'router' attribute", plugin["name"])
4358
+ continue
4359
+ app.include_router(router, prefix=f"/api/plugins/{plugin['name']}")
4360
+ _log.info("Mounted plugin API routes: /api/plugins/%s/", plugin["name"])
4361
+ except Exception as exc:
4362
+ _log.warning("Failed to load plugin %s API routes: %s", plugin["name"], exc)
4363
+
4364
+
4365
+ # Mount plugin API routes before the SPA catch-all.
4366
+ _mount_plugin_api_routes()
4367
+
4368
+ mount_spa(app)
4369
+
4370
+
4371
+ def start_server(
4372
+ host: str = "127.0.0.1",
4373
+ port: int = 9119,
4374
+ open_browser: bool = True,
4375
+ allow_public: bool = False,
4376
+ *,
4377
+ embedded_chat: bool = False,
4378
+ ):
4379
+ """Start the web UI server."""
4380
+ import uvicorn
4381
+
4382
+ global _DASHBOARD_EMBEDDED_CHAT_ENABLED
4383
+ _DASHBOARD_EMBEDDED_CHAT_ENABLED = embedded_chat
4384
+
4385
+ _LOCALHOST = ("127.0.0.1", "localhost", "::1")
4386
+ if host not in _LOCALHOST and not allow_public:
4387
+ raise SystemExit(
4388
+ f"Refusing to bind to {host} — the dashboard exposes API keys "
4389
+ f"and config without robust authentication.\n"
4390
+ f"Use --insecure to override (NOT recommended on untrusted networks)."
4391
+ )
4392
+ if host not in _LOCALHOST:
4393
+ _log.warning(
4394
+ "Binding to %s with --insecure — the dashboard has no robust "
4395
+ "authentication. Only use on trusted networks.", host,
4396
+ )
4397
+
4398
+ # Record the bound host so host_header_middleware can validate incoming
4399
+ # Host headers against it. Defends against DNS rebinding (GHSA-ppp5-vxwm-4cf7).
4400
+ # bound_port is also stashed so /api/pty can build the back-WS URL the
4401
+ # PTY child uses to publish events to the dashboard sidebar.
4402
+ app.state.bound_host = host
4403
+ app.state.bound_port = port
4404
+
4405
+ if open_browser:
4406
+ import webbrowser
4407
+
4408
+ # On headless Linux (no DISPLAY or WAYLAND_DISPLAY) some registered
4409
+ # browsers are TUI programs (links, lynx, www-browser) that try to
4410
+ # take over the terminal. That can send SIGHUP to the server process
4411
+ # and cause an immediate exit even though uvicorn bound successfully.
4412
+ # Skip the auto-open attempt on headless systems and let the user
4413
+ # open the URL manually. macOS and Windows are always considered
4414
+ # display-capable.
4415
+ _has_display = (
4416
+ sys.platform != "linux"
4417
+ or bool(os.environ.get("DISPLAY"))
4418
+ or bool(os.environ.get("WAYLAND_DISPLAY"))
4419
+ )
4420
+
4421
+ if _has_display:
4422
+ def _open():
4423
+ try:
4424
+ time.sleep(1.0)
4425
+ webbrowser.open(f"http://{host}:{port}")
4426
+ except Exception:
4427
+ pass
4428
+
4429
+ threading.Thread(target=_open, daemon=True).start()
4430
+ else:
4431
+ _log.debug(
4432
+ "Skipping browser-open: no DISPLAY or WAYLAND_DISPLAY detected "
4433
+ "(headless Linux). Pass --no-open to suppress this detection."
4434
+ )
4435
+
4436
+ print(f" Hermes Web UI → http://{host}:{port}")
4437
+ uvicorn.run(app, host=host, port=port, log_level="warning")
4438
+