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,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module SystemEvents
5
+ # The registry of event names orchestration can dispatch.
6
+ module Vocabulary
7
+ UnknownEvent = Class.new(ArgumentError)
8
+ Definition = Data.define(:name, :required_keys, :sources)
9
+
10
+ EVENTS = {
11
+ "comment_created" => Definition.new(
12
+ name: "comment_created",
13
+ required_keys: %w[comment creative],
14
+ sources: %w[comment_callback cron a2a drop_trigger trigger_restart]
15
+ )
16
+ }.freeze
17
+
18
+ def self.names
19
+ EVENTS.keys
20
+ end
21
+
22
+ def self.known?(name)
23
+ EVENTS.key?(name.to_s)
24
+ end
25
+
26
+ def self.fetch(name)
27
+ EVENTS.fetch(name.to_s) do
28
+ raise UnknownEvent,
29
+ "Unknown system event #{name.inspect}. Known events: #{names.join(', ')}"
30
+ end
31
+ end
32
+
33
+ # Required keys are advisory. The dispatcher warns rather than refusing
34
+ # a malformed payload, preserving the behavior of existing callers.
35
+ def self.missing_keys(name, payload)
36
+ return [] unless known?(name)
37
+
38
+ payload ||= {}
39
+ fetch(name).required_keys.reject do |key|
40
+ !payload[key].nil? || !payload[key.to_sym].nil?
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -177,7 +177,7 @@ module Tools
177
177
  if include_comments
178
178
  result[:recent_comments] = creative.comments.order(created_at: :desc).limit(3).map do |comment|
179
179
  {
180
- content: ActionView::Base.full_sanitizer.sanitize(comment.content).strip.truncate(200),
180
+ content: Collavre::HtmlText.plain(comment.content).strip.truncate(200),
181
181
  user: comment.user&.display_name || comment.user&.name,
182
182
  created_at: comment.created_at&.iso8601
183
183
  }
@@ -33,8 +33,8 @@ module Tools
33
33
  return { error: "No write permission on this Creative", id: creative_id }
34
34
  end
35
35
 
36
- topic_id = resolve_topic_id(creative, topic_name)
37
- return topic_id if topic_id.is_a?(Hash) && topic_id[:error]
36
+ topic = resolve_topic(creative, topic_name)
37
+ return topic if topic.is_a?(Hash) && topic[:error]
38
38
 
39
39
  parsed = Fugit.parse(schedule)
40
40
  unless parsed.is_a?(Fugit::Cron)
@@ -44,20 +44,11 @@ module Tools
44
44
  suffix = SecureRandom.hex(4)
45
45
  key = "cron_#{creative_id}_#{suffix}"
46
46
 
47
- task = SolidQueue::RecurringTask.create!(
48
- key: key,
49
- class_name: "Collavre::CronActionJob",
50
- schedule: schedule,
51
- queue_name: "default",
52
- static: false,
53
- description: description || "Cron job for creative #{creative_id}",
54
- arguments: [ {
55
- creative_id: creative_id,
56
- topic_id: topic_id,
57
- agent_id: Current.user.id,
58
- message: message
59
- } ]
47
+ task = create_task(
48
+ topic: topic, creative: creative, creative_id: creative_id,
49
+ key: key, schedule: schedule, message: message, description: description
60
50
  )
51
+ return task if task.is_a?(Hash) && task[:error]
61
52
 
62
53
  {
63
54
  success: true,
@@ -72,15 +63,38 @@ module Tools
72
63
 
73
64
  private
74
65
 
75
- def resolve_topic_id(creative, topic_name)
66
+ def create_task(topic:, creative:, creative_id:, key:, schedule:, message:, description:)
67
+ topic.with_lock do
68
+ unless topic.creative_id == creative.effective_origin.id
69
+ return { error: I18n.t("collavre.comments.invalid_topic") }
70
+ end
71
+
72
+ SolidQueue::RecurringTask.create!(
73
+ key: key,
74
+ class_name: "Collavre::CronActionJob",
75
+ schedule: schedule,
76
+ queue_name: "default",
77
+ static: false,
78
+ description: description || "Cron job for creative #{creative_id}",
79
+ arguments: [ {
80
+ creative_id: creative_id,
81
+ topic_id: topic.id,
82
+ agent_id: Current.user.id,
83
+ message: message
84
+ } ]
85
+ )
86
+ end
87
+ end
88
+
89
+ def resolve_topic(creative, topic_name)
76
90
  origin = creative.effective_origin
77
91
  if topic_name.casecmp(Creative::MAIN_TOPIC_NAME).zero?
78
- origin.main_topic(fallback_user: Current.user).id
92
+ origin.main_topic(fallback_user: Current.user)
79
93
  else
80
94
  topic = Topic.find_by(name: topic_name, creative_id: origin.id)
81
95
  return { error: "Topic '#{topic_name}' not found for this creative" } unless topic
82
96
 
83
- topic.id
97
+ topic
84
98
  end
85
99
  end
86
100
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Tools
5
+ # Parses the "list of ids" parameters the topic tools take.
6
+ #
7
+ # MCP schemas describe a list, but what actually arrives depends on the
8
+ # calling model: "12,45", ["12", 45], 12 and "12" are all things an agent
9
+ # writes when it means the same thing. Rejecting four fifths of those is a
10
+ # tool that fails for a reason the caller cannot see in its own request, so
11
+ # every shape is accepted here instead of at four separate call sites.
12
+ #
13
+ # Lenient about shape, strict about content. A token that is not entirely
14
+ # digits raises rather than coercing: to_i turns "12.5" into 12 and
15
+ # "123oops" into 123, and topic_create/topic_branch would then move a
16
+ # different message than the one named and report success. A tool that
17
+ # silently acts on a neighbouring id is worse than one that fails.
18
+ module IdList
19
+ module_function
20
+
21
+ DIGITS = /\A\d+\z/
22
+
23
+ def parse(value)
24
+ Array(value)
25
+ .flat_map { |entry| entry.to_s.split(",") }
26
+ .filter_map { |token| token.strip.presence }
27
+ .map { |token| cast(token) }
28
+ .uniq
29
+ end
30
+
31
+ def cast(token)
32
+ raise ArgumentError, "#{token.inspect} is not a valid id — ids must be whole numbers" unless token.match?(DIGITS)
33
+
34
+ token.to_i
35
+ end
36
+ end
37
+ end
38
+ end
@@ -2,22 +2,76 @@
2
2
 
3
3
  module Collavre
4
4
  module Tools
5
- # Topic write authorization for MCP tool services. Mirrors
6
- # CreativePermissionGuard#require_creative_write! but is callable outside a
7
- # controller request. Attaching a channel injects external messages into a
8
- # topic, so it is a write-equivalent mutation — restrict to users with
9
- # write permission on the topic's effective_origin creative.
5
+ # Topic authorization for MCP tool services. Mirrors
6
+ # CreativePermissionGuard's require_creative_{read,write,admin}! but is
7
+ # callable outside a controller request. Every check resolves the topic's
8
+ # effective_origin creative first, because that is where shares live — a
9
+ # linked creative carries none of its own.
10
+ #
11
+ # The owner short-circuit matches CreativePermissionGuard: an owner can hold
12
+ # no explicit share on their own creative, so has_permission? alone would
13
+ # lock them out of their own topics.
10
14
  module TopicAuthorizer
11
15
  module_function
12
16
 
17
+ # Reading a topic's messages exposes every participant's words, so it is
18
+ # gated at :read — the same level the creative's own body needs.
19
+ def authorize_read!(topic, user: Collavre::Current.user)
20
+ authorize!(topic, :read, user: user)
21
+ end
22
+
23
+ # Posting a message is the topic equivalent of commenting through
24
+ # CommentsController, so it uses the same feedback permission floor.
25
+ def authorize_feedback!(topic, user: Collavre::Current.user)
26
+ authorize!(topic, :feedback, user: user)
27
+ end
28
+
29
+ # Attaching a channel injects external messages into a topic, and creating
30
+ # or archiving one restructures where conversation lands, so both are
31
+ # write-equivalent mutations.
13
32
  def authorize_write!(topic, user: Collavre::Current.user)
33
+ authorize!(topic, :write, user: user)
34
+ end
35
+
36
+ # Renaming a topic rewrites a name other members' links and habits point
37
+ # at, so TopicsController gates it at :admin. Tools must not be the softer
38
+ # door onto the same mutation.
39
+ def authorize_admin!(topic, user: Collavre::Current.user)
40
+ authorize!(topic, :admin, user: user)
41
+ end
42
+
43
+ # Topic creation has no topic to authorize against yet, so the creative is
44
+ # the subject. Kept here so every topic-shaped permission decision reads
45
+ # from one place.
46
+ def authorize_creative!(creative, level, user: Collavre::Current.user)
47
+ origin = creative&.effective_origin
48
+ raise ArgumentError, "Creative is required" unless origin
49
+ return if origin.user == user
50
+ return if user && origin.has_permission?(user, level)
51
+
52
+ raise Collavre::Tools::PermissionDeniedError,
53
+ "No #{level} permission on creative #{origin.id}"
54
+ end
55
+
56
+ def authorize!(topic, level, user: Collavre::Current.user)
14
57
  creative = topic.creative&.effective_origin
15
58
  raise ArgumentError, "Topic has no creative" unless creative
16
59
  return if creative.user == user
17
- return if user && creative.has_permission?(user, :write)
60
+ return if user && creative.has_permission?(user, level)
18
61
 
19
62
  raise Collavre::Tools::PermissionDeniedError,
20
- "No write permission on topic #{topic.id}"
63
+ "No #{level} permission on topic #{topic.id}"
64
+ end
65
+
66
+ # True when the user may read the topic. Used by the multi-topic read
67
+ # tools, which report an inaccessible topic as a per-topic error entry
68
+ # rather than failing the whole call — one unreadable id in a batch of
69
+ # three should not discard the two that worked.
70
+ def readable?(topic, user: Collavre::Current.user)
71
+ authorize_read!(topic, user: user)
72
+ true
73
+ rescue Collavre::Tools::PermissionDeniedError, ArgumentError
74
+ false
21
75
  end
22
76
  end
23
77
  end
@@ -0,0 +1,85 @@
1
+ module Collavre
2
+ require "sorbet-runtime"
3
+ require "rails_mcp_engine"
4
+ module Tools
5
+ # Branches selected messages of a topic into a new topic, copying them.
6
+ #
7
+ # The context-length escape hatch. A topic that has run long is expensive for
8
+ # every agent that reads it and hard for a person to re-enter, but the history
9
+ # is not disposable. Branching carries the few messages the next stretch of
10
+ # work actually depends on into a fresh topic and leaves the original intact
11
+ # as the record.
12
+ class TopicBranchService
13
+ extend T::Sig
14
+ extend ToolMeta
15
+
16
+ tool_name "topic_branch"
17
+ tool_description <<~DESC.strip
18
+ Branch a topic: create a new topic containing COPIES of selected messages
19
+ from an existing one. The originals stay where they are.
20
+
21
+ Use this to reset a conversation that has grown too long to carry, or to
22
+ split one thread that has drifted into two subjects, while keeping the
23
+ source topic as the record. To move messages instead of copying them, use
24
+ topic_create with comment_ids.
25
+
26
+ Get the message ids from topic_messages. At most
27
+ #{::Collavre::TopicBranchService::MAX_BRANCH_COMMENTS} messages per branch
28
+ — pick the ones the next stretch of work depends on rather than the whole
29
+ history. Asking for more is an error, not a silent trim.
30
+
31
+ The new topic is created on the same creative and records the source in
32
+ source_topic_id. Requires feedback permission or better on the creative.
33
+ Main and an inbox's System name are reserved and cannot be assigned to
34
+ the branch.
35
+
36
+ Approval prompts cannot be branched — a copy would lose the approval
37
+ action and read as ordinary history. Passing one is an error.
38
+ DESC
39
+
40
+ tool_param :source_topic_id, description: "The topic to branch from."
41
+ tool_param :comment_ids, description: "Comma-separated ids of the messages to copy, e.g. \"991,994,1002\". They must all belong to the source topic and be visible to you. Get ids from topic_messages."
42
+ tool_param :name, description: "Name for the new topic. Defaults to a generated \"branch:<source name>\" name.", required: false
43
+
44
+ sig do
45
+ params(
46
+ source_topic_id: Integer,
47
+ comment_ids: T.any(String, Integer, T::Array[T.untyped]),
48
+ name: T.nilable(String)
49
+ ).returns(T::Hash[Symbol, T.untyped])
50
+ end
51
+ def call(source_topic_id:, comment_ids:, name: nil)
52
+ user = Current.user || raise("Current.user is required")
53
+ source = Topic.find(source_topic_id)
54
+ TopicAuthorizer.authorize_read!(source, user: user)
55
+
56
+ ids = IdList.parse(comment_ids)
57
+ validate_ids!(ids)
58
+
59
+ # Fully qualified: inside Collavre::Tools, a bare TopicBranchService is
60
+ # this tool, not the branching service it wraps.
61
+ topic = ::Collavre::TopicBranchService.new(
62
+ creative: source.creative, user: user, source_topic: source, name: name
63
+ ).call(comment_ids: ids)
64
+
65
+ Topics::Serializer.for_tool(topic).merge(source_topic_id: source.id, copied_count: ids.size)
66
+ end
67
+
68
+ private
69
+
70
+ # TopicBranchService trims an oversized selection to its cap and reports
71
+ # success for the shortened list, which is right for a UI that already
72
+ # capped the selection but wrong here: a caller that pasted 150 ids would be
73
+ # told it branched them and be missing 50 with nothing to read that says so.
74
+ def validate_ids!(ids)
75
+ cap = ::Collavre::TopicBranchService::MAX_BRANCH_COMMENTS
76
+ raise ArgumentError, "comment_ids is required" if ids.empty?
77
+ return if ids.size <= cap
78
+
79
+ raise ArgumentError,
80
+ "#{ids.size} messages requested but a branch copies at most #{cap}. " \
81
+ "Select fewer messages, or branch more than once."
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,117 @@
1
+ module Collavre
2
+ require "sorbet-runtime"
3
+ require "rails_mcp_engine"
4
+ module Tools
5
+ # Creates a topic on a creative, optionally pinning a primary agent and
6
+ # optionally moving existing messages into it.
7
+ #
8
+ # This is the fan-out primitive. Topics are the concurrency unit — tasks in
9
+ # one topic queue behind each other, tasks in different topics run in parallel
10
+ # — so an agent that can only create creatives can split work up on paper but
11
+ # cannot actually run it in parallel. Creating a topic per unit of work, with
12
+ # the agent that owns it pinned, is what turns a plan into concurrent work.
13
+ class TopicCreateService
14
+ extend T::Sig
15
+ extend ToolMeta
16
+
17
+ tool_name "topic_create"
18
+ tool_description <<~DESC.strip
19
+ Create a topic (conversation thread) on a creative.
20
+
21
+ Topics are how work runs in parallel in Collavre: tasks dispatched in the
22
+ same topic are serialized — one holds the topic's slot while the rest
23
+ queue — while tasks in different topics run concurrently. Split work into
24
+ one topic per independently-runnable unit; do not pile parallel work into
25
+ one topic expecting it to overlap.
26
+
27
+ primary_agent pins one agent to the topic. A pin is EXCLUSIVE: the pinned
28
+ agent alone answers ambient messages there and no other agent responds, so
29
+ use it for a dedicated 1:1 working channel and leave it unset for a thread
30
+ several participants should hear. The agent must already have feedback
31
+ permission or better on the creative, otherwise the pin is refused (a
32
+ pinned agent that cannot answer would silence the topic for everyone).
33
+
34
+ Accepts an agent id, email, or exact name for primary_agent. Searchable
35
+ agents, agents created by the caller, and private agents already shared
36
+ on this creative can be resolved. Omit `name` to get the next default
37
+ name ("Topic 3"). Main and an inbox's System name are reserved and
38
+ cannot be assigned to a new topic.
39
+
40
+ comment_ids moves existing messages out of their current topic into the
41
+ new one. To copy messages and leave the originals in place, use
42
+ topic_branch instead.
43
+
44
+ Requires write permission on the creative.
45
+ DESC
46
+
47
+ tool_param :creative_id, description: "The creative to create the topic on."
48
+ tool_param :name, description: "Topic name. Must be unique on the creative. Defaults to the next generated name, e.g. \"Topic 3\".", required: false
49
+ tool_param :primary_agent, description: "Agent to pin to this topic — id, email, or exact name. The pin is exclusive: only this agent answers here. The agent needs feedback permission or better on the creative.", required: false
50
+ tool_param :comment_ids, description: "Comma-separated ids of existing messages to MOVE into the new topic. Use topic_branch to copy instead of move.", required: false
51
+
52
+ sig do
53
+ params(
54
+ creative_id: Integer,
55
+ name: T.nilable(String),
56
+ primary_agent: T.nilable(String),
57
+ comment_ids: T.nilable(T.any(String, Integer, T::Array[T.untyped]))
58
+ ).returns(T::Hash[Symbol, T.untyped])
59
+ end
60
+ def call(creative_id:, name: nil, primary_agent: nil, comment_ids: nil)
61
+ user = Current.user || raise("Current.user is required")
62
+ creative = Creative.find(creative_id).effective_origin
63
+ TopicAuthorizer.authorize_creative!(creative, :write, user: user)
64
+
65
+ agent = resolve_agent(primary_agent, creative)
66
+ topic = build_topic(creative, name, user)
67
+
68
+ Topic.transaction do
69
+ topic.save!
70
+ topic.set_primary_agent!(agent) if agent
71
+ move_comments(creative, user, topic, comment_ids)
72
+ end
73
+
74
+ broadcast(creative, topic, agent, user)
75
+ Topics::Serializer.for_tool(topic)
76
+ end
77
+
78
+ private
79
+
80
+ # Checked before the topic exists, exactly as TopicsController#create does:
81
+ # a topic created here never carries a session_id, so a Claude Channel
82
+ # session agent can never be routed on it and pinning one would produce a
83
+ # dead topic rather than an error.
84
+ def resolve_agent(primary_agent, creative)
85
+ resolved = Topics::AgentResolver.call(primary_agent, creative: creative)
86
+ return nil if resolved.nil? || resolved == Topics::AgentResolver::CLEAR
87
+
88
+ rejection = Topic.primary_agent_rejection(creative, resolved)
89
+ raise ArgumentError, Topics::PinRejection.message(resolved, rejection) if rejection
90
+
91
+ resolved
92
+ end
93
+
94
+ def build_topic(creative, name, user)
95
+ topic_name = name.presence || Topics::NextName.for(creative)
96
+ Topics::ReservedName.reject!(creative, topic_name)
97
+ creative.topics.build(name: topic_name, user: user)
98
+ end
99
+
100
+ def move_comments(creative, user, topic, comment_ids)
101
+ ids = IdList.parse(comment_ids)
102
+ return if ids.empty?
103
+
104
+ CommentMoveService.new(creative: creative, user: user).call(comment_ids: ids, target_topic_id: topic.id)
105
+ end
106
+
107
+ # user_id in the payload is what makes the creating user's own client select
108
+ # the new topic. Included for parity with TopicsController#create: a user
109
+ # asking an agent to open a topic expects to land in it, the same as if they
110
+ # had made it themselves.
111
+ def broadcast(creative, topic, agent, user)
112
+ payload = agent ? Topics::Serializer.with_agent(topic, agent) : topic.slice(:id, :name)
113
+ TopicsChannel.broadcast_to(creative, { action: "created", topic: payload, user_id: user.id })
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,101 @@
1
+ module Collavre
2
+ require "sorbet-runtime"
3
+ require "rails_mcp_engine"
4
+ module Tools
5
+ # Lists a creative's topics, or describes specific topics by id.
6
+ #
7
+ # This is the planning call. A topic is a Collavre conversation's concurrency
8
+ # unit — work in separate topics runs in parallel, work piled into one topic
9
+ # queues — so an agent deciding how to split a job needs to see what topics
10
+ # already exist, who is pinned to them, and how much conversation each one
11
+ # holds, before it reads a single message.
12
+ class TopicListService
13
+ extend T::Sig
14
+ extend ToolMeta
15
+
16
+ tool_name "topic_list"
17
+ tool_description <<~DESC.strip
18
+ List the topics of a creative, or describe specific topics by id.
19
+
20
+ A topic is a conversation thread on a creative, and it is also the unit of
21
+ agent concurrency: tasks in different topics run in parallel, tasks in the
22
+ same topic queue behind each other. Use this before splitting work up, and
23
+ before calling topic_messages — the per-topic message_count and
24
+ message_chars tell you how much conversation you would be pulling in, so
25
+ you can pick an offset/limit that fits your context.
26
+
27
+ Pass creative_id to list a creative's topics, or topic_ids to describe
28
+ known topics (which may live on different creatives). Returns, per topic:
29
+ id, name, creative_id, archived, main/system flags, source_topic_id (set
30
+ when the topic was branched off another), primary_agent, agent_locked, and
31
+ the message totals.
32
+
33
+ message_chars combines stored message HTML length with a conservative
34
+ estimate for image attachment markers. It is useful for relative sizing,
35
+ not exact budgeting.
36
+
37
+ Requires read permission on each topic's creative. Unknown or unreadable
38
+ ids come back in "errors" instead of failing the call.
39
+ DESC
40
+
41
+ tool_param :creative_id, description: "List every topic on this creative. Either this or topic_ids is required.", required: false
42
+ tool_param :topic_ids, description: "Describe these specific topics. Comma-separated ids, e.g. \"12,45,78\" (max #{TopicSelection::MAX_TOPICS} per call — asking for more is an error, not a silent trim). Either this or creative_id is required.", required: false
43
+ tool_param :include_archived, description: "Include archived topics when listing by creative_id (default: false). Archived topics keep their messages and stay readable by id.", required: false
44
+ tool_param :include_stats, description: "Include message_count / message_chars / last_message_at (default: true). Pass false to skip the totals query on a creative with very many topics.", required: false
45
+ tool_param :include_system, description: "Count authorless system messages in the totals (default: false). Approval prompts are never counted. Matches the topic_messages default so the counts describe the same set of messages you would read back.", required: false
46
+
47
+ sig do
48
+ params(
49
+ creative_id: T.nilable(Integer),
50
+ topic_ids: T.nilable(T.any(String, Integer, T::Array[T.untyped])),
51
+ include_archived: T.nilable(T::Boolean),
52
+ include_stats: T.nilable(T::Boolean),
53
+ include_system: T.nilable(T::Boolean)
54
+ ).returns(T::Hash[Symbol, T.untyped])
55
+ end
56
+ def call(creative_id: nil, topic_ids: nil, include_archived: false, include_stats: true, include_system: false)
57
+ user = require_user!
58
+ include_stats = true if include_stats.nil?
59
+
60
+ topics, errors = select_topics(creative_id, topic_ids, user, include_archived)
61
+ stats = include_stats ? Topics::MessageStats.for(topics, user: user, include_system: include_system) : {}
62
+
63
+ { topics: topics.map { |topic| describe(topic, stats[topic.id]) }, errors: errors }
64
+ end
65
+
66
+ private
67
+
68
+ def require_user!
69
+ Current.user || raise("Current.user is required")
70
+ end
71
+
72
+ def select_topics(creative_id, topic_ids, user, include_archived)
73
+ ids = IdList.parse(topic_ids)
74
+ return TopicSelection.resolve(ids, user: user) if ids.any?
75
+ raise ArgumentError, "Either creative_id or topic_ids is required" if creative_id.blank?
76
+
77
+ [ topics_of(creative_id, user, include_archived), [] ]
78
+ end
79
+
80
+ # effective_origin because topics hang off the origin creative, not off a
81
+ # link to it — passing a linked creative's id would otherwise list nothing.
82
+ def topics_of(creative_id, user, include_archived)
83
+ creative = Creative.find(creative_id).effective_origin
84
+ TopicAuthorizer.authorize_creative!(creative, :read, user: user)
85
+
86
+ scope = creative.topics
87
+ scope = scope.active unless include_archived
88
+ scope.includes(:primary_agent).to_a
89
+ end
90
+
91
+ def describe(topic, stat)
92
+ Topics::Serializer.for_tool(
93
+ topic,
94
+ message_count: stat&.count,
95
+ message_chars: stat&.chars,
96
+ last_message_at: stat&.last_at
97
+ )
98
+ end
99
+ end
100
+ end
101
+ end