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.
Files changed (440) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +0 -1
  3. data/app/assets/stylesheets/collavre/comments_popup.css +165 -99
  4. data/app/assets/stylesheets/collavre/creatives.css +346 -37
  5. data/app/assets/stylesheets/collavre/desktop_setup.css +246 -0
  6. data/app/assets/stylesheets/collavre/gnb.css +64 -3
  7. data/app/assets/stylesheets/collavre/landing.css +108 -3
  8. data/app/assets/stylesheets/collavre/mention_menu.css +32 -0
  9. data/app/assets/stylesheets/collavre/popup.css +14 -0
  10. data/app/assets/stylesheets/collavre/print.css +0 -1
  11. data/app/assets/stylesheets/collavre/search_popup.css +1 -1
  12. data/app/assets/stylesheets/collavre/secret_fields.css +3 -0
  13. data/app/assets/stylesheets/collavre/workspace.css +307 -0
  14. data/app/channels/collavre/agent_channel.rb +14 -21
  15. data/app/channels/collavre/comments_presence_channel.rb +181 -21
  16. data/app/channels/collavre/inbox_badge_channel.rb +7 -1
  17. data/app/components/collavre/inbox/badge_component.rb +6 -13
  18. data/app/components/collavre/progress_filter_component.html.erb +2 -2
  19. data/app/components/collavre/progress_filter_component.rb +20 -0
  20. data/app/controllers/collavre/admin/settings_controller.rb +4 -1
  21. data/app/controllers/collavre/agent_connections_controller.rb +165 -0
  22. data/app/controllers/collavre/agent_gateways_controller.rb +132 -0
  23. data/app/controllers/collavre/agent_provisioning_controller.rb +79 -0
  24. data/app/controllers/collavre/api/v1/agents_controller.rb +45 -97
  25. data/app/controllers/collavre/application_controller.rb +13 -2
  26. data/app/controllers/collavre/comment_read_pointers_controller.rb +69 -40
  27. data/app/controllers/collavre/comments_controller.rb +45 -131
  28. data/app/controllers/collavre/concerns/shareable.rb +9 -3
  29. data/app/controllers/collavre/creatives_controller.rb +144 -11
  30. data/app/controllers/collavre/desktop_gateway_registrations_controller.rb +173 -0
  31. data/app/controllers/collavre/desktop_setup_controller.rb +119 -0
  32. data/app/controllers/collavre/desktop_sidecar_health_controller.rb +35 -0
  33. data/app/controllers/collavre/features_controller.rb +57 -0
  34. data/app/controllers/collavre/invites_controller.rb +12 -1
  35. data/app/controllers/collavre/llm_models_controller.rb +8 -0
  36. data/app/controllers/collavre/notices_controller.rb +20 -0
  37. data/app/controllers/collavre/tasks_controller.rb +40 -6
  38. data/app/controllers/collavre/topics_controller.rb +130 -53
  39. data/app/controllers/collavre/user_creative_preferences_controller.rb +214 -30
  40. data/app/controllers/concerns/collavre/comments/batch_operations.rb +15 -3
  41. data/app/controllers/concerns/collavre/comments/conversion.rb +1 -1
  42. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +118 -8
  43. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +1 -15
  44. data/app/controllers/concerns/collavre/users_controller/registration.rb +2 -2
  45. data/app/errors/collavre/turn_deadline_error.rb +20 -0
  46. data/app/helpers/collavre/application_helper.rb +38 -0
  47. data/app/helpers/collavre/creatives_helper.rb +56 -22
  48. data/app/helpers/collavre/navigation_helper.rb +12 -0
  49. data/app/javascript/collavre.js +1 -2
  50. data/app/javascript/components/InlineLexicalEditor.jsx +30 -21
  51. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +32 -2
  52. data/app/javascript/components/__tests__/creative_tree_row_progress_toggle.test.js +185 -0
  53. data/app/javascript/components/creative_tree_row.js +62 -22
  54. data/app/javascript/controllers/__tests__/agent_connection_controller.test.js +195 -0
  55. data/app/javascript/controllers/__tests__/desktop_proxy_setup_controller.test.js +96 -0
  56. data/app/javascript/controllers/__tests__/index.test.js +42 -0
  57. data/app/javascript/controllers/__tests__/last_visited_creative_controller.test.js +103 -0
  58. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +253 -2
  59. data/app/javascript/controllers/__tests__/progress_filter_controller.test.js +134 -0
  60. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +310 -0
  61. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +111 -0
  62. data/app/javascript/controllers/__tests__/workspace_tree_controller.test.js +1011 -0
  63. data/app/javascript/controllers/agent_connection_controller.js +357 -0
  64. data/app/javascript/controllers/agent_vendor_controller.js +23 -0
  65. data/app/javascript/controllers/comment_controller.js +3 -1
  66. data/app/javascript/controllers/comments/__tests__/feature_cards_controller.test.js +223 -0
  67. data/app/javascript/controllers/comments/__tests__/form_controller_default_placeholder.test.js +83 -0
  68. data/app/javascript/controllers/comments/__tests__/form_controller_draft.test.js +2069 -0
  69. data/app/javascript/controllers/comments/__tests__/form_controller_ime_send.test.js +324 -0
  70. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +28 -2
  71. data/app/javascript/controllers/comments/__tests__/form_controller_search_submit.test.js +106 -0
  72. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft.test.js +134 -0
  73. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_creative_switch.test.js +166 -0
  74. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_failure.test.js +183 -0
  75. data/app/javascript/controllers/comments/__tests__/list_controller_deep_link_topic.test.js +467 -0
  76. data/app/javascript/controllers/comments/__tests__/list_controller_docked.test.js +115 -0
  77. data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +245 -0
  78. data/app/javascript/controllers/comments/__tests__/list_controller_read_pointer.test.js +276 -0
  79. data/app/javascript/controllers/comments/__tests__/list_controller_stream_archived.test.js +221 -0
  80. data/app/javascript/controllers/comments/__tests__/list_controller_stream_search.test.js +104 -0
  81. data/app/javascript/controllers/comments/__tests__/placeholder_controller.test.js +79 -0
  82. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +754 -1
  83. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +717 -15
  84. data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
  85. data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +184 -0
  86. data/app/javascript/controllers/comments/__tests__/topics_controller_archived.test.js +879 -0
  87. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
  88. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_scroll.test.js +92 -0
  89. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +130 -0
  90. data/app/javascript/controllers/comments/__tests__/topics_controller_docked.test.js +32 -0
  91. data/app/javascript/controllers/comments/__tests__/topics_controller_last_topic_echo.test.js +1938 -0
  92. data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +256 -0
  93. data/app/javascript/controllers/comments/__tests__/topics_controller_scroll.test.js +182 -0
  94. data/app/javascript/controllers/comments/__tests__/topics_controller_selection_race.test.js +981 -0
  95. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +313 -0
  96. data/app/javascript/controllers/comments/feature_cards_controller.js +83 -0
  97. data/app/javascript/controllers/comments/form_controller.js +795 -14
  98. data/app/javascript/controllers/comments/list_controller.js +338 -52
  99. data/app/javascript/controllers/comments/placeholder_controller.js +30 -0
  100. data/app/javascript/controllers/comments/popup_controller.js +425 -83
  101. data/app/javascript/controllers/comments/presence_controller.js +317 -55
  102. data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
  103. data/app/javascript/controllers/comments/topics_controller.js +1772 -100
  104. data/app/javascript/controllers/common_popup_controller.js +2 -2
  105. data/app/javascript/controllers/creatives/__tests__/select_mode_controller.test.js +139 -0
  106. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +605 -0
  107. data/app/javascript/controllers/creatives/__tests__/write_access_request_controller.test.js +125 -0
  108. data/app/javascript/controllers/creatives/select_mode_controller.js +7 -2
  109. data/app/javascript/controllers/creatives/tree_controller.js +140 -31
  110. data/app/javascript/controllers/creatives/write_access_request_controller.js +58 -0
  111. data/app/javascript/controllers/desktop_proxy_setup_controller.js +51 -0
  112. data/app/javascript/controllers/gateway_check_controller.js +36 -0
  113. data/app/javascript/controllers/index.js +25 -1
  114. data/app/javascript/controllers/last_visited_creative_controller.js +58 -0
  115. data/app/javascript/controllers/link_creative_controller.js +89 -18
  116. data/app/javascript/controllers/progress_filter_controller.js +12 -22
  117. data/app/javascript/controllers/search_popup_controller.js +64 -46
  118. data/app/javascript/controllers/share_user_search_controller.js +2 -2
  119. data/app/javascript/controllers/topic_list_controller.js +44 -7
  120. data/app/javascript/controllers/workspace_tree_controller.js +662 -0
  121. data/app/javascript/creatives/__tests__/tree_renderer.test.js +80 -0
  122. data/app/javascript/creatives/drag_drop/__tests__/drop_completion_empty_state.test.js +98 -0
  123. data/app/javascript/creatives/drag_drop/__tests__/topic_move_released_agent.test.js +125 -0
  124. data/app/javascript/creatives/drag_drop/event_handlers.js +12 -0
  125. data/app/javascript/creatives/tree_renderer.js +65 -22
  126. data/app/javascript/lib/__tests__/chat_drafts.test.js +1458 -0
  127. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +8 -3
  128. data/app/javascript/lib/__tests__/common_popup_placement.test.js +374 -0
  129. data/app/javascript/lib/__tests__/common_popup_reshow.test.js +131 -0
  130. data/app/javascript/lib/__tests__/creative_link_prefetch.test.js +46 -0
  131. data/app/javascript/lib/__tests__/last_visited_creative.test.js +87 -0
  132. data/app/javascript/lib/__tests__/skill_html_entities.test.js +23 -0
  133. data/app/javascript/lib/__tests__/turbo_stream_actions.test.js +190 -0
  134. data/app/javascript/lib/api/__tests__/creatives.test.js +68 -0
  135. data/app/javascript/lib/api/__tests__/csrf_fetch.test.js +27 -0
  136. data/app/javascript/lib/api/__tests__/topics_save_last_topic.test.js +112 -0
  137. data/app/javascript/lib/api/creatives.js +32 -13
  138. data/app/javascript/lib/api/csrf_fetch.js +6 -4
  139. data/app/javascript/lib/api/topics.js +63 -5
  140. data/app/javascript/lib/chat_drafts.js +786 -0
  141. data/app/javascript/lib/common_popup.js +164 -25
  142. data/app/javascript/lib/creative_link_prefetch.js +27 -0
  143. data/app/javascript/lib/last_visited_creative.js +97 -0
  144. data/app/javascript/lib/lexical/__tests__/creative_link_node.test.js +162 -0
  145. data/app/javascript/lib/lexical/__tests__/creative_link_trigger.test.js +133 -0
  146. data/app/javascript/lib/lexical/__tests__/link_toolbar.test.js +62 -0
  147. data/app/javascript/lib/lexical/creative_link_node.js +101 -0
  148. data/app/javascript/lib/lexical/creative_link_trigger.js +98 -0
  149. data/app/javascript/lib/lexical/link_toolbar.js +23 -0
  150. data/app/javascript/lib/turbo_stream_actions.js +23 -16
  151. data/app/javascript/lib/utils/__tests__/filter_navigation.test.js +210 -0
  152. data/app/javascript/lib/utils/__tests__/markdown.test.js +44 -0
  153. data/app/javascript/lib/utils/filter_navigation.js +100 -0
  154. data/app/javascript/modules/__tests__/command_menu_creative_picker.test.js +62 -0
  155. data/app/javascript/modules/__tests__/command_menu_typeahead.test.js +267 -0
  156. data/app/javascript/modules/__tests__/creative_link_picker.test.js +85 -0
  157. data/app/javascript/modules/__tests__/creative_row_editor_archive.test.js +391 -0
  158. data/app/javascript/modules/__tests__/creative_row_editor_delegated_clicks.test.js +158 -0
  159. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_recovery.test.js +519 -0
  160. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_removal.test.js +346 -0
  161. data/app/javascript/modules/__tests__/creative_row_editor_session.test.js +144 -0
  162. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +30 -0
  163. data/app/javascript/modules/__tests__/creative_tree_empty_state.test.js +186 -0
  164. data/app/javascript/modules/__tests__/dom_listener_registry.test.js +67 -0
  165. data/app/javascript/modules/__tests__/support/inline_editor_dom.js +159 -0
  166. data/app/javascript/modules/command_args_form.js +0 -12
  167. data/app/javascript/modules/command_menu.js +80 -38
  168. data/app/javascript/modules/creative_link_picker.js +56 -0
  169. data/app/javascript/modules/creative_row_editor.js +446 -243
  170. data/app/javascript/modules/creative_row_editor_delegated_clicks.js +87 -0
  171. data/app/javascript/modules/creative_tree_dom.js +4 -0
  172. data/app/javascript/modules/creative_tree_empty_state.js +80 -0
  173. data/app/javascript/modules/dom_listener_registry.js +23 -0
  174. data/app/javascript/modules/mention_menu.js +2 -3
  175. data/app/javascript/utils/__tests__/caret_anchor.test.js +75 -0
  176. data/app/javascript/utils/__tests__/chevron_icons.test.js +15 -0
  177. data/app/javascript/utils/caret_position.js +10 -0
  178. data/app/javascript/utils/chevron_icons.js +7 -0
  179. data/app/jobs/collavre/ai_agent_job.rb +262 -72
  180. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +11 -36
  181. data/app/jobs/collavre/comment_badges_broadcast_job.rb +28 -0
  182. data/app/jobs/collavre/comment_link_preview_job.rb +33 -0
  183. data/app/jobs/collavre/comment_notification_job.rb +14 -0
  184. data/app/jobs/collavre/comment_push_delivery_sweep_job.rb +17 -0
  185. data/app/jobs/collavre/compress_job.rb +33 -22
  186. data/app/jobs/collavre/creative_broadcast_job.rb +161 -48
  187. data/app/jobs/collavre/cron_action_job.rb +36 -18
  188. data/app/jobs/collavre/drop_trigger_job.rb +23 -9
  189. data/app/jobs/collavre/merge_comments_job.rb +33 -16
  190. data/app/jobs/collavre/push_notification_job.rb +62 -18
  191. data/app/jobs/collavre/restore_dropped_dispatches_job.rb +17 -0
  192. data/app/jobs/collavre/stuck_detector_job.rb +1 -1
  193. data/app/jobs/collavre/touch_creative_subtree_job.rb +14 -0
  194. data/app/models/collavre/agent_gateway.rb +200 -0
  195. data/app/models/collavre/agent_subscription.rb +4 -4
  196. data/app/models/collavre/agent_workspace.rb +210 -0
  197. data/app/models/collavre/comment/broadcastable.rb +22 -52
  198. data/app/models/collavre/comment/notifiable.rb +154 -77
  199. data/app/models/collavre/comment/topic_membership.rb +41 -0
  200. data/app/models/collavre/comment.rb +336 -31
  201. data/app/models/collavre/comment_notification_delivery.rb +61 -0
  202. data/app/models/collavre/comment_presence_store/cache_batches.rb +64 -0
  203. data/app/models/collavre/comment_presence_store.rb +272 -12
  204. data/app/models/collavre/comment_read_pointer.rb +9 -15
  205. data/app/models/collavre/creative/describable.rb +8 -2
  206. data/app/models/collavre/creative/permissible.rb +11 -0
  207. data/app/models/collavre/creative.rb +29 -4
  208. data/app/models/collavre/creative_share.rb +109 -4
  209. data/app/models/collavre/current.rb +1 -0
  210. data/app/models/collavre/llm_model.rb +62 -0
  211. data/app/models/collavre/system_setting.rb +12 -1
  212. data/app/models/collavre/task.rb +121 -2
  213. data/app/models/collavre/topic.rb +51 -0
  214. data/app/models/collavre/user.rb +101 -41
  215. data/app/models/collavre/user_creative_preference.rb +3 -1
  216. data/app/services/collavre/agent_provisioning/archive.rb +108 -0
  217. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +16 -3
  218. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +63 -6
  219. data/app/services/collavre/ai_agent/approval_handler.rb +12 -4
  220. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +10 -5
  221. data/app/services/collavre/ai_agent/merged_trigger_comments.rb +164 -0
  222. data/app/services/collavre/ai_agent/message_builder.rb +103 -16
  223. data/app/services/collavre/ai_agent/session_provisioner.rb +3 -7
  224. data/app/services/collavre/ai_agent/task_claim_service.rb +29 -15
  225. data/app/services/collavre/ai_agent/task_reply_service.rb +67 -0
  226. data/app/services/collavre/ai_agent_service.rb +108 -20
  227. data/app/services/collavre/ai_client.rb +211 -21
  228. data/app/services/collavre/cli_proxy/client.rb +147 -0
  229. data/app/services/collavre/cli_proxy/endpoint_policy.rb +81 -0
  230. data/app/services/collavre/cli_proxy/identity.rb +47 -0
  231. data/app/services/collavre/cli_proxy/safe_net_http_adapter.rb +19 -0
  232. data/app/services/collavre/command_menu_service.rb +0 -10
  233. data/app/services/collavre/comment_move_service.rb +96 -7
  234. data/app/services/collavre/comment_snapshot_restore_service.rb +11 -5
  235. data/app/services/collavre/comments/action_executor.rb +48 -20
  236. data/app/services/collavre/comments/command_processor.rb +0 -1
  237. data/app/services/collavre/comments/list_response.rb +133 -0
  238. data/app/services/collavre/comments/read_pointer_writer.rb +210 -0
  239. data/app/services/collavre/comments/read_receipt_broadcaster.rb +157 -0
  240. data/app/services/collavre/comments/read_receipt_index.rb +152 -0
  241. data/app/services/collavre/comments/topic_command.rb +72 -1
  242. data/app/services/collavre/comments/topic_mutation.rb +20 -0
  243. data/app/services/collavre/creatives/breadcrumb_resolver.rb +1 -1
  244. data/app/services/collavre/creatives/comment_badge_index.rb +299 -27
  245. data/app/services/collavre/creatives/reorderer.rb +15 -3
  246. data/app/services/collavre/creatives/tree_builder.rb +1 -1
  247. data/app/services/collavre/creatives/tree_formatter.rb +5 -6
  248. data/app/services/collavre/creatives/workspace_path_resolver.rb +50 -0
  249. data/app/services/collavre/creatives/workspace_tree_builder.rb +92 -0
  250. data/app/services/collavre/crons/recurring_topic_tasks.rb +23 -0
  251. data/app/services/collavre/home_page_path.rb +55 -0
  252. data/app/services/collavre/html_text.rb +64 -0
  253. data/app/services/collavre/http_client.rb +11 -2
  254. data/app/services/collavre/inbox_reply_service.rb +1 -1
  255. data/app/services/collavre/markdown_converter.rb +13 -1
  256. data/app/services/collavre/orchestration/agent_orchestrator.rb +776 -81
  257. data/app/services/collavre/orchestration/arbiter.rb +48 -5
  258. data/app/services/collavre/orchestration/deferred_trigger_scope.rb +53 -0
  259. data/app/services/collavre/orchestration/delivery_record.rb +792 -0
  260. data/app/services/collavre/orchestration/loop_breaker.rb +10 -4
  261. data/app/services/collavre/orchestration/matcher.rb +132 -17
  262. data/app/services/collavre/orchestration/policy_resolver.rb +58 -10
  263. data/app/services/collavre/orchestration/scheduler.rb +10 -4
  264. data/app/services/collavre/orchestration/scheduling_hooks.rb +10 -0
  265. data/app/services/collavre/orchestration/selection.rb +43 -0
  266. data/app/services/collavre/orchestration/stuck_detector.rb +25 -112
  267. data/app/services/collavre/orchestration/task_coalescer.rb +355 -0
  268. data/app/services/collavre/orchestration/topic_slot.rb +73 -0
  269. data/app/services/collavre/system_events/context_builder.rb +60 -11
  270. data/app/services/collavre/system_events/dispatcher.rb +58 -12
  271. data/app/services/collavre/system_events/envelope.rb +73 -0
  272. data/app/services/collavre/system_events/vocabulary.rb +45 -0
  273. data/app/services/collavre/tools/creative_retrieval_service.rb +1 -1
  274. data/app/services/collavre/tools/cron_create_service.rb +32 -18
  275. data/app/services/collavre/tools/id_list.rb +38 -0
  276. data/app/services/collavre/tools/topic_authorizer.rb +61 -7
  277. data/app/services/collavre/tools/topic_branch_service.rb +85 -0
  278. data/app/services/collavre/tools/topic_create_service.rb +117 -0
  279. data/app/services/collavre/tools/topic_list_service.rb +101 -0
  280. data/app/services/collavre/tools/topic_message_create_service.rb +192 -0
  281. data/app/services/collavre/tools/topic_messages_service.rb +346 -0
  282. data/app/services/collavre/tools/topic_move_service.rb +108 -0
  283. data/app/services/collavre/tools/topic_selection.rb +59 -0
  284. data/app/services/collavre/tools/topic_update_service.rb +165 -0
  285. data/app/services/collavre/topic_branch_service.rb +39 -10
  286. data/app/services/collavre/topics/agent_resolver.rb +87 -0
  287. data/app/services/collavre/topics/char_budget.rb +70 -0
  288. data/app/services/collavre/topics/last_topic_preference_payload.rb +18 -0
  289. data/app/services/collavre/topics/message_page.rb +380 -0
  290. data/app/services/collavre/topics/message_page_markdown.rb +102 -0
  291. data/app/services/collavre/topics/message_scope.rb +61 -0
  292. data/app/services/collavre/topics/message_stats.rb +92 -0
  293. data/app/services/collavre/topics/move_blocker.rb +47 -0
  294. data/app/services/collavre/topics/next_name.rb +35 -0
  295. data/app/services/collavre/topics/pin_rejection.rb +25 -0
  296. data/app/services/collavre/topics/read_pointer_relocator.rb +71 -0
  297. data/app/services/collavre/topics/reserved_name.rb +18 -0
  298. data/app/services/collavre/topics/serializer.rb +92 -0
  299. data/app/services/collavre/topics/topic_destroy.rb +30 -0
  300. data/app/services/collavre/topics/topic_move.rb +80 -0
  301. data/app/services/collavre/topics/topic_move_effects.rb +35 -0
  302. data/app/services/collavre/topics/topic_mutation.rb +27 -0
  303. data/app/views/collavre/admin/integrations/_setting_row.html.erb +5 -8
  304. data/app/views/collavre/admin/settings/_system_tab.html.erb +7 -0
  305. data/app/views/collavre/agent_connections/show.html.erb +77 -0
  306. data/app/views/collavre/agent_gateways/_form.html.erb +71 -0
  307. data/app/views/collavre/agent_gateways/edit.html.erb +2 -0
  308. data/app/views/collavre/agent_gateways/index.html.erb +54 -0
  309. data/app/views/collavre/agent_gateways/new.html.erb +2 -0
  310. data/app/views/collavre/comments/_comments_popup.html.erb +32 -17
  311. data/app/views/collavre/comments/_empty_state.html.erb +33 -0
  312. data/app/views/collavre/comments/_empty_state_minimal.html.erb +7 -0
  313. data/app/views/collavre/comments/_feature_card.html.erb +35 -0
  314. data/app/views/collavre/comments/_list.html.erb +3 -2
  315. data/app/views/collavre/comments/_no_search_results.html.erb +3 -0
  316. data/app/views/collavre/creatives/_add_button.html.erb +17 -19
  317. data/app/views/collavre/creatives/_empty_state.html.erb +109 -0
  318. data/app/views/collavre/creatives/_inline_edit_form.html.erb +5 -5
  319. data/app/views/collavre/creatives/_tree_loading_placeholder.html.erb +16 -0
  320. data/app/views/collavre/creatives/_workspace_tree.html.erb +33 -0
  321. data/app/views/collavre/creatives/index.html.erb +79 -18
  322. data/app/views/collavre/desktop_setup/show.html.erb +114 -0
  323. data/app/views/collavre/features/index.html.erb +42 -0
  324. data/app/views/collavre/features/show.html.erb +52 -0
  325. data/app/views/collavre/invitation_mailer/invite.html.erb +1 -1
  326. data/app/views/collavre/invitation_mailer/invite.text.erb +1 -1
  327. data/app/views/collavre/invites/show.html.erb +14 -1
  328. data/app/views/collavre/landing/show.html.erb +5 -1
  329. data/app/views/collavre/shared/_link_creative_modal.html.erb +4 -1
  330. data/app/views/collavre/shared/navigation/_help_button.html.erb +3 -1
  331. data/app/views/collavre/shared/navigation/_panels.html.erb +1 -8
  332. data/app/views/collavre/shared/navigation/_search_form.html.erb +13 -2
  333. data/app/views/collavre/user_themes/index.html.erb +1 -1
  334. data/app/views/collavre/users/_llm_fields.html.erb +65 -0
  335. data/app/views/collavre/users/_org_chart_node.html.erb +1 -1
  336. data/app/views/collavre/users/edit_ai.html.erb +33 -31
  337. data/app/views/collavre/users/new.html.erb +4 -2
  338. data/app/views/collavre/users/new_ai.html.erb +14 -32
  339. data/app/views/collavre/users/show.html.erb +13 -1
  340. data/app/views/layouts/collavre/landing.html.erb +9 -4
  341. data/config/initializers/ruby_llm_openai_reasoning_content.rb +77 -0
  342. data/config/locales/admin.en.yml +3 -1
  343. data/config/locales/admin.ko.yml +3 -1
  344. data/config/locales/agent_gateway.en.yml +217 -0
  345. data/config/locales/agent_gateway.ko.yml +217 -0
  346. data/config/locales/ai_agent.en.yml +0 -1
  347. data/config/locales/ai_agent.ko.yml +0 -1
  348. data/config/locales/comments.en.yml +72 -30
  349. data/config/locales/comments.ko.yml +72 -30
  350. data/config/locales/creatives.en.yml +32 -1
  351. data/config/locales/creatives.ko.yml +32 -1
  352. data/config/locales/features.en.yml +140 -0
  353. data/config/locales/features.ko.yml +140 -0
  354. data/config/locales/invites.en.yml +3 -0
  355. data/config/locales/invites.ko.yml +3 -0
  356. data/config/locales/landing.en.yml +2 -1
  357. data/config/locales/landing.ko.yml +2 -1
  358. data/config/locales/tools.en.yml +20 -0
  359. data/config/locales/tools.ko.yml +20 -0
  360. data/config/locales/users.en.yml +15 -14
  361. data/config/locales/users.ko.yml +14 -14
  362. data/config/routes.rb +45 -19
  363. data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +9 -1
  364. data/db/migrate/20260724000000_add_creative_id_index_to_comments.rb +34 -0
  365. data/db/migrate/20260724000001_add_creative_private_id_index_to_comments.rb +50 -0
  366. data/db/migrate/20260724090000_add_async_comment_notification_state.rb +64 -0
  367. data/db/migrate/20260727000000_add_waiting_notice_scope_to_comments.rb +30 -0
  368. data/db/migrate/20260727010000_add_waiting_notice_scope_to_tasks.rb +26 -0
  369. data/db/migrate/20260805000000_add_creative_workspace_enabled_to_users.rb +5 -0
  370. data/db/migrate/20260807050000_add_dismissed_notices_to_users.rb +5 -0
  371. data/db/migrate/20260809000000_change_creative_workspace_enabled_default_to_true.rb +18 -0
  372. data/db/migrate/20260809000001_create_llm_models.rb +64 -0
  373. data/db/migrate/20260809000002_create_agent_gateways_and_workspaces.rb +35 -0
  374. data/db/migrate/20260809000003_hash_agent_workspace_callback_tokens.rb +44 -0
  375. data/db/migrate/20260809000004_encrypt_agent_workspace_manifest_tokens.rb +59 -0
  376. data/db/migrate/20260810000000_split_agent_workspace_identity_axes.rb +58 -0
  377. data/db/migrate/20260810000001_seed_codex_custom_proxy_llm_models.rb +28 -0
  378. data/db/migrate/20260810010000_add_last_visited_creative_to_users.rb +8 -0
  379. data/db/migrate/20260810020000_add_last_visited_creative_at_to_users.rb +5 -0
  380. data/db/migrate/20260810030000_add_last_visited_creative_navigation_order_to_users.rb +6 -0
  381. data/db/migrate/20260810060000_add_last_visited_creative_issued_sequence_to_users.rb +5 -0
  382. data/db/migrate/20260810100000_add_desktop_managed_to_agent_gateways.rb +40 -0
  383. data/db/migrate/20260810100001_add_desktop_preset_adapter_to_users.rb +38 -0
  384. data/db/migrate/20260811000000_make_agent_gateway_completion_key_nullable.rb +7 -0
  385. data/db/migrate/20260812000000_remove_workflow_columns_from_tasks.rb +101 -0
  386. data/db/migrate/20260812000001_remove_android_devices.rb +24 -0
  387. data/db/migrate/20260812010000_drop_inbox_items.rb +22 -0
  388. data/db/migrate/20260813000000_clear_claude_channel_presence_routing_expressions.rb +16 -0
  389. data/db/migrate/20260813000001_add_last_topic_revision_to_user_creative_preferences.rb +5 -0
  390. data/db/migrate/20260813000002_add_last_topic_save_order_to_user_creative_preferences.rb +6 -0
  391. data/db/migrate/20260813000003_add_last_topic_save_sequences_to_user_creative_preferences.rb +5 -0
  392. data/db/migrate/20260813000004_add_last_topic_save_fences_to_user_creative_preferences.rb +6 -0
  393. data/db/migrate/20260814000000_add_topic_to_comment_read_pointers.rb +79 -0
  394. data/db/migrate/20260814000001_add_creative_topic_private_id_index_to_comments.rb +37 -0
  395. data/db/migrate/20260816000000_add_watermark_index_to_comment_read_pointers.rb +27 -0
  396. data/db/migrate/20260816000001_add_private_recipient_topic_index_to_comments.rb +48 -0
  397. data/db/migrate/20260820000000_add_topic_assigned_at_to_comments.rb +13 -0
  398. data/db/seeds.rb +1 -51
  399. data/lib/collavre/engine.rb +108 -1
  400. data/lib/collavre/feature_card_registry.rb +168 -0
  401. data/lib/collavre/hashed_access_token_lookup.rb +22 -0
  402. data/lib/collavre/sensitive_request_silencer.rb +23 -0
  403. data/lib/collavre/user_extensions.rb +0 -2
  404. data/lib/collavre/version.rb +1 -1
  405. data/lib/collavre.rb +1 -0
  406. data/skills/collavre/SKILL.md +165 -0
  407. data/skills/collavre/package.json +8 -0
  408. data/skills/collavre/references/tool-reference.md +347 -0
  409. data/skills/collavre/scripts/collavre +634 -0
  410. data/skills/collavre/scripts/html_entities.js +24 -0
  411. metadata +228 -30
  412. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +0 -224
  413. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +0 -95
  414. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +0 -31
  415. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +0 -25
  416. data/app/controllers/collavre/inbox_items_controller.rb +0 -70
  417. data/app/controllers/collavre/typo_corrections_controller.rb +0 -39
  418. data/app/javascript/lib/__tests__/typo_correction.test.js +0 -192
  419. data/app/javascript/lib/typo_correction.js +0 -146
  420. data/app/javascript/modules/__tests__/typo_corrector.test.js +0 -365
  421. data/app/javascript/modules/creative_guide.js +0 -67
  422. data/app/javascript/modules/typo_corrector.js +0 -534
  423. data/app/jobs/collavre/inbox_summary_job.rb +0 -24
  424. data/app/mailers/collavre/inbox_mailer.rb +0 -19
  425. data/app/models/collavre/inbox_item.rb +0 -89
  426. data/app/services/collavre/comments/concerns/workflow_support.rb +0 -115
  427. data/app/services/collavre/comments/work_command.rb +0 -161
  428. data/app/services/collavre/comments/workflow_executor.rb +0 -276
  429. data/app/services/collavre/gemini_parent_recommender.rb +0 -77
  430. data/app/services/collavre/mobile/event_summarizer.rb +0 -40
  431. data/app/services/collavre/typo_corrector.rb +0 -188
  432. data/app/views/collavre/inbox_items/_item.html.erb +0 -18
  433. data/app/views/collavre/inbox_items/_items.html.erb +0 -3
  434. data/app/views/collavre/inbox_items/_list.html.erb +0 -7
  435. data/app/views/collavre/inbox_items/index.html.erb +0 -1
  436. data/app/views/collavre/inbox_mailer/daily_summary.html.erb +0 -11
  437. data/app/views/collavre/inbox_mailer/daily_summary.text.erb +0 -8
  438. data/app/views/collavre/users/typo_correction.html.erb +0 -50
  439. data/config/locales/mobile.en.yml +0 -16
  440. 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