collavre 0.15.0 → 0.20.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/code_highlight.css +13 -0
  3. data/app/assets/stylesheets/collavre/comments_popup.css +65 -5
  4. data/app/assets/stylesheets/collavre/creatives.css +5 -2
  5. data/app/controllers/collavre/admin/settings_controller.rb +9 -0
  6. data/app/controllers/collavre/attachments_controller.rb +1 -1
  7. data/app/controllers/collavre/comment_read_pointers_controller.rb +2 -2
  8. data/app/controllers/collavre/comments/snapshots_controller.rb +2 -7
  9. data/app/controllers/collavre/comments_controller.rb +23 -25
  10. data/app/controllers/collavre/creatives_controller.rb +22 -11
  11. data/app/controllers/collavre/emails_controller.rb +2 -0
  12. data/app/controllers/collavre/google_auth_controller.rb +1 -1
  13. data/app/controllers/collavre/inbox_items_controller.rb +1 -1
  14. data/app/controllers/collavre/invites_controller.rb +3 -0
  15. data/app/controllers/collavre/tasks_controller.rb +1 -1
  16. data/app/controllers/collavre/topics_controller.rb +27 -85
  17. data/app/controllers/concerns/collavre/comments/comment_scoping.rb +3 -0
  18. data/app/controllers/concerns/collavre/creative_permission_guard.rb +32 -0
  19. data/app/controllers/concerns/collavre/integration_setup.rb +17 -0
  20. data/app/helpers/collavre/application_helper.rb +30 -2
  21. data/app/javascript/components/InlineLexicalEditor.jsx +7 -3
  22. data/app/javascript/components/creative_tree_row.js +21 -1
  23. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +34 -0
  24. data/app/javascript/controllers/comment_controller.js +19 -1
  25. data/app/javascript/controllers/comment_version_controller.js +2 -1
  26. data/app/javascript/controllers/comments/form_controller.js +7 -4
  27. data/app/javascript/controllers/comments/list_controller.js +43 -4
  28. data/app/javascript/controllers/comments/popup_controller.js +45 -12
  29. data/app/javascript/controllers/comments/presence_controller.js +8 -0
  30. data/app/javascript/controllers/comments/topics_controller.js +50 -31
  31. data/app/javascript/creatives/tree_renderer.js +1 -0
  32. data/app/javascript/lib/__tests__/chat_history.test.js +31 -0
  33. data/app/javascript/lib/chat_history.js +12 -2
  34. data/app/javascript/lib/utils/markdown.js +32 -1
  35. data/app/javascript/modules/command_args_form.js +8 -0
  36. data/app/javascript/modules/creative_row_editor.js +12 -17
  37. data/app/javascript/modules/integration_wizard.js +162 -0
  38. data/app/jobs/collavre/compress_job.rb +1 -0
  39. data/app/jobs/collavre/creative_broadcast_job.rb +4 -1
  40. data/app/jobs/collavre/merge_comments_job.rb +1 -0
  41. data/app/jobs/collavre/trigger_loop_check_job.rb +1 -0
  42. data/app/jobs/collavre/trigger_loop_verify_job.rb +1 -0
  43. data/app/models/collavre/calendar_event.rb +0 -4
  44. data/app/models/collavre/comment/broadcastable.rb +1 -1
  45. data/app/models/collavre/comment.rb +17 -2
  46. data/app/models/collavre/comment_snapshot.rb +0 -1
  47. data/app/models/collavre/creative/describable.rb +10 -1
  48. data/app/models/collavre/creative/realtime_broadcastable.rb +17 -5
  49. data/app/models/collavre/creative.rb +43 -1
  50. data/app/models/collavre/current.rb +1 -1
  51. data/app/models/collavre/inbox_item.rb +0 -4
  52. data/app/models/collavre/system_setting.rb +10 -1
  53. data/app/models/collavre/task.rb +17 -8
  54. data/app/models/collavre/user.rb +11 -1
  55. data/app/models/concerns/collavre/ai_agent_resolvable.rb +0 -8
  56. data/app/services/collavre/ai_agent/message_builder.rb +32 -15
  57. data/app/services/collavre/ai_agent/response_finalizer.rb +2 -1
  58. data/app/services/collavre/ai_agent/session_context_resolver.rb +50 -0
  59. data/app/services/collavre/ai_agent_service.rb +14 -2
  60. data/app/services/collavre/ai_client.rb +13 -0
  61. data/app/services/collavre/command_menu_service.rb +23 -3
  62. data/app/services/collavre/comments/command_processor.rb +1 -1
  63. data/app/services/collavre/comments/mcp_command.rb +27 -8
  64. data/app/services/collavre/comments/mcp_command_builder.rb +4 -3
  65. data/app/services/collavre/creatives/tree_builder.rb +3 -0
  66. data/app/services/collavre/google_calendar_service.rb +32 -6
  67. data/app/services/collavre/markdown_converter.rb +15 -20
  68. data/app/services/collavre/mcp_service.rb +4 -4
  69. data/app/services/collavre/orchestration/agent_orchestrator.rb +2 -2
  70. data/app/services/collavre/tools/creative_batch_service.rb +5 -1
  71. data/app/services/collavre/tools/cron_create_service.rb +9 -6
  72. data/app/services/collavre/topic_branch_service.rb +2 -2
  73. data/app/views/collavre/admin/settings/_system_tab.html.erb +11 -0
  74. data/app/views/collavre/comments/_comment.html.erb +7 -1
  75. data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
  76. data/config/locales/admin.en.yml +3 -0
  77. data/config/locales/admin.ko.yml +3 -0
  78. data/config/locales/comments.en.yml +6 -1
  79. data/config/locales/comments.ko.yml +6 -1
  80. data/db/migrate/20251126040752_add_description_to_creatives.rb +1 -1
  81. data/db/migrate/20260415000000_create_main_topics_for_existing_creatives.rb +69 -0
  82. data/lib/collavre/version.rb +1 -1
  83. metadata +21 -4
  84. data/app/jobs/collavre/permission_cache_cleanup_job.rb +0 -36
  85. data/app/models/collavre/variation.rb +0 -5
  86. data/app/services/collavre/creatives/path_exporter.rb +0 -131
@@ -43,14 +43,15 @@ module Collavre
43
43
  topic = current_topic
44
44
  topic_info = topic ? "\nTopic: #{topic.name} (id: #{topic.id})" : ""
45
45
 
46
+ ancestry = build_ancestry_chain(creative)
47
+
46
48
  if effective.data&.dig("disabled_self_context") == true
47
49
  # Self-context disabled: inject only the ancestry chain so the AI
48
50
  # knows where in the hierarchy the conversation is happening
49
- ancestry = build_ancestry_chain(creative)
50
51
  @injected_creative_ids << creative.id
51
- messages << { role: "user", kind: :creative_context, parts: [ { text: "Current Creative (id: #{creative.id}):#{topic_info}\nPath: #{ancestry}" } ] }
52
+ messages << { role: "user", kind: :creative_context, parts: [ { text: "Creative Path: #{ancestry}#{topic_info}" } ] }
52
53
  else
53
- # Full self-context: inject the creative subtree
54
+ # Full self-context: inject the creative subtree with ancestry breadcrumb
54
55
  children_level = @agent.creative_children_level
55
56
  max_depth = 1 + children_level
56
57
  markdown = ApplicationController.helpers.render_creative_tree_markdown(
@@ -58,12 +59,12 @@ module Collavre
58
59
  )
59
60
 
60
61
  @injected_creative_ids << creative.id
61
- messages << { role: "user", kind: :creative_context, parts: [ { text: "Creative (id: #{creative.id}):#{topic_info}\n#{markdown}" } ] }
62
+ messages << { role: "user", kind: :creative_context, parts: [ { text: "Creative Path: #{ancestry}#{topic_info}\n#{markdown}" } ] }
62
63
  end
63
64
  end
64
65
 
65
66
  def build_ancestry_chain(creative)
66
- creative.self_and_ancestors.reverse.map(&:creative_snippet).join(" > ")
67
+ creative.self_and_ancestors.reverse.map { |c| "#{c.creative_snippet} (id: #{c.id})" }.join(" > ")
67
68
  end
68
69
 
69
70
  def append_context_creatives(messages)
@@ -75,17 +76,20 @@ module Collavre
75
76
 
76
77
  effective_origin = creative.effective_origin(Set.new)
77
78
  context_ids = effective_origin.effective_context_ids
78
- disabled_ids = Array(effective_origin.data&.dig("disabled_context_ids"))
79
+ disabled_ids = effective_origin.effective_disabled_context_ids
79
80
  active_ids = context_ids - disabled_ids - [ creative_id, effective_origin.id ]
80
81
  return if active_ids.empty?
81
82
 
82
83
  children_level = @agent.creative_children_level
83
84
  max_depth = 1 + children_level
84
85
 
86
+ ids_to_load = active_ids.reject { |ctx_id| @injected_creative_ids.include?(ctx_id) }
87
+ creatives_by_id = Creative.where(id: ids_to_load).index_by(&:id)
88
+
85
89
  active_ids.each do |ctx_id|
86
90
  next if @injected_creative_ids.include?(ctx_id)
87
91
 
88
- ctx = Creative.find_by(id: ctx_id)
92
+ ctx = creatives_by_id[ctx_id]
89
93
  next unless ctx
90
94
 
91
95
  @injected_creative_ids << ctx_id
@@ -109,11 +113,12 @@ module Collavre
109
113
  max_depth = 1 + children_level
110
114
 
111
115
  # Extract creative IDs from markdown links like [title](/creatives/123)
112
- content.scan(%r{\[[^\]]*\]\(/creatives/(\d+)\)}).flatten.uniq.each do |id_str|
113
- creative_id = id_str.to_i
114
- next if @injected_creative_ids.include?(creative_id)
116
+ referenced_ids = content.scan(%r{\[[^\]]*\]\(/creatives/(\d+)\)}).flatten.map(&:to_i).uniq
117
+ referenced_ids.reject! { |cid| @injected_creative_ids.include?(cid) }
118
+ creatives_by_id = Creative.where(id: referenced_ids).index_by(&:id)
115
119
 
116
- creative = Creative.find_by(id: creative_id)
120
+ referenced_ids.each do |creative_id|
121
+ creative = creatives_by_id[creative_id]
117
122
  next unless creative
118
123
 
119
124
  @injected_creative_ids << creative_id
@@ -141,7 +146,7 @@ module Collavre
141
146
  history_chars = 0
142
147
  count = 0
143
148
 
144
- Comment.where(creative_id: creative_id, private: false)
149
+ Comment.public_only.where(creative_id: creative_id)
145
150
  .where(topic_id: topic_id)
146
151
  .where.not(user_id: nil)
147
152
  .includes(:user)
@@ -247,9 +252,21 @@ module Collavre
247
252
  # This is slightly broader than what's actually rendered (which is
248
253
  # limited by children_level), but ensures no descendant change is missed.
249
254
  def collect_rendered_creative_ids
250
- @injected_creative_ids.flat_map do |root_id|
251
- Creative.find_by(id: root_id)&.subtree_ids || [ root_id ]
252
- end.uniq
255
+ roots = Creative.where(id: @injected_creative_ids.to_a).to_a
256
+ found_ids = roots.map(&:id).to_set
257
+
258
+ ids = roots.flat_map(&:subtree_ids)
259
+ .concat(@injected_creative_ids.reject { |rid| found_ids.include?(rid) })
260
+
261
+ # Include ancestor IDs used in the breadcrumb so that changes to
262
+ # ancestor titles are detected by context_changed?
263
+ creative_id = @context.dig("creative", "id")
264
+ if creative_id
265
+ ancestor_ids = Creative.find_by(id: creative_id)&.ancestor_ids || []
266
+ ids.concat(ancestor_ids)
267
+ end
268
+
269
+ ids.uniq
253
270
  end
254
271
  end
255
272
  end
@@ -72,7 +72,8 @@ module Collavre
72
72
  reply_comment = @original_comment.creative.comments.create!(
73
73
  content: @response_content,
74
74
  user: @agent,
75
- topic_id: @original_comment.topic_id
75
+ topic_id: @original_comment.topic_id,
76
+ task: @task
76
77
  )
77
78
 
78
79
  log_action("reply_created", { comment_id: reply_comment.id, content: @response_content })
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module AiAgent
5
+ class SessionContextResolver
6
+ def initialize(agent:, messages_data:, system_prompt:)
7
+ @agent = agent
8
+ @messages_data = messages_data
9
+ @system_prompt = system_prompt
10
+ end
11
+
12
+ def resolve
13
+ if @agent.supports_session? && !needs_full_context?
14
+ incremental_payload
15
+ else
16
+ full_payload
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def needs_full_context?
23
+ @messages_data[:first_message] || @messages_data[:context_changed]
24
+ end
25
+
26
+ def full_payload
27
+ msgs = @messages_data[:messages]
28
+ msgs = msgs.reject { |m| m[:kind] == :chat_history } if @agent.supports_session?
29
+
30
+ {
31
+ messages: msgs,
32
+ system_prompt: @system_prompt,
33
+ first_message: @messages_data[:first_message],
34
+ context_changed: @messages_data[:context_changed]
35
+ }
36
+ end
37
+
38
+ def incremental_payload
39
+ trigger_only = @messages_data[:messages].select { |m| m[:kind] == :trigger }
40
+
41
+ {
42
+ messages: trigger_only,
43
+ system_prompt: nil,
44
+ first_message: false,
45
+ context_changed: false
46
+ }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -29,6 +29,9 @@ module Collavre
29
29
  rendering_context = prepare_rendering_context
30
30
  system_prompt = render_system_prompt(rendering_context)
31
31
 
32
+ # Resolve session context (decides what to send)
33
+ resolved = resolve_session_context(messages_data, system_prompt)
34
+
32
35
  # Create placeholder comment if needed
33
36
  @reply_comment = create_reply_comment_if_needed
34
37
 
@@ -48,8 +51,8 @@ module Collavre
48
51
  @lifecycle_manager.broadcast_status("thinking")
49
52
 
50
53
  # Execute AI chat with streaming
51
- @client = build_ai_client(system_prompt)
52
- stream_response(@client, messages_data)
54
+ @client = build_ai_client(resolved[:system_prompt])
55
+ stream_response(@client, resolved)
53
56
 
54
57
  log_action("completion", { response: @streamer.content })
55
58
 
@@ -89,6 +92,14 @@ module Collavre
89
92
  ).build
90
93
  end
91
94
 
95
+ def resolve_session_context(messages_data, system_prompt)
96
+ AiAgent::SessionContextResolver.new(
97
+ agent: @agent,
98
+ messages_data: messages_data,
99
+ system_prompt: system_prompt
100
+ ).resolve
101
+ end
102
+
92
103
  def find_creative
93
104
  creative_id = @context.dig("creative", "id")
94
105
  creative_id ? Creative.find_by(id: creative_id) : nil
@@ -126,6 +137,7 @@ module Collavre
126
137
  content: Comment::STREAMING_PLACEHOLDER_CONTENT,
127
138
  user: @agent,
128
139
  topic_id: @original_comment.topic_id,
140
+ task: @task,
129
141
  skip_dispatch: true # A2A routing handled by A2aDispatcher after finalization
130
142
  )
131
143
  end
@@ -132,9 +132,14 @@ module Collavre
132
132
  chat_opts[:provider] = provider if provider
133
133
  chat_opts[:assume_model_exists] = true if provider
134
134
 
135
+ # Apply current system timeout setting (picks up changes without restart)
136
+ RubyLLM.config.request_timeout = SystemSetting.llm_request_timeout_seconds
137
+
135
138
  RubyLLM.context(&context_block)
136
139
  .chat(**chat_opts).tap do |chat|
137
140
  chat.with_instructions(system_prompt) if system_prompt.present?
141
+ session_id = build_session_id
142
+ chat.with_headers("X-Session-Id" => session_id) if session_id
138
143
  chat.on_tool_call do |tool_call|
139
144
  check_tool_approval!(tool_call)
140
145
  end
@@ -146,6 +151,14 @@ module Collavre
146
151
  end
147
152
  end
148
153
 
154
+ def build_session_id
155
+ creative_id = context&.dig(:creative)&.id
156
+ topic_id = context&.dig(:comment)&.topic_id || context&.dig(:topic_id)
157
+ return nil unless creative_id && topic_id
158
+
159
+ "creative_#{creative_id}_topic_#{topic_id}"
160
+ end
161
+
149
162
  def check_tool_approval!(tool_call)
150
163
  tool_name = tool_call.name
151
164
  task = context&.dig(:task)
@@ -1,7 +1,8 @@
1
1
  module Collavre
2
2
  class CommandMenuService
3
- def initialize(user:)
3
+ def initialize(user:, creative: nil)
4
4
  @user = user
5
+ @creative = creative
5
6
  end
6
7
 
7
8
  def items
@@ -10,7 +11,7 @@ module Collavre
10
11
 
11
12
  private
12
13
 
13
- attr_reader :user
14
+ attr_reader :user, :creative
14
15
 
15
16
  def built_in_items
16
17
  [
@@ -70,12 +71,16 @@ module Collavre
70
71
  next unless tool_name
71
72
 
72
73
  raw_params = tool[:params] || tool["params"]
74
+ schema = normalize_params(raw_params)
75
+ defaults = tool_defaults_for(tool_name)
76
+ apply_defaults_to_schema(schema, defaults) if defaults.present?
77
+
73
78
  {
74
79
  name: tool_name,
75
80
  label: "/#{tool_name}",
76
81
  description: tool[:description] || tool["description"],
77
82
  args: format_args(raw_params),
78
- input_schema: normalize_params(raw_params)
83
+ input_schema: schema
79
84
  }
80
85
  end
81
86
  end
@@ -111,6 +116,21 @@ module Collavre
111
116
  end
112
117
  end
113
118
 
119
+ def tool_defaults_for(tool_name)
120
+ creative&.data&.dig("tools", tool_name)
121
+ end
122
+
123
+ def apply_defaults_to_schema(schema, defaults)
124
+ return unless schema.is_a?(Array) && defaults.is_a?(Hash)
125
+
126
+ schema.each do |param|
127
+ name = param[:name]
128
+ if defaults.key?(name)
129
+ param[:default_value] = defaults[name]
130
+ end
131
+ end
132
+ end
133
+
114
134
  def format_args(params)
115
135
  return if params.blank?
116
136
 
@@ -35,7 +35,7 @@ module Collavre
35
35
  end
36
36
 
37
37
  def mcp_commands
38
- Collavre::Comments::McpCommandBuilder.new(comment: comment, user: user).commands
38
+ Collavre::Comments::McpCommandBuilder.new(comment: comment, user: user, creative: comment.creative).commands
39
39
  end
40
40
  end
41
41
  end
@@ -3,10 +3,11 @@ module Collavre
3
3
 
4
4
  module Comments
5
5
  class McpCommand
6
- def initialize(comment:, user:, tool:, meta_tool_service: default_meta_tool_service)
6
+ def initialize(comment:, user:, tool:, creative: nil, meta_tool_service: default_meta_tool_service)
7
7
  @comment = comment
8
8
  @user = user
9
9
  @tool = tool
10
+ @creative = creative
10
11
  @meta_tool_service = meta_tool_service
11
12
  end
12
13
 
@@ -25,7 +26,7 @@ module Collavre
25
26
 
26
27
  private
27
28
 
28
- attr_reader :comment, :user, :tool, :meta_tool_service
29
+ attr_reader :comment, :user, :tool, :creative, :meta_tool_service
29
30
 
30
31
  def command_match?
31
32
  comment.content.to_s.strip.match?(command_pattern)
@@ -41,11 +42,17 @@ module Collavre
41
42
 
42
43
  def parsed_arguments
43
44
  body = command_body
44
- return {} if body.blank?
45
+ explicit_args = if body.blank?
46
+ {}
47
+ else
48
+ begin
49
+ JSON.parse(body)
50
+ rescue JSON::ParserError
51
+ key_value_arguments(body)
52
+ end
53
+ end
45
54
 
46
- JSON.parse(body)
47
- rescue JSON::ParserError
48
- key_value_arguments(body)
55
+ merge_with_defaults(explicit_args)
49
56
  end
50
57
 
51
58
  def key_value_arguments(body)
@@ -61,6 +68,15 @@ module Collavre
61
68
  nil
62
69
  end
63
70
 
71
+ def merge_with_defaults(explicit_args)
72
+ return explicit_args unless explicit_args.is_a?(Hash)
73
+
74
+ defaults = creative&.data&.dig("tools", tool_name)
75
+ return explicit_args unless defaults.is_a?(Hash)
76
+
77
+ defaults.merge(explicit_args)
78
+ end
79
+
64
80
  def cast_value(value)
65
81
  case value
66
82
  when /\A\d+\z/ then value.to_i
@@ -89,9 +105,12 @@ module Collavre
89
105
  result.to_s
90
106
  end
91
107
 
108
+ escaped_tool = ERB::Util.html_escape(tool_name)
109
+ escaped_content = ERB::Util.html_escape(content)
110
+
92
111
  <<~HTML
93
- <details><summary>#{tool_name} response</summary>
94
- <pre><code>#{content}</code></pre>
112
+ <details><summary>#{escaped_tool} response</summary>
113
+ <pre><code>#{escaped_content}</code></pre>
95
114
  </details>
96
115
  HTML
97
116
  end
@@ -1,9 +1,10 @@
1
1
  module Collavre
2
2
  module Comments
3
3
  class McpCommandBuilder
4
- def initialize(comment:, user:, logger: Rails.logger)
4
+ def initialize(comment:, user:, creative: nil, logger: Rails.logger)
5
5
  @comment = comment
6
6
  @user = user
7
+ @creative = creative
7
8
  @logger = logger
8
9
  end
9
10
 
@@ -13,7 +14,7 @@ module Collavre
13
14
  RailsMcpEngine::Engine.build_tools!
14
15
  tools = meta_tool_service.call(action: "list", tool_name: nil, query: nil, arguments: nil)
15
16
  Array(tools[:tools]).map do |tool|
16
- Collavre::Comments::McpCommand.new(comment: comment, user: user, tool: tool, meta_tool_service: meta_tool_service)
17
+ Collavre::Comments::McpCommand.new(comment: comment, user: user, tool: tool, creative: creative, meta_tool_service: meta_tool_service)
17
18
  end
18
19
  rescue StandardError => e
19
20
  logger.error("MCP command registration failed: #{e.message}")
@@ -22,7 +23,7 @@ module Collavre
22
23
 
23
24
  private
24
25
 
25
- attr_reader :comment, :user, :logger
26
+ attr_reader :comment, :user, :creative, :logger
26
27
 
27
28
  def meta_tool_service
28
29
  @meta_tool_service ||= ::Tools::MetaToolService.new
@@ -75,6 +75,8 @@ module Creatives
75
75
 
76
76
  def cached_can_write?(creative)
77
77
  return false unless user
78
+ # GitHub-synced creatives are always read-only
79
+ return false if creative.github_markdown?
78
80
 
79
81
  if @permission_cache.key?(creative.id)
80
82
  @permission_cache[creative.id]
@@ -122,6 +124,7 @@ module Creatives
122
124
  expanded: expanded,
123
125
  is_root: creative.parent.nil?,
124
126
  archived: creative.archived?,
127
+ github_source: creative.github_markdown?,
125
128
  sequence: creative.sequence,
126
129
  link_url: view_context.collavre.creative_path(creative),
127
130
  templates: template_payload_for(creative, has_children: filtered_children.any?),
@@ -95,6 +95,12 @@ module Collavre
95
95
 
96
96
  def list_calendars
97
97
  @service.list_calendar_lists.items.map { |c| [ c.summary, c.id ] }.to_h
98
+ rescue Google::Apis::AuthorizationError => e
99
+ Rails.logger.error("Google Calendar list_calendars auth error for user #{@user.id}: #{e.message}")
100
+ raise GoogleCalendarError, I18n.t("collavre.google_calendar.errors.reconnect")
101
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Google::Apis::TransmissionError => e
102
+ Rails.logger.error("Google Calendar list_calendars network error for user #{@user.id}: #{e.class} - #{e.message}")
103
+ raise GoogleCalendarError, I18n.t("collavre.google_calendar.errors.network")
98
104
  end
99
105
 
100
106
  private
@@ -109,6 +115,12 @@ module Collavre
109
115
  scope: [ Google::Apis::CalendarV3::AUTH_CALENDAR_APP_CREATED ],
110
116
  refresh_token: token
111
117
  ).tap(&:fetch_access_token!)
118
+ rescue Signet::AuthorizationError => e
119
+ Rails.logger.error("Google Calendar fetch_access_token! auth error for user #{@user.id}: #{e.message}")
120
+ raise GoogleCalendarError, I18n.t("collavre.google_calendar.errors.reconnect")
121
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
122
+ Rails.logger.error("Google Calendar fetch_access_token! network error for user #{@user.id}: #{e.class} - #{e.message}")
123
+ raise GoogleCalendarError, I18n.t("collavre.google_calendar.errors.network")
112
124
  end
113
125
 
114
126
  def refresh_token
@@ -130,10 +142,11 @@ module Collavre
130
142
  end
131
143
 
132
144
  def raw_google_refresh_token
133
- result = ActiveRecord::Base.connection.select_value(
134
- "SELECT google_refresh_token FROM users WHERE id = #{@user.id}"
145
+ ActiveRecord::Base.connection.select_value(
146
+ ActiveRecord::Base.sanitize_sql_array(
147
+ [ "SELECT google_refresh_token FROM users WHERE id = ?", @user.id ]
148
+ )
135
149
  )
136
- result
137
150
  end
138
151
 
139
152
  def encrypted_value?(value)
@@ -160,9 +173,22 @@ module Collavre
160
173
  if @user.calendar_id.nil?
161
174
  @user.calendar_id = create_app_calendar
162
175
  else
163
- calendar = @service.get_calendar(@user.calendar_id)
164
- if calendar.id != @user.calendar_id
165
- @user.calendar_id = create_app_calendar
176
+ begin
177
+ calendar = @service.get_calendar(@user.calendar_id)
178
+ if calendar.id != @user.calendar_id
179
+ @user.calendar_id = create_app_calendar
180
+ end
181
+ rescue Google::Apis::ClientError => e
182
+ if e.status_code == 404
183
+ Rails.logger.error("Google Calendar not found (deleted?) for user #{@user.id}, calendar_id=#{@user.calendar_id}")
184
+ @user.calendar_id = create_app_calendar
185
+ else
186
+ Rails.logger.error("Google Calendar get_calendar error for user #{@user.id}: #{e.class} #{e.status_code} - #{e.message}")
187
+ raise
188
+ end
189
+ rescue Google::Apis::AuthorizationError => e
190
+ Rails.logger.error("Google Calendar get_calendar auth error for user #{@user.id}: #{e.message}")
191
+ raise GoogleCalendarError, I18n.t("collavre.google_calendar.errors.reconnect")
166
192
  end
167
193
  end
168
194
  end
@@ -1,6 +1,7 @@
1
1
  require "base64"
2
2
  require "securerandom"
3
3
  require "nokogiri"
4
+ require "commonmarker"
4
5
 
5
6
  module Collavre
6
7
  # Converts between Markdown and HTML for creative descriptions.
@@ -9,19 +10,20 @@ module Collavre
9
10
  # outside of view contexts and reusable from services (e.g. MarkdownImporter).
10
11
  class MarkdownConverter
11
12
  class << self
12
- # Convert lightweight Markdown (links, bold, images) to HTML.
13
+ # Convert Markdown to HTML using commonmarker (GFM full support).
14
+ # Falls back to lightweight regex conversion for short inline fragments.
13
15
  def markdown_to_html(text, image_refs = {})
14
16
  return "" if text.nil?
15
- html = text.dup
17
+ input = text.dup
16
18
 
17
19
  # Collect reference-style data-URI images: [alt]: <data:...>
18
- html.gsub!(/^\s*\[([^\]]+)\]:\s*<\s*(data:image\/[^>]+)\s*>\s*$/) do
20
+ input.gsub!(/^\s*\[([^\]]+)\]:\s*<\s*(data:image\/[^>]+)\s*>\s*$/) do
19
21
  image_refs[$1] = $2.strip
20
22
  ""
21
23
  end
22
24
 
23
- # Reference-style images: ![alt][ref]
24
- html.gsub!(/(?<!\\)!\[([^\]]*)\]\[([^\]]+)\]/) do
25
+ # Convert data-URI images to Active Storage before rendering
26
+ input.gsub!(/(?<!\\)!\[([^\]]*)\]\[([^\]]+)\]/) do
25
27
  if (data_url = image_refs[$2])
26
28
  data_image_to_attachment(data_url, $1)
27
29
  else
@@ -29,24 +31,17 @@ module Collavre
29
31
  end
30
32
  end
31
33
 
32
- # Inline data-URI images: ![alt](data:...)
33
- html.gsub!(/(?<!\\)!\[([^\]]*)\]\((data:image\/[^)]+)\)/) do
34
+ input.gsub!(/(?<!\\)!\[([^\]]*)\]\((data:image\/[^)]+)\)/) do
34
35
  data_image_to_attachment($2, $1)
35
36
  end
36
37
 
37
- # Inline links: [text](url)
38
- html.gsub!(/(?<!\\)\[([^\]]+)\]\(([^)]+)\)/) do
39
- "<a href=\"#{$2}\">#{$1}</a>"
40
- end
41
-
42
- # Bold: **text** or __text__
43
- html.gsub!(/(?<!\\)(\*\*|__)(.+?)\1/m) do
44
- "<strong>#{$2}</strong>"
45
- end
38
+ # Render with commonmarker (GFM extensions: table, strikethrough, autolink, tasklist, tagfilter)
39
+ html = Commonmarker.to_html(input, options: {
40
+ parse: { smart: true },
41
+ render: { unsafe: true },
42
+ extension: { table: true, strikethrough: true, autolink: true, tasklist: true, tagfilter: true }
43
+ })
46
44
 
47
- # Unescape backslash-escaped chars
48
- html.gsub!(/\\([\\*_\[\]()!#~+\-])/, '\\1')
49
- html.gsub!(/\\\\/, "\\")
50
45
  html.strip!
51
46
  html
52
47
  end
@@ -92,7 +87,7 @@ module Collavre
92
87
  token = "__IMG#{index}__"; index += 1
93
88
  placeholders[token] = "![#{alt_text}](data:#{blob.content_type};base64,#{data})"
94
89
  token
95
- rescue
90
+ rescue StandardError
96
91
  match
97
92
  end
98
93
  else
@@ -39,7 +39,7 @@ module Collavre
39
39
  result: result
40
40
  }
41
41
  )
42
- rescue => e
42
+ rescue StandardError => e
43
43
  Rails.logger.error("Failed to log tool activity: #{e.message}")
44
44
  end
45
45
 
@@ -48,14 +48,14 @@ module Collavre
48
48
  before_call: before_call,
49
49
  after_call: after_call
50
50
  )
51
- puts("Registered tool: #{result}")
51
+ Rails.logger.info("Registered tool: #{result}")
52
52
 
53
53
  if result[:error]
54
54
  error_msg = "Failed to register tool: #{result[:error]}"
55
55
  Rails.logger.error(error_msg)
56
56
  raise error_msg
57
57
  end
58
- rescue => e
58
+ rescue StandardError => e
59
59
  Rails.logger.error("Failed to register tool from source: #{e.message}")
60
60
  raise e
61
61
  end
@@ -131,7 +131,7 @@ module Collavre
131
131
  if result[:error]
132
132
  Rails.logger.error("Failed to delete tool #{tool_name}: #{result[:error]}")
133
133
  end
134
- rescue => e
134
+ rescue StandardError => e
135
135
  Rails.logger.error("Failed to delete tool #{tool_name}: #{e.message}")
136
136
  end
137
137
 
@@ -59,8 +59,8 @@ module Collavre
59
59
  return unless creative_id && context&.key?("topic")
60
60
 
61
61
  topic_id = context.dig("topic", "id")
62
- scope = Comment
63
- .where(creative_id: creative_id, topic_id: topic_id, private: false)
62
+ scope = Comment.public_only
63
+ .where(creative_id: creative_id, topic_id: topic_id)
64
64
  .where.not(user_id: [ task.agent_id, nil ])
65
65
  .order(created_at: :desc)
66
66
  latest_comment = scope.first
@@ -99,7 +99,11 @@ module Collavre
99
99
  return { error: "Creative not found", id: id } unless creative
100
100
  return { error: "No write permission on this Creative", id: id } unless creative.has_permission?(Current.user, :write)
101
101
 
102
- creative.destroy!
102
+ result = Creatives::DestroyService.new(
103
+ creative: creative,
104
+ user: Current.user
105
+ ).call
106
+ return { error: "Failed to destroy creative", id: id } unless result
103
107
  { success: true, id: id, deleted: true }
104
108
  end
105
109
  end
@@ -10,7 +10,7 @@ module Tools
10
10
  tool_description "Create a new recurring scheduled job. The job will periodically post a message to a creative's topic, triggering agent orchestration. Schedule uses cron syntax (e.g., '*/5 * * * *' for every 5 minutes, '0 9 * * *' for daily at 9am)."
11
11
 
12
12
  tool_param :creative_id, description: "The creative ID to post recurring messages to.", required: true
13
- tool_param :topic_name, description: "The topic name within the creative to post to. Use 'Main' for the main topic (topic_id = nil).", required: true
13
+ tool_param :topic_name, description: "The topic name within the creative to post to. Use 'Main' for the default main topic.", required: true
14
14
  tool_param :schedule, description: "Cron schedule expression (e.g., '0 9 * * *' for daily at 9am, '*/30 * * * *' for every 30 minutes).", required: true
15
15
  tool_param :message, description: "The message content to post on each execution. This triggers the agent orchestration pipeline.", required: true
16
16
  tool_param :description, description: "Human-readable description of what this cron job does.", required: false
@@ -73,12 +73,15 @@ module Tools
73
73
  private
74
74
 
75
75
  def resolve_topic_id(creative, topic_name)
76
- return nil if topic_name.casecmp("main").zero?
76
+ origin = creative.effective_origin
77
+ if topic_name.casecmp(Creative::MAIN_TOPIC_NAME).zero?
78
+ origin.main_topic(fallback_user: Current.user).id
79
+ else
80
+ topic = Topic.find_by(name: topic_name, creative_id: origin.id)
81
+ return { error: "Topic '#{topic_name}' not found for this creative" } unless topic
77
82
 
78
- topic = Topic.find_by(name: topic_name, creative_id: creative.effective_origin.id)
79
- return { error: "Topic '#{topic_name}' not found for this creative" } unless topic
80
-
81
- topic.id
83
+ topic.id
84
+ end
82
85
  end
83
86
  end
84
87
  end