collavre 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +0 -1
- data/app/assets/stylesheets/collavre/comments_popup.css +165 -99
- data/app/assets/stylesheets/collavre/creatives.css +346 -37
- data/app/assets/stylesheets/collavre/desktop_setup.css +246 -0
- data/app/assets/stylesheets/collavre/gnb.css +64 -3
- data/app/assets/stylesheets/collavre/landing.css +108 -3
- data/app/assets/stylesheets/collavre/mention_menu.css +32 -0
- data/app/assets/stylesheets/collavre/popup.css +14 -0
- data/app/assets/stylesheets/collavre/print.css +0 -1
- data/app/assets/stylesheets/collavre/search_popup.css +1 -1
- data/app/assets/stylesheets/collavre/secret_fields.css +3 -0
- data/app/assets/stylesheets/collavre/workspace.css +307 -0
- data/app/channels/collavre/agent_channel.rb +14 -21
- data/app/channels/collavre/comments_presence_channel.rb +181 -21
- data/app/channels/collavre/inbox_badge_channel.rb +7 -1
- data/app/components/collavre/inbox/badge_component.rb +6 -13
- data/app/components/collavre/progress_filter_component.html.erb +2 -2
- data/app/components/collavre/progress_filter_component.rb +20 -0
- data/app/controllers/collavre/admin/settings_controller.rb +4 -1
- data/app/controllers/collavre/agent_connections_controller.rb +165 -0
- data/app/controllers/collavre/agent_gateways_controller.rb +132 -0
- data/app/controllers/collavre/agent_provisioning_controller.rb +79 -0
- data/app/controllers/collavre/api/v1/agents_controller.rb +45 -97
- data/app/controllers/collavre/application_controller.rb +13 -2
- data/app/controllers/collavre/comment_read_pointers_controller.rb +69 -40
- data/app/controllers/collavre/comments_controller.rb +45 -131
- data/app/controllers/collavre/concerns/shareable.rb +9 -3
- data/app/controllers/collavre/creatives_controller.rb +144 -11
- data/app/controllers/collavre/desktop_gateway_registrations_controller.rb +173 -0
- data/app/controllers/collavre/desktop_setup_controller.rb +119 -0
- data/app/controllers/collavre/desktop_sidecar_health_controller.rb +35 -0
- data/app/controllers/collavre/features_controller.rb +57 -0
- data/app/controllers/collavre/invites_controller.rb +12 -1
- data/app/controllers/collavre/llm_models_controller.rb +8 -0
- data/app/controllers/collavre/notices_controller.rb +20 -0
- data/app/controllers/collavre/tasks_controller.rb +40 -6
- data/app/controllers/collavre/topics_controller.rb +130 -53
- data/app/controllers/collavre/user_creative_preferences_controller.rb +214 -30
- data/app/controllers/concerns/collavre/comments/batch_operations.rb +15 -3
- data/app/controllers/concerns/collavre/comments/conversion.rb +1 -1
- data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +118 -8
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +1 -15
- data/app/controllers/concerns/collavre/users_controller/registration.rb +2 -2
- data/app/errors/collavre/turn_deadline_error.rb +20 -0
- data/app/helpers/collavre/application_helper.rb +38 -0
- data/app/helpers/collavre/creatives_helper.rb +56 -22
- data/app/helpers/collavre/navigation_helper.rb +12 -0
- data/app/javascript/collavre.js +1 -2
- data/app/javascript/components/InlineLexicalEditor.jsx +30 -21
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +32 -2
- data/app/javascript/components/__tests__/creative_tree_row_progress_toggle.test.js +185 -0
- data/app/javascript/components/creative_tree_row.js +62 -22
- data/app/javascript/controllers/__tests__/agent_connection_controller.test.js +195 -0
- data/app/javascript/controllers/__tests__/desktop_proxy_setup_controller.test.js +96 -0
- data/app/javascript/controllers/__tests__/index.test.js +42 -0
- data/app/javascript/controllers/__tests__/last_visited_creative_controller.test.js +103 -0
- data/app/javascript/controllers/__tests__/link_creative_controller.test.js +253 -2
- data/app/javascript/controllers/__tests__/progress_filter_controller.test.js +134 -0
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +310 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +111 -0
- data/app/javascript/controllers/__tests__/workspace_tree_controller.test.js +1011 -0
- data/app/javascript/controllers/agent_connection_controller.js +357 -0
- data/app/javascript/controllers/agent_vendor_controller.js +23 -0
- data/app/javascript/controllers/comment_controller.js +3 -1
- data/app/javascript/controllers/comments/__tests__/feature_cards_controller.test.js +223 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_default_placeholder.test.js +83 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_draft.test.js +2069 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_ime_send.test.js +324 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +28 -2
- data/app/javascript/controllers/comments/__tests__/form_controller_search_submit.test.js +106 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft.test.js +134 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_creative_switch.test.js +166 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_failure.test.js +183 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_deep_link_topic.test.js +467 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_docked.test.js +115 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +245 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_read_pointer.test.js +276 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_stream_archived.test.js +221 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_stream_search.test.js +104 -0
- data/app/javascript/controllers/comments/__tests__/placeholder_controller.test.js +79 -0
- data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +754 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +717 -15
- data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +184 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_archived.test.js +879 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_creation_scroll.test.js +92 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +130 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_docked.test.js +32 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_last_topic_echo.test.js +1938 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +256 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_scroll.test.js +182 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_selection_race.test.js +981 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +313 -0
- data/app/javascript/controllers/comments/feature_cards_controller.js +83 -0
- data/app/javascript/controllers/comments/form_controller.js +795 -14
- data/app/javascript/controllers/comments/list_controller.js +338 -52
- data/app/javascript/controllers/comments/placeholder_controller.js +30 -0
- data/app/javascript/controllers/comments/popup_controller.js +425 -83
- data/app/javascript/controllers/comments/presence_controller.js +317 -55
- data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
- data/app/javascript/controllers/comments/topics_controller.js +1772 -100
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/creatives/__tests__/select_mode_controller.test.js +139 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +605 -0
- data/app/javascript/controllers/creatives/__tests__/write_access_request_controller.test.js +125 -0
- data/app/javascript/controllers/creatives/select_mode_controller.js +7 -2
- data/app/javascript/controllers/creatives/tree_controller.js +140 -31
- data/app/javascript/controllers/creatives/write_access_request_controller.js +58 -0
- data/app/javascript/controllers/desktop_proxy_setup_controller.js +51 -0
- data/app/javascript/controllers/gateway_check_controller.js +36 -0
- data/app/javascript/controllers/index.js +25 -1
- data/app/javascript/controllers/last_visited_creative_controller.js +58 -0
- data/app/javascript/controllers/link_creative_controller.js +89 -18
- data/app/javascript/controllers/progress_filter_controller.js +12 -22
- data/app/javascript/controllers/search_popup_controller.js +64 -46
- data/app/javascript/controllers/share_user_search_controller.js +2 -2
- data/app/javascript/controllers/topic_list_controller.js +44 -7
- data/app/javascript/controllers/workspace_tree_controller.js +662 -0
- data/app/javascript/creatives/__tests__/tree_renderer.test.js +80 -0
- data/app/javascript/creatives/drag_drop/__tests__/drop_completion_empty_state.test.js +98 -0
- data/app/javascript/creatives/drag_drop/__tests__/topic_move_released_agent.test.js +125 -0
- data/app/javascript/creatives/drag_drop/event_handlers.js +12 -0
- data/app/javascript/creatives/tree_renderer.js +65 -22
- data/app/javascript/lib/__tests__/chat_drafts.test.js +1458 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +8 -3
- data/app/javascript/lib/__tests__/common_popup_placement.test.js +374 -0
- data/app/javascript/lib/__tests__/common_popup_reshow.test.js +131 -0
- data/app/javascript/lib/__tests__/creative_link_prefetch.test.js +46 -0
- data/app/javascript/lib/__tests__/last_visited_creative.test.js +87 -0
- data/app/javascript/lib/__tests__/skill_html_entities.test.js +23 -0
- data/app/javascript/lib/__tests__/turbo_stream_actions.test.js +190 -0
- data/app/javascript/lib/api/__tests__/creatives.test.js +68 -0
- data/app/javascript/lib/api/__tests__/csrf_fetch.test.js +27 -0
- data/app/javascript/lib/api/__tests__/topics_save_last_topic.test.js +112 -0
- data/app/javascript/lib/api/creatives.js +32 -13
- data/app/javascript/lib/api/csrf_fetch.js +6 -4
- data/app/javascript/lib/api/topics.js +63 -5
- data/app/javascript/lib/chat_drafts.js +786 -0
- data/app/javascript/lib/common_popup.js +164 -25
- data/app/javascript/lib/creative_link_prefetch.js +27 -0
- data/app/javascript/lib/last_visited_creative.js +97 -0
- data/app/javascript/lib/lexical/__tests__/creative_link_node.test.js +162 -0
- data/app/javascript/lib/lexical/__tests__/creative_link_trigger.test.js +133 -0
- data/app/javascript/lib/lexical/__tests__/link_toolbar.test.js +62 -0
- data/app/javascript/lib/lexical/creative_link_node.js +101 -0
- data/app/javascript/lib/lexical/creative_link_trigger.js +98 -0
- data/app/javascript/lib/lexical/link_toolbar.js +23 -0
- data/app/javascript/lib/turbo_stream_actions.js +23 -16
- data/app/javascript/lib/utils/__tests__/filter_navigation.test.js +210 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +44 -0
- data/app/javascript/lib/utils/filter_navigation.js +100 -0
- data/app/javascript/modules/__tests__/command_menu_creative_picker.test.js +62 -0
- data/app/javascript/modules/__tests__/command_menu_typeahead.test.js +267 -0
- data/app/javascript/modules/__tests__/creative_link_picker.test.js +85 -0
- data/app/javascript/modules/__tests__/creative_row_editor_archive.test.js +391 -0
- data/app/javascript/modules/__tests__/creative_row_editor_delegated_clicks.test.js +158 -0
- data/app/javascript/modules/__tests__/creative_row_editor_empty_state_recovery.test.js +519 -0
- data/app/javascript/modules/__tests__/creative_row_editor_empty_state_removal.test.js +346 -0
- data/app/javascript/modules/__tests__/creative_row_editor_session.test.js +144 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +30 -0
- data/app/javascript/modules/__tests__/creative_tree_empty_state.test.js +186 -0
- data/app/javascript/modules/__tests__/dom_listener_registry.test.js +67 -0
- data/app/javascript/modules/__tests__/support/inline_editor_dom.js +159 -0
- data/app/javascript/modules/command_args_form.js +0 -12
- data/app/javascript/modules/command_menu.js +80 -38
- data/app/javascript/modules/creative_link_picker.js +56 -0
- data/app/javascript/modules/creative_row_editor.js +446 -243
- data/app/javascript/modules/creative_row_editor_delegated_clicks.js +87 -0
- data/app/javascript/modules/creative_tree_dom.js +4 -0
- data/app/javascript/modules/creative_tree_empty_state.js +80 -0
- data/app/javascript/modules/dom_listener_registry.js +23 -0
- data/app/javascript/modules/mention_menu.js +2 -3
- data/app/javascript/utils/__tests__/caret_anchor.test.js +75 -0
- data/app/javascript/utils/__tests__/chevron_icons.test.js +15 -0
- data/app/javascript/utils/caret_position.js +10 -0
- data/app/javascript/utils/chevron_icons.js +7 -0
- data/app/jobs/collavre/ai_agent_job.rb +262 -72
- data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +11 -36
- data/app/jobs/collavre/comment_badges_broadcast_job.rb +28 -0
- data/app/jobs/collavre/comment_link_preview_job.rb +33 -0
- data/app/jobs/collavre/comment_notification_job.rb +14 -0
- data/app/jobs/collavre/comment_push_delivery_sweep_job.rb +17 -0
- data/app/jobs/collavre/compress_job.rb +33 -22
- data/app/jobs/collavre/creative_broadcast_job.rb +161 -48
- data/app/jobs/collavre/cron_action_job.rb +36 -18
- data/app/jobs/collavre/drop_trigger_job.rb +23 -9
- data/app/jobs/collavre/merge_comments_job.rb +33 -16
- data/app/jobs/collavre/push_notification_job.rb +62 -18
- data/app/jobs/collavre/restore_dropped_dispatches_job.rb +17 -0
- data/app/jobs/collavre/stuck_detector_job.rb +1 -1
- data/app/jobs/collavre/touch_creative_subtree_job.rb +14 -0
- data/app/models/collavre/agent_gateway.rb +200 -0
- data/app/models/collavre/agent_subscription.rb +4 -4
- data/app/models/collavre/agent_workspace.rb +210 -0
- data/app/models/collavre/comment/broadcastable.rb +22 -52
- data/app/models/collavre/comment/notifiable.rb +154 -77
- data/app/models/collavre/comment/topic_membership.rb +41 -0
- data/app/models/collavre/comment.rb +336 -31
- data/app/models/collavre/comment_notification_delivery.rb +61 -0
- data/app/models/collavre/comment_presence_store/cache_batches.rb +64 -0
- data/app/models/collavre/comment_presence_store.rb +272 -12
- data/app/models/collavre/comment_read_pointer.rb +9 -15
- data/app/models/collavre/creative/describable.rb +8 -2
- data/app/models/collavre/creative/permissible.rb +11 -0
- data/app/models/collavre/creative.rb +29 -4
- data/app/models/collavre/creative_share.rb +109 -4
- data/app/models/collavre/current.rb +1 -0
- data/app/models/collavre/llm_model.rb +62 -0
- data/app/models/collavre/system_setting.rb +12 -1
- data/app/models/collavre/task.rb +121 -2
- data/app/models/collavre/topic.rb +51 -0
- data/app/models/collavre/user.rb +101 -41
- data/app/models/collavre/user_creative_preference.rb +3 -1
- data/app/services/collavre/agent_provisioning/archive.rb +108 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +16 -3
- data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +63 -6
- data/app/services/collavre/ai_agent/approval_handler.rb +12 -4
- data/app/services/collavre/ai_agent/claude_channel_adapter.rb +10 -5
- data/app/services/collavre/ai_agent/merged_trigger_comments.rb +164 -0
- data/app/services/collavre/ai_agent/message_builder.rb +103 -16
- data/app/services/collavre/ai_agent/session_provisioner.rb +3 -7
- data/app/services/collavre/ai_agent/task_claim_service.rb +29 -15
- data/app/services/collavre/ai_agent/task_reply_service.rb +67 -0
- data/app/services/collavre/ai_agent_service.rb +108 -20
- data/app/services/collavre/ai_client.rb +211 -21
- data/app/services/collavre/cli_proxy/client.rb +147 -0
- data/app/services/collavre/cli_proxy/endpoint_policy.rb +81 -0
- data/app/services/collavre/cli_proxy/identity.rb +47 -0
- data/app/services/collavre/cli_proxy/safe_net_http_adapter.rb +19 -0
- data/app/services/collavre/command_menu_service.rb +0 -10
- data/app/services/collavre/comment_move_service.rb +96 -7
- data/app/services/collavre/comment_snapshot_restore_service.rb +11 -5
- data/app/services/collavre/comments/action_executor.rb +48 -20
- data/app/services/collavre/comments/command_processor.rb +0 -1
- data/app/services/collavre/comments/list_response.rb +133 -0
- data/app/services/collavre/comments/read_pointer_writer.rb +210 -0
- data/app/services/collavre/comments/read_receipt_broadcaster.rb +157 -0
- data/app/services/collavre/comments/read_receipt_index.rb +152 -0
- data/app/services/collavre/comments/topic_command.rb +72 -1
- data/app/services/collavre/comments/topic_mutation.rb +20 -0
- data/app/services/collavre/creatives/breadcrumb_resolver.rb +1 -1
- data/app/services/collavre/creatives/comment_badge_index.rb +299 -27
- data/app/services/collavre/creatives/reorderer.rb +15 -3
- data/app/services/collavre/creatives/tree_builder.rb +1 -1
- data/app/services/collavre/creatives/tree_formatter.rb +5 -6
- data/app/services/collavre/creatives/workspace_path_resolver.rb +50 -0
- data/app/services/collavre/creatives/workspace_tree_builder.rb +92 -0
- data/app/services/collavre/crons/recurring_topic_tasks.rb +23 -0
- data/app/services/collavre/home_page_path.rb +55 -0
- data/app/services/collavre/html_text.rb +64 -0
- data/app/services/collavre/http_client.rb +11 -2
- data/app/services/collavre/inbox_reply_service.rb +1 -1
- data/app/services/collavre/markdown_converter.rb +13 -1
- data/app/services/collavre/orchestration/agent_orchestrator.rb +776 -81
- data/app/services/collavre/orchestration/arbiter.rb +48 -5
- data/app/services/collavre/orchestration/deferred_trigger_scope.rb +53 -0
- data/app/services/collavre/orchestration/delivery_record.rb +792 -0
- data/app/services/collavre/orchestration/loop_breaker.rb +10 -4
- data/app/services/collavre/orchestration/matcher.rb +132 -17
- data/app/services/collavre/orchestration/policy_resolver.rb +58 -10
- data/app/services/collavre/orchestration/scheduler.rb +10 -4
- data/app/services/collavre/orchestration/scheduling_hooks.rb +10 -0
- data/app/services/collavre/orchestration/selection.rb +43 -0
- data/app/services/collavre/orchestration/stuck_detector.rb +25 -112
- data/app/services/collavre/orchestration/task_coalescer.rb +355 -0
- data/app/services/collavre/orchestration/topic_slot.rb +73 -0
- data/app/services/collavre/system_events/context_builder.rb +60 -11
- data/app/services/collavre/system_events/dispatcher.rb +58 -12
- data/app/services/collavre/system_events/envelope.rb +73 -0
- data/app/services/collavre/system_events/vocabulary.rb +45 -0
- data/app/services/collavre/tools/creative_retrieval_service.rb +1 -1
- data/app/services/collavre/tools/cron_create_service.rb +32 -18
- data/app/services/collavre/tools/id_list.rb +38 -0
- data/app/services/collavre/tools/topic_authorizer.rb +61 -7
- data/app/services/collavre/tools/topic_branch_service.rb +85 -0
- data/app/services/collavre/tools/topic_create_service.rb +117 -0
- data/app/services/collavre/tools/topic_list_service.rb +101 -0
- data/app/services/collavre/tools/topic_message_create_service.rb +192 -0
- data/app/services/collavre/tools/topic_messages_service.rb +346 -0
- data/app/services/collavre/tools/topic_move_service.rb +108 -0
- data/app/services/collavre/tools/topic_selection.rb +59 -0
- data/app/services/collavre/tools/topic_update_service.rb +165 -0
- data/app/services/collavre/topic_branch_service.rb +39 -10
- data/app/services/collavre/topics/agent_resolver.rb +87 -0
- data/app/services/collavre/topics/char_budget.rb +70 -0
- data/app/services/collavre/topics/last_topic_preference_payload.rb +18 -0
- data/app/services/collavre/topics/message_page.rb +380 -0
- data/app/services/collavre/topics/message_page_markdown.rb +102 -0
- data/app/services/collavre/topics/message_scope.rb +61 -0
- data/app/services/collavre/topics/message_stats.rb +92 -0
- data/app/services/collavre/topics/move_blocker.rb +47 -0
- data/app/services/collavre/topics/next_name.rb +35 -0
- data/app/services/collavre/topics/pin_rejection.rb +25 -0
- data/app/services/collavre/topics/read_pointer_relocator.rb +71 -0
- data/app/services/collavre/topics/reserved_name.rb +18 -0
- data/app/services/collavre/topics/serializer.rb +92 -0
- data/app/services/collavre/topics/topic_destroy.rb +30 -0
- data/app/services/collavre/topics/topic_move.rb +80 -0
- data/app/services/collavre/topics/topic_move_effects.rb +35 -0
- data/app/services/collavre/topics/topic_mutation.rb +27 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +5 -8
- data/app/views/collavre/admin/settings/_system_tab.html.erb +7 -0
- data/app/views/collavre/agent_connections/show.html.erb +77 -0
- data/app/views/collavre/agent_gateways/_form.html.erb +71 -0
- data/app/views/collavre/agent_gateways/edit.html.erb +2 -0
- data/app/views/collavre/agent_gateways/index.html.erb +54 -0
- data/app/views/collavre/agent_gateways/new.html.erb +2 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +32 -17
- data/app/views/collavre/comments/_empty_state.html.erb +33 -0
- data/app/views/collavre/comments/_empty_state_minimal.html.erb +7 -0
- data/app/views/collavre/comments/_feature_card.html.erb +35 -0
- data/app/views/collavre/comments/_list.html.erb +3 -2
- data/app/views/collavre/comments/_no_search_results.html.erb +3 -0
- data/app/views/collavre/creatives/_add_button.html.erb +17 -19
- data/app/views/collavre/creatives/_empty_state.html.erb +109 -0
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +5 -5
- data/app/views/collavre/creatives/_tree_loading_placeholder.html.erb +16 -0
- data/app/views/collavre/creatives/_workspace_tree.html.erb +33 -0
- data/app/views/collavre/creatives/index.html.erb +79 -18
- data/app/views/collavre/desktop_setup/show.html.erb +114 -0
- data/app/views/collavre/features/index.html.erb +42 -0
- data/app/views/collavre/features/show.html.erb +52 -0
- data/app/views/collavre/invitation_mailer/invite.html.erb +1 -1
- data/app/views/collavre/invitation_mailer/invite.text.erb +1 -1
- data/app/views/collavre/invites/show.html.erb +14 -1
- data/app/views/collavre/landing/show.html.erb +5 -1
- data/app/views/collavre/shared/_link_creative_modal.html.erb +4 -1
- data/app/views/collavre/shared/navigation/_help_button.html.erb +3 -1
- data/app/views/collavre/shared/navigation/_panels.html.erb +1 -8
- data/app/views/collavre/shared/navigation/_search_form.html.erb +13 -2
- data/app/views/collavre/user_themes/index.html.erb +1 -1
- data/app/views/collavre/users/_llm_fields.html.erb +65 -0
- data/app/views/collavre/users/_org_chart_node.html.erb +1 -1
- data/app/views/collavre/users/edit_ai.html.erb +33 -31
- data/app/views/collavre/users/new.html.erb +4 -2
- data/app/views/collavre/users/new_ai.html.erb +14 -32
- data/app/views/collavre/users/show.html.erb +13 -1
- data/app/views/layouts/collavre/landing.html.erb +9 -4
- data/config/initializers/ruby_llm_openai_reasoning_content.rb +77 -0
- data/config/locales/admin.en.yml +3 -1
- data/config/locales/admin.ko.yml +3 -1
- data/config/locales/agent_gateway.en.yml +217 -0
- data/config/locales/agent_gateway.ko.yml +217 -0
- data/config/locales/ai_agent.en.yml +0 -1
- data/config/locales/ai_agent.ko.yml +0 -1
- data/config/locales/comments.en.yml +72 -30
- data/config/locales/comments.ko.yml +72 -30
- data/config/locales/creatives.en.yml +32 -1
- data/config/locales/creatives.ko.yml +32 -1
- data/config/locales/features.en.yml +140 -0
- data/config/locales/features.ko.yml +140 -0
- data/config/locales/invites.en.yml +3 -0
- data/config/locales/invites.ko.yml +3 -0
- data/config/locales/landing.en.yml +2 -1
- data/config/locales/landing.ko.yml +2 -1
- data/config/locales/tools.en.yml +20 -0
- data/config/locales/tools.ko.yml +20 -0
- data/config/locales/users.en.yml +15 -14
- data/config/locales/users.ko.yml +14 -14
- data/config/routes.rb +45 -19
- data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +9 -1
- data/db/migrate/20260724000000_add_creative_id_index_to_comments.rb +34 -0
- data/db/migrate/20260724000001_add_creative_private_id_index_to_comments.rb +50 -0
- data/db/migrate/20260724090000_add_async_comment_notification_state.rb +64 -0
- data/db/migrate/20260727000000_add_waiting_notice_scope_to_comments.rb +30 -0
- data/db/migrate/20260727010000_add_waiting_notice_scope_to_tasks.rb +26 -0
- data/db/migrate/20260805000000_add_creative_workspace_enabled_to_users.rb +5 -0
- data/db/migrate/20260807050000_add_dismissed_notices_to_users.rb +5 -0
- data/db/migrate/20260809000000_change_creative_workspace_enabled_default_to_true.rb +18 -0
- data/db/migrate/20260809000001_create_llm_models.rb +64 -0
- data/db/migrate/20260809000002_create_agent_gateways_and_workspaces.rb +35 -0
- data/db/migrate/20260809000003_hash_agent_workspace_callback_tokens.rb +44 -0
- data/db/migrate/20260809000004_encrypt_agent_workspace_manifest_tokens.rb +59 -0
- data/db/migrate/20260810000000_split_agent_workspace_identity_axes.rb +58 -0
- data/db/migrate/20260810000001_seed_codex_custom_proxy_llm_models.rb +28 -0
- data/db/migrate/20260810010000_add_last_visited_creative_to_users.rb +8 -0
- data/db/migrate/20260810020000_add_last_visited_creative_at_to_users.rb +5 -0
- data/db/migrate/20260810030000_add_last_visited_creative_navigation_order_to_users.rb +6 -0
- data/db/migrate/20260810060000_add_last_visited_creative_issued_sequence_to_users.rb +5 -0
- data/db/migrate/20260810100000_add_desktop_managed_to_agent_gateways.rb +40 -0
- data/db/migrate/20260810100001_add_desktop_preset_adapter_to_users.rb +38 -0
- data/db/migrate/20260811000000_make_agent_gateway_completion_key_nullable.rb +7 -0
- data/db/migrate/20260812000000_remove_workflow_columns_from_tasks.rb +101 -0
- data/db/migrate/20260812000001_remove_android_devices.rb +24 -0
- data/db/migrate/20260812010000_drop_inbox_items.rb +22 -0
- data/db/migrate/20260813000000_clear_claude_channel_presence_routing_expressions.rb +16 -0
- data/db/migrate/20260813000001_add_last_topic_revision_to_user_creative_preferences.rb +5 -0
- data/db/migrate/20260813000002_add_last_topic_save_order_to_user_creative_preferences.rb +6 -0
- data/db/migrate/20260813000003_add_last_topic_save_sequences_to_user_creative_preferences.rb +5 -0
- data/db/migrate/20260813000004_add_last_topic_save_fences_to_user_creative_preferences.rb +6 -0
- data/db/migrate/20260814000000_add_topic_to_comment_read_pointers.rb +79 -0
- data/db/migrate/20260814000001_add_creative_topic_private_id_index_to_comments.rb +37 -0
- data/db/migrate/20260816000000_add_watermark_index_to_comment_read_pointers.rb +27 -0
- data/db/migrate/20260816000001_add_private_recipient_topic_index_to_comments.rb +48 -0
- data/db/migrate/20260820000000_add_topic_assigned_at_to_comments.rb +13 -0
- data/db/seeds.rb +1 -51
- data/lib/collavre/engine.rb +108 -1
- data/lib/collavre/feature_card_registry.rb +168 -0
- data/lib/collavre/hashed_access_token_lookup.rb +22 -0
- data/lib/collavre/sensitive_request_silencer.rb +23 -0
- data/lib/collavre/user_extensions.rb +0 -2
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +1 -0
- data/skills/collavre/SKILL.md +165 -0
- data/skills/collavre/package.json +8 -0
- data/skills/collavre/references/tool-reference.md +347 -0
- data/skills/collavre/scripts/collavre +634 -0
- data/skills/collavre/scripts/html_entities.js +24 -0
- metadata +228 -30
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +0 -224
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +0 -95
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +0 -31
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +0 -25
- data/app/controllers/collavre/inbox_items_controller.rb +0 -70
- data/app/controllers/collavre/typo_corrections_controller.rb +0 -39
- data/app/javascript/lib/__tests__/typo_correction.test.js +0 -192
- data/app/javascript/lib/typo_correction.js +0 -146
- data/app/javascript/modules/__tests__/typo_corrector.test.js +0 -365
- data/app/javascript/modules/creative_guide.js +0 -67
- data/app/javascript/modules/typo_corrector.js +0 -534
- data/app/jobs/collavre/inbox_summary_job.rb +0 -24
- data/app/mailers/collavre/inbox_mailer.rb +0 -19
- data/app/models/collavre/inbox_item.rb +0 -89
- data/app/services/collavre/comments/concerns/workflow_support.rb +0 -115
- data/app/services/collavre/comments/work_command.rb +0 -161
- data/app/services/collavre/comments/workflow_executor.rb +0 -276
- data/app/services/collavre/gemini_parent_recommender.rb +0 -77
- data/app/services/collavre/mobile/event_summarizer.rb +0 -40
- data/app/services/collavre/typo_corrector.rb +0 -188
- data/app/views/collavre/inbox_items/_item.html.erb +0 -18
- data/app/views/collavre/inbox_items/_items.html.erb +0 -3
- data/app/views/collavre/inbox_items/_list.html.erb +0 -7
- data/app/views/collavre/inbox_items/index.html.erb +0 -1
- data/app/views/collavre/inbox_mailer/daily_summary.html.erb +0 -11
- data/app/views/collavre/inbox_mailer/daily_summary.text.erb +0 -8
- data/app/views/collavre/users/typo_correction.html.erb +0 -50
- data/config/locales/mobile.en.yml +0 -16
- data/config/locales/mobile.ko.yml +0 -16
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
require "rails_mcp_engine"
|
|
6
|
+
module Tools
|
|
7
|
+
# Posts one public message into an existing topic and starts the topic's
|
|
8
|
+
# normal orchestration route.
|
|
9
|
+
class TopicMessageCreateService
|
|
10
|
+
extend T::Sig
|
|
11
|
+
extend ToolMeta
|
|
12
|
+
|
|
13
|
+
tool_name "topic_message_create"
|
|
14
|
+
tool_description <<~DESC.strip
|
|
15
|
+
Post a public message to an existing Collavre topic.
|
|
16
|
+
|
|
17
|
+
Use this after topic_create to give the topic's primary agent its initial
|
|
18
|
+
instruction. A primary-agent pin is exclusive, so an ambient message here
|
|
19
|
+
starts that agent and no other agent. Messages posted to different topics
|
|
20
|
+
can run concurrently; messages posted to the same topic queue behind the
|
|
21
|
+
topic's current work.
|
|
22
|
+
|
|
23
|
+
The caller remains the message author. Human-authored messages use normal
|
|
24
|
+
comment dispatch, while agent-authored messages are dispatched as A2A work
|
|
25
|
+
so an agent can fan work out without impersonating a human. The content is
|
|
26
|
+
stored as written and may use Markdown. An agent cannot post to the topic
|
|
27
|
+
it is currently handling; continue that work in the current turn instead.
|
|
28
|
+
|
|
29
|
+
Requires feedback permission or better on the topic's creative. Archived
|
|
30
|
+
creatives, archived topics, and an inbox's reserved System topic do not
|
|
31
|
+
accept messages through this tool.
|
|
32
|
+
DESC
|
|
33
|
+
|
|
34
|
+
tool_param :topic_id, description: "The topic to post the message to."
|
|
35
|
+
tool_param :content, description: "The public message body or initial instruction. Markdown is supported."
|
|
36
|
+
|
|
37
|
+
sig { params(topic_id: Integer, content: String).returns(T::Hash[Symbol, T.untyped]) }
|
|
38
|
+
def call(topic_id:, content:)
|
|
39
|
+
user = Current.user || raise(I18n.t("collavre.tools.topic_message_create.errors.current_user_required"))
|
|
40
|
+
topic = Topic.find(topic_id)
|
|
41
|
+
TopicAuthorizer.authorize_feedback!(topic, user: user)
|
|
42
|
+
principal = workspace_user(user)
|
|
43
|
+
|
|
44
|
+
comment = create_comment(topic, content, user, principal)
|
|
45
|
+
if user.ai_user?
|
|
46
|
+
selection = prepare_selection(comment, principal)
|
|
47
|
+
reject_selected_self_route!(selection.agents, user, principal)
|
|
48
|
+
dispatch_agent_message(comment, user, principal, selection)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
serialize(comment)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def create_comment(topic, content, user, principal)
|
|
57
|
+
Comment.transaction do
|
|
58
|
+
topic.lock!
|
|
59
|
+
TopicAuthorizer.authorize_feedback!(topic, user: user)
|
|
60
|
+
reject_closed_topic!(topic)
|
|
61
|
+
reject_unrunnable_self_dispatch!(topic, user, principal)
|
|
62
|
+
|
|
63
|
+
comment = topic.comments.create!(
|
|
64
|
+
creative: topic.creative,
|
|
65
|
+
content: content,
|
|
66
|
+
user: user,
|
|
67
|
+
private: false,
|
|
68
|
+
skip_dispatch: user.ai_user?
|
|
69
|
+
)
|
|
70
|
+
selection = prepare_selection(comment, principal) if user.ai_user?
|
|
71
|
+
reject_selected_self_route!(selection&.agents, user, principal)
|
|
72
|
+
|
|
73
|
+
comment
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def reject_closed_topic!(topic)
|
|
78
|
+
if topic.creative.archived?
|
|
79
|
+
raise ArgumentError, I18n.t("collavre.tools.topic_message_create.errors.archived_creative")
|
|
80
|
+
end
|
|
81
|
+
if topic.archived?
|
|
82
|
+
raise ArgumentError, I18n.t("collavre.tools.topic_message_create.errors.archived_topic")
|
|
83
|
+
end
|
|
84
|
+
return unless topic.creative.inbox? && topic.name == Creative::SYSTEM_TOPIC_NAME
|
|
85
|
+
|
|
86
|
+
raise ArgumentError, I18n.t("collavre.tools.topic_message_create.errors.system_topic")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def reject_unrunnable_self_dispatch!(topic, user, principal)
|
|
90
|
+
return unless user.ai_user?
|
|
91
|
+
|
|
92
|
+
same_topic = Current.agent_turn&.dig(:task)&.topic_id == topic.id
|
|
93
|
+
return unless same_topic || (principal.nil? && topic.primary_agent_id == user.id)
|
|
94
|
+
|
|
95
|
+
raise ArgumentError, I18n.t("collavre.tools.topic_message_create.errors.current_topic")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def prepare_selection(comment, principal)
|
|
99
|
+
Orchestration::AgentOrchestrator.prepare_selection(
|
|
100
|
+
"comment_created", dispatch_payload(comment, principal),
|
|
101
|
+
candidate_overrides: candidate_overrides(comment.user, principal)
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def dispatch_agent_message(comment, user, principal, selection)
|
|
106
|
+
selected_agents = selection.agents
|
|
107
|
+
context_for = lambda do |agent|
|
|
108
|
+
next {} unless agent.id == user.id && principal
|
|
109
|
+
|
|
110
|
+
{ "sender" => SystemEvents::ContextBuilder.sender_context_for(principal) }
|
|
111
|
+
end
|
|
112
|
+
payload = dispatch_payload(comment, principal)
|
|
113
|
+
if selected_agents.any? { |agent| agent.id == user.id }
|
|
114
|
+
payload[Orchestration::DeferredTriggerScope::SELF_AUTHORED_COMMENT_ID_KEY] = comment.id
|
|
115
|
+
end
|
|
116
|
+
SystemEvents::Dispatcher.dispatch(
|
|
117
|
+
"comment_created", payload, source: "a2a", parent: parent_envelope,
|
|
118
|
+
selection: selection, context_for: context_for,
|
|
119
|
+
scheduling_hooks: scheduling_hooks(user, comment)
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def scheduling_hooks(user, comment)
|
|
124
|
+
Orchestration::SchedulingHooks.new(
|
|
125
|
+
interaction_callback: ->(agent) { interaction_for(agent, user, comment.creative_id) },
|
|
126
|
+
scheduled_callback: ->(agents) { record_interactions(agents, user, comment.creative_id) }
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def candidate_overrides(user, principal)
|
|
131
|
+
return {} unless principal
|
|
132
|
+
|
|
133
|
+
{ user.id => { "sender" => SystemEvents::ContextBuilder.sender_context_for(principal) } }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def dispatch_payload(comment, principal)
|
|
137
|
+
comment.dispatch_payload.merge(workspace_user_id: principal&.id).tap do |payload|
|
|
138
|
+
payload[:comment][:from_ai] = comment.user.ai_user?
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def reject_selected_self_route!(selected_agents, user, principal)
|
|
143
|
+
return if selected_agents.nil?
|
|
144
|
+
|
|
145
|
+
self_selected = selected_agents.any? { |agent| agent.id == user.id }
|
|
146
|
+
return unless self_selected && principal.nil?
|
|
147
|
+
|
|
148
|
+
raise ArgumentError, I18n.t("collavre.tools.topic_message_create.errors.self_route")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def parent_envelope
|
|
152
|
+
SystemEvents::Envelope.in(Current.agent_turn&.dig(:task)&.trigger_event_payload)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def record_interactions(selected_agents, user, creative_id)
|
|
156
|
+
context = { "creative" => { "id" => creative_id } }
|
|
157
|
+
selected_agents.each do |agent|
|
|
158
|
+
interaction = interaction_for(agent, user, creative_id)
|
|
159
|
+
next unless interaction
|
|
160
|
+
|
|
161
|
+
Orchestration::LoopBreaker.new(context).record_interaction(
|
|
162
|
+
interaction[:from_agent_id], interaction[:to_agent_id], creative_id
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def interaction_for(agent, user, creative_id)
|
|
168
|
+
return if agent.id == user.id
|
|
169
|
+
|
|
170
|
+
{ from_agent_id: user.id, to_agent_id: agent.id, creative_id: creative_id }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def workspace_user(user)
|
|
174
|
+
return Current.agent_turn[:user] if Current.agent_turn
|
|
175
|
+
|
|
176
|
+
creator = user.creator
|
|
177
|
+
creator if creator && !creator.ai_user?
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def serialize(comment)
|
|
181
|
+
{
|
|
182
|
+
id: comment.id,
|
|
183
|
+
topic_id: comment.topic_id,
|
|
184
|
+
creative_id: comment.creative_id,
|
|
185
|
+
content: comment.content,
|
|
186
|
+
author: { id: comment.user_id, name: comment.user.display_name },
|
|
187
|
+
created_at: comment.created_at.iso8601
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
require "rails_mcp_engine"
|
|
4
|
+
module Tools
|
|
5
|
+
# Reads a page of messages from one or more topics.
|
|
6
|
+
#
|
|
7
|
+
# Multi-topic in one call, but never interleaved: each topic is windowed and
|
|
8
|
+
# reported on its own. A merged cross-topic timeline would be the wrong answer
|
|
9
|
+
# to the question people actually ask ("summarize #a, #b and #c") — three
|
|
10
|
+
# conversations shuffled by timestamp read as none of them — and it would make
|
|
11
|
+
# offset meaningless, since a merged offset moves when any of the topics grows.
|
|
12
|
+
#
|
|
13
|
+
# Per-topic windows keep offset reproducible, and max_chars keeps the total
|
|
14
|
+
# bounded so asking for three topics cannot return three times the context you
|
|
15
|
+
# planned for.
|
|
16
|
+
class TopicMessagesService
|
|
17
|
+
extend T::Sig
|
|
18
|
+
extend ToolMeta
|
|
19
|
+
|
|
20
|
+
# Roughly a mid-sized page of prose per call. The cap is on the whole
|
|
21
|
+
# response rather than per topic because the caller's context is the shared
|
|
22
|
+
# resource: five topics at a comfortable per-topic size is not a comfortable
|
|
23
|
+
# call.
|
|
24
|
+
DEFAULT_MAX_CHARS = 40_000
|
|
25
|
+
MAX_MAX_CHARS = 200_000
|
|
26
|
+
|
|
27
|
+
# A successful bounded fallback needs enough room to explain why the
|
|
28
|
+
# requested response was omitted. Values below this are raised to the
|
|
29
|
+
# minimum instead of returning a response wider than the cap just to spell
|
|
30
|
+
# out that the cap was too small.
|
|
31
|
+
MIN_MAX_CHARS = 160
|
|
32
|
+
|
|
33
|
+
# Each topic also pays for its own heading, totals line and "More:" call —
|
|
34
|
+
# or, if it does not fit, its not-fetched notice, which is about the same
|
|
35
|
+
# size. Charged separately from the messages so that a call for many topics
|
|
36
|
+
# cannot overrun the cap on section furniture alone.
|
|
37
|
+
#
|
|
38
|
+
# json restates the same section as the seventeen keys of the entry hash
|
|
39
|
+
# plus their delimiters — around 285 characters measured, against the two
|
|
40
|
+
# lines markdown prints — so the reserve is per format rather than one
|
|
41
|
+
# constant that is right for one of them and wrong for the other.
|
|
42
|
+
TOPIC_HEADER_CHARS = { "markdown" => 350, "json" => 525 }.freeze
|
|
43
|
+
|
|
44
|
+
# The trailing "output hit max_chars" notice, which only exists when the
|
|
45
|
+
# budget ran out and so cannot be paid for out of what is left of it.
|
|
46
|
+
TRUNCATION_CHARS = 120
|
|
47
|
+
NOTHING_FITS_REASON = "max_chars is too small to return anything for this topic"
|
|
48
|
+
|
|
49
|
+
tool_name "topic_messages"
|
|
50
|
+
tool_description <<~DESC.strip
|
|
51
|
+
Read messages from one or more Collavre topics, newest first, with paging.
|
|
52
|
+
|
|
53
|
+
Messages are selected from the newest end: offset 0 is the latest message,
|
|
54
|
+
offset 100 starts 100 messages back. Within the returned window they are
|
|
55
|
+
rendered oldest-to-newest by default so the transcript reads forwards
|
|
56
|
+
(pass order: "desc" for newest-first rendering).
|
|
57
|
+
|
|
58
|
+
Multiple topics per call are windowed independently and returned grouped
|
|
59
|
+
by topic — offset/limit apply to EACH topic, never to a merged timeline.
|
|
60
|
+
Use this to summarize several topics at once, e.g.
|
|
61
|
+
topic_messages(topic_ids: "12,45,78", limit: 100).
|
|
62
|
+
Multi-topic calls are first-page reads: each topic returns its own cursor
|
|
63
|
+
and snapshot, so continue each topic in a separate call rather than
|
|
64
|
+
applying one topic's continuation state to the whole batch.
|
|
65
|
+
|
|
66
|
+
Every topic reports total_count and total_chars for the whole topic, so
|
|
67
|
+
you can tell how much you have not read yet, plus has_more, next_offset,
|
|
68
|
+
and next_cursor for the next call. Pass next_cursor and the returned
|
|
69
|
+
newest_message_id back on follow-up pages. The snapshot id excludes newly
|
|
70
|
+
created messages; the cursor also excludes older messages moved into the
|
|
71
|
+
topic later and keeps paging stable when earlier rows leave. It binds a
|
|
72
|
+
clipped continuation to that row's content version, so an edit restarts
|
|
73
|
+
the changed message at character zero rather than applying a stale offset.
|
|
74
|
+
|
|
75
|
+
If one message is too large for the response cap, its row remains at the
|
|
76
|
+
same next_offset and next_content_offset identifies the next character to
|
|
77
|
+
read. Repeat those values with next_cursor and the snapshot id until
|
|
78
|
+
next_content_offset is absent; only then has that message row been
|
|
79
|
+
consumed.
|
|
80
|
+
|
|
81
|
+
Markdown "More:" calls repeat the resolved limit and max_chars as well
|
|
82
|
+
as the cursor, snapshot, order, scope, and content offset, so following
|
|
83
|
+
one keeps the same context budget and paging semantics.
|
|
84
|
+
|
|
85
|
+
Image attachments are appended to message content as explicit markers
|
|
86
|
+
with filename, content type, byte size, and a readable public-asset URL.
|
|
87
|
+
Image-only comments therefore remain visible, and attachment metadata is
|
|
88
|
+
covered by the same max_chars/content_offset continuation as prose.
|
|
89
|
+
|
|
90
|
+
The response is capped at max_chars in total, measured against the format
|
|
91
|
+
you asked for — json is charged for its field names and string escaping,
|
|
92
|
+
so the same call returns fewer messages as json than as markdown. A topic
|
|
93
|
+
that does not fit is returned partially or marked not-fetched rather than
|
|
94
|
+
silently trimmed — check "truncated" and each topic's next_offset. A
|
|
95
|
+
single message wider than the whole cap is clipped rather than dropped,
|
|
96
|
+
given a content continuation and counted in the topic's clipped_count. If
|
|
97
|
+
fixed
|
|
98
|
+
metadata such as a topic name cannot fit, the tool returns a bounded
|
|
99
|
+
error instead of exceeding max_chars or silently omitting a topic.
|
|
100
|
+
|
|
101
|
+
Long topics: call topic_list first to see message_count, then page. To
|
|
102
|
+
summarize a whole large topic, follow next_cursor while keeping
|
|
103
|
+
max_message_id fixed, until has_more is false.
|
|
104
|
+
|
|
105
|
+
Requires read permission on each topic's creative. Private messages you
|
|
106
|
+
cannot see and approval prompts are always excluded; authorless system
|
|
107
|
+
notices are excluded by default (see include_system). Unknown or
|
|
108
|
+
unreadable ids are reported per topic instead of failing the call.
|
|
109
|
+
DESC
|
|
110
|
+
|
|
111
|
+
tool_param :topic_ids, description: "Topic ids to read. Comma-separated for several, e.g. \"12,45,78\" (max #{TopicSelection::MAX_TOPICS} per call — asking for more is an error, not a silent trim). A single id also works."
|
|
112
|
+
tool_param :offset, description: "How many messages back from the newest to start, applied per topic (default: 0).", required: false
|
|
113
|
+
tool_param :cursor, description: "Opaque per-topic snapshot/keyset cursor returned as next_cursor. Pass it on a single-topic follow-up page so later move-ins stay excluded and moved or deleted earlier messages cannot shift and skip unread rows.", required: false
|
|
114
|
+
tool_param :limit, description: "Messages per topic (default: #{Topics::MessagePage::DEFAULT_LIMIT}, max #{Topics::MessagePage::MAX_LIMIT}).", required: false
|
|
115
|
+
tool_param :order, description: "Rendering order within the returned window: 'asc' (default, oldest-to-newest — reads as a transcript) or 'desc' (newest first). Does not change which messages the window selects.", required: false, enum: Topics::MessagePage::ORDERS
|
|
116
|
+
tool_param :max_message_id, description: "Only consider messages with id <= this. Pass the newest_message_id from your first page on every single-topic follow-up page so paging stays on one snapshot of a live topic.", required: false
|
|
117
|
+
tool_param :content_offset, description: "Character offset within the first selected message (default: 0). For a clipped message, make a single-topic follow-up that repeats next_offset and next_cursor, and pass next_content_offset here with the same max_message_id until the whole row has been returned.", required: false
|
|
118
|
+
tool_param :include_system, description: "Include authorless system notices such as concurrency and channel announcements (default: false). Approval prompts are never returned.", required: false
|
|
119
|
+
tool_param :max_chars, description: "Character cap for the whole response across all topics, measured in the format you requested (default: #{DEFAULT_MAX_CHARS}, min #{MIN_MAX_CHARS}, max #{MAX_MAX_CHARS}). Values below the minimum are raised to it.", required: false
|
|
120
|
+
tool_param :format, description: "'markdown' (default, compact transcript) or 'json' (same data, structured; costs noticeably more of max_chars for the same messages, since field names and string escaping are charged too).", required: false, enum: Topics::CharBudget::FORMATS
|
|
121
|
+
|
|
122
|
+
sig do
|
|
123
|
+
params(
|
|
124
|
+
topic_ids: T.any(String, Integer, T::Array[T.untyped]),
|
|
125
|
+
options: T.untyped
|
|
126
|
+
).returns(T.any(String, T::Hash[Symbol, T.untyped]))
|
|
127
|
+
end
|
|
128
|
+
def call(topic_ids:, **options)
|
|
129
|
+
options.assert_valid_keys(
|
|
130
|
+
:offset, :limit, :order, :cursor, :max_message_id, :content_offset, :include_system, :max_chars, :format
|
|
131
|
+
)
|
|
132
|
+
user = Current.user || raise("Current.user is required")
|
|
133
|
+
ids = IdList.parse(topic_ids)
|
|
134
|
+
raise ArgumentError, "topic_ids is required" if ids.empty?
|
|
135
|
+
reject_batch_continuation!(ids, options)
|
|
136
|
+
|
|
137
|
+
# The cap and the format it counts in are resolved together and carried
|
|
138
|
+
# through as one object, because what a message costs is what emitting it
|
|
139
|
+
# in this format costs. Budgeting in markdown's units and then returning
|
|
140
|
+
# json is how a 40k cap emits 60k.
|
|
141
|
+
budget = Topics::CharBudget.new(chars: clamp_chars(options[:max_chars]), format: options[:format])
|
|
142
|
+
|
|
143
|
+
payload = build_payload(
|
|
144
|
+
ids: ids, user: user,
|
|
145
|
+
options: page_options(options, budget)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
response = budget.json? ? payload : Topics::MessagePageMarkdown.call(payload)
|
|
149
|
+
enforce_cap(response, budget)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def reject_batch_continuation!(ids, options)
|
|
155
|
+
return if ids.one?
|
|
156
|
+
return unless options[:cursor].present? || options[:max_message_id].present? ||
|
|
157
|
+
options[:content_offset].to_i.positive?
|
|
158
|
+
|
|
159
|
+
raise ArgumentError,
|
|
160
|
+
"cursor, max_message_id, and content_offset continuation values belong to one topic; " \
|
|
161
|
+
"continue each topic in a separate topic_messages call."
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def page_options(options, budget)
|
|
165
|
+
{
|
|
166
|
+
offset: options.fetch(:offset, 0).to_i,
|
|
167
|
+
cursor: options[:cursor],
|
|
168
|
+
limit: options[:limit],
|
|
169
|
+
order: options[:order],
|
|
170
|
+
max_message_id: options[:max_message_id],
|
|
171
|
+
content_offset: options.fetch(:content_offset, 0).to_i,
|
|
172
|
+
include_system: options.fetch(:include_system, false),
|
|
173
|
+
budget: budget
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def build_payload(ids:, user:, options:)
|
|
178
|
+
topics, errors = TopicSelection.resolve(ids, user: user)
|
|
179
|
+
entries = collect(topics, errors, user, options)
|
|
180
|
+
|
|
181
|
+
{
|
|
182
|
+
topics: entries + errors,
|
|
183
|
+
truncated: entries.any? { |entry| entry[:skipped_reason] || entry[:budget_limited] || entry[:clipped_count] },
|
|
184
|
+
max_chars: options[:budget].chars
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Topics are served in request order and the budget is spent as it goes, so
|
|
189
|
+
# an early huge topic can leave later ones unfetched. That is reported per
|
|
190
|
+
# topic rather than smoothed over: the caller asked for a specific order and
|
|
191
|
+
# needs to know which of its topics it is still missing.
|
|
192
|
+
#
|
|
193
|
+
# What is charged is what is emitted — message envelopes as well as prose.
|
|
194
|
+
# Counting content alone let a few hundred one-word messages print their
|
|
195
|
+
# ids, timestamps and authors for free and sail past the cap.
|
|
196
|
+
#
|
|
197
|
+
# Every requested topic prints a section whether or not it fits, so all the
|
|
198
|
+
# headers are reserved before any topic spends on messages. Reserving them
|
|
199
|
+
# one at a time would charge only the topics that succeed, and the
|
|
200
|
+
# not-fetched notices — which exist precisely because the budget ran out —
|
|
201
|
+
# would push the response over the cap that skipping them was enforcing.
|
|
202
|
+
def collect(topics, errors, user, options)
|
|
203
|
+
budget = options[:budget]
|
|
204
|
+
remaining = budget.chars - reserved_chars(topics, errors, budget.format)
|
|
205
|
+
served = false
|
|
206
|
+
|
|
207
|
+
topics.map do |topic|
|
|
208
|
+
next not_fetched(topic, options[:offset], options[:content_offset], options[:cursor], served) if remaining <= 0
|
|
209
|
+
|
|
210
|
+
entry = page_entry(topic, user, options, remaining)
|
|
211
|
+
remaining -= entry[:billed_chars].to_i
|
|
212
|
+
served = true
|
|
213
|
+
entry
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def reserved_chars(topics, errors, format)
|
|
218
|
+
per_topic = TOPIC_HEADER_CHARS.fetch(format)
|
|
219
|
+
TRUNCATION_CHARS + topics.sum { |topic| per_topic + topic.name.to_s.length } +
|
|
220
|
+
error_chars(errors, format)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def error_chars(errors, format)
|
|
224
|
+
return errors.sum { |error| error.to_json.length + 1 } if format == "json"
|
|
225
|
+
|
|
226
|
+
errors.sum { |error| Topics::MessagePageMarkdown.render_error(error).length + 1 }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def page_entry(topic, user, options, remaining)
|
|
230
|
+
page = Topics::MessagePage.new(
|
|
231
|
+
topic: topic, user: user,
|
|
232
|
+
offset: options[:offset], limit: options[:limit], order: options[:order] || Topics::MessagePage::DEFAULT_ORDER,
|
|
233
|
+
include_system: options[:include_system], max_message_id: options[:max_message_id],
|
|
234
|
+
content_offset: options[:content_offset], cursor: options[:cursor],
|
|
235
|
+
budget: options[:budget].with(chars: remaining)
|
|
236
|
+
).call
|
|
237
|
+
|
|
238
|
+
serialize(page, include_system: options[:include_system], max_chars: options[:budget].chars)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def serialize(page, include_system:, max_chars:)
|
|
242
|
+
{
|
|
243
|
+
topic_id: page.topic.id,
|
|
244
|
+
topic_name: page.topic.name,
|
|
245
|
+
creative_id: page.topic.creative_id,
|
|
246
|
+
total_count: page.total_count,
|
|
247
|
+
total_chars: page.total_chars,
|
|
248
|
+
offset: page.offset,
|
|
249
|
+
limit: page.limit,
|
|
250
|
+
max_chars: max_chars,
|
|
251
|
+
order: page.order,
|
|
252
|
+
returned_count: page.returned_count,
|
|
253
|
+
returned_chars: page.returned_chars,
|
|
254
|
+
# What this topic charged against max_chars: what emitting these
|
|
255
|
+
# messages in the requested format costs, not what their prose measures.
|
|
256
|
+
billed_chars: page.billed_chars,
|
|
257
|
+
has_more: page.has_more?,
|
|
258
|
+
next_offset: page.next_offset,
|
|
259
|
+
next_cursor: page.next_cursor,
|
|
260
|
+
next_content_offset: page.next_content_offset,
|
|
261
|
+
newest_message_id: page.newest_message_id,
|
|
262
|
+
# Echoed so the rendered "More:" line can repeat it. A continuation
|
|
263
|
+
# that drops it pages a narrower set at the same offset and skips
|
|
264
|
+
# whatever the wider first page had already counted.
|
|
265
|
+
include_system: (true if include_system),
|
|
266
|
+
# Only present when the shared max_chars budget, rather than `limit`,
|
|
267
|
+
# is what ended this topic's window — the caller's fix differs: raise
|
|
268
|
+
# max_chars or ask for fewer topics, not just page again.
|
|
269
|
+
budget_limited: page.budget_exhausted.presence,
|
|
270
|
+
# Present only when max_chars cut a message short rather than cutting
|
|
271
|
+
# the window between rows. next_content_offset resumes the same row.
|
|
272
|
+
clipped_count: page.clipped_count.nonzero?,
|
|
273
|
+
skipped_reason: nothing_fits_reason(page),
|
|
274
|
+
messages: page.messages
|
|
275
|
+
}.compact
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def nothing_fits_reason(page)
|
|
279
|
+
NOTHING_FITS_REASON if page.returned_count.zero? && page.budget_exhausted
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Carries has_more/next_offset so the caller can resume this topic without
|
|
283
|
+
# having to work out that "no messages" here means "not read yet" rather
|
|
284
|
+
# than "empty topic".
|
|
285
|
+
#
|
|
286
|
+
# The reason distinguishes the two ways to run out of room, because the
|
|
287
|
+
# fixes differ: drop topics from the call, or raise the cap. Blaming
|
|
288
|
+
# "earlier topics" when this was the first one sends the caller the wrong way.
|
|
289
|
+
def not_fetched(topic, offset, content_offset, cursor, served)
|
|
290
|
+
reason = if served
|
|
291
|
+
"max_chars budget spent on earlier topics"
|
|
292
|
+
else
|
|
293
|
+
NOTHING_FITS_REASON
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
{
|
|
297
|
+
topic_id: topic.id,
|
|
298
|
+
topic_name: topic.name,
|
|
299
|
+
creative_id: topic.creative_id,
|
|
300
|
+
offset: offset,
|
|
301
|
+
next_content_offset: content_offset.presence,
|
|
302
|
+
returned_count: 0,
|
|
303
|
+
returned_chars: 0,
|
|
304
|
+
billed_chars: 0,
|
|
305
|
+
has_more: true,
|
|
306
|
+
next_offset: offset,
|
|
307
|
+
next_cursor: cursor.presence,
|
|
308
|
+
skipped_reason: reason,
|
|
309
|
+
messages: []
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def clamp_chars(max_chars)
|
|
314
|
+
value = max_chars.to_i
|
|
315
|
+
return DEFAULT_MAX_CHARS if value <= 0
|
|
316
|
+
|
|
317
|
+
value.clamp(MIN_MAX_CHARS, MAX_MAX_CHARS)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Message budgeting normally keeps the rendered response below max_chars,
|
|
321
|
+
# but fixed metadata can be wider than its budget by itself: a tiny cap, a
|
|
322
|
+
# batch of skipped topics, or one unrestricted topic name is enough. The
|
|
323
|
+
# final emitted form is the only authoritative measurement, so replace an
|
|
324
|
+
# oversized result with a bounded error instead of silently chopping a
|
|
325
|
+
# topic name or returning malformed JSON/Markdown.
|
|
326
|
+
def enforce_cap(response, budget)
|
|
327
|
+
emitted_chars = budget.json? ? response.to_json.length : response.length
|
|
328
|
+
return response if emitted_chars <= budget.chars
|
|
329
|
+
|
|
330
|
+
bounded_error(budget, required_chars: emitted_chars)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def bounded_error(budget, required_chars:)
|
|
334
|
+
return {
|
|
335
|
+
error: "topic_messages metadata exceeds max_chars",
|
|
336
|
+
required_chars: required_chars,
|
|
337
|
+
max_chars: budget.chars,
|
|
338
|
+
truncated: true
|
|
339
|
+
} if budget.json?
|
|
340
|
+
|
|
341
|
+
"> topic_messages metadata needs #{required_chars} chars; max_chars is #{budget.chars}. " \
|
|
342
|
+
"Request fewer topics or raise max_chars.\n"
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
require "rails_mcp_engine"
|
|
6
|
+
module Tools
|
|
7
|
+
# Moves one complete topic to another creative using the same relocation
|
|
8
|
+
# primitive as the drag-and-drop UI.
|
|
9
|
+
class TopicMoveService
|
|
10
|
+
extend T::Sig
|
|
11
|
+
extend ToolMeta
|
|
12
|
+
|
|
13
|
+
tool_name "topic_move"
|
|
14
|
+
tool_description <<~DESC.strip
|
|
15
|
+
Move a complete topic to another creative while keeping its topic id,
|
|
16
|
+
messages, archive state, and read cursors.
|
|
17
|
+
|
|
18
|
+
The primary-agent pin moves too when that agent can still respond on the
|
|
19
|
+
destination. Otherwise the pin is released and the result explains why,
|
|
20
|
+
so the moved topic remains routable. Creative permissions do not move:
|
|
21
|
+
people who could read the source may lose access at the destination.
|
|
22
|
+
|
|
23
|
+
Requires admin permission on the topic's current creative and write
|
|
24
|
+
permission on the destination. The destination id may be a linked
|
|
25
|
+
creative; the topic moves to its origin. Topic names are unique per
|
|
26
|
+
creative. Main, an inbox's System topic, and live agent session topics
|
|
27
|
+
cannot be moved. Wait for or cancel active agent work before moving its
|
|
28
|
+
topic, and wait for any dropped-message restoration to settle. Cancel
|
|
29
|
+
recurring cron jobs targeting the topic and recreate them after the move.
|
|
30
|
+
A topic referenced by a Drop Trigger loop cannot move.
|
|
31
|
+
DESC
|
|
32
|
+
|
|
33
|
+
tool_param :topic_id, description: "The topic to move."
|
|
34
|
+
tool_param :creative_id, description: "The destination creative. Linked creatives resolve to their origin."
|
|
35
|
+
|
|
36
|
+
sig { params(topic_id: Integer, creative_id: Integer).returns(T::Hash[Symbol, T.untyped]) }
|
|
37
|
+
def call(topic_id:, creative_id:)
|
|
38
|
+
user = Current.user || raise(I18n.t("collavre.tools.topic_move.errors.current_user_required"))
|
|
39
|
+
topic = Topic.find(topic_id)
|
|
40
|
+
authorize_source!(topic, user)
|
|
41
|
+
target = Creative.find(creative_id).effective_origin
|
|
42
|
+
authorize_target!(target, user)
|
|
43
|
+
source = topic.creative.effective_origin
|
|
44
|
+
|
|
45
|
+
released_agent, released_reason = move(topic, target, source, user)
|
|
46
|
+
|
|
47
|
+
Topics::Serializer.for_tool(topic).merge(
|
|
48
|
+
moved_from_creative_id: source.id,
|
|
49
|
+
released_primary_agent: released_agent_payload(released_agent, released_reason, target)
|
|
50
|
+
).compact
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def move(topic, target, source, user)
|
|
56
|
+
effects = Topics::TopicMoveEffects.new(topic, source, target)
|
|
57
|
+
Topics::TopicMove.new(topic: topic, target_creative: target).call(after_commit: effects.method(:call)) do |locked_topic|
|
|
58
|
+
authorize_source!(locked_topic, user)
|
|
59
|
+
authorize_target!(target, user)
|
|
60
|
+
validate_move!(locked_topic, target)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def authorize_source!(topic, user)
|
|
65
|
+
TopicAuthorizer.authorize_admin!(topic, user: user)
|
|
66
|
+
rescue PermissionDeniedError
|
|
67
|
+
raise PermissionDeniedError, I18n.t("collavre.tools.topic_move.errors.source_permission")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def authorize_target!(target, user)
|
|
71
|
+
TopicAuthorizer.authorize_creative!(target, :write, user: user)
|
|
72
|
+
rescue PermissionDeniedError
|
|
73
|
+
raise PermissionDeniedError, I18n.t("collavre.tools.topic_move.errors.target_permission")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_move!(topic, target)
|
|
77
|
+
raise ArgumentError, I18n.t("collavre.tools.topic_move.errors.same_creative") if topic.creative_id == target.id
|
|
78
|
+
raise ArgumentError, I18n.t("collavre.tools.topic_move.errors.reserved_topic") if reserved?(topic, target)
|
|
79
|
+
raise ArgumentError, I18n.t("collavre.tools.topic_move.errors.session_topic") if topic.session_id.present?
|
|
80
|
+
return unless target.topics.where(name: topic.name).exists?
|
|
81
|
+
|
|
82
|
+
raise ArgumentError, I18n.t("collavre.tools.topic_move.errors.duplicate_name", name: topic.name)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def reserved?(topic, target)
|
|
86
|
+
Topics::ReservedName.reserved?(topic.creative, topic.name) ||
|
|
87
|
+
Topics::ReservedName.reserved?(target, topic.name)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def released_agent_payload(agent, reason, target)
|
|
91
|
+
return unless agent
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
id: agent.id,
|
|
95
|
+
name: agent.display_name,
|
|
96
|
+
reason: reason.to_s,
|
|
97
|
+
message: released_agent_message(agent, reason, target)
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def released_agent_message(agent, reason, target)
|
|
102
|
+
key = reason == :session_agent_outside_session_topic ?
|
|
103
|
+
"primary_agent_released_session_agent" : "primary_agent_released"
|
|
104
|
+
I18n.t("collavre.topics.move.#{key}", agent: agent.display_name, creative: target.creative_snippet)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|