comodor 0.19.2__tar.gz → 0.20.1__tar.gz

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 (237) hide show
  1. {comodor-0.19.2 → comodor-0.20.1}/PKG-INFO +3 -2
  2. {comodor-0.19.2 → comodor-0.20.1}/README.md +2 -1
  3. {comodor-0.19.2 → comodor-0.20.1}/docs/README.md +1 -0
  4. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/_version.py +2 -2
  5. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/browser/cdp.py +21 -123
  6. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/channels/__init__.py +17 -1
  7. comodor-0.20.1/src/comodor/channels/markdown.py +322 -0
  8. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/cli.py +6 -0
  9. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/config.py +56 -5
  10. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/doctor.py +7 -2
  11. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/help.py +28 -0
  12. comodor-0.20.1/src/comodor/net/ws.py +210 -0
  13. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/setup.py +32 -1
  14. comodor-0.20.1/src/comodor/slack/__init__.py +7 -0
  15. comodor-0.20.1/src/comodor/slack/api.py +237 -0
  16. comodor-0.20.1/src/comodor/slack/blocks.py +221 -0
  17. comodor-0.20.1/src/comodor/slack/bot.py +746 -0
  18. comodor-0.20.1/src/comodor/slack/commands.py +521 -0
  19. comodor-0.20.1/src/comodor/slack/service.py +33 -0
  20. comodor-0.20.1/src/comodor/slack/socket.py +202 -0
  21. comodor-0.20.1/src/comodor/slack/unit.py +24 -0
  22. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/telegram/bot.py +7 -3
  23. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/server.py +26 -0
  24. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/session.py +266 -0
  25. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/ui.js +216 -36
  26. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/bot.py +4 -1
  27. comodor-0.20.1/tests/test_markdown.py +310 -0
  28. comodor-0.20.1/tests/test_slack.py +488 -0
  29. {comodor-0.19.2 → comodor-0.20.1}/tests/test_web.py +107 -0
  30. comodor-0.19.2/tests/test_markdown.py +0 -103
  31. {comodor-0.19.2 → comodor-0.20.1}/.gitignore +0 -0
  32. {comodor-0.19.2 → comodor-0.20.1}/LICENSE +0 -0
  33. {comodor-0.19.2 → comodor-0.20.1}/pyproject.toml +0 -0
  34. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/__init__.py +0 -0
  35. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/__main__.py +0 -0
  36. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/acp/__init__.py +0 -0
  37. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/acp/agent.py +0 -0
  38. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/acp/commands.py +0 -0
  39. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/acp/jsonrpc.py +0 -0
  40. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/agent/__init__.py +0 -0
  41. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/agent/context.py +0 -0
  42. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/agent/loop.py +0 -0
  43. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/agent/prompts.py +0 -0
  44. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/agent/spawn.py +0 -0
  45. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/agent/tokens.py +0 -0
  46. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/browser/__init__.py +0 -0
  47. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/browser/launch.py +0 -0
  48. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/browser/page.py +0 -0
  49. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/catalogue.py +0 -0
  50. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/channels/daemon.py +0 -0
  51. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/channels/unit.py +0 -0
  52. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/desktop/__init__.py +0 -0
  53. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/desktop/guard.py +0 -0
  54. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/desktop/keys.py +0 -0
  55. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/desktop/overlay.py +0 -0
  56. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/desktop/png.py +0 -0
  57. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/desktop/screen.py +0 -0
  58. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/desktop/win32.py +0 -0
  59. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/events.py +0 -0
  60. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/__init__.py +0 -0
  61. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/associations.py +0 -0
  62. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/bm25.py +0 -0
  63. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/hotindex.py +0 -0
  64. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/memory.py +0 -0
  65. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/progress.py +0 -0
  66. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/reflect.py +0 -0
  67. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/rules.py +0 -0
  68. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/signals.py +0 -0
  69. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/store.py +0 -0
  70. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/writer.py +0 -0
  71. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/local/__init__.py +0 -0
  72. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/local/catalogue.py +0 -0
  73. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/local/commands.py +0 -0
  74. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/local/download.py +0 -0
  75. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/local/models.json +0 -0
  76. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/local/provider.py +0 -0
  77. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/local/runtime.py +0 -0
  78. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/local/store.py +0 -0
  79. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/mcp/__init__.py +0 -0
  80. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/mcp/catalogue.py +0 -0
  81. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/mcp/commands.py +0 -0
  82. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/mcp/http.py +0 -0
  83. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/mcp/manager.py +0 -0
  84. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/mcp/protocol.py +0 -0
  85. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/migrate.py +0 -0
  86. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/net/__init__.py +0 -0
  87. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/net/http.py +0 -0
  88. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/net/sse.py +0 -0
  89. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/paths.py +0 -0
  90. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/__init__.py +0 -0
  91. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/anthropic.py +0 -0
  92. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/base.py +0 -0
  93. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/caching.py +0 -0
  94. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/discover.py +0 -0
  95. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/fake.py +0 -0
  96. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/gateway.py +0 -0
  97. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/models.py +0 -0
  98. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/oauth.py +0 -0
  99. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/openai_compat.py +0 -0
  100. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/registry.py +0 -0
  101. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/questions.py +0 -0
  102. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/safety/__init__.py +0 -0
  103. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/safety/checkpoints.py +0 -0
  104. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/safety/permissions.py +0 -0
  105. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/safety/redact.py +0 -0
  106. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/session/__init__.py +0 -0
  107. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/session/search.py +0 -0
  108. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/session/store.py +0 -0
  109. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/skills/__init__.py +0 -0
  110. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/skills/catalogue.py +0 -0
  111. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/skills/commands.py +0 -0
  112. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/skills/examples.py +0 -0
  113. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/skills/loader.py +0 -0
  114. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/skills/propose.py +0 -0
  115. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/skills/registry.py +0 -0
  116. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/telegram/__init__.py +0 -0
  117. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/telegram/api.py +0 -0
  118. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/telegram/commands.py +0 -0
  119. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/telegram/keyboard.py +0 -0
  120. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/telegram/service.py +0 -0
  121. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/telegram/unit.py +0 -0
  122. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/__init__.py +0 -0
  123. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/ask.py +0 -0
  124. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/base.py +0 -0
  125. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/browse.py +0 -0
  126. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/browser.py +0 -0
  127. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/computer.py +0 -0
  128. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/delegate.py +0 -0
  129. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/fs.py +0 -0
  130. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/history.py +0 -0
  131. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/mcp.py +0 -0
  132. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/overflow.py +0 -0
  133. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/registry.py +0 -0
  134. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/search.py +0 -0
  135. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/shell.py +0 -0
  136. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/skills.py +0 -0
  137. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/todo.py +0 -0
  138. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/web.py +0 -0
  139. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/__init__.py +0 -0
  140. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/app.py +0 -0
  141. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/banner.py +0 -0
  142. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/bidi.py +0 -0
  143. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/chooser.py +0 -0
  144. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/clipboard.py +0 -0
  145. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/console.py +0 -0
  146. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/input/__init__.py +0 -0
  147. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/input/keys.py +0 -0
  148. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/input/reader.py +0 -0
  149. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/layout.py +0 -0
  150. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/markdown.py +0 -0
  151. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/screen.py +0 -0
  152. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/theme.py +0 -0
  153. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/__init__.py +0 -0
  154. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/buttons.py +0 -0
  155. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/chat.py +0 -0
  156. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/history.py +0 -0
  157. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/overlay.py +0 -0
  158. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/panel.py +0 -0
  159. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/progress.py +0 -0
  160. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/prompt.py +0 -0
  161. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/questions.py +0 -0
  162. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/statusbar.py +0 -0
  163. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/toast.py +0 -0
  164. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/welcome.py +0 -0
  165. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/uninstall.py +0 -0
  166. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/update.py +0 -0
  167. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/__init__.py +0 -0
  168. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/commands.py +0 -0
  169. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/index.html +0 -0
  170. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/ui.css +0 -0
  171. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/vazirmatn-OFL.txt +0 -0
  172. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/vazirmatn.woff2 +0 -0
  173. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/__init__.py +0 -0
  174. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/api.py +0 -0
  175. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/commands.py +0 -0
  176. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/guide.py +0 -0
  177. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/menu.py +0 -0
  178. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/service.py +0 -0
  179. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/tunnel.py +0 -0
  180. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/unit.py +0 -0
  181. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/webhook.py +0 -0
  182. {comodor-0.19.2 → comodor-0.20.1}/src/comodor/workspace.py +0 -0
  183. {comodor-0.19.2 → comodor-0.20.1}/tests/conftest.py +0 -0
  184. {comodor-0.19.2 → comodor-0.20.1}/tests/support/fake_mcp_server.py +0 -0
  185. {comodor-0.19.2 → comodor-0.20.1}/tests/test_acp.py +0 -0
  186. {comodor-0.19.2 → comodor-0.20.1}/tests/test_agent_loop.py +0 -0
  187. {comodor-0.19.2 → comodor-0.20.1}/tests/test_app.py +0 -0
  188. {comodor-0.19.2 → comodor-0.20.1}/tests/test_associations.py +0 -0
  189. {comodor-0.19.2 → comodor-0.20.1}/tests/test_banner.py +0 -0
  190. {comodor-0.19.2 → comodor-0.20.1}/tests/test_bidi.py +0 -0
  191. {comodor-0.19.2 → comodor-0.20.1}/tests/test_browser.py +0 -0
  192. {comodor-0.19.2 → comodor-0.20.1}/tests/test_caching.py +0 -0
  193. {comodor-0.19.2 → comodor-0.20.1}/tests/test_channel_service.py +0 -0
  194. {comodor-0.19.2 → comodor-0.20.1}/tests/test_chooser.py +0 -0
  195. {comodor-0.19.2 → comodor-0.20.1}/tests/test_computer_tool.py +0 -0
  196. {comodor-0.19.2 → comodor-0.20.1}/tests/test_config_save.py +0 -0
  197. {comodor-0.19.2 → comodor-0.20.1}/tests/test_copy_and_bands.py +0 -0
  198. {comodor-0.19.2 → comodor-0.20.1}/tests/test_delegate.py +0 -0
  199. {comodor-0.19.2 → comodor-0.20.1}/tests/test_desktop.py +0 -0
  200. {comodor-0.19.2 → comodor-0.20.1}/tests/test_discover.py +0 -0
  201. {comodor-0.19.2 → comodor-0.20.1}/tests/test_doctor.py +0 -0
  202. {comodor-0.19.2 → comodor-0.20.1}/tests/test_help.py +0 -0
  203. {comodor-0.19.2 → comodor-0.20.1}/tests/test_history.py +0 -0
  204. {comodor-0.19.2 → comodor-0.20.1}/tests/test_import_command.py +0 -0
  205. {comodor-0.19.2 → comodor-0.20.1}/tests/test_input.py +0 -0
  206. {comodor-0.19.2 → comodor-0.20.1}/tests/test_layout.py +0 -0
  207. {comodor-0.19.2 → comodor-0.20.1}/tests/test_learning.py +0 -0
  208. {comodor-0.19.2 → comodor-0.20.1}/tests/test_local.py +0 -0
  209. {comodor-0.19.2 → comodor-0.20.1}/tests/test_mcp.py +0 -0
  210. {comodor-0.19.2 → comodor-0.20.1}/tests/test_mcp_http.py +0 -0
  211. {comodor-0.19.2 → comodor-0.20.1}/tests/test_migrate.py +0 -0
  212. {comodor-0.19.2 → comodor-0.20.1}/tests/test_oauth.py +0 -0
  213. {comodor-0.19.2 → comodor-0.20.1}/tests/test_overflow.py +0 -0
  214. {comodor-0.19.2 → comodor-0.20.1}/tests/test_overlay.py +0 -0
  215. {comodor-0.19.2 → comodor-0.20.1}/tests/test_performance.py +0 -0
  216. {comodor-0.19.2 → comodor-0.20.1}/tests/test_prefix_stability.py +0 -0
  217. {comodor-0.19.2 → comodor-0.20.1}/tests/test_progress.py +0 -0
  218. {comodor-0.19.2 → comodor-0.20.1}/tests/test_propose.py +0 -0
  219. {comodor-0.19.2 → comodor-0.20.1}/tests/test_providers.py +0 -0
  220. {comodor-0.19.2 → comodor-0.20.1}/tests/test_questions.py +0 -0
  221. {comodor-0.19.2 → comodor-0.20.1}/tests/test_real_browser.py +0 -0
  222. {comodor-0.19.2 → comodor-0.20.1}/tests/test_real_desktop.py +0 -0
  223. {comodor-0.19.2 → comodor-0.20.1}/tests/test_real_web_ui.py +0 -0
  224. {comodor-0.19.2 → comodor-0.20.1}/tests/test_reflex.py +0 -0
  225. {comodor-0.19.2 → comodor-0.20.1}/tests/test_run_loop.py +0 -0
  226. {comodor-0.19.2 → comodor-0.20.1}/tests/test_setup.py +0 -0
  227. {comodor-0.19.2 → comodor-0.20.1}/tests/test_setup_import.py +0 -0
  228. {comodor-0.19.2 → comodor-0.20.1}/tests/test_skill_budget.py +0 -0
  229. {comodor-0.19.2 → comodor-0.20.1}/tests/test_skill_catalogue.py +0 -0
  230. {comodor-0.19.2 → comodor-0.20.1}/tests/test_skills.py +0 -0
  231. {comodor-0.19.2 → comodor-0.20.1}/tests/test_switching_model.py +0 -0
  232. {comodor-0.19.2 → comodor-0.20.1}/tests/test_telegram.py +0 -0
  233. {comodor-0.19.2 → comodor-0.20.1}/tests/test_tools.py +0 -0
  234. {comodor-0.19.2 → comodor-0.20.1}/tests/test_uninstall.py +0 -0
  235. {comodor-0.19.2 → comodor-0.20.1}/tests/test_update.py +0 -0
  236. {comodor-0.19.2 → comodor-0.20.1}/tests/test_welcome.py +0 -0
  237. {comodor-0.19.2 → comodor-0.20.1}/tests/test_whatsapp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: comodor
3
- Version: 0.19.2
3
+ Version: 0.20.1
4
4
  Summary: Comodor — a self-improving terminal coding agent with a Rich TUI
5
5
  Project-URL: Homepage, https://comodor.ai
6
6
  Project-URL: Repository, https://github.com/ifekri/Comodor
@@ -75,7 +75,7 @@ falling or not. A number, not a claim.
75
75
  | **Never surprises you** | Reading is silent. Writing shows a diff. Commands ask. Every change is checkpointed and `/undo` puts it back. [→](docs/safety.md) |
76
76
  | **Drives a real browser** | One that runs JavaScript, keeps cookies and can log in — not a page fetcher. [→](docs/browser.md) |
77
77
  | **Uses your screen** | Mouse and keyboard in any application, with a halo showing where it is about to click before it clicks. [→](docs/computer.md) |
78
- | **Runs on your phone** | Telegram or WhatsApp, the whole interface as buttons, running in the background whether or not a terminal is open. Read-only until you say otherwise. [→](docs/telegram.md) · [→](docs/whatsapp.md) |
78
+ | **Runs on your phone** | Telegram, Slack or WhatsApp — the whole interface as buttons, running in the background whether or not a terminal is open. Read-only until you say otherwise. [→](docs/telegram.md) · [→](docs/slack.md) · [→](docs/whatsapp.md) |
79
79
  | **Runs a model locally** | Pick one, watch it download, use it with the network unplugged. No key, no account. [→](docs/local-models.md) |
80
80
  | **Lives in your editor** | Speaks ACP, so VS Code, JetBrains and Zed drive it from their own panels. [→](docs/acp.md) |
81
81
  | **Follows your procedures** | Write a skill once; it loads when the work matches. 147 ready to install. [→](docs/skills.md) |
@@ -174,6 +174,7 @@ Esc stop it F3 cycle mode
174
174
  | [What it can do](docs/tools.md) | The 13 tools, and when it uses each |
175
175
  | [Safety and permissions](docs/safety.md) | What it asks, and what it never does |
176
176
  | [From your phone](docs/telegram.md) | The Telegram bot, and who it answers |
177
+ | [From Slack](docs/slack.md) | Socket Mode — five minutes, and no public address |
177
178
  | [From WhatsApp](docs/whatsapp.md) | The Cloud API — longer to set up; Telegram does the same thing |
178
179
  | [Models on your machine](docs/local-models.md) | Downloading one, running it offline |
179
180
  | [Configuration](docs/configuration.md) | Every setting, and what wins |
@@ -48,7 +48,7 @@ falling or not. A number, not a claim.
48
48
  | **Never surprises you** | Reading is silent. Writing shows a diff. Commands ask. Every change is checkpointed and `/undo` puts it back. [→](docs/safety.md) |
49
49
  | **Drives a real browser** | One that runs JavaScript, keeps cookies and can log in — not a page fetcher. [→](docs/browser.md) |
50
50
  | **Uses your screen** | Mouse and keyboard in any application, with a halo showing where it is about to click before it clicks. [→](docs/computer.md) |
51
- | **Runs on your phone** | Telegram or WhatsApp, the whole interface as buttons, running in the background whether or not a terminal is open. Read-only until you say otherwise. [→](docs/telegram.md) · [→](docs/whatsapp.md) |
51
+ | **Runs on your phone** | Telegram, Slack or WhatsApp — the whole interface as buttons, running in the background whether or not a terminal is open. Read-only until you say otherwise. [→](docs/telegram.md) · [→](docs/slack.md) · [→](docs/whatsapp.md) |
52
52
  | **Runs a model locally** | Pick one, watch it download, use it with the network unplugged. No key, no account. [→](docs/local-models.md) |
53
53
  | **Lives in your editor** | Speaks ACP, so VS Code, JetBrains and Zed drive it from their own panels. [→](docs/acp.md) |
54
54
  | **Follows your procedures** | Write a skill once; it loads when the work matches. 147 ready to install. [→](docs/skills.md) |
@@ -147,6 +147,7 @@ Esc stop it F3 cycle mode
147
147
  | [What it can do](docs/tools.md) | The 13 tools, and when it uses each |
148
148
  | [Safety and permissions](docs/safety.md) | What it asks, and what it never does |
149
149
  | [From your phone](docs/telegram.md) | The Telegram bot, and who it answers |
150
+ | [From Slack](docs/slack.md) | Socket Mode — five minutes, and no public address |
150
151
  | [From WhatsApp](docs/whatsapp.md) | The Cloud API — longer to set up; Telegram does the same thing |
151
152
  | [Models on your machine](docs/local-models.md) | Downloading one, running it offline |
152
153
  | [Configuration](docs/configuration.md) | Every setting, and what wins |
@@ -42,6 +42,7 @@ ends with the agent doing something useful.
42
42
  | | |
43
43
  |---|---|
44
44
  | [From your phone](telegram.md) | The Telegram bot: pairing, the buttons, and who it answers |
45
+ | [From Slack](slack.md) | Socket Mode — five minutes, no public address, and it answers in threads |
45
46
  | [From WhatsApp](whatsapp.md) | The Cloud API — about twenty minutes and technical. Telegram does the same in one |
46
47
  | [Models on your machine](local-models.md) | Downloading one, running it offline, adding to the list |
47
48
  | [Questions](questions.md) | The form it puts up when a request reads two ways |
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.19.2'
22
- __version_tuple__ = version_tuple = (0, 19, 2)
21
+ __version__ = version = '0.20.1'
22
+ __version_tuple__ = version_tuple = (0, 20, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -24,17 +24,16 @@ Two things about the protocol that are easy to get wrong:
24
24
 
25
25
  from __future__ import annotations
26
26
 
27
- import base64
28
27
  import json
29
- import os
30
- import socket
31
- import struct
32
28
  import threading
33
29
  import time
34
30
  import urllib.error
35
31
  import urllib.request
36
32
  from typing import Any, Callable
37
33
 
34
+ from ..net.ws import WebSocket as _WebSocket
35
+ from ..net.ws import WebSocketError
36
+
38
37
  DEFAULT_TIMEOUT = 30.0
39
38
  #: Long enough for a slow page on a slow machine, short enough that a hung
40
39
  #: renderer does not hold the turn until the agent's own deadline.
@@ -50,135 +49,34 @@ class BrowserError(RuntimeError):
50
49
  # --------------------------------------------------------------------------- #
51
50
 
52
51
 
53
- class WebSocket:
54
- """RFC 6455, client side, and only the parts CDP uses.
52
+ class WebSocket(_WebSocket):
53
+ """The general client, with the browser's vocabulary on its failures.
55
54
 
56
- No extensions, no compression, no continuation frames on send: CDP sends
57
- JSON text and so do we. Fragmented *incoming* frames are reassembled,
58
- because a large accessibility tree arrives in pieces.
55
+ The frame handling moved to `net.ws` when Slack's Socket Mode became a
56
+ second caller for it. This keeps the errors saying "the browser", because
57
+ everything above catches `BrowserError` and a person reading a traceback
58
+ about a websocket is a person who has to work out which websocket.
59
59
  """
60
60
 
61
61
  def __init__(self, url: str, timeout: float = DEFAULT_TIMEOUT) -> None:
62
- _, _, rest = url.partition("://")
63
- hostport, _, path = rest.partition("/")
64
- host, _, port = hostport.partition(":")
65
62
  try:
66
- self._socket = socket.create_connection((host, int(port or 80)),
67
- timeout=timeout)
68
- except OSError as error:
69
- raise BrowserError(f"could not connect to the browser: {error}") from error
70
- self._socket.settimeout(timeout)
71
-
72
- key = base64.b64encode(os.urandom(16)).decode()
73
- self._socket.sendall(
74
- f"GET /{path} HTTP/1.1\r\n"
75
- f"Host: {hostport}\r\n"
76
- f"Upgrade: websocket\r\n"
77
- f"Connection: Upgrade\r\n"
78
- f"Sec-WebSocket-Key: {key}\r\n"
79
- f"Sec-WebSocket-Version: 13\r\n\r\n".encode())
80
-
81
- header = b""
82
- while b"\r\n\r\n" not in header:
83
- chunk = self._socket.recv(1)
84
- if not chunk:
85
- raise BrowserError("the browser closed the connection during the handshake")
86
- header += chunk
87
- if len(header) > 8192:
88
- raise BrowserError("the browser sent a handshake we cannot read")
89
- if b" 101" not in header.split(b"\r\n")[0]:
90
- raise BrowserError(f"the browser refused the upgrade: "
91
- f"{header.split(chr(13).encode())[0][:80]!r}")
92
- self._buffer = b""
93
- self._send_lock = threading.Lock()
94
-
95
- # -- frames ------------------------------------------------------------ #
63
+ super().__init__(url, timeout=timeout)
64
+ except WebSocketError as error:
65
+ raise BrowserError(
66
+ f"could not connect to the browser: {error}") from error
96
67
 
97
68
  def send(self, text: str) -> None:
98
- payload = text.encode()
99
- frame = bytearray([0x81]) # FIN + text
100
- length = len(payload)
101
- if length < 126:
102
- frame.append(0x80 | length)
103
- elif length < 65536:
104
- frame.append(0x80 | 126)
105
- frame += struct.pack(">H", length)
106
- else:
107
- frame.append(0x80 | 127)
108
- frame += struct.pack(">Q", length)
109
- # A client must mask; a server must not. Chrome enforces both.
110
- mask = os.urandom(4)
111
- frame += mask
112
- frame += bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
113
- with self._send_lock:
114
- try:
115
- self._socket.sendall(bytes(frame))
116
- except OSError as error:
117
- raise BrowserError(f"the browser went away: {error}") from error
69
+ try:
70
+ super().send(text)
71
+ except WebSocketError as error:
72
+ raise BrowserError(f"the browser went away: {error}") from error
118
73
 
119
74
  def receive(self) -> str:
120
- """One complete message, reassembled if it arrived in fragments."""
121
- parts: list[bytes] = []
122
- while True:
123
- first, second = self._exactly(2)
124
- final = bool(first & 0x80)
125
- opcode = first & 0x0F
126
- length = second & 0x7F
127
- if length == 126:
128
- length = struct.unpack(">H", self._exactly(2))[0]
129
- elif length == 127:
130
- length = struct.unpack(">Q", self._exactly(8))[0]
131
- mask = self._exactly(4) if second & 0x80 else b""
132
- payload = self._exactly(length)
133
- if mask:
134
- payload = bytes(byte ^ mask[index % 4]
135
- for index, byte in enumerate(payload))
136
-
137
- if opcode == 0x8: # close
138
- raise BrowserError("the browser closed the connection")
139
- if opcode == 0x9: # ping -> pong
140
- self._pong(payload)
141
- continue
142
- if opcode == 0xA: # pong
143
- continue
144
-
145
- parts.append(payload)
146
- if final:
147
- return b"".join(parts).decode("utf-8", "replace")
148
-
149
- def _pong(self, payload: bytes) -> None:
150
- frame = bytearray([0x8A, 0x80 | len(payload)])
151
- mask = os.urandom(4)
152
- frame += mask
153
- frame += bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
154
- with self._send_lock:
155
- try:
156
- self._socket.sendall(bytes(frame))
157
- except OSError:
158
- pass
159
-
160
- def _exactly(self, count: int) -> bytes:
161
- while len(self._buffer) < count:
162
- try:
163
- chunk = self._socket.recv(65536)
164
- except socket.timeout as error:
165
- raise BrowserError("the browser stopped answering") from error
166
- except OSError as error:
167
- raise BrowserError(f"the browser went away: {error}") from error
168
- if not chunk:
169
- raise BrowserError("the browser closed the connection")
170
- self._buffer += chunk
171
- taken, self._buffer = self._buffer[:count], self._buffer[count:]
172
- return taken
173
-
174
- def settimeout(self, timeout: float) -> None:
175
- self._socket.settimeout(timeout)
176
-
177
- def close(self) -> None:
178
75
  try:
179
- self._socket.close()
180
- except OSError:
181
- pass
76
+ return super().receive()
77
+ except WebSocketError as error:
78
+ raise BrowserError(
79
+ f"the browser closed the connection: {error}") from error
182
80
 
183
81
 
184
82
  # --------------------------------------------------------------------------- #
@@ -65,9 +65,25 @@ def _whatsapp_ready(settings: Any) -> tuple[bool, str]:
65
65
  return True, ""
66
66
 
67
67
 
68
+ def _slack_ready(settings: Any) -> tuple[bool, str]:
69
+ if not settings.bot_token:
70
+ return False, ("Not connected. `comodor slack connect` sets up the "
71
+ "workspace and the two tokens.")
72
+ if not settings.app_token:
73
+ return False, ("No app-level token, so Socket Mode cannot open — "
74
+ "`comodor slack connect --app-token xapp-…`")
75
+ if not settings.allowed:
76
+ return False, ("Nobody is paired, so it would answer nobody. "
77
+ "`comodor slack pair` first.")
78
+ return True, ""
79
+
80
+
68
81
  TELEGRAM = Channel(name="telegram", label="Telegram", section="telegram",
69
82
  ready=_telegram_ready)
70
83
  WHATSAPP = Channel(name="whatsapp", label="WhatsApp", section="whatsapp",
71
84
  ready=_whatsapp_ready)
72
85
 
73
- CHANNELS = (TELEGRAM, WHATSAPP)
86
+ SLACK = Channel(name="slack", label="Slack", section="slack",
87
+ ready=_slack_ready)
88
+
89
+ CHANNELS = (TELEGRAM, WHATSAPP, SLACK)
@@ -0,0 +1,322 @@
1
+ """The model writes Markdown. None of these three chat apps reads Markdown.
2
+
3
+ Every one of them was being handed the model's answer as-is, so a reply that
4
+ said
5
+
6
+ Use **`Path.resolve()`** — see [the docs](https://docs.python.org/3/).
7
+
8
+ arrived on somebody's phone as exactly that, asterisks and brackets and all.
9
+ A person reading that does not think "the renderer is wrong"; they think the
10
+ tool is sloppy, and a code fence full of backticks is worse — it is the part of
11
+ an answer that most needs to be set apart, and it was the part that looked most
12
+ broken.
13
+
14
+ So the answer is converted, once, into whatever the destination actually
15
+ speaks. Three targets and no two of them agree:
16
+
17
+ Telegram HTML: <b>, <i>, <code>, <pre>, <a href>, <blockquote>
18
+ Slack mrkdwn: *bold*, _italic_, `code`, <url|text>
19
+ WhatsApp *bold*, _italic_, ```mono```, and no link markup at all
20
+
21
+ **Order is the whole of the correctness here.** Code comes out first and is
22
+ replaced by a sentinel, so that an asterisk inside a shell command is never
23
+ mistaken for emphasis and a `<` inside a code block is escaped as text rather
24
+ than parsed as a tag. Everything else is done on what is left, and the code is
25
+ put back last.
26
+
27
+ Two traps this is deliberately careful about, because both produce output that
28
+ looks *almost* right:
29
+
30
+ *Underscores in identifiers.* `some_var_name` is not italics. An underscore
31
+ only opens emphasis at a word boundary, which is why `_italic_` works and
32
+ `snake_case_name` survives.
33
+
34
+ *Unmatched markers.* A lone `**` from a truncated stream is left as text rather
35
+ than swallowing the rest of the message into bold.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import html
41
+ import re
42
+ from dataclasses import dataclass
43
+
44
+ #: The one character that may not appear in model output, used to seal a
45
+ #: rendered piece so no later pass can read it as markup.
46
+ NUL = chr(0)
47
+
48
+ #: Sentinels for the parts that must not be touched by anything else. Control
49
+ #: characters, because no model output contains them and no regex below can
50
+ #: match one by accident.
51
+ CODE_MARK = "\x00c{}\x00"
52
+ BLOCK_MARK = "\x00b{}\x00"
53
+
54
+ FENCE = re.compile(r"```([A-Za-z0-9_+-]*)[ \t]*\r?\n(.*?)```", re.S)
55
+ #: A fence with nothing after it — a stream that stopped mid-block.
56
+ OPEN_FENCE = re.compile(r"```([A-Za-z0-9_+-]*)[ \t]*\r?\n(.*)\Z", re.S)
57
+ INLINE_CODE = re.compile(r"`([^`\n]+)`")
58
+
59
+ LINK = re.compile(r"\[([^\]\n]+)\]\(\s*<?([^)\s>]+)>?\s*\)")
60
+ BOLD = re.compile(r"\*\*(?!\s)(.+?)(?<!\s)\*\*", re.S)
61
+ BOLD_ALT = re.compile(r"(?<![\w*])__(?!\s)(.+?)(?<!\s)__(?![\w*])", re.S)
62
+ ITALIC = re.compile(r"(?<![\w*])\*(?!\s)([^*\n]+?)(?<!\s)\*(?![\w*])")
63
+ ITALIC_ALT = re.compile(r"(?<![\w_])_(?!\s)([^_\n]+?)(?<!\s)_(?![\w_])")
64
+ STRIKE = re.compile(r"~~(?!\s)(.+?)(?<!\s)~~", re.S)
65
+
66
+ HEADING = re.compile(r"^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$")
67
+ BULLET = re.compile(r"^(\s*)[-*+]\s+(.*)$")
68
+ NUMBERED = re.compile(r"^(\s*)(\d{1,3})[.)]\s+(.*)$")
69
+ QUOTE = re.compile(r"^\s{0,3}>\s?(.*)$")
70
+ RULE = re.compile(r"^\s{0,3}([-*_])\s*(?:\1\s*){2,}$")
71
+
72
+ #: What a horizontal rule becomes. A row of box drawing rather than three
73
+ #: hyphens, which every one of these apps renders as three hyphens.
74
+ RULE_LINE = "─" * 24
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class Flavour:
79
+ """One destination's idea of markup."""
80
+
81
+ name: str
82
+ bold: tuple[str, str]
83
+ italic: tuple[str, str]
84
+ strike: tuple[str, str]
85
+ code: tuple[str, str]
86
+ #: Given (language, body) return the whole block.
87
+ block: object
88
+ #: Given (text, href) return the link.
89
+ link: object
90
+ #: Given the line's text return a quoted line.
91
+ quote: object
92
+ #: How to make text safe in this flavour.
93
+ escape: object
94
+ bullet: str = "• "
95
+ #: Whether emphasis may wrap a code span. WhatsApp's only
96
+ #: monospace is a triple backtick, and `*```x```*` renders
97
+ #: as neither bold nor code.
98
+ nest_code: bool = True
99
+
100
+
101
+ def _html_escape(text: str) -> str:
102
+ return html.escape(text or "", quote=False)
103
+
104
+
105
+ def _slack_escape(text: str) -> str:
106
+ """Only the three Slack reads as markup in ordinary text.
107
+
108
+ Not the asterisks and underscores: those are what this module is putting
109
+ in, deliberately.
110
+ """
111
+ return (text.replace("&", "&amp;").replace("<", "&lt;")
112
+ .replace(">", "&gt;"))
113
+
114
+
115
+ def _plain(text: str) -> str:
116
+ return text or ""
117
+
118
+
119
+ TELEGRAM = Flavour(
120
+ name="telegram",
121
+ bold=("<b>", "</b>"),
122
+ italic=("<i>", "</i>"),
123
+ strike=("<s>", "</s>"),
124
+ code=("<code>", "</code>"),
125
+ block=lambda language, body: (
126
+ f'<pre><code class="language-{language}">{body}</code></pre>'
127
+ if language else f"<pre>{body}</pre>"),
128
+ link=lambda text, href: f'<a href="{href}">{text}</a>',
129
+ quote=lambda text: f"<blockquote>{text}</blockquote>",
130
+ escape=_html_escape,
131
+ )
132
+
133
+ SLACK = Flavour(
134
+ name="slack",
135
+ bold=("*", "*"),
136
+ italic=("_", "_"),
137
+ strike=("~", "~"),
138
+ code=("`", "`"),
139
+ block=lambda language, body: f"```\n{body}\n```",
140
+ link=lambda text, href: f"<{href}|{text}>",
141
+ # A raw `>`: `&gt;` renders as the character and makes no
142
+ # blockquote, which is the whole point of the line.
143
+ quote=lambda text: f"> {text}",
144
+ escape=_slack_escape,
145
+ )
146
+
147
+ WHATSAPP = Flavour(
148
+ name="whatsapp",
149
+ bold=("*", "*"),
150
+ italic=("_", "_"),
151
+ strike=("~", "~"),
152
+ # WhatsApp has no single-backtick span. Three is what it understands, and
153
+ # it renders inline as monospace, which is what was wanted.
154
+ code=("```", "```"),
155
+ block=lambda language, body: f"```\n{body}\n```",
156
+ # No link markup at all: WhatsApp finds URLs on its own. So the label is
157
+ # kept and the address follows it, rather than one of them being lost.
158
+ link=lambda text, href: (href if text.strip() == href.strip()
159
+ else f"{text} ({href})"),
160
+ quote=lambda text: f"> {text}",
161
+ escape=_plain,
162
+ nest_code=False,
163
+ )
164
+
165
+ FLAVOURS = {f.name: f for f in (TELEGRAM, SLACK, WHATSAPP)}
166
+
167
+
168
+ def render(text: str, flavour: Flavour) -> str:
169
+ """The model's Markdown, in the markup this destination understands."""
170
+ if not text:
171
+ return ""
172
+
173
+ blocks: list[str] = []
174
+ text = _lift_blocks(text, blocks, flavour)
175
+
176
+ lines = [_line(line, flavour) for line in text.replace("\r\n", "\n").split("\n")]
177
+ out = "\n".join(lines)
178
+
179
+ # The code goes back last, so nothing above could have touched it.
180
+ for index, body in enumerate(blocks):
181
+ out = out.replace(BLOCK_MARK.format(index), body)
182
+ return out
183
+
184
+
185
+ def _lift_blocks(text: str, blocks: list[str], flavour: Flavour) -> str:
186
+ """Pull fenced code out and leave a sentinel where each was."""
187
+
188
+ def take(match: re.Match) -> str:
189
+ language = (match.group(1) or "").strip()
190
+ body = match.group(2)
191
+ if body.endswith("\n"):
192
+ body = body[:-1]
193
+ blocks.append(flavour.block(language, flavour.escape(body)))
194
+ return BLOCK_MARK.format(len(blocks) - 1)
195
+
196
+ text = FENCE.sub(take, text)
197
+ # A stream cut off mid-block still has to render as code rather than as a
198
+ # row of backticks followed by the rest of the answer in italics.
199
+ return OPEN_FENCE.sub(take, text)
200
+
201
+
202
+ def _line(line: str, flavour: Flavour) -> str:
203
+ """One line, as whatever kind of line it is."""
204
+ if BLOCK_MARK.format(0)[0] in line and line.strip().startswith("\x00"):
205
+ return line # a lifted code block
206
+ if not line.strip():
207
+ return ""
208
+
209
+ if RULE.match(line):
210
+ return RULE_LINE
211
+
212
+ heading = HEADING.match(line)
213
+ if heading:
214
+ # None of the three has headings. Bold is what a heading is for.
215
+ return _apply(flavour.bold, _inline(heading.group(2), flavour))
216
+
217
+ quoted = QUOTE.match(line)
218
+ if quoted:
219
+ return flavour.quote(_inline(quoted.group(1), flavour))
220
+
221
+ bullet = BULLET.match(line)
222
+ if bullet:
223
+ indent = " " * len(bullet.group(1))
224
+ return f"{indent}{flavour.bullet}{_inline(bullet.group(2), flavour)}"
225
+
226
+ numbered = NUMBERED.match(line)
227
+ if numbered:
228
+ indent = " " * len(numbered.group(1))
229
+ return (f"{indent}{numbered.group(2)}. "
230
+ f"{_inline(numbered.group(3), flavour)}")
231
+
232
+ return _inline(line, flavour)
233
+
234
+
235
+ def _apply(pair: tuple[str, str], text: str) -> str:
236
+ return f"{pair[0]}{text}{pair[1]}"
237
+
238
+
239
+ def _inline(text: str, flavour: Flavour) -> str:
240
+ """Everything that happens inside one line.
241
+
242
+ Every replacement is sealed behind a sentinel as it is made, and they are
243
+ all put back at the end. Without that, the later passes read the earlier
244
+ ones: on Slack and WhatsApp bold emits `*`, so running italic afterwards
245
+ matched every bold word and turned it italic instead.
246
+ """
247
+ spans: list[str] = []
248
+
249
+ def seal(rendered: str) -> str:
250
+ spans.append(rendered)
251
+ return CODE_MARK.format(len(spans) - 1)
252
+
253
+ text = INLINE_CODE.sub(
254
+ lambda m: seal(_apply(flavour.code, flavour.escape(m.group(1)))), text)
255
+ text = flavour.escape(text)
256
+
257
+ # Links before emphasis: a URL can hold underscores and a title can hold
258
+ # asterisks, and neither of those is markup.
259
+ text = LINK.sub(
260
+ lambda m: seal(flavour.link(_open(m.group(1), spans), m.group(2))),
261
+ text)
262
+
263
+ text = BOLD.sub(lambda m: seal(_emphasis(flavour, "bold", m.group(1),
264
+ spans)), text)
265
+ text = BOLD_ALT.sub(lambda m: seal(_emphasis(flavour, "bold", m.group(1),
266
+ spans)), text)
267
+ text = STRIKE.sub(lambda m: seal(_apply(flavour.strike, m.group(1))), text)
268
+ text = ITALIC.sub(lambda m: seal(_emphasis(flavour, "italic", m.group(1),
269
+ spans)), text)
270
+ text = ITALIC_ALT.sub(lambda m: seal(_emphasis(flavour, "italic",
271
+ m.group(1), spans)), text)
272
+
273
+ return _unseal(text, spans)
274
+
275
+
276
+ def _emphasis(flavour: Flavour, kind: str, inner: str,
277
+ spans: list[str]) -> str:
278
+ """Emphasis around some text, or around a code span.
279
+
280
+ A flavour that cannot nest the two gets the code alone: on WhatsApp the
281
+ only monospace is a triple backtick, and `*```x```*` renders as neither
282
+ bold nor code.
283
+ """
284
+ if not flavour.nest_code and _is_only_code(inner, spans, flavour):
285
+ return inner
286
+ pair = flavour.bold if kind == "bold" else flavour.italic
287
+ return _apply(pair, inner)
288
+
289
+
290
+ def _is_only_code(inner: str, spans: list[str], flavour: Flavour) -> bool:
291
+ match = re.fullmatch(NUL + r"c(\d+)" + NUL, inner)
292
+ if not match:
293
+ return False
294
+ span = spans[int(match.group(1))]
295
+ return span.startswith(flavour.code[0])
296
+
297
+
298
+ def _open(text: str, spans: list[str]) -> str:
299
+ """A link's own label, with any sentinels inside it restored."""
300
+ return _unseal(text, spans)
301
+
302
+
303
+ def _unseal(text: str, spans: list[str]) -> str:
304
+ """Put every sealed piece back, including ones nested inside others."""
305
+ for _ in range(6):
306
+ if NUL not in text:
307
+ break
308
+ for index in range(len(spans) - 1, -1, -1):
309
+ text = text.replace(CODE_MARK.format(index), spans[index])
310
+ return text
311
+
312
+
313
+ def to_telegram(text: str) -> str:
314
+ return render(text, TELEGRAM)
315
+
316
+
317
+ def to_slack(text: str) -> str:
318
+ return render(text, SLACK)
319
+
320
+
321
+ def to_whatsapp(text: str) -> str:
322
+ return render(text, WHATSAPP)
@@ -104,6 +104,7 @@ def build_parser() -> argparse.ArgumentParser:
104
104
  from .local.commands import register as register_local
105
105
  from .mcp.commands import register as register_mcp
106
106
  from .skills.commands import register as register_skills
107
+ from .slack.commands import register as register_slack
107
108
  from .telegram.commands import register as register_telegram
108
109
  from .web.commands import register as register_web
109
110
  from .whatsapp.commands import register as register_whatsapp
@@ -112,6 +113,7 @@ def build_parser() -> argparse.ArgumentParser:
112
113
  register_local(sub)
113
114
  register_telegram(sub)
114
115
  register_whatsapp(sub)
116
+ register_slack(sub)
115
117
  register_skills(sub)
116
118
  register_web(sub)
117
119
  register_acp(sub)
@@ -828,6 +830,10 @@ def main(argv: list[str] | None = None) -> int:
828
830
  from .whatsapp.commands import run as run_whatsapp
829
831
 
830
832
  return run_whatsapp(config, args)
833
+ if args.command == "slack":
834
+ from .slack.commands import run as run_slack
835
+
836
+ return run_slack(config, args)
831
837
  if args.command == "skills":
832
838
  from .skills.commands import run as run_skills
833
839