octo-agent 0.11.2
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.
- checksums.yaml +7 -0
- data/.clacky/skills/commit/SKILL.md +423 -0
- data/.clacky/skills/gem-release/SKILL.md +199 -0
- data/.clacky/skills/gem-release/scripts/release.sh +304 -0
- data/.clacky/skills/oss-upload/SKILL.md +47 -0
- data/.octorules +106 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +76 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CONTRIBUTING.md +92 -0
- data/Dockerfile +28 -0
- data/LICENSE.txt +22 -0
- data/POSITIONING.md +46 -0
- data/README.md +134 -0
- data/README_CN.md +134 -0
- data/Rakefile +34 -0
- data/benchmark/fixtures/sample_project/Gemfile +3 -0
- data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
- data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
- data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
- data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
- data/benchmark/results/EVALUATION_REPORT.md +165 -0
- data/benchmark/results/baseline_20260511_174424.json +128 -0
- data/benchmark/results/report_20260511_175256.json +271 -0
- data/benchmark/results/report_20260511_175444.json +271 -0
- data/benchmark/results/treatment_20260511_175103.json +130 -0
- data/benchmark/runner.rb +441 -0
- data/bin/octo +7 -0
- data/docs/agent-first-ui-design.md +77 -0
- data/docs/billing-system.md +318 -0
- data/docs/channel-architecture.md +235 -0
- data/docs/engineering-article.md +343 -0
- data/docs/session-skill-invocation.md +69 -0
- data/docs/time_machine_design.md +247 -0
- data/docs/ui2-architecture.md +124 -0
- data/homebrew/README.md +96 -0
- data/homebrew/openocto.rb +24 -0
- data/lib/octo/agent/hook_manager.rb +61 -0
- data/lib/octo/agent/llm_caller.rb +800 -0
- data/lib/octo/agent/memory_updater.rb +246 -0
- data/lib/octo/agent/message_compressor.rb +225 -0
- data/lib/octo/agent/message_compressor_helper.rb +869 -0
- data/lib/octo/agent/next_message_suggester.rb +215 -0
- data/lib/octo/agent/session_serializer.rb +685 -0
- data/lib/octo/agent/skill_auto_creator.rb +114 -0
- data/lib/octo/agent/skill_evolution.rb +61 -0
- data/lib/octo/agent/skill_manager.rb +466 -0
- data/lib/octo/agent/skill_reflector.rb +89 -0
- data/lib/octo/agent/system_prompt_builder.rb +101 -0
- data/lib/octo/agent/time_machine.rb +214 -0
- data/lib/octo/agent/tool_executor.rb +454 -0
- data/lib/octo/agent/tool_registry.rb +150 -0
- data/lib/octo/agent.rb +2180 -0
- data/lib/octo/agent_config.rb +989 -0
- data/lib/octo/agent_profile.rb +112 -0
- data/lib/octo/anthropic_stream_aggregator.rb +137 -0
- data/lib/octo/background_task_registry.rb +324 -0
- data/lib/octo/banner.rb +34 -0
- data/lib/octo/bedrock_stream_aggregator.rb +137 -0
- data/lib/octo/block_font.rb +331 -0
- data/lib/octo/cli.rb +968 -0
- data/lib/octo/client.rb +623 -0
- data/lib/octo/default_agents/SOUL.md +3 -0
- data/lib/octo/default_agents/USER.md +1 -0
- data/lib/octo/default_agents/base_prompt.md +66 -0
- data/lib/octo/default_agents/coding/profile.yml +2 -0
- data/lib/octo/default_agents/coding/system_prompt.md +67 -0
- data/lib/octo/default_agents/general/profile.yml +2 -0
- data/lib/octo/default_agents/general/system_prompt.md +16 -0
- data/lib/octo/default_parsers/doc_parser.rb +69 -0
- data/lib/octo/default_parsers/docx_parser.rb +188 -0
- data/lib/octo/default_parsers/pdf_parser.rb +120 -0
- data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/octo/default_parsers/pptx_parser.rb +140 -0
- data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
- data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
- data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
- data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
- data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
- data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
- data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
- data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
- data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
- data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
- data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
- data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
- data/lib/octo/default_skills/onboard/SKILL.md +578 -0
- data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
- data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
- data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
- data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
- data/lib/octo/default_skills/personal-website/publish.rb +235 -0
- data/lib/octo/default_skills/product-help/SKILL.md +123 -0
- data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
- data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
- data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
- data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
- data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
- data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
- data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
- data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
- data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
- data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
- data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
- data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
- data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
- data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
- data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
- data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
- data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
- data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
- data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
- data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
- data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
- data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
- data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
- data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
- data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
- data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
- data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
- data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
- data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
- data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
- data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
- data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
- data/lib/octo/idle_compression_timer.rb +115 -0
- data/lib/octo/json_ui_controller.rb +204 -0
- data/lib/octo/message_format/anthropic.rb +409 -0
- data/lib/octo/message_format/bedrock.rb +361 -0
- data/lib/octo/message_format/open_ai.rb +222 -0
- data/lib/octo/message_history.rb +373 -0
- data/lib/octo/openai_stream_aggregator.rb +130 -0
- data/lib/octo/plain_ui_controller.rb +166 -0
- data/lib/octo/providers.rb +534 -0
- data/lib/octo/server/browser_manager.rb +397 -0
- data/lib/octo/server/channel/adapters/base.rb +82 -0
- data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
- data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
- data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
- data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
- data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
- data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
- data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
- data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
- data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
- data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
- data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
- data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
- data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
- data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
- data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
- data/lib/octo/server/channel/channel_config.rb +178 -0
- data/lib/octo/server/channel/channel_manager.rb +468 -0
- data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
- data/lib/octo/server/channel.rb +33 -0
- data/lib/octo/server/discover.rb +77 -0
- data/lib/octo/server/epipe_safe_io.rb +105 -0
- data/lib/octo/server/http_server.rb +3554 -0
- data/lib/octo/server/scheduler.rb +317 -0
- data/lib/octo/server/server_master.rb +325 -0
- data/lib/octo/server/session_registry.rb +431 -0
- data/lib/octo/server/web_ui_controller.rb +487 -0
- data/lib/octo/session_manager.rb +385 -0
- data/lib/octo/skill.rb +466 -0
- data/lib/octo/skill_loader.rb +328 -0
- data/lib/octo/tools/base.rb +118 -0
- data/lib/octo/tools/browser.rb +625 -0
- data/lib/octo/tools/edit.rb +165 -0
- data/lib/octo/tools/file_reader.rb +549 -0
- data/lib/octo/tools/glob.rb +162 -0
- data/lib/octo/tools/grep.rb +356 -0
- data/lib/octo/tools/invoke_skill.rb +96 -0
- data/lib/octo/tools/list_tasks.rb +54 -0
- data/lib/octo/tools/redo_task.rb +41 -0
- data/lib/octo/tools/request_user_feedback.rb +84 -0
- data/lib/octo/tools/security.rb +333 -0
- data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
- data/lib/octo/tools/terminal/persistent_session.rb +268 -0
- data/lib/octo/tools/terminal/safe_rm.sh +106 -0
- data/lib/octo/tools/terminal/session_manager.rb +213 -0
- data/lib/octo/tools/terminal.rb +1828 -0
- data/lib/octo/tools/todo_manager.rb +374 -0
- data/lib/octo/tools/trash_manager.rb +388 -0
- data/lib/octo/tools/undo_task.rb +35 -0
- data/lib/octo/tools/web_fetch.rb +242 -0
- data/lib/octo/tools/web_search.rb +260 -0
- data/lib/octo/tools/write.rb +77 -0
- data/lib/octo/ui2/block_font.rb +10 -0
- data/lib/octo/ui2/components/base_component.rb +163 -0
- data/lib/octo/ui2/components/command_suggestions.rb +290 -0
- data/lib/octo/ui2/components/common_component.rb +96 -0
- data/lib/octo/ui2/components/inline_input.rb +226 -0
- data/lib/octo/ui2/components/input_area.rb +1338 -0
- data/lib/octo/ui2/components/message_component.rb +99 -0
- data/lib/octo/ui2/components/modal_component.rb +419 -0
- data/lib/octo/ui2/components/todo_area.rb +149 -0
- data/lib/octo/ui2/components/tool_component.rb +107 -0
- data/lib/octo/ui2/components/welcome_banner.rb +139 -0
- data/lib/octo/ui2/layout_manager.rb +807 -0
- data/lib/octo/ui2/line_editor.rb +363 -0
- data/lib/octo/ui2/markdown_renderer.rb +100 -0
- data/lib/octo/ui2/output_buffer.rb +370 -0
- data/lib/octo/ui2/progress_handle.rb +362 -0
- data/lib/octo/ui2/progress_indicator.rb +55 -0
- data/lib/octo/ui2/screen_buffer.rb +273 -0
- data/lib/octo/ui2/terminal_detector.rb +119 -0
- data/lib/octo/ui2/theme_manager.rb +85 -0
- data/lib/octo/ui2/themes/base_theme.rb +105 -0
- data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
- data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
- data/lib/octo/ui2/thinking_verbs.rb +26 -0
- data/lib/octo/ui2/ui_controller.rb +1625 -0
- data/lib/octo/ui2/view_renderer.rb +177 -0
- data/lib/octo/ui2.rb +40 -0
- data/lib/octo/ui_interface.rb +154 -0
- data/lib/octo/utils/arguments_parser.rb +191 -0
- data/lib/octo/utils/browser_detector.rb +195 -0
- data/lib/octo/utils/encoding.rb +92 -0
- data/lib/octo/utils/environment_detector.rb +140 -0
- data/lib/octo/utils/file_ignore_helper.rb +170 -0
- data/lib/octo/utils/file_processor.rb +601 -0
- data/lib/octo/utils/gitignore_parser.rb +154 -0
- data/lib/octo/utils/limit_stack.rb +152 -0
- data/lib/octo/utils/logger.rb +124 -0
- data/lib/octo/utils/login_shell.rb +72 -0
- data/lib/octo/utils/model_pricing.rb +646 -0
- data/lib/octo/utils/parser_manager.rb +165 -0
- data/lib/octo/utils/path_helper.rb +15 -0
- data/lib/octo/utils/scripts_manager.rb +59 -0
- data/lib/octo/utils/string_matcher.rb +158 -0
- data/lib/octo/utils/trash_directory.rb +112 -0
- data/lib/octo/utils/workspace_rules.rb +46 -0
- data/lib/octo/version.rb +5 -0
- data/lib/octo/web/app.css +7141 -0
- data/lib/octo/web/app.js +543 -0
- data/lib/octo/web/apple-touch-icon.png +0 -0
- data/lib/octo/web/auth.js +150 -0
- data/lib/octo/web/channels.js +276 -0
- data/lib/octo/web/datepicker.js +205 -0
- data/lib/octo/web/favicon.png +0 -0
- data/lib/octo/web/i18n.js +1073 -0
- data/lib/octo/web/icon-512.png +0 -0
- data/lib/octo/web/icon-dark.svg +25 -0
- data/lib/octo/web/icon.svg +29 -0
- data/lib/octo/web/index.html +871 -0
- data/lib/octo/web/marked.min.js +69 -0
- data/lib/octo/web/onboard.js +491 -0
- data/lib/octo/web/profile.js +442 -0
- data/lib/octo/web/sessions.js +4421 -0
- data/lib/octo/web/settings.js +913 -0
- data/lib/octo/web/sidebar.js +32 -0
- data/lib/octo/web/skills.js +885 -0
- data/lib/octo/web/tasks.js +297 -0
- data/lib/octo/web/theme.js +105 -0
- data/lib/octo/web/trash.js +343 -0
- data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
- data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
- data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/katex.min.css +1 -0
- data/lib/octo/web/vendor/katex/katex.min.js +1 -0
- data/lib/octo/web/version.js +449 -0
- data/lib/octo/web/weixin-qr.html +209 -0
- data/lib/octo/web/ws-dispatcher.js +357 -0
- data/lib/octo/web/ws.js +128 -0
- data/lib/octo.rb +145 -0
- data/scripts/build/build.sh +329 -0
- data/scripts/build/lib/apt.sh +56 -0
- data/scripts/build/lib/brew.sh +89 -0
- data/scripts/build/lib/colors.sh +17 -0
- data/scripts/build/lib/gem.sh +95 -0
- data/scripts/build/lib/mise.sh +125 -0
- data/scripts/build/lib/network.sh +157 -0
- data/scripts/build/lib/os.sh +57 -0
- data/scripts/build/lib/shell.sh +37 -0
- data/scripts/build/src/install.sh.cc +174 -0
- data/scripts/build/src/install_browser.sh.cc +101 -0
- data/scripts/build/src/install_full.sh.cc +290 -0
- data/scripts/build/src/install_rails_deps.sh.cc +145 -0
- data/scripts/build/src/install_system_deps.sh.cc +123 -0
- data/scripts/build/src/uninstall.sh.cc +101 -0
- data/scripts/install.ps1 +532 -0
- data/scripts/install.sh +567 -0
- data/scripts/install_browser.sh +479 -0
- data/scripts/install_full.sh +838 -0
- data/scripts/install_rails_deps.sh +746 -0
- data/scripts/install_system_deps.sh +518 -0
- data/scripts/uninstall.sh +287 -0
- data/sig/octo.rbs +4 -0
- metadata +614 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "channel_ui_controller"
|
|
4
|
+
|
|
5
|
+
module Octo
|
|
6
|
+
module Channel
|
|
7
|
+
# ChannelManager starts and supervises IM platform adapter threads.
|
|
8
|
+
# When an inbound message arrives it:
|
|
9
|
+
# 1. Resolves (or auto-creates) a Session bound to this IM identity
|
|
10
|
+
# 2. Retrieves the WebUIController for that session
|
|
11
|
+
# 3. Creates a ChannelUIController and subscribes it to the WebUIController
|
|
12
|
+
# 4. Runs the agent task via run_agent_task (same as HttpServer)
|
|
13
|
+
# 5. Unsubscribes the ChannelUIController when the task finishes
|
|
14
|
+
#
|
|
15
|
+
# Thread model: each adapter runs two long-lived threads (read loop + ping).
|
|
16
|
+
# ChannelManager itself is non-blocking — call #start from HttpServer after
|
|
17
|
+
# the WEBrick server has started.
|
|
18
|
+
#
|
|
19
|
+
# Session binding: the first message from an IM identity automatically creates
|
|
20
|
+
# a new session and binds it. Users can use /bind <session_id> to switch to an
|
|
21
|
+
# existing WebUI session instead. Bindings are stored in the session registry as
|
|
22
|
+
# :channel_keys => Set of channel key strings.
|
|
23
|
+
# WebUI sessions are persisted by HttpServer — channel adds no extra persistence.
|
|
24
|
+
class ChannelManager
|
|
25
|
+
# @param session_registry [Octo::Server::SessionRegistry]
|
|
26
|
+
# @param session_builder [Proc] (name:, working_dir:) => session_id — from HttpServer
|
|
27
|
+
# @param run_agent_task [Proc] (session_id, agent, &task) — from HttpServer
|
|
28
|
+
# @param interrupt_session [Proc] (session_id) — from HttpServer
|
|
29
|
+
# @param channel_config [Octo::ChannelConfig]
|
|
30
|
+
# @param binding_mode [:user | :chat | :chat_user] how to map IM identities to sessions.
|
|
31
|
+
# :chat_user (default) — one session per (chat, user) pair. Most natural:
|
|
32
|
+
# private chat = that user's session; in a group each
|
|
33
|
+
# user has their own session; the same user across
|
|
34
|
+
# different groups keeps those contexts separate.
|
|
35
|
+
# :chat — one session per chat (all users in a group share it).
|
|
36
|
+
# :user — one session per user (merges DMs and all groups).
|
|
37
|
+
def initialize(session_registry:, session_builder:, run_agent_task:, interrupt_session:, channel_config:, binding_mode: :chat_user)
|
|
38
|
+
@registry = session_registry
|
|
39
|
+
@session_builder = session_builder
|
|
40
|
+
@run_agent_task = run_agent_task
|
|
41
|
+
@interrupt_session = interrupt_session
|
|
42
|
+
@channel_config = channel_config
|
|
43
|
+
@binding_mode = binding_mode
|
|
44
|
+
@adapters = []
|
|
45
|
+
@adapter_threads = []
|
|
46
|
+
@running = false
|
|
47
|
+
@mutex = Mutex.new
|
|
48
|
+
@session_counters = Hash.new(0) # platform => count, for short session names
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Start all enabled adapters in background threads. Non-blocking.
|
|
52
|
+
def start
|
|
53
|
+
enabled_platforms = @channel_config.enabled_platforms
|
|
54
|
+
if enabled_platforms.empty?
|
|
55
|
+
Octo::Logger.info("[ChannelManager] No channels configured — skipping")
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
Octo::Logger.info("[ChannelManager] Starting channels: #{enabled_platforms.join(", ")}")
|
|
60
|
+
@running = true
|
|
61
|
+
enabled_platforms.each { |platform| start_adapter(platform) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Stop all adapters gracefully.
|
|
65
|
+
def stop
|
|
66
|
+
@running = false
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
@adapters.each { |adapter| safe_stop_adapter(adapter) }
|
|
69
|
+
@adapters.clear
|
|
70
|
+
end
|
|
71
|
+
@adapter_threads.each { |t| t.join(1) }
|
|
72
|
+
@adapter_threads.clear
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Array<Symbol>] platforms currently running
|
|
76
|
+
def running_platforms
|
|
77
|
+
@mutex.synchronize { @adapters.map(&:platform_id) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Proactively send a message to a user on the given platform.
|
|
81
|
+
#
|
|
82
|
+
# For Weixin (iLink protocol) a context_token is required for every outbound
|
|
83
|
+
# message. This method looks up the most-recently cached token for user_id.
|
|
84
|
+
# If no token is found the message cannot be delivered and nil is returned.
|
|
85
|
+
#
|
|
86
|
+
# For Feishu and WeCom the chat_id / user_id is sufficient — no token needed.
|
|
87
|
+
#
|
|
88
|
+
# @param platform [Symbol, String] e.g. :weixin, :feishu, :wecom
|
|
89
|
+
# @param user_id [String] IM user identifier
|
|
90
|
+
# @param message [String] plain-text (or markdown) message to send
|
|
91
|
+
# @return [Hash, nil] adapter result hash, or nil on failure
|
|
92
|
+
def send_to_user(platform, user_id, message)
|
|
93
|
+
platform = platform.to_sym
|
|
94
|
+
adapter = @mutex.synchronize { @adapters.find { |a| a.platform_id == platform } }
|
|
95
|
+
|
|
96
|
+
unless adapter
|
|
97
|
+
Octo::Logger.warn("[ChannelManager] send_to_user: no running adapter for :#{platform}")
|
|
98
|
+
return nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
Octo::Logger.info("[ChannelManager] send_to_user :#{platform} → #{user_id}")
|
|
102
|
+
adapter.send_text(user_id, message)
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
Octo::Logger.error("[ChannelManager] send_to_user failed: #{e.message}")
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Return a list of known user IDs for the given platform.
|
|
109
|
+
# Collected from every message that has been processed since the server started.
|
|
110
|
+
# Weixin stores context_tokens keyed by user_id; feishu/wecom track chat_ids
|
|
111
|
+
# via the session binding table in the registry.
|
|
112
|
+
#
|
|
113
|
+
# @param platform [Symbol, String]
|
|
114
|
+
# @return [Array<String>]
|
|
115
|
+
def known_users(platform)
|
|
116
|
+
platform = platform.to_sym
|
|
117
|
+
adapter = @mutex.synchronize { @adapters.find { |a| a.platform_id == platform } }
|
|
118
|
+
return [] unless adapter
|
|
119
|
+
|
|
120
|
+
# Weixin adapter exposes @context_tokens whose keys are user_ids
|
|
121
|
+
if adapter.respond_to?(:context_token_user_ids)
|
|
122
|
+
return adapter.context_token_user_ids
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Fallback: scan session registry for channel_keys matching this platform.
|
|
126
|
+
# Key formats depend on binding_mode:
|
|
127
|
+
# :user → "platform:user:USER_ID"
|
|
128
|
+
# :chat → "platform:chat:CHAT_ID"
|
|
129
|
+
# :chat_user → "platform:chat:CHAT_ID:user:USER_ID"
|
|
130
|
+
#
|
|
131
|
+
# For send_text we need the chat_id (Feishu/WeCom use chat_id as the
|
|
132
|
+
# receive_id for outbound messages), so we extract the chat portion.
|
|
133
|
+
prefix = "#{platform}:"
|
|
134
|
+
ids = []
|
|
135
|
+
@registry.list.each do |summary|
|
|
136
|
+
@registry.with_session(summary[:id]) do |s|
|
|
137
|
+
(s[:channel_keys] || []).each do |key|
|
|
138
|
+
next unless key.start_with?(prefix)
|
|
139
|
+
|
|
140
|
+
remainder = key.sub(prefix, "") # e.g. "chat:OC_ID:user:OU_ID" or "user:UID" or "chat:CID"
|
|
141
|
+
ids << extract_chat_id(remainder)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
ids.compact.uniq
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Hot-reload a single platform adapter with updated config.
|
|
149
|
+
# Stops the existing adapter (if running), then starts a new one if enabled.
|
|
150
|
+
# @param platform [Symbol]
|
|
151
|
+
# @param config [Octo::ChannelConfig]
|
|
152
|
+
def reload_platform(platform, config)
|
|
153
|
+
# Stop existing adapter for this platform
|
|
154
|
+
@mutex.synchronize do
|
|
155
|
+
existing = @adapters.find { |a| a.platform_id == platform }
|
|
156
|
+
if existing
|
|
157
|
+
safe_stop_adapter(existing)
|
|
158
|
+
@adapters.delete(existing)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Start new adapter if enabled
|
|
163
|
+
if config.enabled?(platform)
|
|
164
|
+
@channel_config = config
|
|
165
|
+
start_adapter(platform)
|
|
166
|
+
Octo::Logger.info("[ChannelManager] :#{platform} adapter reloaded")
|
|
167
|
+
else
|
|
168
|
+
Octo::Logger.info("[ChannelManager] :#{platform} disabled — adapter not started")
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def start_adapter(platform)
|
|
174
|
+
klass = Adapters.find(platform)
|
|
175
|
+
unless klass
|
|
176
|
+
Octo::Logger.warn("[ChannelManager] No adapter registered for :#{platform} — skipping")
|
|
177
|
+
return
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
raw_config = @channel_config.platform_config(platform)
|
|
181
|
+
Octo::Logger.info("[ChannelManager] Initializing :#{platform} adapter")
|
|
182
|
+
adapter = klass.new(raw_config)
|
|
183
|
+
|
|
184
|
+
errors = adapter.validate_config(raw_config)
|
|
185
|
+
if errors.any?
|
|
186
|
+
Octo::Logger.warn("[ChannelManager] Config errors for :#{platform}: #{errors.join(", ")}")
|
|
187
|
+
return
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
@mutex.synchronize { @adapters << adapter }
|
|
191
|
+
Octo::Logger.info("[ChannelManager] :#{platform} adapter ready, starting thread")
|
|
192
|
+
|
|
193
|
+
thread = Thread.new do
|
|
194
|
+
Thread.current.name = "channel-#{platform}"
|
|
195
|
+
adapter_loop(adapter)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
@adapter_threads << thread
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def adapter_loop(adapter)
|
|
202
|
+
Octo::Logger.info("[ChannelManager] :#{adapter.platform_id} adapter loop started")
|
|
203
|
+
adapter.start do |event|
|
|
204
|
+
summary = event[:text].to_s.lines.first.to_s.strip[0, 80]
|
|
205
|
+
summary = "[image]" if summary.empty? && !event[:files].to_a.empty?
|
|
206
|
+
Octo::Logger.info("[ChannelManager] :#{adapter.platform_id} message from #{event[:user_id]} in #{event[:chat_id]}: #{summary}")
|
|
207
|
+
route_message(adapter, event)
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
Octo::Logger.warn("[ChannelManager] Error routing :#{adapter.platform_id} message: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
|
|
210
|
+
adapter.send_text(event[:chat_id], "Error: #{e.message}")
|
|
211
|
+
end
|
|
212
|
+
rescue StandardError => e
|
|
213
|
+
Octo::Logger.warn("[ChannelManager] :#{adapter.platform_id} adapter crashed: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
|
|
214
|
+
if @running
|
|
215
|
+
Octo::Logger.info("[ChannelManager] :#{adapter.platform_id} restarting in 5s...")
|
|
216
|
+
sleep 5
|
|
217
|
+
retry
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def route_message(adapter, event)
|
|
222
|
+
text = event[:text]&.strip
|
|
223
|
+
files = event[:files] || []
|
|
224
|
+
return if (text.nil? || text.empty?) && files.empty?
|
|
225
|
+
|
|
226
|
+
# Handle built-in commands
|
|
227
|
+
if text&.start_with?("/")
|
|
228
|
+
handle_command(adapter, event, text)
|
|
229
|
+
return
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
session_id = resolve_session(event)
|
|
233
|
+
session_id = auto_create_session(adapter, event) unless session_id
|
|
234
|
+
|
|
235
|
+
session = @registry.get(session_id)
|
|
236
|
+
unless session
|
|
237
|
+
Octo::Logger.warn("[ChannelManager] Session #{session_id[0, 8]} not found in registry after create")
|
|
238
|
+
adapter.send_text(event[:chat_id], "Failed to initialize session. Please try again.")
|
|
239
|
+
return
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
Octo::Logger.info("[ChannelManager] Routing to session #{session_id[0, 8]} (status=#{session[:status]})")
|
|
243
|
+
|
|
244
|
+
# If session is running, interrupt it automatically (mimics CLI behavior)
|
|
245
|
+
if session[:status] == :running
|
|
246
|
+
Octo::Logger.info("[ChannelManager] Session busy, interrupting previous task")
|
|
247
|
+
@interrupt_session.call(session_id)
|
|
248
|
+
# Wait briefly for the thread to catch the interrupt and update status
|
|
249
|
+
sleep 0.1
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
agent = session[:agent]
|
|
253
|
+
web_ui = session[:ui]
|
|
254
|
+
|
|
255
|
+
# Update reply context so responses thread under the current message.
|
|
256
|
+
# channel_ui is bound to the session for its full lifetime (created in auto_create_session).
|
|
257
|
+
channel_ui_for_session(session_id)&.update_message_context(event)
|
|
258
|
+
|
|
259
|
+
# Sync the inbound message to WebUI so it shows up in the browser session.
|
|
260
|
+
# source: :channel prevents the message from being echoed back to the IM channel.
|
|
261
|
+
web_ui&.show_user_message(text, source: :channel) unless text.nil? || text.empty?
|
|
262
|
+
|
|
263
|
+
# Start typing keepalive BEFORE sending any message.
|
|
264
|
+
# sendmessage cancels the typing indicator in WeChat protocol,
|
|
265
|
+
# so keepalive must be running when "Thinking..." is sent so it
|
|
266
|
+
# immediately re-asserts the typing state after that message.
|
|
267
|
+
chat_id = event[:chat_id]
|
|
268
|
+
context_token = event[:context_token]
|
|
269
|
+
adapter.start_typing_keepalive(chat_id, context_token) if adapter.respond_to?(:start_typing_keepalive)
|
|
270
|
+
|
|
271
|
+
# Acknowledge to the IM channel only — WebUI doesn't need a "Thinking..." noise.
|
|
272
|
+
adapter.send_text(chat_id, "Thinking...")
|
|
273
|
+
|
|
274
|
+
@run_agent_task.call(session_id, agent) do
|
|
275
|
+
begin
|
|
276
|
+
agent.run(text, files: files)
|
|
277
|
+
ensure
|
|
278
|
+
adapter.stop_typing_keepalive(chat_id) if adapter.respond_to?(:stop_typing_keepalive)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def handle_command(adapter, event, text)
|
|
284
|
+
chat_id = event[:chat_id]
|
|
285
|
+
key = channel_key(event)
|
|
286
|
+
|
|
287
|
+
case text
|
|
288
|
+
when /\A\/bind\s+(\S+)\z/i
|
|
289
|
+
arg = Regexp.last_match(1)
|
|
290
|
+
# Support numeric index from /list (1-based)
|
|
291
|
+
session_id = if arg =~ /\A\d+\z/
|
|
292
|
+
recent = @registry.list.last(5).reverse
|
|
293
|
+
idx = arg.to_i - 1
|
|
294
|
+
recent[idx]&.fetch(:id, nil)
|
|
295
|
+
else
|
|
296
|
+
arg
|
|
297
|
+
end
|
|
298
|
+
unless session_id && @registry.get(session_id)
|
|
299
|
+
adapter.send_text(chat_id, "Session not found. Use /list to see available sessions.")
|
|
300
|
+
return
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Detach channel_ui from the old session's web_ui, reattach to the new one.
|
|
304
|
+
old_session_id = resolve_session(event)
|
|
305
|
+
channel_ui = old_session_id ? channel_ui_for_session(old_session_id) : nil
|
|
306
|
+
|
|
307
|
+
if channel_ui
|
|
308
|
+
@registry.with_session(old_session_id) { |s| s[:ui]&.unsubscribe_channel(channel_ui); s.delete(:channel_ui) }
|
|
309
|
+
else
|
|
310
|
+
channel_ui = ChannelUIController.new(event, adapter)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
bind_key_to_session(key, session_id)
|
|
314
|
+
@registry.with_session(session_id) do |s|
|
|
315
|
+
s[:ui]&.subscribe_channel(channel_ui)
|
|
316
|
+
s[:channel_ui] = channel_ui
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
Octo::Logger.info("[ChannelManager] Bound #{key} -> session #{session_id[0, 8]}")
|
|
320
|
+
adapter.send_text(chat_id, "Bound to session `#{session_id[0, 8]}`.")
|
|
321
|
+
|
|
322
|
+
when "/stop"
|
|
323
|
+
session_id = resolve_session(event)
|
|
324
|
+
unless session_id
|
|
325
|
+
adapter.send_text(chat_id, "No session bound.")
|
|
326
|
+
return
|
|
327
|
+
end
|
|
328
|
+
@interrupt_session.call(session_id)
|
|
329
|
+
adapter.send_text(chat_id, "Task interrupted.")
|
|
330
|
+
|
|
331
|
+
when "/unbind"
|
|
332
|
+
unbound = false
|
|
333
|
+
@registry.list.each do |summary|
|
|
334
|
+
@registry.with_session(summary[:id]) do |s|
|
|
335
|
+
unbound = true if s[:channel_keys]&.delete(key)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
adapter.send_text(chat_id, unbound ? "Unbound." : "No binding found.")
|
|
339
|
+
|
|
340
|
+
when "/status"
|
|
341
|
+
session_id = resolve_session(event)
|
|
342
|
+
if session_id
|
|
343
|
+
session = @registry.get(session_id)
|
|
344
|
+
adapter.send_text(chat_id, "Bound to session `#{session_id[0, 8]}` (status: #{session&.dig(:status) || "unknown"})")
|
|
345
|
+
else
|
|
346
|
+
adapter.send_text(chat_id, "No session bound yet. Send any message to auto-create one.")
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
when "/list"
|
|
350
|
+
list_sessions(adapter, chat_id)
|
|
351
|
+
|
|
352
|
+
else
|
|
353
|
+
adapter.send_text(chat_id,
|
|
354
|
+
"Commands:\n" \
|
|
355
|
+
" /bind <n|session_id> - switch to a session (use /list to see numbers)\n" \
|
|
356
|
+
" /unbind - remove binding\n" \
|
|
357
|
+
" /stop - interrupt current task\n" \
|
|
358
|
+
" /status - show current binding\n" \
|
|
359
|
+
" /list - show recent sessions")
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def resolve_session(event)
|
|
364
|
+
key = channel_key(event)
|
|
365
|
+
@registry.list.each do |summary|
|
|
366
|
+
found = nil
|
|
367
|
+
@registry.with_session(summary[:id]) { |s| found = s[:channel_keys]&.include?(key) }
|
|
368
|
+
return summary[:id] if found
|
|
369
|
+
end
|
|
370
|
+
nil
|
|
371
|
+
rescue StandardError => e
|
|
372
|
+
Octo::Logger.error("[ChannelManager] Session resolve failed: #{e.message}")
|
|
373
|
+
nil
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def auto_create_session(adapter, event)
|
|
377
|
+
key = channel_key(event)
|
|
378
|
+
platform = event[:platform].to_s
|
|
379
|
+
count = @mutex.synchronize { @session_counters[platform] += 1 }
|
|
380
|
+
name = "#{platform}-#{count}"
|
|
381
|
+
session_id = @session_builder.call(name: name, working_dir: Dir.home, source: :channel)
|
|
382
|
+
bind_key_to_session(key, session_id)
|
|
383
|
+
|
|
384
|
+
# Create a long-lived ChannelUIController for this session and subscribe it
|
|
385
|
+
# to the session's WebUIController. It stays for the session's full lifetime
|
|
386
|
+
# so all events (agent output, errors, status) flow through web_ui → channel_ui.
|
|
387
|
+
channel_ui = ChannelUIController.new(event, adapter)
|
|
388
|
+
@registry.with_session(session_id) do |s|
|
|
389
|
+
s[:ui]&.subscribe_channel(channel_ui)
|
|
390
|
+
s[:channel_ui] = channel_ui
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
Octo::Logger.info("[ChannelManager] Auto-created session #{session_id[0, 8]} for #{key}")
|
|
394
|
+
session_id
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Retrieve the ChannelUIController bound to a session (if any).
|
|
398
|
+
def channel_ui_for_session(session_id)
|
|
399
|
+
result = nil
|
|
400
|
+
@registry.with_session(session_id) { |s| result = s[:channel_ui] }
|
|
401
|
+
result
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def bind_key_to_session(key, session_id)
|
|
405
|
+
@registry.list.each do |summary|
|
|
406
|
+
@registry.with_session(summary[:id]) { |s| s[:channel_keys]&.delete(key) }
|
|
407
|
+
end
|
|
408
|
+
@registry.with_session(session_id) do |s|
|
|
409
|
+
s[:channel_keys] ||= Set.new
|
|
410
|
+
s[:channel_keys].add(key)
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def list_sessions(adapter, chat_id)
|
|
415
|
+
sessions = @registry.list.last(5).reverse
|
|
416
|
+
if sessions.empty?
|
|
417
|
+
adapter.send_text(chat_id, "No sessions available.")
|
|
418
|
+
return
|
|
419
|
+
end
|
|
420
|
+
lines = sessions.each_with_index.map do |s, i|
|
|
421
|
+
name = s[:name].to_s.empty? ? "(unnamed)" : s[:name]
|
|
422
|
+
time = s[:updated_at].to_s[5, 11]&.tr("T", " ") || "-"
|
|
423
|
+
"#{i + 1}. `#{s[:id][0, 8]}` #{name} (#{s[:status]}) #{time}"
|
|
424
|
+
end
|
|
425
|
+
adapter.send_text(chat_id, "Recent sessions:\n#{lines.join("\n")}\n\nUse `/bind <n>` to switch.")
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def channel_key(event)
|
|
429
|
+
platform = event[:platform].to_s
|
|
430
|
+
case @binding_mode
|
|
431
|
+
when :chat then "#{platform}:chat:#{event[:chat_id]}"
|
|
432
|
+
when :user then "#{platform}:user:#{event[:user_id]}"
|
|
433
|
+
else # :chat_user (default)
|
|
434
|
+
"#{platform}:chat:#{event[:chat_id]}:user:#{event[:user_id]}"
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Extract the chat_id from the remainder of a channel_key (after removing "platform:" prefix).
|
|
439
|
+
#
|
|
440
|
+
# Possible formats:
|
|
441
|
+
# "chat:CHAT_ID:user:USER_ID" → CHAT_ID (chat_user mode)
|
|
442
|
+
# "chat:CHAT_ID" → CHAT_ID (chat mode)
|
|
443
|
+
# "user:USER_ID" → USER_ID (user mode — use user_id as fallback)
|
|
444
|
+
#
|
|
445
|
+
# For Feishu/WeCom send_text, the chat_id is what's needed as receive_id.
|
|
446
|
+
private def extract_chat_id(remainder)
|
|
447
|
+
if remainder.start_with?("chat:")
|
|
448
|
+
# "chat:CHAT_ID:user:USER_ID" or "chat:CHAT_ID"
|
|
449
|
+
after_chat = remainder.sub("chat:", "")
|
|
450
|
+
# If there's a ":user:" segment, strip it and everything after
|
|
451
|
+
idx = after_chat.index(":user:")
|
|
452
|
+
idx ? after_chat[0...idx] : after_chat
|
|
453
|
+
elsif remainder.start_with?("user:")
|
|
454
|
+
# user-only mode: no chat_id available, use user_id
|
|
455
|
+
remainder.sub("user:", "")
|
|
456
|
+
else
|
|
457
|
+
remainder
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def safe_stop_adapter(adapter)
|
|
462
|
+
adapter.stop
|
|
463
|
+
rescue StandardError => e
|
|
464
|
+
Octo::Logger.warn("[ChannelManager] Error stopping #{adapter.platform_id}: #{e.message}")
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../ui_interface"
|
|
4
|
+
|
|
5
|
+
module Octo
|
|
6
|
+
module Channel
|
|
7
|
+
# ChannelUIController implements UIInterface for IM platform sessions.
|
|
8
|
+
# It is registered as a subscriber on WebUIController so that every
|
|
9
|
+
# agent output event is forwarded here and sent back to the IM platform.
|
|
10
|
+
#
|
|
11
|
+
# Design notes:
|
|
12
|
+
# - Tool calls / results / diffs / token usage are intentionally suppressed
|
|
13
|
+
# to keep IM chat clean. Only high-signal events are forwarded.
|
|
14
|
+
# - Buffering: file/shell previews accumulate in a buffer and are flushed
|
|
15
|
+
# as one message before the next assistant message, avoiding flooding.
|
|
16
|
+
# - request_confirmation is not invoked directly on this class — the Web
|
|
17
|
+
# UI handles the blocking wait and only sends show_warning notifications.
|
|
18
|
+
class ChannelUIController
|
|
19
|
+
include Octo::UIInterface
|
|
20
|
+
|
|
21
|
+
BUFFER_FLUSH_SIZE = 5 # flush early when buffer is large
|
|
22
|
+
|
|
23
|
+
attr_reader :platform, :chat_id
|
|
24
|
+
|
|
25
|
+
def initialize(event, adapter)
|
|
26
|
+
@platform = event[:platform]
|
|
27
|
+
@chat_id = event[:chat_id]
|
|
28
|
+
@message_id = event[:message_id] # original message to reply under
|
|
29
|
+
@adapter = adapter
|
|
30
|
+
@buffer = []
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Update the reply context for the current inbound message.
|
|
35
|
+
# Called at the start of each route_message so replies are threaded correctly.
|
|
36
|
+
# Also updates chat_id — a session may span multiple chats (e.g. same user
|
|
37
|
+
# in both a direct message and a group), and each inbound event dictates
|
|
38
|
+
# where outbound replies should be routed.
|
|
39
|
+
# @param event [Hash] inbound event with :message_id and :chat_id
|
|
40
|
+
def update_message_context(event)
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
@message_id = event[:message_id]
|
|
43
|
+
@chat_id = event[:chat_id] if event[:chat_id]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# === Output display ===
|
|
48
|
+
|
|
49
|
+
# Forward WebUI user messages to the IM channel so both sides stay in sync.
|
|
50
|
+
# Prefixed with the product/user context so it's clear who sent it.
|
|
51
|
+
def show_user_message(content)
|
|
52
|
+
return if content.nil? || content.to_s.strip.empty?
|
|
53
|
+
|
|
54
|
+
send_text("[USER] #{content}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def show_assistant_message(content, files:)
|
|
58
|
+
flush_buffer
|
|
59
|
+
Octo::Logger.info("[ChannelUI] show_assistant_message files=#{files.size} content_len=#{content.to_s.length}")
|
|
60
|
+
# Strip file:// markdown links from the text sent to IM channels —
|
|
61
|
+
# the actual files are delivered via send_file() below, so the
|
|
62
|
+
# raw markdown links would just be noise in the chat.
|
|
63
|
+
text = content.to_s.gsub(/!?\[[^\]]*\]\(file:\/\/[^)]+\)/, "").strip
|
|
64
|
+
send_text(text) unless text.empty?
|
|
65
|
+
flush_adapter_pending
|
|
66
|
+
files.each do |f|
|
|
67
|
+
Octo::Logger.info("[ChannelUI] sending file path=#{f[:path].inspect} name=#{f[:name].inspect}")
|
|
68
|
+
send_file(f[:path], f[:name])
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def show_tool_call(name, args)
|
|
73
|
+
# Suppress — too noisy for IM
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def show_tool_result(result)
|
|
77
|
+
# Suppress — too noisy for IM
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def show_tool_error(error)
|
|
81
|
+
msg = error.is_a?(Exception) ? error.message : error.to_s
|
|
82
|
+
send_text("Tool error: #{msg}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def show_tool_args(formatted_args)
|
|
86
|
+
# Suppress
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def show_file_write_preview(path, is_new_file:)
|
|
90
|
+
action = is_new_file ? "create" : "overwrite"
|
|
91
|
+
buffer_line("#{action}: #{path}")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def show_file_edit_preview(path)
|
|
95
|
+
buffer_line("edit: #{path}")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def show_shell_preview(command)
|
|
99
|
+
buffer_line("$ #{command}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def show_file_error(error_message)
|
|
103
|
+
send_text("File error: #{error_message}")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def show_diff(old_content, new_content, max_lines: 50)
|
|
107
|
+
# Diffs are too verbose for IM — suppress
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def show_token_usage(token_data)
|
|
111
|
+
# Suppress
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def show_complete(iterations:, duration: nil, cache_stats: nil, awaiting_user_feedback: false)
|
|
115
|
+
flush_buffer
|
|
116
|
+
parts = ["Done", "#{iterations} step#{"s" if iterations != 1}"]
|
|
117
|
+
parts << "#{duration.round(1)}s" if duration
|
|
118
|
+
send_text(parts.join(" · "))
|
|
119
|
+
flush_adapter_pending
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def append_output(content)
|
|
123
|
+
return if content.nil? || content.to_s.strip.empty?
|
|
124
|
+
|
|
125
|
+
send_text(content)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# === Status messages ===
|
|
129
|
+
|
|
130
|
+
def show_info(message, prefix_newline: true)
|
|
131
|
+
# Suppress informational noise in IM
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def show_warning(message)
|
|
135
|
+
send_text("Warning: #{message}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def show_error(message)
|
|
139
|
+
send_text("Error: #{message}")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def show_success(message)
|
|
143
|
+
send_text(message)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def log(message, level: :info)
|
|
147
|
+
# Suppress
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# === Progress ===
|
|
151
|
+
|
|
152
|
+
def show_progress(message = nil, prefix_newline: true, output_buffer: nil)
|
|
153
|
+
# Suppress — progress spinner has no IM equivalent
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# === State updates (no-ops for IM) ===
|
|
157
|
+
|
|
158
|
+
def update_sessionbar(tasks: nil, status: nil, latency: nil); end
|
|
159
|
+
def update_todos(todos); end
|
|
160
|
+
def set_working_status; end
|
|
161
|
+
def set_idle_status; end
|
|
162
|
+
|
|
163
|
+
# === Blocking interaction ===
|
|
164
|
+
# Not called directly — WebUIController handles the blocking wait
|
|
165
|
+
# and only notifies IM via show_warning. Implemented as auto-approve
|
|
166
|
+
# as a safety fallback in case this is ever called directly.
|
|
167
|
+
def request_confirmation(message, default: true)
|
|
168
|
+
send_text("Confirmation requested (auto-approved): #{message}")
|
|
169
|
+
default
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# === Input control / lifecycle (no-ops) ===
|
|
173
|
+
|
|
174
|
+
def clear_input; end
|
|
175
|
+
def set_input_tips(message, type: :info); end
|
|
176
|
+
def stop; end
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def send_text(text)
|
|
180
|
+
text = text.to_s.gsub(/<think>[\s\S]*?<\/think>\n*/i, "").strip
|
|
181
|
+
return if text.empty?
|
|
182
|
+
|
|
183
|
+
@adapter.send_text(@chat_id, text, reply_to: @message_id)
|
|
184
|
+
rescue StandardError => e
|
|
185
|
+
Octo::Logger.warn("[ChannelUI] send_text failed", platform: @platform, chat_id: @chat_id, error: e)
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def send_file(path, name = nil)
|
|
190
|
+
if @adapter.respond_to?(:send_file)
|
|
191
|
+
@adapter.send_file(@chat_id, path, name: name)
|
|
192
|
+
else
|
|
193
|
+
# Fallback for adapters that don't support file sending
|
|
194
|
+
send_text("File: #{name || File.basename(path)}\n#{path}")
|
|
195
|
+
end
|
|
196
|
+
rescue StandardError => e
|
|
197
|
+
Octo::Logger.warn("[ChannelUI] send_file failed (#{@platform}/#{@chat_id}): #{e.message}")
|
|
198
|
+
send_text("Failed to send file: #{File.basename(path)}\nError: #{e.message}")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def buffer_line(line)
|
|
202
|
+
@mutex.synchronize do
|
|
203
|
+
@buffer << line
|
|
204
|
+
flush_buffer_unlocked if @buffer.size >= BUFFER_FLUSH_SIZE
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def flush_buffer
|
|
209
|
+
@mutex.synchronize { flush_buffer_unlocked }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def flush_buffer_unlocked
|
|
213
|
+
return if @buffer.empty?
|
|
214
|
+
|
|
215
|
+
send_text(@buffer.join("\n"))
|
|
216
|
+
@buffer.clear
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def flush_adapter_pending
|
|
220
|
+
@adapter.flush_pending(@chat_id) if @adapter.respond_to?(:flush_pending)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|