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,2967 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SQLite State Store for Hermes Agent.
4
+
5
+ Provides persistent session storage with FTS5 full-text search, replacing
6
+ the per-session JSONL file approach. Stores session metadata, full message
7
+ history, and model configuration for CLI and gateway sessions.
8
+
9
+ Key design decisions:
10
+ - WAL mode for concurrent readers + one writer (gateway multi-platform)
11
+ - FTS5 virtual table for fast text search across all session messages
12
+ - Compression-triggered session splitting via parent_session_id chains
13
+ - Batch runner and RL trajectories are NOT stored here (separate systems)
14
+ - Session source tagging ('cli', 'telegram', 'discord', etc.) for filtering
15
+ """
16
+
17
+ import json
18
+ import logging
19
+ import random
20
+ import re
21
+ import sqlite3
22
+ import threading
23
+ import time
24
+ from pathlib import Path
25
+
26
+ from agent.memory_manager import sanitize_context
27
+ from calvyn_constants import get_hermes_home
28
+ from typing import Any, Callable, Dict, List, Optional, TypeVar
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ T = TypeVar("T")
33
+
34
+ DEFAULT_DB_PATH = get_hermes_home() / "state.db"
35
+
36
+ SCHEMA_VERSION = 11
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # WAL-compatibility fallback
40
+ # ---------------------------------------------------------------------------
41
+ # SQLite's WAL mode requires shared-memory (mmap) coordination and fcntl
42
+ # byte-range locks that don't reliably work on network filesystems (NFS,
43
+ # SMB/CIFS, some FUSE mounts, WSL1). Upstream documents this explicitly:
44
+ # https://www.sqlite.org/wal.html#sometimes_queries_return_sqlite_busy_in_wal_mode
45
+ #
46
+ # On those filesystems ``PRAGMA journal_mode=WAL`` raises
47
+ # ``sqlite3.OperationalError: locking protocol`` (SQLITE_PROTOCOL). If we
48
+ # propagate that, every feature backed by state.db / kanban.db breaks
49
+ # silently — /resume, /title, /history, /branch, kanban dispatcher, etc.
50
+ #
51
+ # Instead, fall back to ``journal_mode=DELETE`` (the pre-WAL default) which
52
+ # works on NFS. Concurrency drops — concurrent readers are blocked during
53
+ # a write — but the feature works.
54
+ _WAL_INCOMPAT_MARKERS = (
55
+ "locking protocol", # SQLITE_PROTOCOL on NFS/SMB
56
+ "not authorized", # Some FUSE mounts block WAL pragma outright
57
+ "disk i/o error", # Flaky network FS during WAL setup
58
+ )
59
+
60
+ # Last SessionDB() init error, per-process. Surfaced in /resume and
61
+ # related slash-command error strings so users know WHY the DB is
62
+ # unavailable instead of getting a bare "Session database not available."
63
+ # Only SessionDB.__init__ writes to this; kanban_db.connect() failures
64
+ # do not update it (by design — kanban failures are reported via their
65
+ # own caller's error handling, not via /resume-style slash commands).
66
+ _last_init_error: Optional[str] = None
67
+ _last_init_error_lock = threading.Lock()
68
+
69
+ # Paths for which we've already logged a WAL-fallback WARNING. Without
70
+ # this, kanban_db.connect() (called on every kanban operation — see
71
+ # hermes_cli/kanban_db.py for ~30 call sites) would re-log the same
72
+ # filesystem-incompat warning on every connection, filling errors.log.
73
+ _wal_fallback_warned_paths: set[str] = set()
74
+ _wal_fallback_warned_lock = threading.Lock()
75
+
76
+
77
+ def _set_last_init_error(msg: Optional[str]) -> None:
78
+ """Record (or clear) the most recent state.db init failure.
79
+
80
+ Thread-safe via _last_init_error_lock. Callers pass a message to
81
+ record a failure or None to clear. SessionDB.__init__ only calls
82
+ this to SET on failure — it deliberately does NOT clear on success,
83
+ because in a multi-threaded caller (e.g. gateway / web_server per-
84
+ request SessionDB() instantiation), a concurrent successful open
85
+ racing past a different thread's failure would erase the cause
86
+ string that thread's /resume handler is about to format. Explicit
87
+ clears (e.g. test fixtures) are still supported by passing None.
88
+ """
89
+ global _last_init_error
90
+ with _last_init_error_lock:
91
+ _last_init_error = msg
92
+
93
+
94
+ def get_last_init_error() -> Optional[str]:
95
+ """Return the most recent state.db init failure, if any.
96
+
97
+ Slash-command handlers (``/resume``, ``/title``, ``/history``, ``/branch``)
98
+ call this to surface the underlying cause in their error messages when
99
+ ``_session_db is None``. Returns ``None`` if SessionDB initialized
100
+ successfully (or hasn't been attempted).
101
+ """
102
+ return _last_init_error
103
+
104
+
105
+ def format_session_db_unavailable(prefix: str = "Session database not available") -> str:
106
+ """Format a user-facing 'session DB unavailable' message with cause.
107
+
108
+ When ``SessionDB()`` init fails, callers set ``_session_db = None`` and
109
+ several slash commands (/resume, /title, /history, /branch) previously
110
+ responded with a bare ``"Session database not available."`` — no
111
+ indication of WHY. This helper includes the captured cause (typically
112
+ ``"locking protocol"`` from NFS/SMB) and points users at the known
113
+ culprit so they can fix it themselves.
114
+
115
+ Example output:
116
+ Session database not available: locking protocol (state.db may be
117
+ on NFS/SMB — see https://www.sqlite.org/wal.html).
118
+ """
119
+ cause = get_last_init_error()
120
+ if not cause:
121
+ return f"{prefix}."
122
+ hint = ""
123
+ if any(marker in cause.lower() for marker in _WAL_INCOMPAT_MARKERS):
124
+ hint = " (state.db may be on NFS/SMB/FUSE — see https://www.sqlite.org/wal.html)"
125
+ return f"{prefix}: {cause}{hint}."
126
+
127
+
128
+ def apply_wal_with_fallback(
129
+ conn: sqlite3.Connection,
130
+ *,
131
+ db_label: str = "state.db",
132
+ ) -> str:
133
+ """Set ``journal_mode=WAL`` on ``conn``, falling back to DELETE on failure.
134
+
135
+ Returns the journal mode actually set (``"wal"`` or ``"delete"``).
136
+
137
+ On WAL-incompatible filesystems (NFS, SMB, some FUSE), SQLite raises
138
+ ``OperationalError("locking protocol")`` when setting WAL. We fall
139
+ back to DELETE mode — the pre-WAL default, which works on NFS — and
140
+ log one WARNING explaining why.
141
+
142
+ The WARNING is deduplicated per ``db_label``: repeated connections
143
+ to the same underlying DB (e.g. kanban_db.connect() which is called
144
+ on every kanban operation) log once per process, not once per call.
145
+ Different db_labels log independently, so state.db and kanban.db
146
+ each get one warning on the same NFS mount.
147
+
148
+ Shared by :class:`SessionDB` and ``hermes_cli.kanban_db.connect`` so
149
+ both databases get identical fallback behavior.
150
+ """
151
+ try:
152
+ conn.execute("PRAGMA journal_mode=WAL")
153
+ return "wal"
154
+ except sqlite3.OperationalError as exc:
155
+ msg = str(exc).lower()
156
+ if not any(marker in msg for marker in _WAL_INCOMPAT_MARKERS):
157
+ # Unrelated OperationalError — don't silently swallow.
158
+ raise
159
+ _log_wal_fallback_once(db_label, exc)
160
+ conn.execute("PRAGMA journal_mode=DELETE")
161
+ return "delete"
162
+
163
+
164
+ def _log_wal_fallback_once(db_label: str, exc: Exception) -> None:
165
+ """Log a single WARNING per (process, db_label) about WAL fallback.
166
+
167
+ Without this dedup, NFS users running kanban (which opens a fresh
168
+ connection on every operation — see hermes_cli/kanban_db.py) would
169
+ fill errors.log with hundreds of identical warnings per hour.
170
+ """
171
+ with _wal_fallback_warned_lock:
172
+ if db_label in _wal_fallback_warned_paths:
173
+ return
174
+ _wal_fallback_warned_paths.add(db_label)
175
+ logger.warning(
176
+ "%s: WAL journal_mode unsupported on this filesystem (%s) — "
177
+ "falling back to journal_mode=DELETE (slower rollback-journal "
178
+ "mode; reduces concurrency but works on NFS/SMB/FUSE). See "
179
+ "https://www.sqlite.org/wal.html for details. This warning "
180
+ "fires once per process per database.",
181
+ db_label,
182
+ exc,
183
+ )
184
+
185
+ SCHEMA_SQL = """
186
+ CREATE TABLE IF NOT EXISTS schema_version (
187
+ version INTEGER NOT NULL
188
+ );
189
+
190
+ CREATE TABLE IF NOT EXISTS sessions (
191
+ id TEXT PRIMARY KEY,
192
+ source TEXT NOT NULL,
193
+ user_id TEXT,
194
+ model TEXT,
195
+ model_config TEXT,
196
+ system_prompt TEXT,
197
+ parent_session_id TEXT,
198
+ started_at REAL NOT NULL,
199
+ ended_at REAL,
200
+ end_reason TEXT,
201
+ message_count INTEGER DEFAULT 0,
202
+ tool_call_count INTEGER DEFAULT 0,
203
+ input_tokens INTEGER DEFAULT 0,
204
+ output_tokens INTEGER DEFAULT 0,
205
+ cache_read_tokens INTEGER DEFAULT 0,
206
+ cache_write_tokens INTEGER DEFAULT 0,
207
+ reasoning_tokens INTEGER DEFAULT 0,
208
+ billing_provider TEXT,
209
+ billing_base_url TEXT,
210
+ billing_mode TEXT,
211
+ estimated_cost_usd REAL,
212
+ actual_cost_usd REAL,
213
+ cost_status TEXT,
214
+ cost_source TEXT,
215
+ pricing_version TEXT,
216
+ title TEXT,
217
+ api_call_count INTEGER DEFAULT 0,
218
+ handoff_state TEXT,
219
+ handoff_platform TEXT,
220
+ handoff_error TEXT,
221
+ FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
222
+ );
223
+
224
+ CREATE TABLE IF NOT EXISTS messages (
225
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
226
+ session_id TEXT NOT NULL REFERENCES sessions(id),
227
+ role TEXT NOT NULL,
228
+ content TEXT,
229
+ tool_call_id TEXT,
230
+ tool_calls TEXT,
231
+ tool_name TEXT,
232
+ timestamp REAL NOT NULL,
233
+ token_count INTEGER,
234
+ finish_reason TEXT,
235
+ reasoning TEXT,
236
+ reasoning_content TEXT,
237
+ reasoning_details TEXT,
238
+ codex_reasoning_items TEXT,
239
+ codex_message_items TEXT
240
+ );
241
+
242
+ CREATE TABLE IF NOT EXISTS state_meta (
243
+ key TEXT PRIMARY KEY,
244
+ value TEXT
245
+ );
246
+
247
+ CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);
248
+ CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id);
249
+ CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
250
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);
251
+ """
252
+
253
+ FTS_SQL = """
254
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
255
+ content
256
+ );
257
+
258
+ CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
259
+ INSERT INTO messages_fts(rowid, content) VALUES (
260
+ new.id,
261
+ COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
262
+ );
263
+ END;
264
+
265
+ CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
266
+ DELETE FROM messages_fts WHERE rowid = old.id;
267
+ END;
268
+
269
+ CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
270
+ DELETE FROM messages_fts WHERE rowid = old.id;
271
+ INSERT INTO messages_fts(rowid, content) VALUES (
272
+ new.id,
273
+ COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
274
+ );
275
+ END;
276
+ """
277
+
278
+ # Trigram FTS5 table for CJK substring search. The default unicode61
279
+ # tokenizer splits CJK characters into individual tokens, breaking phrase
280
+ # matching. The trigram tokenizer creates overlapping 3-byte sequences so
281
+ # substring queries work natively for any script (CJK, Thai, etc.).
282
+ FTS_TRIGRAM_SQL = """
283
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5(
284
+ content,
285
+ tokenize='trigram'
286
+ );
287
+
288
+ CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_insert AFTER INSERT ON messages BEGIN
289
+ INSERT INTO messages_fts_trigram(rowid, content) VALUES (
290
+ new.id,
291
+ COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
292
+ );
293
+ END;
294
+
295
+ CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_delete AFTER DELETE ON messages BEGIN
296
+ DELETE FROM messages_fts_trigram WHERE rowid = old.id;
297
+ END;
298
+
299
+ CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_update AFTER UPDATE ON messages BEGIN
300
+ DELETE FROM messages_fts_trigram WHERE rowid = old.id;
301
+ INSERT INTO messages_fts_trigram(rowid, content) VALUES (
302
+ new.id,
303
+ COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
304
+ );
305
+ END;
306
+ """
307
+
308
+
309
+ class SessionDB:
310
+ """
311
+ SQLite-backed session storage with FTS5 search.
312
+
313
+ Thread-safe for the common gateway pattern (multiple reader threads,
314
+ single writer via WAL mode). Each method opens its own cursor.
315
+ """
316
+
317
+ # ── Write-contention tuning ──
318
+ # With multiple hermes processes (gateway + CLI sessions + worktree agents)
319
+ # all sharing one state.db, WAL write-lock contention causes visible TUI
320
+ # freezes. SQLite's built-in busy handler uses a deterministic sleep
321
+ # schedule that causes convoy effects under high concurrency.
322
+ #
323
+ # Instead, we keep the SQLite timeout short (1s) and handle retries at the
324
+ # application level with random jitter, which naturally staggers competing
325
+ # writers and avoids the convoy.
326
+ _WRITE_MAX_RETRIES = 15
327
+ _WRITE_RETRY_MIN_S = 0.020 # 20ms
328
+ _WRITE_RETRY_MAX_S = 0.150 # 150ms
329
+ # Attempt a PASSIVE WAL checkpoint every N successful writes.
330
+ _CHECKPOINT_EVERY_N_WRITES = 50
331
+
332
+ def __init__(self, db_path: Path = None):
333
+ self.db_path = db_path or DEFAULT_DB_PATH
334
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
335
+
336
+ self._lock = threading.Lock()
337
+ self._write_count = 0
338
+ try:
339
+ self._conn = sqlite3.connect(
340
+ str(self.db_path),
341
+ check_same_thread=False,
342
+ # Short timeout — application-level retry with random jitter
343
+ # handles contention instead of sitting in SQLite's internal
344
+ # busy handler for up to 30s.
345
+ timeout=1.0,
346
+ # Autocommit mode: Python's default isolation_level=""
347
+ # auto-starts transactions on DML, which conflicts with our
348
+ # explicit BEGIN IMMEDIATE. None = we manage transactions
349
+ # ourselves.
350
+ isolation_level=None,
351
+ )
352
+ self._conn.row_factory = sqlite3.Row
353
+ apply_wal_with_fallback(self._conn, db_label="state.db")
354
+ self._conn.execute("PRAGMA foreign_keys=ON")
355
+
356
+ self._init_schema()
357
+ except Exception as exc:
358
+ # Capture the cause so /resume and friends can surface WHY the
359
+ # session DB is unavailable instead of a bare "Session database
360
+ # not available." Callers that catch this exception keep their
361
+ # existing ``self._session_db = None`` degradation path.
362
+ #
363
+ # Note: we deliberately do NOT clear _last_init_error on the
364
+ # success path (no else branch). In multi-threaded callers
365
+ # (gateway, web_server per-request SessionDB()), a concurrent
366
+ # successful open racing past this failure would erase the
367
+ # cause that another thread's /resume is about to format.
368
+ # Tests that need to reset the state can call
369
+ # ``calvyn_state._set_last_init_error(None)`` explicitly.
370
+ _set_last_init_error(f"{type(exc).__name__}: {exc}")
371
+ raise
372
+
373
+ # ── Core write helper ──
374
+
375
+ def _execute_write(self, fn: Callable[[sqlite3.Connection], T]) -> T:
376
+ """Execute a write transaction with BEGIN IMMEDIATE and jitter retry.
377
+
378
+ *fn* receives the connection and should perform INSERT/UPDATE/DELETE
379
+ statements. The caller must NOT call ``commit()`` — that's handled
380
+ here after *fn* returns.
381
+
382
+ BEGIN IMMEDIATE acquires the WAL write lock at transaction start
383
+ (not at commit time), so lock contention surfaces immediately.
384
+ On ``database is locked``, we release the Python lock, sleep a
385
+ random 20-150ms, and retry — breaking the convoy pattern that
386
+ SQLite's built-in deterministic backoff creates.
387
+
388
+ Returns whatever *fn* returns.
389
+ """
390
+ last_err: Optional[Exception] = None
391
+ for attempt in range(self._WRITE_MAX_RETRIES):
392
+ try:
393
+ with self._lock:
394
+ self._conn.execute("BEGIN IMMEDIATE")
395
+ try:
396
+ result = fn(self._conn)
397
+ self._conn.commit()
398
+ except BaseException:
399
+ try:
400
+ self._conn.rollback()
401
+ except Exception:
402
+ pass
403
+ raise
404
+ # Success — periodic best-effort checkpoint.
405
+ self._write_count += 1
406
+ if self._write_count % self._CHECKPOINT_EVERY_N_WRITES == 0:
407
+ self._try_wal_checkpoint()
408
+ return result
409
+ except sqlite3.OperationalError as exc:
410
+ err_msg = str(exc).lower()
411
+ if "locked" in err_msg or "busy" in err_msg:
412
+ last_err = exc
413
+ if attempt < self._WRITE_MAX_RETRIES - 1:
414
+ jitter = random.uniform(
415
+ self._WRITE_RETRY_MIN_S,
416
+ self._WRITE_RETRY_MAX_S,
417
+ )
418
+ time.sleep(jitter)
419
+ continue
420
+ # Non-lock error or retries exhausted — propagate.
421
+ raise
422
+ # Retries exhausted (shouldn't normally reach here).
423
+ raise last_err or sqlite3.OperationalError(
424
+ "database is locked after max retries"
425
+ )
426
+
427
+ def _try_wal_checkpoint(self) -> None:
428
+ """Best-effort PASSIVE WAL checkpoint. Never blocks, never raises.
429
+
430
+ Flushes committed WAL frames back into the main DB file for any
431
+ frames that no other connection currently needs. Keeps the WAL
432
+ from growing unbounded when many processes hold persistent
433
+ connections.
434
+ """
435
+ try:
436
+ with self._lock:
437
+ result = self._conn.execute(
438
+ "PRAGMA wal_checkpoint(PASSIVE)"
439
+ ).fetchone()
440
+ if result and result[1] > 0:
441
+ logger.debug(
442
+ "WAL checkpoint: %d/%d pages checkpointed",
443
+ result[2], result[1],
444
+ )
445
+ except Exception:
446
+ pass # Best effort — never fatal.
447
+
448
+ def close(self):
449
+ """Close the database connection.
450
+
451
+ Attempts a PASSIVE WAL checkpoint first so that exiting processes
452
+ help keep the WAL file from growing unbounded.
453
+ """
454
+ with self._lock:
455
+ if self._conn:
456
+ try:
457
+ self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
458
+ except Exception:
459
+ pass
460
+ self._conn.close()
461
+ self._conn = None
462
+
463
+ @staticmethod
464
+ def _parse_schema_columns(schema_sql: str) -> Dict[str, Dict[str, str]]:
465
+ """Extract expected columns per table from SCHEMA_SQL.
466
+
467
+ Uses an in-memory SQLite database to parse the SQL — SQLite itself
468
+ handles all syntax (DEFAULT expressions with commas, inline
469
+ REFERENCES, CHECK constraints, etc.) so there are zero regex
470
+ edge cases. The in-memory DB is opened, the schema DDL is
471
+ executed, and PRAGMA table_info extracts the column metadata.
472
+
473
+ Adding a column to SCHEMA_SQL is all that's needed; the
474
+ reconciliation loop picks it up automatically.
475
+ """
476
+ ref = sqlite3.connect(":memory:")
477
+ try:
478
+ ref.executescript(schema_sql)
479
+ table_columns: Dict[str, Dict[str, str]] = {}
480
+ for (tbl,) in ref.execute(
481
+ "SELECT name FROM sqlite_master "
482
+ "WHERE type='table' AND name NOT LIKE 'sqlite_%'"
483
+ ).fetchall():
484
+ cols: Dict[str, str] = {}
485
+ for row in ref.execute(
486
+ f'PRAGMA table_info("{tbl}")'
487
+ ).fetchall():
488
+ # row: (cid, name, type, notnull, dflt_value, pk)
489
+ col_name = row[1]
490
+ col_type = row[2] or ""
491
+ notnull = row[3]
492
+ default = row[4]
493
+ pk = row[5]
494
+ # Reconstruct the type expression for ALTER TABLE ADD COLUMN
495
+ parts = [col_type] if col_type else []
496
+ if notnull and not pk:
497
+ parts.append("NOT NULL")
498
+ if default is not None:
499
+ parts.append(f"DEFAULT {default}")
500
+ cols[col_name] = " ".join(parts)
501
+ table_columns[tbl] = cols
502
+ return table_columns
503
+ finally:
504
+ ref.close()
505
+
506
+ def _reconcile_columns(self, cursor: sqlite3.Cursor) -> None:
507
+ """Ensure live tables have every column declared in SCHEMA_SQL.
508
+
509
+ Follows the Beets/sqlite-utils pattern: the CREATE TABLE definition
510
+ in SCHEMA_SQL is the single source of truth for the desired schema.
511
+ On every startup this method diffs the live columns (via PRAGMA
512
+ table_info) against the declared columns, and ADDs any that are
513
+ missing.
514
+
515
+ This makes column additions a declarative operation — just add
516
+ the column to SCHEMA_SQL and it appears on the next startup.
517
+ Version-gated migration blocks are no longer needed for ADD COLUMN.
518
+ """
519
+ expected = self._parse_schema_columns(SCHEMA_SQL)
520
+ for table_name, declared_cols in expected.items():
521
+ # Get current columns from the live table
522
+ try:
523
+ rows = cursor.execute(
524
+ f'PRAGMA table_info("{table_name}")'
525
+ ).fetchall()
526
+ except sqlite3.OperationalError:
527
+ continue # Table doesn't exist yet (shouldn't happen after executescript)
528
+ live_cols = set()
529
+ for row in rows:
530
+ # PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)
531
+ name = row[1] if isinstance(row, (tuple, list)) else row["name"]
532
+ live_cols.add(name)
533
+
534
+ for col_name, col_type in declared_cols.items():
535
+ if col_name not in live_cols:
536
+ safe_name = col_name.replace('"', '""')
537
+ try:
538
+ cursor.execute(
539
+ f'ALTER TABLE "{table_name}" ADD COLUMN "{safe_name}" {col_type}'
540
+ )
541
+ except sqlite3.OperationalError as exc:
542
+ # Expected: "duplicate column name" from a race or
543
+ # re-run. Unexpected: "Cannot add a NOT NULL column
544
+ # with default value NULL" from a schema mistake.
545
+ # Log at DEBUG so it's visible in agent.log.
546
+ logger.debug(
547
+ "reconcile %s.%s: %s", table_name, col_name, exc,
548
+ )
549
+
550
+ def _init_schema(self):
551
+ """Create tables and FTS if they don't exist, reconcile columns.
552
+
553
+ Schema management follows the declarative reconciliation pattern
554
+ (Beets, sqlite-utils): SCHEMA_SQL is the single source of truth.
555
+ On existing databases, _reconcile_columns() diffs live columns
556
+ against SCHEMA_SQL and ADDs any missing ones. This eliminates
557
+ the version-gated migration chain for column additions, making
558
+ it impossible for reordered or inserted migrations to skip columns.
559
+
560
+ The schema_version table is retained for future data migrations
561
+ (transforming existing rows) which cannot be handled declaratively.
562
+ """
563
+ cursor = self._conn.cursor()
564
+
565
+ cursor.executescript(SCHEMA_SQL)
566
+
567
+ # ── Declarative column reconciliation ──────────────────────────
568
+ # Diff live tables against SCHEMA_SQL and ADD any missing columns.
569
+ # This is idempotent and self-healing: even if a version-gated
570
+ # migration was skipped (e.g. due to version renumbering), the
571
+ # column gets created here.
572
+ self._reconcile_columns(cursor)
573
+
574
+ # ── Schema version bookkeeping ─────────────────────────────────
575
+ # Bump to current so future data migrations (if any) can gate on
576
+ # version. No version-gated column additions remain.
577
+ cursor.execute("SELECT version FROM schema_version LIMIT 1")
578
+ row = cursor.fetchone()
579
+ if row is None:
580
+ cursor.execute(
581
+ "INSERT INTO schema_version (version) VALUES (?)",
582
+ (SCHEMA_VERSION,),
583
+ )
584
+ else:
585
+ current_version = row["version"] if isinstance(row, sqlite3.Row) else row[0]
586
+ # Data migrations that can't be expressed declaratively (row
587
+ # backfills, index changes tied to a specific version step) stay
588
+ # in a version-gated chain. Column additions are handled by
589
+ # _reconcile_columns() above and no longer need entries here.
590
+ if current_version < 10:
591
+ # v10: trigram FTS5 table for CJK/substring search. The
592
+ # virtual table + triggers are created unconditionally via
593
+ # FTS_TRIGRAM_SQL below, but existing rows need a one-time
594
+ # backfill into the FTS index.
595
+ try:
596
+ cursor.execute("SELECT * FROM messages_fts_trigram LIMIT 0")
597
+ _fts_trigram_exists = True
598
+ except sqlite3.OperationalError:
599
+ _fts_trigram_exists = False
600
+ if not _fts_trigram_exists:
601
+ cursor.executescript(FTS_TRIGRAM_SQL)
602
+ cursor.execute(
603
+ "INSERT INTO messages_fts_trigram(rowid, content) "
604
+ "SELECT id, content FROM messages WHERE content IS NOT NULL"
605
+ )
606
+ if current_version < 11:
607
+ # v11: re-index FTS5 tables to cover tool_name + tool_calls and
608
+ # switch from external-content to inline mode. Existing DBs have
609
+ # old-schema FTS tables and triggers that IF NOT EXISTS won't
610
+ # overwrite, so we drop them explicitly and let the post-migration
611
+ # existence checks (below) recreate them from FTS_SQL /
612
+ # FTS_TRIGRAM_SQL, then backfill every message row. Fixes #16751.
613
+ for _trig in (
614
+ "messages_fts_insert",
615
+ "messages_fts_delete",
616
+ "messages_fts_update",
617
+ "messages_fts_trigram_insert",
618
+ "messages_fts_trigram_delete",
619
+ "messages_fts_trigram_update",
620
+ ):
621
+ try:
622
+ cursor.execute(f"DROP TRIGGER IF EXISTS {_trig}")
623
+ except sqlite3.OperationalError:
624
+ pass
625
+ for _tbl in ("messages_fts", "messages_fts_trigram"):
626
+ try:
627
+ cursor.execute(f"DROP TABLE IF EXISTS {_tbl}")
628
+ except sqlite3.OperationalError:
629
+ pass
630
+ # Recreate virtual tables + triggers with the new inline-mode
631
+ # schema that indexes content || tool_name || tool_calls.
632
+ cursor.executescript(FTS_SQL)
633
+ cursor.executescript(FTS_TRIGRAM_SQL)
634
+ # Backfill both indexes from every existing messages row.
635
+ cursor.execute(
636
+ "INSERT INTO messages_fts(rowid, content) "
637
+ "SELECT id, "
638
+ "COALESCE(content, '') || ' ' || "
639
+ "COALESCE(tool_name, '') || ' ' || "
640
+ "COALESCE(tool_calls, '') "
641
+ "FROM messages"
642
+ )
643
+ cursor.execute(
644
+ "INSERT INTO messages_fts_trigram(rowid, content) "
645
+ "SELECT id, "
646
+ "COALESCE(content, '') || ' ' || "
647
+ "COALESCE(tool_name, '') || ' ' || "
648
+ "COALESCE(tool_calls, '') "
649
+ "FROM messages"
650
+ )
651
+ if current_version < SCHEMA_VERSION:
652
+ cursor.execute(
653
+ "UPDATE schema_version SET version = ?",
654
+ (SCHEMA_VERSION,),
655
+ )
656
+
657
+ # Unique title index — always ensure it exists
658
+ try:
659
+ cursor.execute(
660
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique "
661
+ "ON sessions(title) WHERE title IS NOT NULL"
662
+ )
663
+ except sqlite3.OperationalError:
664
+ pass # Index already exists
665
+
666
+ # FTS5 setup (separate because CREATE VIRTUAL TABLE can't be in executescript with IF NOT EXISTS reliably)
667
+ try:
668
+ cursor.execute("SELECT * FROM messages_fts LIMIT 0")
669
+ except sqlite3.OperationalError:
670
+ cursor.executescript(FTS_SQL)
671
+
672
+ # Trigram FTS5 for CJK/substring search
673
+ try:
674
+ cursor.execute("SELECT * FROM messages_fts_trigram LIMIT 0")
675
+ except sqlite3.OperationalError:
676
+ cursor.executescript(FTS_TRIGRAM_SQL)
677
+
678
+ self._conn.commit()
679
+
680
+ # =========================================================================
681
+ # Session lifecycle
682
+ # =========================================================================
683
+
684
+ def _insert_session_row(
685
+ self,
686
+ session_id: str,
687
+ source: str,
688
+ model: str = None,
689
+ model_config: Dict[str, Any] = None,
690
+ system_prompt: str = None,
691
+ user_id: str = None,
692
+ parent_session_id: str = None,
693
+ ) -> None:
694
+ """Shared INSERT OR IGNORE for session rows."""
695
+ def _do(conn):
696
+ conn.execute(
697
+ """INSERT OR IGNORE INTO sessions (id, source, user_id, model, model_config,
698
+ system_prompt, parent_session_id, started_at)
699
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
700
+ (
701
+ session_id,
702
+ source,
703
+ user_id,
704
+ model,
705
+ json.dumps(model_config) if model_config else None,
706
+ system_prompt,
707
+ parent_session_id,
708
+ time.time(),
709
+ ),
710
+ )
711
+ self._execute_write(_do)
712
+
713
+ def create_session(self, session_id: str, source: str, **kwargs) -> str:
714
+ """Create a new session record. Returns the session_id."""
715
+ self._insert_session_row(session_id, source, **kwargs)
716
+ return session_id
717
+ def end_session(self, session_id: str, end_reason: str) -> None:
718
+ """Mark a session as ended.
719
+
720
+ No-ops when the session is already ended. The first end_reason wins:
721
+ compression-split sessions must keep their ``end_reason = 'compression'``
722
+ record even if a later stale ``end_session()`` call (e.g. from a
723
+ desynced CLI session_id after ``/resume`` or ``/branch``) targets them
724
+ with a different reason. Use ``reopen_session()`` first if you
725
+ intentionally need to re-end a closed session with a new reason.
726
+ """
727
+ def _do(conn):
728
+ conn.execute(
729
+ "UPDATE sessions SET ended_at = ?, end_reason = ? "
730
+ "WHERE id = ? AND ended_at IS NULL",
731
+ (time.time(), end_reason, session_id),
732
+ )
733
+ self._execute_write(_do)
734
+
735
+ def reopen_session(self, session_id: str) -> None:
736
+ """Clear ended_at/end_reason so a session can be resumed."""
737
+ def _do(conn):
738
+ conn.execute(
739
+ "UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?",
740
+ (session_id,),
741
+ )
742
+ self._execute_write(_do)
743
+
744
+ def update_system_prompt(self, session_id: str, system_prompt: str) -> None:
745
+ """Store the full assembled system prompt snapshot."""
746
+ def _do(conn):
747
+ conn.execute(
748
+ "UPDATE sessions SET system_prompt = ? WHERE id = ?",
749
+ (system_prompt, session_id),
750
+ )
751
+ self._execute_write(_do)
752
+
753
+ def update_token_counts(
754
+ self,
755
+ session_id: str,
756
+ input_tokens: int = 0,
757
+ output_tokens: int = 0,
758
+ model: str = None,
759
+ cache_read_tokens: int = 0,
760
+ cache_write_tokens: int = 0,
761
+ reasoning_tokens: int = 0,
762
+ estimated_cost_usd: Optional[float] = None,
763
+ actual_cost_usd: Optional[float] = None,
764
+ cost_status: Optional[str] = None,
765
+ cost_source: Optional[str] = None,
766
+ pricing_version: Optional[str] = None,
767
+ billing_provider: Optional[str] = None,
768
+ billing_base_url: Optional[str] = None,
769
+ billing_mode: Optional[str] = None,
770
+ api_call_count: int = 0,
771
+ absolute: bool = False,
772
+ ) -> None:
773
+ """Update token counters and backfill model if not already set.
774
+
775
+ When *absolute* is False (default), values are **incremented** — use
776
+ this for per-API-call deltas (CLI path).
777
+
778
+ When *absolute* is True, values are **set directly** — use this when
779
+ the caller already holds cumulative totals (gateway path, where the
780
+ cached agent accumulates across messages).
781
+ """
782
+ # Ensure the session row exists so the UPDATE doesn't silently affect
783
+ # 0 rows. Under concurrent load (cron + kanban + delegate_task) the
784
+ # initial create_session() may have failed due to SQLite locking.
785
+ # INSERT OR IGNORE is cheap and idempotent.
786
+ self._insert_session_row(session_id, "unknown", model=model)
787
+ if absolute:
788
+ sql = """UPDATE sessions SET
789
+ input_tokens = ?,
790
+ output_tokens = ?,
791
+ cache_read_tokens = ?,
792
+ cache_write_tokens = ?,
793
+ reasoning_tokens = ?,
794
+ estimated_cost_usd = COALESCE(?, 0),
795
+ actual_cost_usd = CASE
796
+ WHEN ? IS NULL THEN actual_cost_usd
797
+ ELSE ?
798
+ END,
799
+ cost_status = COALESCE(?, cost_status),
800
+ cost_source = COALESCE(?, cost_source),
801
+ pricing_version = COALESCE(?, pricing_version),
802
+ billing_provider = COALESCE(billing_provider, ?),
803
+ billing_base_url = COALESCE(billing_base_url, ?),
804
+ billing_mode = COALESCE(billing_mode, ?),
805
+ model = COALESCE(model, ?),
806
+ api_call_count = ?
807
+ WHERE id = ?"""
808
+ else:
809
+ sql = """UPDATE sessions SET
810
+ input_tokens = input_tokens + ?,
811
+ output_tokens = output_tokens + ?,
812
+ cache_read_tokens = cache_read_tokens + ?,
813
+ cache_write_tokens = cache_write_tokens + ?,
814
+ reasoning_tokens = reasoning_tokens + ?,
815
+ estimated_cost_usd = COALESCE(estimated_cost_usd, 0) + COALESCE(?, 0),
816
+ actual_cost_usd = CASE
817
+ WHEN ? IS NULL THEN actual_cost_usd
818
+ ELSE COALESCE(actual_cost_usd, 0) + ?
819
+ END,
820
+ cost_status = COALESCE(?, cost_status),
821
+ cost_source = COALESCE(?, cost_source),
822
+ pricing_version = COALESCE(?, pricing_version),
823
+ billing_provider = COALESCE(billing_provider, ?),
824
+ billing_base_url = COALESCE(billing_base_url, ?),
825
+ billing_mode = COALESCE(billing_mode, ?),
826
+ model = COALESCE(model, ?),
827
+ api_call_count = COALESCE(api_call_count, 0) + ?
828
+ WHERE id = ?"""
829
+ params = (
830
+ input_tokens,
831
+ output_tokens,
832
+ cache_read_tokens,
833
+ cache_write_tokens,
834
+ reasoning_tokens,
835
+ estimated_cost_usd,
836
+ actual_cost_usd,
837
+ actual_cost_usd,
838
+ cost_status,
839
+ cost_source,
840
+ pricing_version,
841
+ billing_provider,
842
+ billing_base_url,
843
+ billing_mode,
844
+ model,
845
+ api_call_count,
846
+ session_id,
847
+ )
848
+ def _do(conn):
849
+ conn.execute(sql, params)
850
+ self._execute_write(_do)
851
+
852
+ def ensure_session(
853
+ self,
854
+ session_id: str,
855
+ source: str = "unknown",
856
+ model: str = None,
857
+ **kwargs,
858
+ ) -> str:
859
+ """Ensure a session row exists (INSERT OR IGNORE). Accepts optional kwargs."""
860
+ self._insert_session_row(session_id, source, model=model, **kwargs)
861
+ return session_id
862
+
863
+ def prune_empty_ghost_sessions(self, sessions_dir: "Optional[Path]" = None) -> int:
864
+ """Remove empty TUI ghost sessions (no messages, no title, >24hr old)."""
865
+ cutoff = time.time() - 86400 # Only sessions older than 24 hours
866
+
867
+ def _do(conn):
868
+ rows = conn.execute("""
869
+ SELECT id FROM sessions
870
+ WHERE source = 'tui'
871
+ AND title IS NULL
872
+ AND ended_at IS NOT NULL
873
+ AND started_at < ?
874
+ AND NOT EXISTS (
875
+ SELECT 1 FROM messages WHERE messages.session_id = sessions.id
876
+ )
877
+ """, (cutoff,)).fetchall()
878
+ ids = [r[0] if isinstance(r, (tuple, list)) else r["id"] for r in rows]
879
+ if ids:
880
+ placeholders = ",".join("?" * len(ids))
881
+ conn.execute(
882
+ f"DELETE FROM sessions WHERE id IN ({placeholders})", ids
883
+ )
884
+ return ids
885
+
886
+ removed_ids = self._execute_write(_do) or []
887
+ # Clean up any on-disk session files (belt-and-suspenders)
888
+ if sessions_dir and removed_ids:
889
+ for sid in removed_ids:
890
+ self._remove_session_files(sessions_dir, sid)
891
+ return len(removed_ids)
892
+
893
+ def finalize_orphaned_compression_sessions(self) -> int:
894
+ """Mark orphaned compression continuation sessions as ended.
895
+
896
+ Targets child sessions that were never finalized: parent is ended
897
+ with reason='compression', child has messages but no end_reason/ended_at
898
+ and api_call_count=0. Non-destructive: preserves all messages and sets
899
+ end_reason='orphaned_compression'. Fix for #20001.
900
+ """
901
+ cutoff = time.time() - 604800 # 7 days
902
+
903
+ def _do(conn):
904
+ now = time.time()
905
+ result = conn.execute(
906
+ """
907
+ UPDATE sessions
908
+ SET ended_at = ?,
909
+ end_reason = 'orphaned_compression'
910
+ WHERE api_call_count = 0
911
+ AND end_reason IS NULL
912
+ AND ended_at IS NULL
913
+ AND started_at < ?
914
+ AND parent_session_id IS NOT NULL
915
+ AND EXISTS (
916
+ SELECT 1 FROM sessions p
917
+ WHERE p.id = sessions.parent_session_id
918
+ AND p.end_reason = 'compression'
919
+ AND p.ended_at IS NOT NULL
920
+ )
921
+ AND EXISTS (
922
+ SELECT 1 FROM messages m
923
+ WHERE m.session_id = sessions.id
924
+ )
925
+ """,
926
+ (now, cutoff),
927
+ )
928
+ return result.rowcount
929
+
930
+ return self._execute_write(_do) or 0
931
+
932
+ def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
933
+ """Get a session by ID."""
934
+ with self._lock:
935
+ cursor = self._conn.execute(
936
+ "SELECT * FROM sessions WHERE id = ?", (session_id,)
937
+ )
938
+ row = cursor.fetchone()
939
+ return dict(row) if row else None
940
+
941
+ def resolve_session_id(self, session_id_or_prefix: str) -> Optional[str]:
942
+ """Resolve an exact or uniquely prefixed session ID to the full ID.
943
+
944
+ Returns the exact ID when it exists. Otherwise treats the input as a
945
+ prefix and returns the single matching session ID if the prefix is
946
+ unambiguous. Returns None for no matches or ambiguous prefixes.
947
+ """
948
+ exact = self.get_session(session_id_or_prefix)
949
+ if exact:
950
+ return exact["id"]
951
+
952
+ escaped = (
953
+ session_id_or_prefix
954
+ .replace("\\", "\\\\")
955
+ .replace("%", "\\%")
956
+ .replace("_", "\\_")
957
+ )
958
+ with self._lock:
959
+ cursor = self._conn.execute(
960
+ "SELECT id FROM sessions WHERE id LIKE ? ESCAPE '\\' ORDER BY started_at DESC LIMIT 2",
961
+ (f"{escaped}%",),
962
+ )
963
+ matches = [row["id"] for row in cursor.fetchall()]
964
+ if len(matches) == 1:
965
+ return matches[0]
966
+ return None
967
+
968
+ # Maximum length for session titles
969
+ MAX_TITLE_LENGTH = 100
970
+
971
+ @staticmethod
972
+ def sanitize_title(title: Optional[str]) -> Optional[str]:
973
+ """Validate and sanitize a session title.
974
+
975
+ - Strips leading/trailing whitespace
976
+ - Removes ASCII control characters (0x00-0x1F, 0x7F) and problematic
977
+ Unicode control chars (zero-width, RTL/LTR overrides, etc.)
978
+ - Collapses internal whitespace runs to single spaces
979
+ - Normalizes empty/whitespace-only strings to None
980
+ - Enforces MAX_TITLE_LENGTH
981
+
982
+ Returns the cleaned title string or None.
983
+ Raises ValueError if the title exceeds MAX_TITLE_LENGTH after cleaning.
984
+ """
985
+ if not title:
986
+ return None
987
+
988
+ # Remove ASCII control characters (0x00-0x1F, 0x7F) but keep
989
+ # whitespace chars (\t=0x09, \n=0x0A, \r=0x0D) so they can be
990
+ # normalized to spaces by the whitespace collapsing step below
991
+ cleaned = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', title)
992
+
993
+ # Remove problematic Unicode control characters:
994
+ # - Zero-width chars (U+200B-U+200F, U+FEFF)
995
+ # - Directional overrides (U+202A-U+202E, U+2066-U+2069)
996
+ # - Object replacement (U+FFFC), interlinear annotation (U+FFF9-U+FFFB)
997
+ cleaned = re.sub(
998
+ r'[\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff\ufffc\ufff9-\ufffb]',
999
+ '', cleaned,
1000
+ )
1001
+
1002
+ # Collapse internal whitespace runs and strip
1003
+ cleaned = re.sub(r'\s+', ' ', cleaned).strip()
1004
+
1005
+ if not cleaned:
1006
+ return None
1007
+
1008
+ if len(cleaned) > SessionDB.MAX_TITLE_LENGTH:
1009
+ raise ValueError(
1010
+ f"Title too long ({len(cleaned)} chars, max {SessionDB.MAX_TITLE_LENGTH})"
1011
+ )
1012
+
1013
+ return cleaned
1014
+
1015
+ def set_session_title(self, session_id: str, title: str) -> bool:
1016
+ """Set or update a session's title.
1017
+
1018
+ Returns True if session was found and title was set.
1019
+ Raises ValueError if title is already in use by another session,
1020
+ or if the title fails validation (too long, invalid characters).
1021
+ Empty/whitespace-only strings are normalized to None (clearing the title).
1022
+ """
1023
+ title = self.sanitize_title(title)
1024
+ def _do(conn):
1025
+ if title:
1026
+ # Check uniqueness (allow the same session to keep its own title)
1027
+ cursor = conn.execute(
1028
+ "SELECT id FROM sessions WHERE title = ? AND id != ?",
1029
+ (title, session_id),
1030
+ )
1031
+ conflict = cursor.fetchone()
1032
+ if conflict:
1033
+ raise ValueError(
1034
+ f"Title '{title}' is already in use by session {conflict['id']}"
1035
+ )
1036
+ cursor = conn.execute(
1037
+ "UPDATE sessions SET title = ? WHERE id = ?",
1038
+ (title, session_id),
1039
+ )
1040
+ return cursor.rowcount
1041
+ rowcount = self._execute_write(_do)
1042
+ return rowcount > 0
1043
+
1044
+ def get_session_title(self, session_id: str) -> Optional[str]:
1045
+ """Get the title for a session, or None."""
1046
+ with self._lock:
1047
+ cursor = self._conn.execute(
1048
+ "SELECT title FROM sessions WHERE id = ?", (session_id,)
1049
+ )
1050
+ row = cursor.fetchone()
1051
+ return row["title"] if row else None
1052
+
1053
+ def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
1054
+ """Look up a session by exact title. Returns session dict or None."""
1055
+ with self._lock:
1056
+ cursor = self._conn.execute(
1057
+ "SELECT * FROM sessions WHERE title = ?", (title,)
1058
+ )
1059
+ row = cursor.fetchone()
1060
+ return dict(row) if row else None
1061
+
1062
+ def resolve_session_by_title(self, title: str) -> Optional[str]:
1063
+ """Resolve a title to a session ID, preferring the latest in a lineage.
1064
+
1065
+ If the exact title exists, returns that session's ID.
1066
+ If not, searches for "title #N" variants and returns the latest one.
1067
+ If the exact title exists AND numbered variants exist, returns the
1068
+ latest numbered variant (the most recent continuation).
1069
+ """
1070
+ # First try exact match
1071
+ exact = self.get_session_by_title(title)
1072
+
1073
+ # Also search for numbered variants: "title #2", "title #3", etc.
1074
+ # Escape SQL LIKE wildcards (%, _) in the title to prevent false matches
1075
+ escaped = title.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
1076
+ with self._lock:
1077
+ cursor = self._conn.execute(
1078
+ "SELECT id, title, started_at FROM sessions "
1079
+ "WHERE title LIKE ? ESCAPE '\\' ORDER BY started_at DESC",
1080
+ (f"{escaped} #%",),
1081
+ )
1082
+ numbered = cursor.fetchall()
1083
+
1084
+ if numbered:
1085
+ # Return the most recent numbered variant
1086
+ return numbered[0]["id"]
1087
+ elif exact:
1088
+ return exact["id"]
1089
+ return None
1090
+
1091
+ def get_next_title_in_lineage(self, base_title: str) -> str:
1092
+ """Generate the next title in a lineage (e.g., "my session" → "my session #2").
1093
+
1094
+ Strips any existing " #N" suffix to find the base name, then finds
1095
+ the highest existing number and increments.
1096
+ """
1097
+ # Strip existing #N suffix to find the true base
1098
+ match = re.match(r'^(.*?) #(\d+)$', base_title)
1099
+ if match:
1100
+ base = match.group(1)
1101
+ else:
1102
+ base = base_title
1103
+
1104
+ # Find all existing numbered variants
1105
+ # Escape SQL LIKE wildcards (%, _) in the base to prevent false matches
1106
+ escaped = base.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
1107
+ with self._lock:
1108
+ cursor = self._conn.execute(
1109
+ "SELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\\'",
1110
+ (base, f"{escaped} #%"),
1111
+ )
1112
+ existing = [row["title"] for row in cursor.fetchall()]
1113
+
1114
+ if not existing:
1115
+ return base # No conflict, use the base name as-is
1116
+
1117
+ # Find the highest number
1118
+ max_num = 1 # The unnumbered original counts as #1
1119
+ for t in existing:
1120
+ m = re.match(r'^.* #(\d+)$', t)
1121
+ if m:
1122
+ max_num = max(max_num, int(m.group(1)))
1123
+
1124
+ return f"{base} #{max_num + 1}"
1125
+
1126
+ def get_compression_tip(self, session_id: str) -> Optional[str]:
1127
+ """Walk the compression-continuation chain forward and return the tip.
1128
+
1129
+ A compression continuation is a child session where:
1130
+ 1. The parent's ``end_reason = 'compression'``
1131
+ 2. The child was created AFTER the parent was ended (started_at >= ended_at)
1132
+
1133
+ The second condition distinguishes compression continuations from
1134
+ delegate subagents or branch children, which can also have a
1135
+ ``parent_session_id`` but were created while the parent was still live.
1136
+
1137
+ Returns the session_id of the latest continuation in the chain, or the
1138
+ input ``session_id`` if it isn't part of a compression chain (or if the
1139
+ input itself doesn't exist).
1140
+ """
1141
+ current = session_id
1142
+ # Bound the walk defensively — compression chains this deep are
1143
+ # pathological and shouldn't happen in practice. 100 = plenty.
1144
+ for _ in range(100):
1145
+ with self._lock:
1146
+ cursor = self._conn.execute(
1147
+ "SELECT id FROM sessions "
1148
+ "WHERE parent_session_id = ? "
1149
+ " AND started_at >= ("
1150
+ " SELECT ended_at FROM sessions "
1151
+ " WHERE id = ? AND end_reason = 'compression'"
1152
+ " ) "
1153
+ "ORDER BY started_at DESC LIMIT 1",
1154
+ (current, current),
1155
+ )
1156
+ row = cursor.fetchone()
1157
+ if row is None:
1158
+ return current
1159
+ current = row["id"]
1160
+ return current
1161
+
1162
+ def list_sessions_rich(
1163
+ self,
1164
+ source: str = None,
1165
+ exclude_sources: List[str] = None,
1166
+ limit: int = 20,
1167
+ offset: int = 0,
1168
+ include_children: bool = False,
1169
+ project_compression_tips: bool = True,
1170
+ order_by_last_active: bool = False,
1171
+ ) -> List[Dict[str, Any]]:
1172
+ """List sessions with preview (first user message) and last active timestamp.
1173
+
1174
+ Returns dicts with keys: id, source, model, title, started_at, ended_at,
1175
+ message_count, preview (first 60 chars of first user message),
1176
+ last_active (timestamp of last message).
1177
+
1178
+ Uses a single query with correlated subqueries instead of N+2 queries.
1179
+
1180
+ By default, child sessions (subagent runs, compression continuations)
1181
+ are excluded. Pass ``include_children=True`` to include them.
1182
+
1183
+ With ``project_compression_tips=True`` (default), sessions that are
1184
+ roots of compression chains are projected forward to their latest
1185
+ continuation — one logical conversation = one list entry, showing the
1186
+ live continuation's id/message_count/title/last_active. This prevents
1187
+ compressed continuations from being invisible to users while keeping
1188
+ delegate subagents and branches hidden. Pass ``False`` to return the
1189
+ raw root rows (useful for admin/debug UIs).
1190
+
1191
+ Pass ``order_by_last_active=True`` to sort by most-recent activity
1192
+ instead of original conversation start time. For compression chains,
1193
+ the "most-recent activity" is taken from the live tip (not the root),
1194
+ so an old conversation that was compressed and continued recently
1195
+ surfaces in the correct slot. Ordering is computed at SQL level via
1196
+ a recursive CTE that walks compression-continuation edges, so LIMIT
1197
+ and OFFSET still apply efficiently.
1198
+ """
1199
+ where_clauses = []
1200
+ params = []
1201
+
1202
+ if not include_children:
1203
+ # Show root sessions and branch sessions (whose parent ended with
1204
+ # end_reason='branched' before the child was created), while still
1205
+ # hiding sub-agent runs and compression continuations (which also
1206
+ # carry a parent_session_id but were spawned while the parent was
1207
+ # still live — i.e., started_at < parent.ended_at).
1208
+ where_clauses.append(
1209
+ "(s.parent_session_id IS NULL"
1210
+ " OR EXISTS (SELECT 1 FROM sessions p"
1211
+ " WHERE p.id = s.parent_session_id"
1212
+ " AND p.end_reason = 'branched'"
1213
+ " AND s.started_at >= p.ended_at))"
1214
+ )
1215
+
1216
+ if source:
1217
+ where_clauses.append("s.source = ?")
1218
+ params.append(source)
1219
+ if exclude_sources:
1220
+ placeholders = ",".join("?" for _ in exclude_sources)
1221
+ where_clauses.append(f"s.source NOT IN ({placeholders})")
1222
+ params.extend(exclude_sources)
1223
+
1224
+ where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
1225
+ if order_by_last_active:
1226
+ # Compute effective_last_active by walking each surfaced session's
1227
+ # compression-continuation chain forward in SQL and taking the MAX
1228
+ # timestamp across the chain. This lets us ORDER BY + LIMIT at SQL
1229
+ # level instead of fetching every row and sorting in Python, while
1230
+ # still surfacing old compression roots whose live tip is fresh.
1231
+ #
1232
+ # The CTE seeds from rows the outer WHERE admits (roots + branch
1233
+ # children), then recursively joins forward through
1234
+ # compression-continuation edges using the same criteria as
1235
+ # get_compression_tip (parent.end_reason='compression' AND
1236
+ # child.started_at >= parent.ended_at).
1237
+ query = f"""
1238
+ WITH RECURSIVE chain(root_id, cur_id) AS (
1239
+ SELECT s.id, s.id FROM sessions s {where_sql}
1240
+ UNION ALL
1241
+ SELECT c.root_id, child.id
1242
+ FROM chain c
1243
+ JOIN sessions parent ON parent.id = c.cur_id
1244
+ JOIN sessions child ON child.parent_session_id = c.cur_id
1245
+ WHERE parent.end_reason = 'compression'
1246
+ AND child.started_at >= parent.ended_at
1247
+ ),
1248
+ chain_max AS (
1249
+ SELECT
1250
+ root_id,
1251
+ MAX(COALESCE(
1252
+ (SELECT MAX(m.timestamp) FROM messages m WHERE m.session_id = cur_id),
1253
+ (SELECT started_at FROM sessions ss WHERE ss.id = cur_id)
1254
+ )) AS effective_last_active
1255
+ FROM chain
1256
+ GROUP BY root_id
1257
+ )
1258
+ SELECT s.*,
1259
+ COALESCE(
1260
+ (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
1261
+ FROM messages m
1262
+ WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
1263
+ ORDER BY m.timestamp, m.id LIMIT 1),
1264
+ ''
1265
+ ) AS _preview_raw,
1266
+ COALESCE(
1267
+ (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
1268
+ s.started_at
1269
+ ) AS last_active,
1270
+ COALESCE(cm.effective_last_active, s.started_at) AS _effective_last_active
1271
+ FROM sessions s
1272
+ LEFT JOIN chain_max cm ON cm.root_id = s.id
1273
+ {where_sql}
1274
+ ORDER BY _effective_last_active DESC, s.started_at DESC, s.id DESC
1275
+ LIMIT ? OFFSET ?
1276
+ """
1277
+ # WHERE params apply twice (CTE seed + outer select).
1278
+ params = params + params + [limit, offset]
1279
+ else:
1280
+ query = f"""
1281
+ SELECT s.*,
1282
+ COALESCE(
1283
+ (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
1284
+ FROM messages m
1285
+ WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
1286
+ ORDER BY m.timestamp, m.id LIMIT 1),
1287
+ ''
1288
+ ) AS _preview_raw,
1289
+ COALESCE(
1290
+ (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
1291
+ s.started_at
1292
+ ) AS last_active
1293
+ FROM sessions s
1294
+ {where_sql}
1295
+ ORDER BY s.started_at DESC
1296
+ LIMIT ? OFFSET ?
1297
+ """
1298
+ params.extend([limit, offset])
1299
+ with self._lock:
1300
+ cursor = self._conn.execute(query, params)
1301
+ rows = cursor.fetchall()
1302
+ sessions = []
1303
+ for row in rows:
1304
+ s = dict(row)
1305
+ # Build the preview from the raw substring
1306
+ raw = s.pop("_preview_raw", "").strip()
1307
+ if raw:
1308
+ text = raw[:60]
1309
+ s["preview"] = text + ("..." if len(raw) > 60 else "")
1310
+ else:
1311
+ s["preview"] = ""
1312
+ # Drop the internal ordering column so callers see a clean dict.
1313
+ s.pop("_effective_last_active", None)
1314
+ sessions.append(s)
1315
+
1316
+ # Project compression roots forward to their tips. Each row whose
1317
+ # end_reason is 'compression' has a continuation child; replace the
1318
+ # surfaced fields (id, message_count, title, last_active, ended_at,
1319
+ # end_reason, preview) with the tip's values so the list entry acts
1320
+ # as the live conversation. Keep the root's started_at to preserve
1321
+ # chronological ordering by original conversation start.
1322
+ if project_compression_tips and not include_children:
1323
+ projected = []
1324
+ for s in sessions:
1325
+ if s.get("end_reason") != "compression":
1326
+ projected.append(s)
1327
+ continue
1328
+ tip_id = self.get_compression_tip(s["id"])
1329
+ if tip_id == s["id"]:
1330
+ projected.append(s)
1331
+ continue
1332
+ tip_row = self._get_session_rich_row(tip_id)
1333
+ if not tip_row:
1334
+ projected.append(s)
1335
+ continue
1336
+ # Preserve the root's started_at for stable sort order, but
1337
+ # surface the tip's identity and activity data.
1338
+ merged = dict(s)
1339
+ for key in (
1340
+ "id", "ended_at", "end_reason", "message_count",
1341
+ "tool_call_count", "title", "last_active", "preview",
1342
+ "model", "system_prompt",
1343
+ ):
1344
+ if key in tip_row:
1345
+ merged[key] = tip_row[key]
1346
+ merged["_lineage_root_id"] = s["id"]
1347
+ projected.append(merged)
1348
+ sessions = projected
1349
+
1350
+ return sessions
1351
+
1352
+ def _get_session_rich_row(self, session_id: str) -> Optional[Dict[str, Any]]:
1353
+ """Fetch a single session with the same enriched columns as
1354
+ ``list_sessions_rich`` (preview + last_active). Returns None if the
1355
+ session doesn't exist.
1356
+ """
1357
+ query = """
1358
+ SELECT s.*,
1359
+ COALESCE(
1360
+ (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
1361
+ FROM messages m
1362
+ WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
1363
+ ORDER BY m.timestamp, m.id LIMIT 1),
1364
+ ''
1365
+ ) AS _preview_raw,
1366
+ COALESCE(
1367
+ (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
1368
+ s.started_at
1369
+ ) AS last_active
1370
+ FROM sessions s
1371
+ WHERE s.id = ?
1372
+ """
1373
+ with self._lock:
1374
+ cursor = self._conn.execute(query, (session_id,))
1375
+ row = cursor.fetchone()
1376
+ if not row:
1377
+ return None
1378
+ s = dict(row)
1379
+ raw = s.pop("_preview_raw", "").strip()
1380
+ if raw:
1381
+ text = raw[:60]
1382
+ s["preview"] = text + ("..." if len(raw) > 60 else "")
1383
+ else:
1384
+ s["preview"] = ""
1385
+ return s
1386
+
1387
+ # =========================================================================
1388
+ # Message storage
1389
+ # =========================================================================
1390
+
1391
+ # Sentinel prefix used to distinguish JSON-encoded structured content
1392
+ # (multimodal messages: lists of parts like text + image_url) from plain
1393
+ # string content. The NUL byte is not legal in normal text, so this
1394
+ # cannot collide with real user content.
1395
+ _CONTENT_JSON_PREFIX = "\x00json:"
1396
+
1397
+ @classmethod
1398
+ def _encode_content(cls, content: Any) -> Any:
1399
+ """Serialize structured (list/dict) message content for sqlite.
1400
+
1401
+ sqlite3 can only bind ``str``, ``bytes``, ``int``, ``float``, and ``None``
1402
+ to query parameters. Multimodal messages have ``content`` as a list of
1403
+ parts (``[{"type": "text", ...}, {"type": "image_url", ...}]``), which
1404
+ raises ``ProgrammingError: Error binding parameter N: type 'list' is
1405
+ not supported`` when bound directly.
1406
+
1407
+ Returns the value unchanged when it's already a safe scalar, or a
1408
+ sentinel-prefixed JSON string for lists/dicts. Paired with
1409
+ :meth:`_decode_content` on read.
1410
+ """
1411
+ if content is None or isinstance(content, (str, bytes, int, float)):
1412
+ return content
1413
+ try:
1414
+ return cls._CONTENT_JSON_PREFIX + json.dumps(content)
1415
+ except (TypeError, ValueError):
1416
+ # Last-resort fallback: stringify so persistence never fails.
1417
+ return str(content)
1418
+
1419
+ @classmethod
1420
+ def _decode_content(cls, content: Any) -> Any:
1421
+ """Reverse :meth:`_encode_content`; returns scalars unchanged."""
1422
+ if isinstance(content, str) and content.startswith(cls._CONTENT_JSON_PREFIX):
1423
+ try:
1424
+ return json.loads(content[len(cls._CONTENT_JSON_PREFIX):])
1425
+ except (json.JSONDecodeError, TypeError):
1426
+ logger.warning(
1427
+ "Failed to decode JSON-encoded message content; "
1428
+ "returning raw string"
1429
+ )
1430
+ return content
1431
+ return content
1432
+
1433
+ def append_message(
1434
+ self,
1435
+ session_id: str,
1436
+ role: str,
1437
+ content: str = None,
1438
+ tool_name: str = None,
1439
+ tool_calls: Any = None,
1440
+ tool_call_id: str = None,
1441
+ token_count: int = None,
1442
+ finish_reason: str = None,
1443
+ reasoning: str = None,
1444
+ reasoning_content: str = None,
1445
+ reasoning_details: Any = None,
1446
+ codex_reasoning_items: Any = None,
1447
+ codex_message_items: Any = None,
1448
+ ) -> int:
1449
+ """
1450
+ Append a message to a session. Returns the message row ID.
1451
+
1452
+ Also increments the session's message_count (and tool_call_count
1453
+ if role is 'tool' or tool_calls is present).
1454
+ """
1455
+ # Serialize structured fields to JSON before entering the write txn
1456
+ reasoning_details_json = (
1457
+ json.dumps(reasoning_details)
1458
+ if reasoning_details else None
1459
+ )
1460
+ codex_items_json = (
1461
+ json.dumps(codex_reasoning_items)
1462
+ if codex_reasoning_items else None
1463
+ )
1464
+ codex_message_items_json = (
1465
+ json.dumps(codex_message_items)
1466
+ if codex_message_items else None
1467
+ )
1468
+ tool_calls_json = json.dumps(tool_calls) if tool_calls else None
1469
+ # Multimodal content (list of parts) must be JSON-encoded: sqlite3
1470
+ # cannot bind list/dict parameters directly.
1471
+ stored_content = self._encode_content(content)
1472
+
1473
+ # Pre-compute tool call count
1474
+ num_tool_calls = 0
1475
+ if tool_calls is not None:
1476
+ num_tool_calls = len(tool_calls) if isinstance(tool_calls, list) else 1
1477
+
1478
+ def _do(conn):
1479
+ cursor = conn.execute(
1480
+ """INSERT INTO messages (session_id, role, content, tool_call_id,
1481
+ tool_calls, tool_name, timestamp, token_count, finish_reason,
1482
+ reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
1483
+ codex_message_items)
1484
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
1485
+ (
1486
+ session_id,
1487
+ role,
1488
+ stored_content,
1489
+ tool_call_id,
1490
+ tool_calls_json,
1491
+ tool_name,
1492
+ time.time(),
1493
+ token_count,
1494
+ finish_reason,
1495
+ reasoning,
1496
+ reasoning_content,
1497
+ reasoning_details_json,
1498
+ codex_items_json,
1499
+ codex_message_items_json,
1500
+ ),
1501
+ )
1502
+ msg_id = cursor.lastrowid
1503
+
1504
+ # Update counters
1505
+ if num_tool_calls > 0:
1506
+ conn.execute(
1507
+ """UPDATE sessions SET message_count = message_count + 1,
1508
+ tool_call_count = tool_call_count + ? WHERE id = ?""",
1509
+ (num_tool_calls, session_id),
1510
+ )
1511
+ else:
1512
+ conn.execute(
1513
+ "UPDATE sessions SET message_count = message_count + 1 WHERE id = ?",
1514
+ (session_id,),
1515
+ )
1516
+ return msg_id
1517
+
1518
+ return self._execute_write(_do)
1519
+
1520
+ def replace_messages(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
1521
+ """Atomically replace every message for a session.
1522
+
1523
+ Used by transcript-rewrite flows such as /retry, /undo, and /compress.
1524
+ The delete + reinsert sequence must commit as one transaction so a
1525
+ mid-rewrite failure does not leave SQLite with a partial transcript.
1526
+ """
1527
+
1528
+ def _do(conn):
1529
+ conn.execute(
1530
+ "DELETE FROM messages WHERE session_id = ?", (session_id,)
1531
+ )
1532
+ conn.execute(
1533
+ "UPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?",
1534
+ (session_id,),
1535
+ )
1536
+
1537
+ now_ts = time.time()
1538
+ total_messages = 0
1539
+ total_tool_calls = 0
1540
+ for msg in messages:
1541
+ role = msg.get("role", "unknown")
1542
+ tool_calls = msg.get("tool_calls")
1543
+ reasoning_details = msg.get("reasoning_details") if role == "assistant" else None
1544
+ codex_reasoning_items = (
1545
+ msg.get("codex_reasoning_items") if role == "assistant" else None
1546
+ )
1547
+ codex_message_items = (
1548
+ msg.get("codex_message_items") if role == "assistant" else None
1549
+ )
1550
+
1551
+ reasoning_details_json = (
1552
+ json.dumps(reasoning_details) if reasoning_details else None
1553
+ )
1554
+ codex_items_json = (
1555
+ json.dumps(codex_reasoning_items) if codex_reasoning_items else None
1556
+ )
1557
+ codex_message_items_json = (
1558
+ json.dumps(codex_message_items) if codex_message_items else None
1559
+ )
1560
+ tool_calls_json = json.dumps(tool_calls) if tool_calls else None
1561
+
1562
+ conn.execute(
1563
+ """INSERT INTO messages (session_id, role, content, tool_call_id,
1564
+ tool_calls, tool_name, timestamp, token_count, finish_reason,
1565
+ reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
1566
+ codex_message_items)
1567
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
1568
+ (
1569
+ session_id,
1570
+ role,
1571
+ self._encode_content(msg.get("content")),
1572
+ msg.get("tool_call_id"),
1573
+ tool_calls_json,
1574
+ msg.get("tool_name"),
1575
+ now_ts,
1576
+ msg.get("token_count"),
1577
+ msg.get("finish_reason"),
1578
+ msg.get("reasoning") if role == "assistant" else None,
1579
+ msg.get("reasoning_content") if role == "assistant" else None,
1580
+ reasoning_details_json,
1581
+ codex_items_json,
1582
+ codex_message_items_json,
1583
+ ),
1584
+ )
1585
+ total_messages += 1
1586
+ if tool_calls is not None:
1587
+ total_tool_calls += (
1588
+ len(tool_calls) if isinstance(tool_calls, list) else 1
1589
+ )
1590
+ now_ts += 1e-6
1591
+
1592
+ conn.execute(
1593
+ "UPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?",
1594
+ (total_messages, total_tool_calls, session_id),
1595
+ )
1596
+
1597
+ self._execute_write(_do)
1598
+
1599
+ def get_messages(self, session_id: str) -> List[Dict[str, Any]]:
1600
+ """Load all messages for a session, ordered by insertion order."""
1601
+ with self._lock:
1602
+ cursor = self._conn.execute(
1603
+ "SELECT * FROM messages WHERE session_id = ? ORDER BY id",
1604
+ (session_id,),
1605
+ )
1606
+ rows = cursor.fetchall()
1607
+ result = []
1608
+ for row in rows:
1609
+ msg = dict(row)
1610
+ if "content" in msg:
1611
+ msg["content"] = self._decode_content(msg["content"])
1612
+ if msg.get("tool_calls"):
1613
+ try:
1614
+ msg["tool_calls"] = json.loads(msg["tool_calls"])
1615
+ except (json.JSONDecodeError, TypeError):
1616
+ logger.warning("Failed to deserialize tool_calls in get_messages, falling back to []")
1617
+ msg["tool_calls"] = []
1618
+ result.append(msg)
1619
+ return result
1620
+
1621
+ def resolve_resume_session_id(self, session_id: str) -> str:
1622
+ """Redirect a resume target to the descendant session that holds the messages.
1623
+
1624
+ Context compression ends the current session and forks a new child session
1625
+ (linked via ``parent_session_id``). The flush cursor is reset, so the
1626
+ child is where new messages actually land — the parent ends up with
1627
+ ``message_count = 0`` rows unless messages had already been flushed to
1628
+ it before compression. See #15000.
1629
+
1630
+ This helper walks ``parent_session_id`` forward from ``session_id`` and
1631
+ returns the first descendant in the chain that has at least one message
1632
+ row. If the original session already has messages, or no descendant
1633
+ has any, the original ``session_id`` is returned unchanged.
1634
+
1635
+ The chain is always walked via the child whose ``started_at`` is
1636
+ latest; that matches the single-chain shape that compression creates.
1637
+ A depth cap (32) guards against accidental loops in malformed data.
1638
+ """
1639
+ if not session_id:
1640
+ return session_id
1641
+
1642
+ with self._lock:
1643
+ # If this session already has messages, nothing to redirect.
1644
+ try:
1645
+ row = self._conn.execute(
1646
+ "SELECT 1 FROM messages WHERE session_id = ? LIMIT 1",
1647
+ (session_id,),
1648
+ ).fetchone()
1649
+ except Exception:
1650
+ return session_id
1651
+ if row is not None:
1652
+ return session_id
1653
+
1654
+ # Walk descendants: at each step, pick the most-recently-started
1655
+ # child session; stop once we find one with messages.
1656
+ current = session_id
1657
+ seen = {current}
1658
+ for _ in range(32):
1659
+ try:
1660
+ child_row = self._conn.execute(
1661
+ "SELECT id FROM sessions "
1662
+ "WHERE parent_session_id = ? "
1663
+ "ORDER BY started_at DESC, id DESC LIMIT 1",
1664
+ (current,),
1665
+ ).fetchone()
1666
+ except Exception:
1667
+ return session_id
1668
+ if child_row is None:
1669
+ return session_id
1670
+ child_id = child_row["id"] if hasattr(child_row, "keys") else child_row[0]
1671
+ if not child_id or child_id in seen:
1672
+ return session_id
1673
+ seen.add(child_id)
1674
+ try:
1675
+ msg_row = self._conn.execute(
1676
+ "SELECT 1 FROM messages WHERE session_id = ? LIMIT 1",
1677
+ (child_id,),
1678
+ ).fetchone()
1679
+ except Exception:
1680
+ return session_id
1681
+ if msg_row is not None:
1682
+ return child_id
1683
+ current = child_id
1684
+ return session_id
1685
+
1686
+ def get_messages_as_conversation(
1687
+ self, session_id: str, include_ancestors: bool = False
1688
+ ) -> List[Dict[str, Any]]:
1689
+ """
1690
+ Load messages in the OpenAI conversation format (role + content dicts).
1691
+ Used by the gateway to restore conversation history.
1692
+ """
1693
+ session_ids = [session_id]
1694
+ if include_ancestors:
1695
+ session_ids = self._session_lineage_root_to_tip(session_id)
1696
+
1697
+ with self._lock:
1698
+ placeholders = ",".join("?" for _ in session_ids)
1699
+ rows = self._conn.execute(
1700
+ "SELECT role, content, tool_call_id, tool_calls, tool_name, "
1701
+ "finish_reason, reasoning, reasoning_content, reasoning_details, "
1702
+ "codex_reasoning_items, codex_message_items "
1703
+ f"FROM messages WHERE session_id IN ({placeholders}) ORDER BY id",
1704
+ tuple(session_ids),
1705
+ ).fetchall()
1706
+
1707
+ messages = []
1708
+ for row in rows:
1709
+ content = self._decode_content(row["content"])
1710
+ if row["role"] in {"user", "assistant"} and isinstance(content, str):
1711
+ content = sanitize_context(content).strip()
1712
+ msg = {"role": row["role"], "content": content}
1713
+ if row["tool_call_id"]:
1714
+ msg["tool_call_id"] = row["tool_call_id"]
1715
+ if row["tool_name"]:
1716
+ msg["tool_name"] = row["tool_name"]
1717
+ if row["tool_calls"]:
1718
+ try:
1719
+ msg["tool_calls"] = json.loads(row["tool_calls"])
1720
+ except (json.JSONDecodeError, TypeError):
1721
+ logger.warning("Failed to deserialize tool_calls in conversation replay, falling back to []")
1722
+ msg["tool_calls"] = []
1723
+ # Restore reasoning fields on assistant messages so providers
1724
+ # that replay reasoning (OpenRouter, OpenAI, Nous) receive
1725
+ # coherent multi-turn reasoning context.
1726
+ if row["role"] == "assistant":
1727
+ if row["finish_reason"]:
1728
+ msg["finish_reason"] = row["finish_reason"]
1729
+ if row["reasoning"]:
1730
+ msg["reasoning"] = row["reasoning"]
1731
+ if row["reasoning_content"] is not None:
1732
+ msg["reasoning_content"] = row["reasoning_content"]
1733
+ if row["reasoning_details"]:
1734
+ try:
1735
+ msg["reasoning_details"] = json.loads(row["reasoning_details"])
1736
+ except (json.JSONDecodeError, TypeError):
1737
+ logger.warning("Failed to deserialize reasoning_details, falling back to None")
1738
+ msg["reasoning_details"] = None
1739
+ if row["codex_reasoning_items"]:
1740
+ try:
1741
+ msg["codex_reasoning_items"] = json.loads(row["codex_reasoning_items"])
1742
+ except (json.JSONDecodeError, TypeError):
1743
+ logger.warning("Failed to deserialize codex_reasoning_items, falling back to None")
1744
+ msg["codex_reasoning_items"] = None
1745
+ if row["codex_message_items"]:
1746
+ try:
1747
+ msg["codex_message_items"] = json.loads(row["codex_message_items"])
1748
+ except (json.JSONDecodeError, TypeError):
1749
+ logger.warning("Failed to deserialize codex_message_items, falling back to None")
1750
+ msg["codex_message_items"] = None
1751
+ if include_ancestors and self._is_duplicate_replayed_user_message(messages, msg):
1752
+ continue
1753
+ messages.append(msg)
1754
+ return messages
1755
+
1756
+ def _session_lineage_root_to_tip(self, session_id: str) -> List[str]:
1757
+ if not session_id:
1758
+ return [session_id]
1759
+
1760
+ chain = []
1761
+ current = session_id
1762
+ seen = set()
1763
+ with self._lock:
1764
+ for _ in range(100):
1765
+ if not current or current in seen:
1766
+ break
1767
+ seen.add(current)
1768
+ chain.append(current)
1769
+ row = self._conn.execute(
1770
+ "SELECT parent_session_id FROM sessions WHERE id = ?",
1771
+ (current,),
1772
+ ).fetchone()
1773
+ if row is None:
1774
+ break
1775
+ current = row["parent_session_id"] if hasattr(row, "keys") else row[0]
1776
+ return list(reversed(chain)) or [session_id]
1777
+
1778
+ @staticmethod
1779
+ def _is_duplicate_replayed_user_message(messages: List[Dict[str, Any]], msg: Dict[str, Any]) -> bool:
1780
+ if msg.get("role") != "user":
1781
+ return False
1782
+ content = msg.get("content")
1783
+ if not isinstance(content, str) or not content:
1784
+ return False
1785
+ for prev in reversed(messages):
1786
+ if prev.get("role") == "user" and prev.get("content") == content:
1787
+ return True
1788
+ if prev.get("role") == "assistant" and (prev.get("content") or prev.get("tool_calls")):
1789
+ return False
1790
+ return False
1791
+
1792
+ # =========================================================================
1793
+ # Search
1794
+ # =========================================================================
1795
+
1796
+ @staticmethod
1797
+ def _sanitize_fts5_query(query: str) -> str:
1798
+ """Sanitize user input for safe use in FTS5 MATCH queries.
1799
+
1800
+ FTS5 has its own query syntax where characters like ``"``, ``(``, ``)``,
1801
+ ``+``, ``*``, ``{``, ``}`` and bare boolean operators (``AND``, ``OR``,
1802
+ ``NOT``) have special meaning. Passing raw user input directly to
1803
+ MATCH can cause ``sqlite3.OperationalError``.
1804
+
1805
+ Strategy:
1806
+ - Preserve properly paired quoted phrases (``"exact phrase"``)
1807
+ - Strip unmatched FTS5-special characters that would cause errors
1808
+ - Wrap unquoted hyphenated and dotted terms in quotes so FTS5
1809
+ matches them as exact phrases instead of splitting on the
1810
+ hyphen/dot (e.g. ``chat-send``, ``P2.2``, ``my-app.config.ts``)
1811
+ """
1812
+ # Step 1: Extract balanced double-quoted phrases and protect them
1813
+ # from further processing via numbered placeholders.
1814
+ _quoted_parts: list = []
1815
+
1816
+ def _preserve_quoted(m: re.Match) -> str:
1817
+ _quoted_parts.append(m.group(0))
1818
+ return f"\x00Q{len(_quoted_parts) - 1}\x00"
1819
+
1820
+ sanitized = re.sub(r'"[^"]*"', _preserve_quoted, query)
1821
+
1822
+ # Step 2: Strip remaining (unmatched) FTS5-special characters
1823
+ sanitized = re.sub(r'[+{}()\"^]', " ", sanitized)
1824
+
1825
+ # Step 3: Collapse repeated * (e.g. "***") into a single one,
1826
+ # and remove leading * (prefix-only needs at least one char before *)
1827
+ sanitized = re.sub(r"\*+", "*", sanitized)
1828
+ sanitized = re.sub(r"(^|\s)\*", r"\1", sanitized)
1829
+
1830
+ # Step 4: Remove dangling boolean operators at start/end that would
1831
+ # cause syntax errors (e.g. "hello AND" or "OR world")
1832
+ sanitized = re.sub(r"(?i)^(AND|OR|NOT)\b\s*", "", sanitized.strip())
1833
+ sanitized = re.sub(r"(?i)\s+(AND|OR|NOT)\s*$", "", sanitized.strip())
1834
+
1835
+ # Step 5: Wrap unquoted dotted and/or hyphenated terms in double
1836
+ # quotes. FTS5's tokenizer splits on dots and hyphens, turning
1837
+ # ``chat-send`` into ``chat AND send`` and ``P2.2`` into ``p2 AND 2``.
1838
+ # Quoting preserves phrase semantics. A single pass avoids the
1839
+ # double-quoting bug that would occur if dotted, hyphenated and underscored
1840
+ # patterns were applied sequentially (e.g. ``my-app.config``).
1841
+ sanitized = re.sub(r"\b(\w+(?:[._-]\w+)+)\b", r'"\1"', sanitized)
1842
+
1843
+ # Step 6: Restore preserved quoted phrases
1844
+ for i, quoted in enumerate(_quoted_parts):
1845
+ sanitized = sanitized.replace(f"\x00Q{i}\x00", quoted)
1846
+
1847
+ return sanitized.strip()
1848
+
1849
+
1850
+ @staticmethod
1851
+ def _is_cjk_codepoint(cp: int) -> bool:
1852
+ return (0x4E00 <= cp <= 0x9FFF or # CJK Unified Ideographs
1853
+ 0x3400 <= cp <= 0x4DBF or # CJK Extension A
1854
+ 0x20000 <= cp <= 0x2A6DF or # CJK Extension B
1855
+ 0x3000 <= cp <= 0x303F or # CJK Symbols
1856
+ 0x3040 <= cp <= 0x309F or # Hiragana
1857
+ 0x30A0 <= cp <= 0x30FF or # Katakana
1858
+ 0xAC00 <= cp <= 0xD7AF) # Hangul Syllables
1859
+
1860
+ @staticmethod
1861
+ def _contains_cjk(text: str) -> bool:
1862
+ """Check if text contains CJK (Chinese, Japanese, Korean) characters."""
1863
+ for ch in text:
1864
+ cp = ord(ch)
1865
+ if (0x4E00 <= cp <= 0x9FFF or # CJK Unified Ideographs
1866
+ 0x3400 <= cp <= 0x4DBF or # CJK Extension A
1867
+ 0x20000 <= cp <= 0x2A6DF or # CJK Extension B
1868
+ 0x3000 <= cp <= 0x303F or # CJK Symbols
1869
+ 0x3040 <= cp <= 0x309F or # Hiragana
1870
+ 0x30A0 <= cp <= 0x30FF or # Katakana
1871
+ 0xAC00 <= cp <= 0xD7AF): # Hangul Syllables
1872
+ return True
1873
+ return False
1874
+
1875
+ @classmethod
1876
+ def _count_cjk(cls, text: str) -> int:
1877
+ """Count CJK characters in text."""
1878
+ return sum(1 for ch in text if cls._is_cjk_codepoint(ord(ch)))
1879
+
1880
+ def search_messages(
1881
+ self,
1882
+ query: str,
1883
+ source_filter: List[str] = None,
1884
+ exclude_sources: List[str] = None,
1885
+ role_filter: List[str] = None,
1886
+ limit: int = 20,
1887
+ offset: int = 0,
1888
+ ) -> List[Dict[str, Any]]:
1889
+ """
1890
+ Full-text search across session messages using FTS5.
1891
+
1892
+ Supports FTS5 query syntax:
1893
+ - Simple keywords: "docker deployment"
1894
+ - Phrases: '"exact phrase"'
1895
+ - Boolean: "docker OR kubernetes", "python NOT java"
1896
+ - Prefix: "deploy*"
1897
+
1898
+ Returns matching messages with session metadata, content snippet,
1899
+ and surrounding context (1 message before and after the match).
1900
+ """
1901
+ if not query or not query.strip():
1902
+ return []
1903
+
1904
+ query = self._sanitize_fts5_query(query)
1905
+ if not query:
1906
+ return []
1907
+
1908
+ # Build WHERE clauses dynamically
1909
+ where_clauses = ["messages_fts MATCH ?"]
1910
+ params: list = [query]
1911
+
1912
+ if source_filter is not None:
1913
+ source_placeholders = ",".join("?" for _ in source_filter)
1914
+ where_clauses.append(f"s.source IN ({source_placeholders})")
1915
+ params.extend(source_filter)
1916
+
1917
+ if exclude_sources is not None:
1918
+ exclude_placeholders = ",".join("?" for _ in exclude_sources)
1919
+ where_clauses.append(f"s.source NOT IN ({exclude_placeholders})")
1920
+ params.extend(exclude_sources)
1921
+
1922
+ if role_filter:
1923
+ role_placeholders = ",".join("?" for _ in role_filter)
1924
+ where_clauses.append(f"m.role IN ({role_placeholders})")
1925
+ params.extend(role_filter)
1926
+
1927
+ where_sql = " AND ".join(where_clauses)
1928
+ params.extend([limit, offset])
1929
+
1930
+ sql = f"""
1931
+ SELECT
1932
+ m.id,
1933
+ m.session_id,
1934
+ m.role,
1935
+ snippet(messages_fts, 0, '>>>', '<<<', '...', 40) AS snippet,
1936
+ m.content,
1937
+ m.timestamp,
1938
+ m.tool_name,
1939
+ s.source,
1940
+ s.model,
1941
+ s.started_at AS session_started
1942
+ FROM messages_fts
1943
+ JOIN messages m ON m.id = messages_fts.rowid
1944
+ JOIN sessions s ON s.id = m.session_id
1945
+ WHERE {where_sql}
1946
+ ORDER BY rank
1947
+ LIMIT ? OFFSET ?
1948
+ """
1949
+
1950
+ # CJK queries bypass the unicode61 FTS5 table. The default tokenizer
1951
+ # splits CJK characters into individual tokens, so "大别山项目" becomes
1952
+ # "大 AND 别 AND 山 AND 项 AND 目" — producing false positives and
1953
+ # missing exact phrase matches.
1954
+ #
1955
+ # For queries with 3+ CJK characters, we use the trigram FTS5 table
1956
+ # (indexed substring matching with ranking and snippets). For shorter
1957
+ # CJK queries (1-2 chars), trigram can't match (it needs ≥9 UTF-8
1958
+ # bytes = 3 CJK chars), so we fall back to LIKE.
1959
+ is_cjk = self._contains_cjk(query)
1960
+ if is_cjk:
1961
+ raw_query = query.strip('"').strip()
1962
+ cjk_count = self._count_cjk(raw_query)
1963
+
1964
+ # Per-token CJK length check (#20494): trigram needs >=3 CJK chars
1965
+ # per token. A query like "广西 OR 桂林 OR 漓江" has cjk_count=6
1966
+ # (>=3) but each individual token is only 2 chars — trigram returns 0.
1967
+ # Route to LIKE when any non-operator CJK token is <3 CJK chars.
1968
+ _tokens_for_check = [
1969
+ t for t in raw_query.split()
1970
+ if t.upper() not in {"AND", "OR", "NOT"} and self._contains_cjk(t)
1971
+ ]
1972
+ _any_short_cjk = any(
1973
+ self._count_cjk(t) < 3 for t in _tokens_for_check
1974
+ )
1975
+
1976
+ if cjk_count >= 3 and not _any_short_cjk:
1977
+ # Trigram FTS5 path — quote each non-operator token to handle
1978
+ # FTS5 special chars (%, *, etc.) while preserving boolean
1979
+ # operators (AND, OR, NOT) for multi-term queries.
1980
+ tokens = raw_query.split()
1981
+ parts = []
1982
+ for tok in tokens:
1983
+ if tok.upper() in {"AND", "OR", "NOT"}:
1984
+ parts.append(tok)
1985
+ else:
1986
+ parts.append('"' + tok.replace('"', '""') + '"')
1987
+ trigram_query = " ".join(parts)
1988
+ tri_where = ["messages_fts_trigram MATCH ?"]
1989
+ tri_params: list = [trigram_query]
1990
+ if source_filter is not None:
1991
+ tri_where.append(f"s.source IN ({','.join('?' for _ in source_filter)})")
1992
+ tri_params.extend(source_filter)
1993
+ if exclude_sources is not None:
1994
+ tri_where.append(f"s.source NOT IN ({','.join('?' for _ in exclude_sources)})")
1995
+ tri_params.extend(exclude_sources)
1996
+ if role_filter:
1997
+ tri_where.append(f"m.role IN ({','.join('?' for _ in role_filter)})")
1998
+ tri_params.extend(role_filter)
1999
+ tri_sql = f"""
2000
+ SELECT
2001
+ m.id,
2002
+ m.session_id,
2003
+ m.role,
2004
+ snippet(messages_fts_trigram, 0, '>>>', '<<<', '...', 40) AS snippet,
2005
+ m.content,
2006
+ m.timestamp,
2007
+ m.tool_name,
2008
+ s.source,
2009
+ s.model,
2010
+ s.started_at AS session_started
2011
+ FROM messages_fts_trigram
2012
+ JOIN messages m ON m.id = messages_fts_trigram.rowid
2013
+ JOIN sessions s ON s.id = m.session_id
2014
+ WHERE {' AND '.join(tri_where)}
2015
+ ORDER BY rank
2016
+ LIMIT ? OFFSET ?
2017
+ """
2018
+ tri_params.extend([limit, offset])
2019
+ with self._lock:
2020
+ try:
2021
+ tri_cursor = self._conn.execute(tri_sql, tri_params)
2022
+ except sqlite3.OperationalError:
2023
+ matches = []
2024
+ else:
2025
+ matches = [dict(row) for row in tri_cursor.fetchall()]
2026
+ else:
2027
+ # Short / mixed CJK query: trigram cannot match tokens with
2028
+ # <3 CJK chars. Fall back to LIKE substring search.
2029
+ # For multi-token OR queries (e.g. "广西 OR 桂林 OR 漓江"),
2030
+ # build one LIKE condition per non-operator token so each term
2031
+ # is matched independently (#20494).
2032
+ non_op_tokens = [
2033
+ t for t in raw_query.split()
2034
+ if t.upper() not in {"AND", "OR", "NOT"}
2035
+ ] or [raw_query]
2036
+ token_clauses = []
2037
+ like_params: list = []
2038
+ for tok in non_op_tokens:
2039
+ esc = tok.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
2040
+ token_clauses.append(
2041
+ "(m.content LIKE ? ESCAPE '\\' OR m.tool_name LIKE ? ESCAPE '\\' OR m.tool_calls LIKE ? ESCAPE '\\')"
2042
+ )
2043
+ like_params += [f"%{esc}%", f"%{esc}%", f"%{esc}%"]
2044
+ like_where = [f"({' OR '.join(token_clauses)})"]
2045
+ if source_filter is not None:
2046
+ like_where.append(f"s.source IN ({','.join('?' for _ in source_filter)})")
2047
+ like_params.extend(source_filter)
2048
+ if exclude_sources is not None:
2049
+ like_where.append(f"s.source NOT IN ({','.join('?' for _ in exclude_sources)})")
2050
+ like_params.extend(exclude_sources)
2051
+ if role_filter:
2052
+ like_where.append(f"m.role IN ({','.join('?' for _ in role_filter)})")
2053
+ like_params.extend(role_filter)
2054
+ like_sql = f"""
2055
+ SELECT m.id, m.session_id, m.role,
2056
+ substr(m.content,
2057
+ max(1, instr(m.content, ?) - 40),
2058
+ 120) AS snippet,
2059
+ m.content, m.timestamp, m.tool_name,
2060
+ s.source, s.model, s.started_at AS session_started
2061
+ FROM messages m
2062
+ JOIN sessions s ON s.id = m.session_id
2063
+ WHERE {' AND '.join(like_where)}
2064
+ ORDER BY m.timestamp DESC
2065
+ LIMIT ? OFFSET ?
2066
+ """
2067
+ like_params.extend([limit, offset])
2068
+ # instr() for snippet uses first search token
2069
+ like_params = [non_op_tokens[0]] + like_params
2070
+ with self._lock:
2071
+ like_cursor = self._conn.execute(like_sql, like_params)
2072
+ matches = [dict(row) for row in like_cursor.fetchall()]
2073
+ else:
2074
+ with self._lock:
2075
+ try:
2076
+ cursor = self._conn.execute(sql, params)
2077
+ except sqlite3.OperationalError:
2078
+ # FTS5 query syntax error despite sanitization — return empty
2079
+ return []
2080
+ else:
2081
+ matches = [dict(row) for row in cursor.fetchall()]
2082
+
2083
+ # Add surrounding context (1 message before + after each match).
2084
+ # Done outside the lock so we don't hold it across N sequential queries.
2085
+ for match in matches:
2086
+ try:
2087
+ with self._lock:
2088
+ ctx_cursor = self._conn.execute(
2089
+ """WITH target AS (
2090
+ SELECT session_id, timestamp, id
2091
+ FROM messages
2092
+ WHERE id = ?
2093
+ )
2094
+ SELECT role, content
2095
+ FROM (
2096
+ SELECT m.id, m.timestamp, m.role, m.content
2097
+ FROM messages m
2098
+ JOIN target t ON t.session_id = m.session_id
2099
+ WHERE (m.timestamp < t.timestamp)
2100
+ OR (m.timestamp = t.timestamp AND m.id < t.id)
2101
+ ORDER BY m.timestamp DESC, m.id DESC
2102
+ LIMIT 1
2103
+ )
2104
+ UNION ALL
2105
+ SELECT role, content
2106
+ FROM messages
2107
+ WHERE id = ?
2108
+ UNION ALL
2109
+ SELECT role, content
2110
+ FROM (
2111
+ SELECT m.id, m.timestamp, m.role, m.content
2112
+ FROM messages m
2113
+ JOIN target t ON t.session_id = m.session_id
2114
+ WHERE (m.timestamp > t.timestamp)
2115
+ OR (m.timestamp = t.timestamp AND m.id > t.id)
2116
+ ORDER BY m.timestamp ASC, m.id ASC
2117
+ LIMIT 1
2118
+ )""",
2119
+ (match["id"], match["id"]),
2120
+ )
2121
+ context_msgs = []
2122
+ for r in ctx_cursor.fetchall():
2123
+ raw = r["content"]
2124
+ decoded = self._decode_content(raw)
2125
+ # Multimodal context: render a compact text-only
2126
+ # summary for search previews.
2127
+ if isinstance(decoded, list):
2128
+ text_parts = [
2129
+ p.get("text", "") for p in decoded
2130
+ if isinstance(p, dict) and p.get("type") == "text"
2131
+ ]
2132
+ text = " ".join(t for t in text_parts if t).strip()
2133
+ preview = text or "[multimodal content]"
2134
+ elif isinstance(decoded, str):
2135
+ preview = decoded
2136
+ else:
2137
+ preview = ""
2138
+ context_msgs.append(
2139
+ {"role": r["role"], "content": preview[:200]}
2140
+ )
2141
+ match["context"] = context_msgs
2142
+ except Exception:
2143
+ match["context"] = []
2144
+
2145
+ # Remove full content from result (snippet is enough, saves tokens)
2146
+ for match in matches:
2147
+ match.pop("content", None)
2148
+
2149
+ return matches
2150
+
2151
+ def search_sessions(
2152
+ self,
2153
+ source: str = None,
2154
+ limit: int = 20,
2155
+ offset: int = 0,
2156
+ ) -> List[Dict[str, Any]]:
2157
+ """List sessions, optionally filtered by source.
2158
+
2159
+ Returns rows enriched with a computed ``last_active`` column (latest
2160
+ message timestamp for the session, falling back to ``started_at``),
2161
+ ordered by most-recently-used first.
2162
+ """
2163
+ select_with_last_active = (
2164
+ "SELECT s.*, COALESCE(m.last_active, s.started_at) AS last_active "
2165
+ "FROM sessions s "
2166
+ "LEFT JOIN ("
2167
+ "SELECT session_id, MAX(timestamp) AS last_active "
2168
+ "FROM messages GROUP BY session_id"
2169
+ ") m ON m.session_id = s.id "
2170
+ )
2171
+ with self._lock:
2172
+ if source:
2173
+ cursor = self._conn.execute(
2174
+ f"{select_with_last_active}"
2175
+ "WHERE s.source = ? "
2176
+ "ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?",
2177
+ (source, limit, offset),
2178
+ )
2179
+ else:
2180
+ cursor = self._conn.execute(
2181
+ f"{select_with_last_active}"
2182
+ "ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?",
2183
+ (limit, offset),
2184
+ )
2185
+ return [dict(row) for row in cursor.fetchall()]
2186
+
2187
+ # =========================================================================
2188
+ # Utility
2189
+ # =========================================================================
2190
+
2191
+ def session_count(self, source: str = None) -> int:
2192
+ """Count sessions, optionally filtered by source."""
2193
+ with self._lock:
2194
+ if source:
2195
+ cursor = self._conn.execute(
2196
+ "SELECT COUNT(*) FROM sessions WHERE source = ?", (source,)
2197
+ )
2198
+ else:
2199
+ cursor = self._conn.execute("SELECT COUNT(*) FROM sessions")
2200
+ return cursor.fetchone()[0]
2201
+
2202
+ def message_count(self, session_id: str = None) -> int:
2203
+ """Count messages, optionally for a specific session."""
2204
+ with self._lock:
2205
+ if session_id:
2206
+ cursor = self._conn.execute(
2207
+ "SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,)
2208
+ )
2209
+ else:
2210
+ cursor = self._conn.execute("SELECT COUNT(*) FROM messages")
2211
+ return cursor.fetchone()[0]
2212
+
2213
+ # =========================================================================
2214
+ # Export and cleanup
2215
+ # =========================================================================
2216
+
2217
+ def export_session(self, session_id: str) -> Optional[Dict[str, Any]]:
2218
+ """Export a single session with all its messages as a dict."""
2219
+ session = self.get_session(session_id)
2220
+ if not session:
2221
+ return None
2222
+ messages = self.get_messages(session_id)
2223
+ return {**session, "messages": messages}
2224
+
2225
+ def export_all(self, source: str = None) -> List[Dict[str, Any]]:
2226
+ """
2227
+ Export all sessions (with messages) as a list of dicts.
2228
+ Suitable for writing to a JSONL file for backup/analysis.
2229
+ """
2230
+ sessions = self.search_sessions(source=source, limit=100000)
2231
+ results = []
2232
+ for session in sessions:
2233
+ messages = self.get_messages(session["id"])
2234
+ results.append({**session, "messages": messages})
2235
+ return results
2236
+
2237
+ def clear_messages(self, session_id: str) -> None:
2238
+ """Delete all messages for a session and reset its counters."""
2239
+ def _do(conn):
2240
+ conn.execute(
2241
+ "DELETE FROM messages WHERE session_id = ?", (session_id,)
2242
+ )
2243
+ conn.execute(
2244
+ "UPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?",
2245
+ (session_id,),
2246
+ )
2247
+ self._execute_write(_do)
2248
+
2249
+ @staticmethod
2250
+ def _remove_session_files(sessions_dir: Optional[Path], session_id: str) -> None:
2251
+ """Remove on-disk transcript files for a session.
2252
+
2253
+ Cleans up ``{session_id}.json``, ``{session_id}.jsonl``, and any
2254
+ ``request_dump_{session_id}_*.json`` files left by the gateway.
2255
+ Silently skips files that don't exist and swallows OSError so a
2256
+ filesystem hiccup never blocks a DB operation.
2257
+ """
2258
+ if sessions_dir is None:
2259
+ return
2260
+ for suffix in (".json", ".jsonl"):
2261
+ p = sessions_dir / f"{session_id}{suffix}"
2262
+ try:
2263
+ p.unlink(missing_ok=True)
2264
+ except OSError:
2265
+ pass
2266
+ # request_dump files use session_id as a prefix component
2267
+ try:
2268
+ for p in sessions_dir.glob(f"request_dump_{session_id}_*.json"):
2269
+ try:
2270
+ p.unlink(missing_ok=True)
2271
+ except OSError:
2272
+ pass
2273
+ except OSError:
2274
+ pass
2275
+
2276
+ def delete_session(
2277
+ self,
2278
+ session_id: str,
2279
+ sessions_dir: Optional[Path] = None,
2280
+ ) -> bool:
2281
+ """Delete a session and all its messages.
2282
+
2283
+ Child sessions are orphaned (parent_session_id set to NULL) rather
2284
+ than cascade-deleted, so they remain accessible independently.
2285
+ When *sessions_dir* is provided, also removes on-disk transcript
2286
+ files (``.json`` / ``.jsonl`` / ``request_dump_*``) for the deleted
2287
+ session. Returns True if the session was found and deleted.
2288
+ """
2289
+ def _do(conn):
2290
+ cursor = conn.execute(
2291
+ "SELECT COUNT(*) FROM sessions WHERE id = ?", (session_id,)
2292
+ )
2293
+ if cursor.fetchone()[0] == 0:
2294
+ return False
2295
+ # Orphan child sessions so FK constraint is satisfied
2296
+ conn.execute(
2297
+ "UPDATE sessions SET parent_session_id = NULL "
2298
+ "WHERE parent_session_id = ?",
2299
+ (session_id,),
2300
+ )
2301
+ conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
2302
+ conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
2303
+ return True
2304
+
2305
+ deleted = self._execute_write(_do)
2306
+ if deleted:
2307
+ self._remove_session_files(sessions_dir, session_id)
2308
+ return deleted
2309
+
2310
+ def prune_sessions(
2311
+ self,
2312
+ older_than_days: int = 90,
2313
+ source: str = None,
2314
+ sessions_dir: Optional[Path] = None,
2315
+ ) -> int:
2316
+ """Delete sessions older than N days. Returns count of deleted sessions.
2317
+
2318
+ Only prunes ended sessions (not active ones). Child sessions outside
2319
+ the prune window are orphaned (parent_session_id set to NULL) rather
2320
+ than cascade-deleted. When *sessions_dir* is provided, also removes
2321
+ on-disk transcript files (``.json`` / ``.jsonl`` /
2322
+ ``request_dump_*``) for every pruned session, outside the DB
2323
+ transaction.
2324
+ """
2325
+ cutoff = time.time() - (older_than_days * 86400)
2326
+ removed_ids: list[str] = []
2327
+
2328
+ def _do(conn):
2329
+ if source:
2330
+ cursor = conn.execute(
2331
+ """SELECT id FROM sessions
2332
+ WHERE started_at < ? AND ended_at IS NOT NULL AND source = ?""",
2333
+ (cutoff, source),
2334
+ )
2335
+ else:
2336
+ cursor = conn.execute(
2337
+ "SELECT id FROM sessions WHERE started_at < ? AND ended_at IS NOT NULL",
2338
+ (cutoff,),
2339
+ )
2340
+ session_ids = {row["id"] for row in cursor.fetchall()}
2341
+
2342
+ if not session_ids:
2343
+ return 0
2344
+
2345
+ # Orphan any sessions whose parent is about to be deleted
2346
+ placeholders = ",".join("?" * len(session_ids))
2347
+ conn.execute(
2348
+ f"UPDATE sessions SET parent_session_id = NULL "
2349
+ f"WHERE parent_session_id IN ({placeholders})",
2350
+ list(session_ids),
2351
+ )
2352
+
2353
+ for sid in session_ids:
2354
+ conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
2355
+ conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
2356
+ removed_ids.append(sid)
2357
+ return len(session_ids)
2358
+
2359
+ count = self._execute_write(_do)
2360
+ # Clean up on-disk files outside the DB transaction
2361
+ for sid in removed_ids:
2362
+ self._remove_session_files(sessions_dir, sid)
2363
+ return count
2364
+
2365
+ # ── Meta key/value (for scheduler bookkeeping) ──
2366
+
2367
+ def get_meta(self, key: str) -> Optional[str]:
2368
+ """Read a value from the state_meta key/value store."""
2369
+ with self._lock:
2370
+ row = self._conn.execute(
2371
+ "SELECT value FROM state_meta WHERE key = ?", (key,)
2372
+ ).fetchone()
2373
+ if row is None:
2374
+ return None
2375
+ return row["value"] if isinstance(row, sqlite3.Row) else row[0]
2376
+
2377
+ def set_meta(self, key: str, value: str) -> None:
2378
+ """Write a value to the state_meta key/value store."""
2379
+ def _do(conn):
2380
+ conn.execute(
2381
+ "INSERT INTO state_meta (key, value) VALUES (?, ?) "
2382
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
2383
+ (key, value),
2384
+ )
2385
+ self._execute_write(_do)
2386
+
2387
+ def apply_telegram_topic_migration(self) -> None:
2388
+ """Create Telegram DM topic-mode tables on explicit /topic opt-in.
2389
+
2390
+ This migration is deliberately not part of automatic SessionDB startup
2391
+ reconciliation. Operators must be able to upgrade Hermes, keep the old
2392
+ Telegram bot behavior running, and only mutate topic-mode state when the
2393
+ user executes /topic to opt into the feature.
2394
+
2395
+ Schema versions:
2396
+ v1 — initial shape (no ON DELETE CASCADE on session_id FK)
2397
+ v2 — session_id FK gets ON DELETE CASCADE so session pruning
2398
+ automatically clears bindings.
2399
+ """
2400
+ def _do(conn):
2401
+ conn.executescript(
2402
+ """
2403
+ CREATE TABLE IF NOT EXISTS telegram_dm_topic_mode (
2404
+ chat_id TEXT PRIMARY KEY,
2405
+ user_id TEXT NOT NULL,
2406
+ enabled INTEGER NOT NULL DEFAULT 1,
2407
+ activated_at REAL NOT NULL,
2408
+ updated_at REAL NOT NULL,
2409
+ has_topics_enabled INTEGER,
2410
+ allows_users_to_create_topics INTEGER,
2411
+ capability_checked_at REAL,
2412
+ intro_message_id TEXT,
2413
+ pinned_message_id TEXT
2414
+ );
2415
+
2416
+ CREATE TABLE IF NOT EXISTS telegram_dm_topic_bindings (
2417
+ chat_id TEXT NOT NULL,
2418
+ thread_id TEXT NOT NULL,
2419
+ user_id TEXT NOT NULL,
2420
+ session_key TEXT NOT NULL,
2421
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
2422
+ managed_mode TEXT NOT NULL DEFAULT 'auto',
2423
+ linked_at REAL NOT NULL,
2424
+ updated_at REAL NOT NULL,
2425
+ PRIMARY KEY (chat_id, thread_id)
2426
+ );
2427
+
2428
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_session
2429
+ ON telegram_dm_topic_bindings(session_id);
2430
+
2431
+ CREATE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_user
2432
+ ON telegram_dm_topic_bindings(user_id, chat_id);
2433
+ """
2434
+ )
2435
+
2436
+ # v1 → v2: rebuild telegram_dm_topic_bindings if its session_id FK
2437
+ # lacks ON DELETE CASCADE. SQLite can't ALTER a foreign key, so we
2438
+ # rebuild the table. Only runs once per DB (version gate).
2439
+ current = conn.execute(
2440
+ "SELECT value FROM state_meta WHERE key = ?",
2441
+ ("telegram_dm_topic_schema_version",),
2442
+ ).fetchone()
2443
+ current_version = int(current[0]) if current and str(current[0]).isdigit() else 0
2444
+ if current_version < 2:
2445
+ fk_rows = conn.execute(
2446
+ "PRAGMA foreign_key_list('telegram_dm_topic_bindings')"
2447
+ ).fetchall()
2448
+ needs_rebuild = any(
2449
+ row[2] == "sessions" and (row[6] or "") != "CASCADE"
2450
+ for row in fk_rows
2451
+ )
2452
+ if needs_rebuild:
2453
+ conn.executescript(
2454
+ """
2455
+ CREATE TABLE telegram_dm_topic_bindings_new (
2456
+ chat_id TEXT NOT NULL,
2457
+ thread_id TEXT NOT NULL,
2458
+ user_id TEXT NOT NULL,
2459
+ session_key TEXT NOT NULL,
2460
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
2461
+ managed_mode TEXT NOT NULL DEFAULT 'auto',
2462
+ linked_at REAL NOT NULL,
2463
+ updated_at REAL NOT NULL,
2464
+ PRIMARY KEY (chat_id, thread_id)
2465
+ );
2466
+ INSERT INTO telegram_dm_topic_bindings_new
2467
+ SELECT chat_id, thread_id, user_id, session_key,
2468
+ session_id, managed_mode, linked_at, updated_at
2469
+ FROM telegram_dm_topic_bindings;
2470
+ DROP TABLE telegram_dm_topic_bindings;
2471
+ ALTER TABLE telegram_dm_topic_bindings_new
2472
+ RENAME TO telegram_dm_topic_bindings;
2473
+ CREATE UNIQUE INDEX idx_telegram_dm_topic_bindings_session
2474
+ ON telegram_dm_topic_bindings(session_id);
2475
+ CREATE INDEX idx_telegram_dm_topic_bindings_user
2476
+ ON telegram_dm_topic_bindings(user_id, chat_id);
2477
+ """
2478
+ )
2479
+
2480
+ conn.execute(
2481
+ "INSERT INTO state_meta (key, value) VALUES (?, ?) "
2482
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
2483
+ ("telegram_dm_topic_schema_version", "2"),
2484
+ )
2485
+ self._execute_write(_do)
2486
+
2487
+ def enable_telegram_topic_mode(
2488
+ self,
2489
+ *,
2490
+ chat_id: str,
2491
+ user_id: str,
2492
+ has_topics_enabled: Optional[bool] = None,
2493
+ allows_users_to_create_topics: Optional[bool] = None,
2494
+ ) -> None:
2495
+ """Enable Telegram DM topic mode for one private chat/user.
2496
+
2497
+ This method intentionally owns the explicit topic migration. Ordinary
2498
+ SessionDB startup must not create these side tables.
2499
+ """
2500
+ self.apply_telegram_topic_migration()
2501
+ now = time.time()
2502
+
2503
+ def _to_int(value: Optional[bool]) -> Optional[int]:
2504
+ if value is None:
2505
+ return None
2506
+ return 1 if value else 0
2507
+
2508
+ def _do(conn):
2509
+ conn.execute(
2510
+ """
2511
+ INSERT INTO telegram_dm_topic_mode (
2512
+ chat_id, user_id, enabled, activated_at, updated_at,
2513
+ has_topics_enabled, allows_users_to_create_topics,
2514
+ capability_checked_at
2515
+ ) VALUES (?, ?, 1, ?, ?, ?, ?, ?)
2516
+ ON CONFLICT(chat_id) DO UPDATE SET
2517
+ user_id = excluded.user_id,
2518
+ enabled = 1,
2519
+ updated_at = excluded.updated_at,
2520
+ has_topics_enabled = excluded.has_topics_enabled,
2521
+ allows_users_to_create_topics = excluded.allows_users_to_create_topics,
2522
+ capability_checked_at = excluded.capability_checked_at
2523
+ """,
2524
+ (
2525
+ str(chat_id),
2526
+ str(user_id),
2527
+ now,
2528
+ now,
2529
+ _to_int(has_topics_enabled),
2530
+ _to_int(allows_users_to_create_topics),
2531
+ now,
2532
+ ),
2533
+ )
2534
+ self._execute_write(_do)
2535
+
2536
+ def disable_telegram_topic_mode(
2537
+ self,
2538
+ *,
2539
+ chat_id: str,
2540
+ clear_bindings: bool = True,
2541
+ ) -> None:
2542
+ """Disable Telegram DM topic mode for one private chat.
2543
+
2544
+ When ``clear_bindings`` is True (default) the (chat_id, thread_id)
2545
+ bindings for this chat are also cleared so re-enabling later
2546
+ starts from a clean slate. Set to False if the operator wants to
2547
+ preserve bindings for a later re-enable.
2548
+
2549
+ Never creates the topic-mode tables from scratch; if they don't
2550
+ exist there is nothing to disable and the call is a no-op.
2551
+ """
2552
+ def _do(conn):
2553
+ try:
2554
+ conn.execute(
2555
+ "UPDATE telegram_dm_topic_mode SET enabled = 0, updated_at = ? "
2556
+ "WHERE chat_id = ?",
2557
+ (time.time(), str(chat_id)),
2558
+ )
2559
+ if clear_bindings:
2560
+ conn.execute(
2561
+ "DELETE FROM telegram_dm_topic_bindings WHERE chat_id = ?",
2562
+ (str(chat_id),),
2563
+ )
2564
+ except sqlite3.OperationalError:
2565
+ # Tables don't exist yet — nothing to disable.
2566
+ return
2567
+ self._execute_write(_do)
2568
+
2569
+ def is_telegram_topic_mode_enabled(self, *, chat_id: str, user_id: str) -> bool:
2570
+ """Return whether Telegram DM topic mode is enabled for this chat/user."""
2571
+ with self._lock:
2572
+ try:
2573
+ row = self._conn.execute(
2574
+ """
2575
+ SELECT enabled FROM telegram_dm_topic_mode
2576
+ WHERE chat_id = ? AND user_id = ?
2577
+ """,
2578
+ (str(chat_id), str(user_id)),
2579
+ ).fetchone()
2580
+ except sqlite3.OperationalError:
2581
+ return False
2582
+ if row is None:
2583
+ return False
2584
+ enabled = row["enabled"] if isinstance(row, sqlite3.Row) else row[0]
2585
+ return bool(enabled)
2586
+
2587
+ def get_telegram_topic_binding(
2588
+ self,
2589
+ *,
2590
+ chat_id: str,
2591
+ thread_id: str,
2592
+ ) -> Optional[Dict[str, Any]]:
2593
+ """Return the session binding for a Telegram DM topic, if present."""
2594
+ with self._lock:
2595
+ try:
2596
+ row = self._conn.execute(
2597
+ """
2598
+ SELECT * FROM telegram_dm_topic_bindings
2599
+ WHERE chat_id = ? AND thread_id = ?
2600
+ """,
2601
+ (str(chat_id), str(thread_id)),
2602
+ ).fetchone()
2603
+ except sqlite3.OperationalError:
2604
+ return None
2605
+ return dict(row) if row else None
2606
+
2607
+ def bind_telegram_topic(
2608
+ self,
2609
+ *,
2610
+ chat_id: str,
2611
+ thread_id: str,
2612
+ user_id: str,
2613
+ session_key: str,
2614
+ session_id: str,
2615
+ managed_mode: str = "auto",
2616
+ ) -> None:
2617
+ """Bind one Telegram DM topic thread to one Hermes session.
2618
+
2619
+ A Hermes session may only be linked to one Telegram topic in MVP.
2620
+ Rebinding the same topic to the same session is idempotent; trying to
2621
+ link the same session to a different topic raises ValueError.
2622
+ """
2623
+ self.apply_telegram_topic_migration()
2624
+ now = time.time()
2625
+ chat_id = str(chat_id)
2626
+ thread_id = str(thread_id)
2627
+ user_id = str(user_id)
2628
+ session_key = str(session_key)
2629
+ session_id = str(session_id)
2630
+
2631
+ def _do(conn):
2632
+ existing_session = conn.execute(
2633
+ """
2634
+ SELECT chat_id, thread_id FROM telegram_dm_topic_bindings
2635
+ WHERE session_id = ?
2636
+ """,
2637
+ (session_id,),
2638
+ ).fetchone()
2639
+ if existing_session is not None:
2640
+ linked_chat = existing_session["chat_id"] if isinstance(existing_session, sqlite3.Row) else existing_session[0]
2641
+ linked_thread = existing_session["thread_id"] if isinstance(existing_session, sqlite3.Row) else existing_session[1]
2642
+ if str(linked_chat) != chat_id or str(linked_thread) != thread_id:
2643
+ raise ValueError("session is already linked to another Telegram topic")
2644
+
2645
+ conn.execute(
2646
+ """
2647
+ INSERT INTO telegram_dm_topic_bindings (
2648
+ chat_id, thread_id, user_id, session_key, session_id,
2649
+ managed_mode, linked_at, updated_at
2650
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2651
+ ON CONFLICT(chat_id, thread_id) DO UPDATE SET
2652
+ user_id = excluded.user_id,
2653
+ session_key = excluded.session_key,
2654
+ session_id = excluded.session_id,
2655
+ managed_mode = excluded.managed_mode,
2656
+ updated_at = excluded.updated_at
2657
+ """,
2658
+ (
2659
+ chat_id,
2660
+ thread_id,
2661
+ user_id,
2662
+ session_key,
2663
+ session_id,
2664
+ managed_mode,
2665
+ now,
2666
+ now,
2667
+ ),
2668
+ )
2669
+ self._execute_write(_do)
2670
+
2671
+ def is_telegram_session_linked_to_topic(self, *, session_id: str) -> bool:
2672
+ """Return True if a Hermes session is already bound to any Telegram DM topic.
2673
+
2674
+ Read-only: does NOT trigger the telegram-topic migration. If the
2675
+ topic-mode tables have not been created yet (i.e. nobody has run
2676
+ ``/topic`` in this profile), the session is by definition unbound
2677
+ and we return False.
2678
+ """
2679
+ with self._lock:
2680
+ try:
2681
+ row = self._conn.execute(
2682
+ """
2683
+ SELECT 1 FROM telegram_dm_topic_bindings
2684
+ WHERE session_id = ?
2685
+ LIMIT 1
2686
+ """,
2687
+ (str(session_id),),
2688
+ ).fetchone()
2689
+ except sqlite3.OperationalError:
2690
+ return False
2691
+ return row is not None
2692
+
2693
+ def list_unlinked_telegram_sessions_for_user(
2694
+ self,
2695
+ *,
2696
+ chat_id: str,
2697
+ user_id: str,
2698
+ limit: int = 10,
2699
+ ) -> List[Dict[str, Any]]:
2700
+ """List previous Telegram sessions for this user that are not bound to a topic.
2701
+
2702
+ Read-only: does NOT trigger the telegram-topic migration. If the
2703
+ topic-mode tables are absent, fall back to a simpler query that
2704
+ just returns this user's Telegram sessions — there can't be any
2705
+ bindings yet.
2706
+ """
2707
+ with self._lock:
2708
+ try:
2709
+ rows = self._conn.execute(
2710
+ """
2711
+ SELECT s.*,
2712
+ COALESCE(
2713
+ (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
2714
+ FROM messages m
2715
+ WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
2716
+ ORDER BY m.timestamp, m.id LIMIT 1),
2717
+ ''
2718
+ ) AS _preview_raw,
2719
+ COALESCE(
2720
+ (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
2721
+ s.started_at
2722
+ ) AS last_active
2723
+ FROM sessions s
2724
+ WHERE s.source = 'telegram'
2725
+ AND s.user_id = ?
2726
+ AND NOT EXISTS (
2727
+ SELECT 1 FROM telegram_dm_topic_bindings b
2728
+ WHERE b.session_id = s.id
2729
+ )
2730
+ ORDER BY last_active DESC, s.started_at DESC
2731
+ LIMIT ?
2732
+ """,
2733
+ (str(user_id), int(limit)),
2734
+ ).fetchall()
2735
+ except sqlite3.OperationalError:
2736
+ # telegram_dm_topic_bindings doesn't exist yet — no bindings
2737
+ # means every telegram session for this user is "unlinked".
2738
+ rows = self._conn.execute(
2739
+ """
2740
+ SELECT s.*,
2741
+ COALESCE(
2742
+ (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
2743
+ FROM messages m
2744
+ WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
2745
+ ORDER BY m.timestamp, m.id LIMIT 1),
2746
+ ''
2747
+ ) AS _preview_raw,
2748
+ COALESCE(
2749
+ (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
2750
+ s.started_at
2751
+ ) AS last_active
2752
+ FROM sessions s
2753
+ WHERE s.source = 'telegram'
2754
+ AND s.user_id = ?
2755
+ ORDER BY last_active DESC, s.started_at DESC
2756
+ LIMIT ?
2757
+ """,
2758
+ (str(user_id), int(limit)),
2759
+ ).fetchall()
2760
+
2761
+ sessions: List[Dict[str, Any]] = []
2762
+ for row in rows:
2763
+ session = dict(row)
2764
+ raw = str(session.pop("_preview_raw", "") or "").strip()
2765
+ session["preview"] = raw[:60] + ("..." if len(raw) > 60 else "") if raw else ""
2766
+ sessions.append(session)
2767
+ return sessions
2768
+
2769
+ # ── Space reclamation ──
2770
+
2771
+ def vacuum(self) -> None:
2772
+ """Run VACUUM to reclaim disk space after large deletes.
2773
+
2774
+ SQLite does not shrink the database file when rows are deleted —
2775
+ freed pages just get reused on the next insert. After a prune that
2776
+ removed hundreds of sessions, the file stays bloated unless we
2777
+ explicitly VACUUM.
2778
+
2779
+ VACUUM rewrites the entire DB, so it's expensive (seconds per
2780
+ 100MB) and cannot run inside a transaction. It also acquires an
2781
+ exclusive lock, so callers must ensure no other writers are
2782
+ active. Safe to call at startup before the gateway/CLI starts
2783
+ serving traffic.
2784
+ """
2785
+ # VACUUM cannot be executed inside a transaction.
2786
+ with self._lock:
2787
+ # Best-effort WAL checkpoint first, then VACUUM.
2788
+ try:
2789
+ self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
2790
+ except Exception:
2791
+ pass
2792
+ self._conn.execute("VACUUM")
2793
+
2794
+ def maybe_auto_prune_and_vacuum(
2795
+ self,
2796
+ retention_days: int = 90,
2797
+ min_interval_hours: int = 24,
2798
+ vacuum: bool = True,
2799
+ sessions_dir: Optional[Path] = None,
2800
+ ) -> Dict[str, Any]:
2801
+ """Idempotent auto-maintenance: prune old sessions + optional VACUUM.
2802
+
2803
+ Records the last run timestamp in state_meta so subsequent calls
2804
+ within ``min_interval_hours`` no-op. Designed to be called once at
2805
+ startup from long-lived entrypoints (CLI, gateway, cron scheduler).
2806
+
2807
+ When *sessions_dir* is provided, on-disk transcript files
2808
+ (``.json`` / ``.jsonl`` / ``request_dump_*``) for pruned sessions
2809
+ are removed as part of the same sweep (issue #3015).
2810
+
2811
+ Never raises. On any failure, logs a warning and returns a dict
2812
+ with ``"error"`` set.
2813
+
2814
+ Returns a dict with keys:
2815
+ - ``"skipped"`` (bool) — true if within min_interval_hours of last run
2816
+ - ``"pruned"`` (int) — number of sessions deleted
2817
+ - ``"vacuumed"`` (bool) — true if VACUUM ran
2818
+ - ``"error"`` (str, optional) — present only on failure
2819
+ """
2820
+ result: Dict[str, Any] = {"skipped": False, "pruned": 0, "vacuumed": False}
2821
+ try:
2822
+ # Skip if another process/call did maintenance recently.
2823
+ last_raw = self.get_meta("last_auto_prune")
2824
+ now = time.time()
2825
+ if last_raw:
2826
+ try:
2827
+ last_ts = float(last_raw)
2828
+ if now - last_ts < min_interval_hours * 3600:
2829
+ result["skipped"] = True
2830
+ return result
2831
+ except (TypeError, ValueError):
2832
+ pass # corrupt meta; treat as no prior run
2833
+
2834
+ pruned = self.prune_sessions(
2835
+ older_than_days=retention_days,
2836
+ sessions_dir=sessions_dir,
2837
+ )
2838
+ result["pruned"] = pruned
2839
+
2840
+ # Only VACUUM if we actually freed rows — VACUUM on a tight DB
2841
+ # is wasted I/O. Threshold keeps small DBs from paying the cost.
2842
+ if vacuum and pruned > 0:
2843
+ try:
2844
+ self.vacuum()
2845
+ result["vacuumed"] = True
2846
+ except Exception as exc:
2847
+ logger.warning("state.db VACUUM failed: %s", exc)
2848
+
2849
+ # Record the attempt even if pruned == 0, so we don't retry
2850
+ # every startup within the min_interval_hours window.
2851
+ self.set_meta("last_auto_prune", str(now))
2852
+
2853
+ if pruned > 0:
2854
+ logger.info(
2855
+ "state.db auto-maintenance: pruned %d session(s) older than %d days%s",
2856
+ pruned,
2857
+ retention_days,
2858
+ " + VACUUM" if result["vacuumed"] else "",
2859
+ )
2860
+ except Exception as exc:
2861
+ # Maintenance must never block startup. Log and return error marker.
2862
+ logger.warning("state.db auto-maintenance failed: %s", exc)
2863
+ result["error"] = str(exc)
2864
+
2865
+ return result
2866
+
2867
+ # ── Handoff (cross-platform session transfer) ──────────────────────────
2868
+ #
2869
+ # State machine:
2870
+ # None — no handoff in flight
2871
+ # "pending" — CLI requested handoff, gateway hasn't picked it up yet
2872
+ # "running" — gateway is processing (session switch + synthetic turn)
2873
+ # "completed"— gateway successfully delivered the synthetic turn
2874
+ # "failed" — gateway hit an error; reason in handoff_error
2875
+ #
2876
+ # The CLI writes "pending" then poll-waits for terminal state. The gateway
2877
+ # watcher transitions pending→running→{completed,failed}.
2878
+
2879
+ def request_handoff(self, session_id: str, platform: str) -> bool:
2880
+ """Mark a session as pending handoff to the given platform.
2881
+
2882
+ Returns True if the row was found and not already in flight; False if
2883
+ the session is already in a non-terminal handoff state.
2884
+ """
2885
+ def _do(conn):
2886
+ cur = conn.execute(
2887
+ "UPDATE sessions "
2888
+ "SET handoff_state = 'pending', "
2889
+ " handoff_platform = ?, "
2890
+ " handoff_error = NULL "
2891
+ "WHERE id = ? AND (handoff_state IS NULL "
2892
+ " OR handoff_state IN ('completed', 'failed'))",
2893
+ (platform, session_id),
2894
+ )
2895
+ return cur.rowcount > 0
2896
+ return self._execute_write(_do)
2897
+
2898
+ def get_handoff_state(self, session_id: str) -> Optional[Dict[str, Any]]:
2899
+ """Read the current handoff state for a session.
2900
+
2901
+ Returns ``{"state", "platform", "error"}`` or None if the session has
2902
+ no handoff record.
2903
+ """
2904
+ try:
2905
+ cur = self._conn.execute(
2906
+ "SELECT handoff_state, handoff_platform, handoff_error "
2907
+ "FROM sessions WHERE id = ?",
2908
+ (session_id,),
2909
+ )
2910
+ row = cur.fetchone()
2911
+ if not row:
2912
+ return None
2913
+ return {
2914
+ "state": row["handoff_state"],
2915
+ "platform": row["handoff_platform"],
2916
+ "error": row["handoff_error"],
2917
+ }
2918
+ except Exception:
2919
+ return None
2920
+
2921
+ def list_pending_handoffs(self) -> List[Dict[str, Any]]:
2922
+ """Return all sessions in handoff_state='pending', oldest first.
2923
+
2924
+ Used by the gateway's handoff watcher.
2925
+ """
2926
+ try:
2927
+ cur = self._conn.execute(
2928
+ "SELECT * FROM sessions "
2929
+ "WHERE handoff_state = 'pending' "
2930
+ "ORDER BY started_at ASC"
2931
+ )
2932
+ return [dict(r) for r in cur.fetchall()]
2933
+ except Exception:
2934
+ return []
2935
+
2936
+ def claim_handoff(self, session_id: str) -> bool:
2937
+ """Atomically transition pending → running. Returns True if claimed."""
2938
+ def _do(conn):
2939
+ cur = conn.execute(
2940
+ "UPDATE sessions SET handoff_state = 'running' "
2941
+ "WHERE id = ? AND handoff_state = 'pending'",
2942
+ (session_id,),
2943
+ )
2944
+ return cur.rowcount > 0
2945
+ return self._execute_write(_do)
2946
+
2947
+ def complete_handoff(self, session_id: str) -> None:
2948
+ """Mark a handoff as completed."""
2949
+ def _do(conn):
2950
+ conn.execute(
2951
+ "UPDATE sessions SET handoff_state = 'completed', "
2952
+ "handoff_error = NULL WHERE id = ?",
2953
+ (session_id,),
2954
+ )
2955
+ self._execute_write(_do)
2956
+
2957
+ def fail_handoff(self, session_id: str, error: str) -> None:
2958
+ """Mark a handoff as failed and record the reason."""
2959
+ def _do(conn):
2960
+ conn.execute(
2961
+ "UPDATE sessions SET handoff_state = 'failed', "
2962
+ "handoff_error = ? WHERE id = ?",
2963
+ (error[:500], session_id),
2964
+ )
2965
+ self._execute_write(_do)
2966
+
2967
+