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.
- {comodor-0.19.2 → comodor-0.20.1}/PKG-INFO +3 -2
- {comodor-0.19.2 → comodor-0.20.1}/README.md +2 -1
- {comodor-0.19.2 → comodor-0.20.1}/docs/README.md +1 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/_version.py +2 -2
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/browser/cdp.py +21 -123
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/channels/__init__.py +17 -1
- comodor-0.20.1/src/comodor/channels/markdown.py +322 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/cli.py +6 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/config.py +56 -5
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/doctor.py +7 -2
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/help.py +28 -0
- comodor-0.20.1/src/comodor/net/ws.py +210 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/setup.py +32 -1
- comodor-0.20.1/src/comodor/slack/__init__.py +7 -0
- comodor-0.20.1/src/comodor/slack/api.py +237 -0
- comodor-0.20.1/src/comodor/slack/blocks.py +221 -0
- comodor-0.20.1/src/comodor/slack/bot.py +746 -0
- comodor-0.20.1/src/comodor/slack/commands.py +521 -0
- comodor-0.20.1/src/comodor/slack/service.py +33 -0
- comodor-0.20.1/src/comodor/slack/socket.py +202 -0
- comodor-0.20.1/src/comodor/slack/unit.py +24 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/telegram/bot.py +7 -3
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/server.py +26 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/session.py +266 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/ui.js +216 -36
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/bot.py +4 -1
- comodor-0.20.1/tests/test_markdown.py +310 -0
- comodor-0.20.1/tests/test_slack.py +488 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_web.py +107 -0
- comodor-0.19.2/tests/test_markdown.py +0 -103
- {comodor-0.19.2 → comodor-0.20.1}/.gitignore +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/LICENSE +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/pyproject.toml +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/__main__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/acp/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/acp/agent.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/acp/commands.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/acp/jsonrpc.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/agent/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/agent/context.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/agent/loop.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/agent/prompts.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/agent/spawn.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/agent/tokens.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/browser/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/browser/launch.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/browser/page.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/catalogue.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/channels/daemon.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/channels/unit.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/desktop/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/desktop/guard.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/desktop/keys.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/desktop/overlay.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/desktop/png.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/desktop/screen.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/desktop/win32.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/events.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/associations.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/bm25.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/hotindex.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/memory.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/progress.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/reflect.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/rules.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/signals.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/store.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/learning/writer.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/local/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/local/catalogue.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/local/commands.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/local/download.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/local/models.json +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/local/provider.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/local/runtime.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/local/store.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/mcp/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/mcp/catalogue.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/mcp/commands.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/mcp/http.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/mcp/manager.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/mcp/protocol.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/migrate.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/net/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/net/http.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/net/sse.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/paths.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/anthropic.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/base.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/caching.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/discover.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/fake.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/gateway.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/models.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/oauth.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/openai_compat.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/providers/registry.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/questions.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/safety/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/safety/checkpoints.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/safety/permissions.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/safety/redact.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/session/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/session/search.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/session/store.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/skills/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/skills/catalogue.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/skills/commands.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/skills/examples.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/skills/loader.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/skills/propose.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/skills/registry.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/telegram/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/telegram/api.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/telegram/commands.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/telegram/keyboard.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/telegram/service.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/telegram/unit.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/ask.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/base.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/browse.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/browser.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/computer.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/delegate.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/fs.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/history.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/mcp.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/overflow.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/registry.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/search.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/shell.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/skills.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/todo.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/tools/web.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/app.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/banner.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/bidi.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/chooser.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/clipboard.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/console.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/input/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/input/keys.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/input/reader.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/layout.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/markdown.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/screen.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/theme.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/buttons.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/chat.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/history.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/overlay.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/panel.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/progress.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/prompt.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/questions.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/statusbar.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/toast.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/ui/widgets/welcome.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/uninstall.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/update.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/commands.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/index.html +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/ui.css +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/vazirmatn-OFL.txt +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/web/vazirmatn.woff2 +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/api.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/commands.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/guide.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/menu.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/service.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/tunnel.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/unit.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/whatsapp/webhook.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/src/comodor/workspace.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/conftest.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/support/fake_mcp_server.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_acp.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_agent_loop.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_app.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_associations.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_banner.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_bidi.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_browser.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_caching.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_channel_service.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_chooser.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_computer_tool.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_config_save.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_copy_and_bands.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_delegate.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_desktop.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_discover.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_doctor.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_help.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_history.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_import_command.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_input.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_layout.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_learning.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_local.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_mcp.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_mcp_http.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_migrate.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_oauth.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_overflow.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_overlay.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_performance.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_prefix_stability.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_progress.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_propose.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_providers.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_questions.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_real_browser.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_real_desktop.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_real_web_ui.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_reflex.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_run_loop.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_setup.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_setup_import.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_skill_budget.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_skill_catalogue.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_skills.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_switching_model.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_telegram.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_tools.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_uninstall.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_update.py +0 -0
- {comodor-0.19.2 → comodor-0.20.1}/tests/test_welcome.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
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
|
-
"""
|
|
52
|
+
class WebSocket(_WebSocket):
|
|
53
|
+
"""The general client, with the browser's vocabulary on its failures.
|
|
55
54
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
180
|
-
except
|
|
181
|
-
|
|
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
|
-
|
|
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("&", "&").replace("<", "<")
|
|
112
|
+
.replace(">", ">"))
|
|
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 `>`: `>` 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
|
|