brainiac-discord 0.0.1

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.
@@ -0,0 +1,863 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "open3"
5
+
6
+ module Brainiac
7
+ module Plugins
8
+ module Discord
9
+ # Discord message handler — the main dispatch function.
10
+ #
11
+ # Handles incoming messages: authorization, cross-agent routing, project detection,
12
+ # worktree management, prompt building, agent spawning, and response monitoring.
13
+ module Message
14
+ class << self
15
+ def handle(message, agent_key, bot_token, bot_user_id)
16
+ channel_id = message["channel_id"]
17
+ message_id = message["id"]
18
+ author = message["author"]
19
+ content = message["content"] || ""
20
+ is_bot = !author["bot"].nil?
21
+
22
+ sender_agent_key = Gateway.detect_sender_agent(author, agent_key) if is_bot
23
+ return if is_bot && !sender_agent_key
24
+
25
+ mentions = message["mentions"] || []
26
+ mentioned = mentions.any? { |m| m["id"].to_s == bot_user_id.to_s } ||
27
+ content.match?(/<@!?#{Regexp.escape(bot_user_id.to_s)}>/)
28
+ return if sender_agent_key && !validate_cross_agent_dispatch(sender_agent_key, agent_key, mentioned, content, channel_id)
29
+
30
+ is_reply_to_bot, referenced_message = detect_reply_to_bot(message, channel_id, mentioned, bot_token, bot_user_id)
31
+ channel_info, is_thread, is_dm, in_own_thread = detect_channel_context(message, channel_id, mentioned, is_reply_to_bot, bot_token,
32
+ bot_user_id)
33
+ return if should_stand_down?(in_own_thread, mentioned, is_reply_to_bot, is_bot, agent_key, mentions, content)
34
+ return unless mentioned || in_own_thread || is_dm || is_reply_to_bot
35
+
36
+ record_human_comment("discord-#{channel_id}") unless is_bot
37
+
38
+ clean_content = prepare_content(content, bot_user_id)
39
+ attachment_paths = download_attachments(message, message_id, agent_key)
40
+ clean_content = append_attachments(clean_content, attachment_paths)
41
+ return if clean_content.empty? && attachment_paths.empty?
42
+
43
+ reply_context = build_reply_context(referenced_message)
44
+ discord_user = author["username"]
45
+ discord_user_id = author["id"]
46
+ agent_name = agent_display_name(agent_key) || agent_key.capitalize
47
+
48
+ channel_info, is_thread, is_dm = ensure_channel_info(channel_info, is_thread, is_dm, channel_id, bot_token)
49
+ parent_channel_id = is_thread ? channel_info&.dig("parent_id") || channel_id : channel_id
50
+ channel_history = Api.fetch_channel_history(channel_id, message_id, token: bot_token, limit: is_thread ? 25 : 10)
51
+ location = is_dm ? "DM" : (is_thread ? "thread" : "channel") # rubocop:disable Style/NestedTernaryOperator
52
+ LOG.info "[Discord:#{agent_name}] Message from #{discord_user} in #{location} #{channel_id}: #{clean_content[0..100]}" if defined?(LOG)
53
+
54
+ reload_projects!
55
+ reload_agent_registry!
56
+ Config.reload!
57
+ return unless authorize_user(discord_user, discord_user_id, message, channel_id, message_id, agent_name, bot_token)
58
+
59
+ tags = parse_inline_tags(clean_content)
60
+ project_key, project_config = resolve_project(tags[:project], parent_channel_id, agent_name, channel_id, message_id, bot_token)
61
+
62
+ route_dispatch(
63
+ agent_key: agent_key, agent_name: agent_name, bot_token: bot_token, is_bot: is_bot,
64
+ channel_id: channel_id, message_id: message_id, message: message,
65
+ clean_content: clean_content, clean_content_for_prompt: tags[:clean_text],
66
+ chat_mode: tags[:chat_mode], is_thread: is_thread, is_dm: is_dm,
67
+ channel_info: channel_info, parent_channel_id: parent_channel_id,
68
+ discord_user: discord_user, reply_context: reply_context,
69
+ channel_history: channel_history, project_key: project_key,
70
+ project_config: project_config, attachment_paths: attachment_paths
71
+ )
72
+ end
73
+
74
+ private
75
+
76
+ def validate_cross_agent_dispatch(sender_agent_key, agent_key, mentioned, content, channel_id)
77
+ return false unless mentioned
78
+
79
+ if content.match?(/created\s+card\s+#?\d+/i) || content.match?(/assigned\s+.*card\s+#?\d+/i) || content.match?(/card\s+#?\d+.*assigned/i)
80
+ sender_display = agent_display_name(sender_agent_key) || sender_agent_key.capitalize
81
+ agent_display = agent_display_name(agent_key) || agent_key.capitalize
82
+ LOG.info "[Discord:#{agent_display}] Ignoring cross-agent mention from #{sender_display} — card creation/assignment" if defined?(LOG)
83
+ return false
84
+ end
85
+
86
+ depth_key = "discord-#{channel_id}"
87
+ unless agent_dispatch_allowed?(depth_key)
88
+ sender_display = agent_display_name(sender_agent_key) || sender_agent_key.capitalize
89
+ agent_display = agent_display_name(agent_key) || agent_key.capitalize
90
+ LOG.info "[Discord:#{agent_display}] Blocking cross-agent dispatch from #{sender_display} — depth limit reached" if defined?(LOG)
91
+ return false
92
+ end
93
+ record_agent_dispatch(depth_key)
94
+ true
95
+ end
96
+
97
+ def detect_reply_to_bot(message, channel_id, mentioned, bot_token, bot_user_id)
98
+ is_reply_to_bot = false
99
+ referenced_message = nil
100
+
101
+ if message["message_reference"]
102
+ ref_msg_id = message.dig("message_reference", "message_id")
103
+ ref_channel = message.dig("message_reference", "channel_id") || channel_id
104
+ if ref_msg_id
105
+ referenced_message = Api.request(:get, "/channels/#{ref_channel}/messages/#{ref_msg_id}", token: bot_token)
106
+ is_reply_to_bot = !mentioned && referenced_message && referenced_message.dig("author", "id") == bot_user_id
107
+ end
108
+ end
109
+
110
+ [is_reply_to_bot, referenced_message]
111
+ end
112
+
113
+ def detect_channel_context(_message, channel_id, mentioned, is_reply_to_bot, bot_token, bot_user_id)
114
+ channel_info = nil
115
+ is_thread = false
116
+ is_dm = false
117
+ in_own_thread = false
118
+
119
+ if !mentioned && !is_reply_to_bot
120
+ channel_info = Api.fetch_channel_info(channel_id, token: bot_token)
121
+ is_thread = channel_info && [11, 12].include?(channel_info["type"])
122
+ is_dm = channel_info && channel_info["type"] == 1
123
+ in_own_thread = is_thread && channel_info["owner_id"] == bot_user_id
124
+ end
125
+
126
+ [channel_info, is_thread, is_dm, in_own_thread]
127
+ end
128
+
129
+ def should_stand_down?(in_own_thread, mentioned, is_reply_to_bot, is_bot, agent_key, mentions, content)
130
+ return false unless in_own_thread && !mentioned && !is_reply_to_bot && !is_bot
131
+
132
+ other_bot_mentioned = false
133
+ Gateway.each_bot do |key, info|
134
+ next if key == agent_key
135
+ next unless info[:user_id]
136
+ next unless mentions.any? { |m| m["id"].to_s == info[:user_id].to_s } ||
137
+ content.match?(/<@!?#{Regexp.escape(info[:user_id].to_s)}>/)
138
+
139
+ other_bot_mentioned = true
140
+ break
141
+ end
142
+
143
+ unless other_bot_mentioned
144
+ Config.user_mappings.each_value do |discord_id|
145
+ next unless mentions.any? { |m| m["id"].to_s == discord_id.to_s } ||
146
+ content.match?(/<@!?#{Regexp.escape(discord_id.to_s)}>/)
147
+
148
+ other_bot_mentioned = true
149
+ break
150
+ end
151
+ end
152
+
153
+ if other_bot_mentioned
154
+ agent_display = agent_display_name(agent_key) || agent_key.capitalize
155
+ LOG.info "[Discord:#{agent_display}] Standing down in own thread — human directing message to another agent" if defined?(LOG)
156
+ end
157
+
158
+ other_bot_mentioned
159
+ end
160
+
161
+ def prepare_content(content, bot_user_id)
162
+ content.gsub(/<@!?#{bot_user_id}>/, "").strip
163
+ end
164
+
165
+ def append_attachments(clean_content, attachment_paths)
166
+ return clean_content if attachment_paths.empty?
167
+
168
+ clean_content += "\n\n" unless clean_content.empty?
169
+ clean_content + attachment_paths.join("\n")
170
+ end
171
+
172
+ def download_attachments(message, message_id, agent_key)
173
+ attachments = message["attachments"] || []
174
+ paths = []
175
+ agent_display = agent_display_name(agent_key) || agent_key.capitalize
176
+
177
+ attachments.each do |att|
178
+ url = att["url"]
179
+ filename = att["filename"]
180
+ content_type = att["content_type"] || ""
181
+ next unless content_type.start_with?("image/")
182
+
183
+ temp_dir = File.join(Delivery::BRAINIAC_DIR_PATH, "tmp", "discord", "attachments")
184
+ FileUtils.mkdir_p(temp_dir)
185
+ temp_path = File.join(temp_dir, "#{message_id}-#{filename}")
186
+
187
+ begin
188
+ uri = URI(url)
189
+ http = Net::HTTP.new(uri.host, uri.port)
190
+ http.use_ssl = true
191
+ response = http.get(uri.path + (uri.query ? "?#{uri.query}" : ""))
192
+
193
+ if response.code.to_i == 200
194
+ File.binwrite(temp_path, response.body)
195
+ paths << temp_path
196
+ LOG.info "[Discord:#{agent_display}] Downloaded attachment: #{filename} (#{content_type})" if defined?(LOG)
197
+ elsif defined?(LOG)
198
+ LOG.warn "[Discord:#{agent_display}] Failed to download attachment #{filename}: HTTP #{response.code}"
199
+ end
200
+ rescue StandardError => e
201
+ LOG.error "[Discord:#{agent_display}] Error downloading attachment #{filename}: #{e.message}" if defined?(LOG)
202
+ end
203
+ end
204
+ paths
205
+ end
206
+
207
+ def build_reply_context(referenced_message)
208
+ return "" unless referenced_message && referenced_message["content"]
209
+
210
+ ref_author = referenced_message.dig("author", "username") || "unknown"
211
+ ref_text = referenced_message["content"].strip
212
+ return "" if ref_text.empty?
213
+
214
+ "**Replying to #{ref_author}:**\n> #{ref_text}\n\n"
215
+ end
216
+
217
+ def ensure_channel_info(channel_info, is_thread, is_dm, channel_id, bot_token)
218
+ return [channel_info, is_thread, is_dm] if channel_info
219
+
220
+ channel_info = Api.fetch_channel_info(channel_id, token: bot_token)
221
+ is_thread = channel_info && [11, 12].include?(channel_info["type"])
222
+ is_dm = channel_info && channel_info["type"] == 1
223
+ [channel_info, is_thread, is_dm]
224
+ end
225
+
226
+ def authorize_user(discord_user, discord_user_id, message, channel_id, message_id, agent_name, bot_token)
227
+ authorized_users = Config.authorized_user_ids
228
+ authorized_roles = Config.authorized_role_ids
229
+
230
+ return true if authorized_users.empty? && authorized_roles.empty?
231
+
232
+ user_authorized = authorized_users.include?(discord_user_id)
233
+ member_roles = message.dig("member", "roles") || []
234
+
235
+ if member_roles.empty? && message["guild_id"]
236
+ guild_member = Api.fetch_guild_member(message["guild_id"], discord_user_id, token: bot_token)
237
+ member_roles = guild_member["roles"] || [] if guild_member
238
+ end
239
+
240
+ role_authorized = member_roles.intersect?(authorized_roles)
241
+
242
+ unless user_authorized || role_authorized
243
+ if defined?(LOG)
244
+ LOG.info "[Discord:#{agent_name}] Unauthorized user #{discord_user} (#{discord_user_id}), roles: #{member_roles.inspect}"
245
+ end
246
+ Api.add_reaction(channel_id, message_id, "🚫", token: bot_token)
247
+ return false
248
+ end
249
+
250
+ true
251
+ end
252
+
253
+ def resolve_project(inline_project_key, parent_channel_id, agent_name, channel_id, message_id, bot_token)
254
+ if inline_project_key && PROJECTS.key?(inline_project_key)
255
+ project_key = inline_project_key
256
+ project_config = PROJECTS[inline_project_key]
257
+ LOG.info "[Discord:#{agent_name}] Using inline project: #{project_key} (#{project_config["repo_path"]})" if defined?(LOG)
258
+ else
259
+ if inline_project_key && !PROJECTS.key?(inline_project_key)
260
+ if defined?(LOG)
261
+ LOG.warn "[Discord:#{agent_name}] Unknown inline project '#{inline_project_key}', " \
262
+ "falling back to channel mapping. Available: #{PROJECTS.keys.join(", ")}"
263
+ end
264
+ Thread.new { Api.add_reaction(channel_id, message_id, "⚠️", token: bot_token) }
265
+ end
266
+ project_key, project_config, _mapping = Config.find_project_for_channel(parent_channel_id)
267
+ if project_key
268
+ LOG.info "[Discord:#{agent_name}] Using channel-mapped project: #{project_key}" if defined?(LOG)
269
+ elsif defined?(LOG)
270
+ LOG.info "[Discord:#{agent_name}] No project context (no inline tag or channel mapping)"
271
+ end
272
+ end
273
+ [project_key, project_config]
274
+ end
275
+
276
+ def route_dispatch(agent_key:, agent_name:, bot_token:, is_bot:, channel_id:, message_id:, message:,
277
+ clean_content:, clean_content_for_prompt:, chat_mode:, is_thread:, is_dm:,
278
+ channel_info:, parent_channel_id:, discord_user:, reply_context:,
279
+ channel_history:, project_key:, project_config:, attachment_paths:)
280
+ session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
281
+ supersede_key = "discord-#{agent_key}-#{channel_id}"
282
+ if session_active?(session_key)
283
+ Api.add_reaction(channel_id, message_id, "⏳", token: bot_token)
284
+ return
285
+ end
286
+ handle_supersede(is_bot, supersede_key, discord_user, agent_name, bot_token)
287
+ Thread.new do
288
+ Api.remove_reaction(channel_id, message_id, "🛑", token: bot_token)
289
+ Api.add_reaction(channel_id, message_id, "👀", token: bot_token)
290
+ end
291
+
292
+ begin
293
+ project_context = build_project_context(project_key, project_config, agent_name)
294
+
295
+ dispatch_session(
296
+ agent_key: agent_key, agent_name: agent_name, bot_token: bot_token,
297
+ channel_id: channel_id, message_id: message_id, message: message,
298
+ clean_content: clean_content, clean_content_for_prompt: clean_content_for_prompt,
299
+ chat_mode: chat_mode, is_thread: is_thread, is_dm: is_dm,
300
+ channel_info: channel_info, parent_channel_id: parent_channel_id,
301
+ discord_user: discord_user, reply_context: reply_context,
302
+ channel_history: channel_history, project_key: project_key,
303
+ project_config: project_config, project_context: project_context,
304
+ session_key: session_key, supersede_key: supersede_key,
305
+ attachment_paths: attachment_paths, is_bot: is_bot
306
+ )
307
+ rescue StandardError => e
308
+ LOG.error "[Discord:#{agent_name}] Dispatch failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}" if defined?(LOG)
309
+ Thread.new do
310
+ Api.remove_reaction(channel_id, message_id, "👀", token: bot_token)
311
+ Api.add_reaction(channel_id, message_id, "❌", token: bot_token)
312
+ end
313
+ error_msg = "⚠️ Something went wrong while preparing your request:\n```\n#{e.message.lines.first(3).join.strip}\n```"
314
+ Api.send_message(channel_id, error_msg, token: bot_token, reply_to: message_id)
315
+ end
316
+ end
317
+
318
+ def handle_supersede(is_bot, supersede_key, discord_user, agent_name, bot_token)
319
+ return if is_bot
320
+
321
+ prev = find_supersedable_session(supersede_key)
322
+ return unless prev
323
+
324
+ if defined?(LOG)
325
+ LOG.info "[Discord:#{agent_name}] Superseding previous session #{prev[:session_key]} (pid: #{prev[:pid]}) for follow-up from #{discord_user}"
326
+ end
327
+ kill_session(prev[:session_key])
328
+ if prev[:message_id] && prev[:channel_id]
329
+ Thread.new do
330
+ Api.remove_reaction(prev[:channel_id], prev[:message_id], "👀", token: bot_token)
331
+ Api.add_reaction(prev[:channel_id], prev[:message_id], "❌", token: bot_token)
332
+ end
333
+ end
334
+ (prev[:draft_files] || []).each { |f| FileUtils.rm_f(f) }
335
+ end
336
+
337
+ def build_project_context(project_key, project_config, agent_name)
338
+ if project_config
339
+ repo_path = project_config["repo_path"]
340
+ debounced_repo_fetch(repo_path)
341
+ default_branch = get_default_branch(repo_path)
342
+ lines = ["## Project Context", "Project: #{project_key}", "Source directory: `#{repo_path}`",
343
+ "Default branch: `#{default_branch}`"]
344
+ lines << "GitHub: #{project_config["github_repo"]}" if project_config["github_repo"]
345
+ lines << ""
346
+ lines << "This is the project's source code directory. When asked to modify, inspect, or work on this project, " \
347
+ "go directly to `#{repo_path}` — do NOT search for it."
348
+ lines << ""
349
+ lines << "### All registered projects"
350
+ PROJECTS.each { |key, cfg| lines << "- **#{key}**: `#{cfg["repo_path"]}`" }
351
+ LOG.info "[Discord:#{agent_name}] Built project context for #{project_key} (#{repo_path})" if defined?(LOG)
352
+ else
353
+ lines = ["## Project Context", "No specific project mapped to this channel.", "",
354
+ "### Registered projects (use `[project:name]` to target one)"]
355
+ PROJECTS.each { |key, cfg| lines << "- **#{key}**: `#{cfg["repo_path"]}`" }
356
+ LOG.info "[Discord:#{agent_name}] No project context - showing available projects" if defined?(LOG)
357
+ end
358
+ lines.join("\n")
359
+ end
360
+
361
+ def dispatch_session(agent_key:, agent_name:, bot_token:, channel_id:, message_id:, message:,
362
+ clean_content:, clean_content_for_prompt:, chat_mode:, is_thread:, is_dm:,
363
+ channel_info:, parent_channel_id:, discord_user:, reply_context:,
364
+ channel_history:, project_key:, project_config:, project_context:,
365
+ session_key:, supersede_key:, attachment_paths:, is_bot:)
366
+ timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
367
+ response_dir = File.join(Delivery::BRAINIAC_DIR_PATH, "tmp")
368
+ response_basename = "discord-response-#{timestamp}-#{agent_key}-#{message_id}"
369
+ response_file = File.join(Delivery::DRAFT_DIR, "#{response_basename}.md")
370
+
371
+ root_message = Api.find_root_message(message, channel_id, bot_token)
372
+ card_id = is_thread ? "discord-#{parent_channel_id}-#{channel_id}" : "discord-#{channel_id}-#{root_message[:id]}"
373
+ thread_root_context = build_thread_root_context(is_thread, root_message, parent_channel_id, channel_id, bot_token)
374
+ brain_context = build_brain_context(agent_name: agent_name, card_title: clean_content, comment_body: clean_content)
375
+
376
+ should_resume, thread_worktree_path, thread_cli_provider, thread_model, thread_effort = manage_worktree(
377
+ agent_key: agent_key, agent_name: agent_name, channel_id: channel_id, message_id: message_id,
378
+ is_thread: is_thread, is_dm: is_dm, project_config: project_config, clean_content: clean_content,
379
+ chat_mode: chat_mode, bot_token: bot_token
380
+ )
381
+
382
+ prompt = build_prompt(
383
+ should_resume: should_resume, thread_worktree_path: thread_worktree_path,
384
+ clean_content_for_prompt: clean_content_for_prompt,
385
+ discord_user: discord_user, channel_name: channel_info&.dig("name") || channel_id, reply_context: reply_context,
386
+ channel_history: channel_history, thread_root_context: thread_root_context,
387
+ project_context: project_context, response_file: response_file, card_id: card_id,
388
+ brain_context: brain_context, agent_name: agent_name
389
+ )
390
+
391
+ work_dir = chat_mode_fallback(agent_key, agent_name, message_id, chat_mode, thread_worktree_path) ||
392
+ (project_config ? project_config["repo_path"] : Dir.pwd)
393
+ prompt_file = File.join(response_dir, "discord-prompt-#{timestamp}-#{agent_key}-#{message_id}.md")
394
+ File.write(prompt_file, prompt)
395
+
396
+ model, effort, cli_provider_override = resolve_overrides(
397
+ clean_content, project_config, thread_model, thread_effort, thread_cli_provider
398
+ )
399
+
400
+ meta_file = File.join(Delivery::DRAFT_DIR, "#{response_basename}.meta.json")
401
+ write_meta(meta_file,
402
+ channel_id: channel_id, message_id: message_id, agent_key: agent_key,
403
+ agent_name: agent_name, is_dm: is_dm, is_thread: is_thread,
404
+ clean_content: clean_content,
405
+ explicit_model: explicit_model_tag?(clean_content, project_config) ? model : nil,
406
+ explicit_effort: clean_content.match?(/\[effort:\w+\]/i) ? effort : nil)
407
+
408
+ spawn_agent(
409
+ agent_key: agent_key, agent_name: agent_name, bot_token: bot_token,
410
+ channel_id: channel_id, message_id: message_id, discord_user: discord_user,
411
+ work_dir: work_dir, prompt_file: prompt_file, response_file: response_file,
412
+ meta_file: meta_file, model: model, effort: effort, should_resume: should_resume,
413
+ cli_provider_override: cli_provider_override, project_config: project_config,
414
+ session_key: session_key, supersede_key: supersede_key,
415
+ attachment_paths: attachment_paths, timestamp: timestamp, response_dir: response_dir
416
+ )
417
+ end
418
+
419
+ def build_thread_root_context(is_thread, root_message, parent_channel_id, channel_id, bot_token)
420
+ return "" unless is_thread
421
+
422
+ root_content = root_message[:content]
423
+ root_author = root_message[:author]
424
+
425
+ if root_content.nil? || root_content.empty?
426
+ parent_msg = Api.fetch_message(parent_channel_id, channel_id, token: bot_token, log_errors: false)
427
+ if parent_msg && parent_msg["content"] && !parent_msg["content"].strip.empty?
428
+ root_content = parent_msg["content"].strip
429
+ root_author = parent_msg.dig("author", "username") || "unknown"
430
+ end
431
+ end
432
+
433
+ return "" unless root_content && !root_content.empty?
434
+
435
+ "### Original Message (thread starter)\n#{root_author || "unknown"}: #{root_content}\n\n"
436
+ end
437
+
438
+ def chat_mode_fallback(agent_key, agent_name, message_id, chat_mode, thread_worktree_path)
439
+ return thread_worktree_path unless chat_mode && !thread_worktree_path
440
+
441
+ chat_tmp_dir = File.join(Delivery::BRAINIAC_DIR_PATH, "tmp", "chat", "#{agent_key}-#{message_id}")
442
+ FileUtils.mkdir_p(chat_tmp_dir)
443
+ LOG.info "[Discord:#{agent_name}] Chat mode fallback tmp dir at #{chat_tmp_dir}" if defined?(LOG)
444
+ chat_tmp_dir
445
+ end
446
+
447
+ def resolve_overrides(clean_content, project_config, thread_model, thread_effort, thread_cli_provider)
448
+ cli_provider = detect_cli_provider(text: clean_content) || thread_cli_provider
449
+ has_explicit_model = explicit_model_tag?(clean_content, project_config)
450
+ has_explicit_effort = clean_content.match?(/\[effort:\w+\]/i)
451
+
452
+ model = if has_explicit_model
453
+ detect_model(project_config, text: clean_content)
454
+ elsif thread_model
455
+ thread_model
456
+ else
457
+ project_config ? detect_model(project_config, text: clean_content) : nil
458
+ end
459
+
460
+ effort = if has_explicit_effort
461
+ detect_effort(project_config, text: clean_content)
462
+ elsif thread_effort
463
+ thread_effort
464
+ else
465
+ project_config ? detect_effort(project_config, text: clean_content) : nil
466
+ end
467
+
468
+ [model, effort, cli_provider]
469
+ end
470
+
471
+ def explicit_model_tag?(clean_content, project_config)
472
+ return false unless project_config
473
+
474
+ allowed_models = resolve_project_cli_config(project_config)["allowed_models"] || {}
475
+ model_tag_match = clean_content.match(/\[(\w+)\]/i)
476
+ model_tag_match && allowed_models.key?(model_tag_match[1].downcase)
477
+ end
478
+
479
+ def write_meta(meta_file, channel_id:, message_id:, agent_key:, agent_name:, is_dm:, is_thread:,
480
+ clean_content:, explicit_model:, explicit_effort:)
481
+ File.write(meta_file, JSON.pretty_generate({
482
+ channel_id: channel_id, message_id: message_id,
483
+ agent_key: agent_key, agent_name: agent_name,
484
+ is_dm: is_dm, is_thread: is_thread,
485
+ clean_content: clean_content[0..80],
486
+ cli_provider: detect_cli_provider(text: clean_content),
487
+ model: explicit_model, effort: explicit_effort,
488
+ created_at: Time.now.iso8601
489
+ }))
490
+ end
491
+
492
+ def manage_worktree(agent_key:, agent_name:, channel_id:, message_id:, is_thread:, is_dm:, project_config:, clean_content:, chat_mode:,
493
+ bot_token:)
494
+ should_resume = false
495
+ thread_worktree_path = nil
496
+ thread_cli_provider = nil
497
+ thread_model = nil
498
+ thread_effort = nil
499
+
500
+ # Pre-create thread for channel messages with a project
501
+ pre_created_thread_id = nil
502
+ if !is_thread && !is_dm && project_config
503
+ pre_created_thread_id = pre_create_thread(agent_key, agent_name, channel_id, message_id, clean_content, bot_token)
504
+ end
505
+
506
+ effective_thread_id = is_thread ? channel_id : pre_created_thread_id
507
+ thread_map_key = "#{agent_key}:#{effective_thread_id}" if effective_thread_id
508
+
509
+ if project_config && thread_map_key
510
+ thread_map = Config.thread_map_mutex.synchronize { Config.load_thread_map }
511
+ existing = thread_map[thread_map_key]
512
+
513
+ if existing && (existing["chat_mode"] || (existing["worktree"] && File.directory?(existing["worktree"])))
514
+ thread_worktree_path = existing["worktree"]
515
+ thread_cli_provider = existing["cli_provider"]
516
+ thread_model = existing["model"]
517
+ thread_effort = existing["effort"]
518
+ chat_mode = true if existing["chat_mode"]
519
+ should_resume = thread_resume?(project_config, clean_content, thread_cli_provider, agent_name)
520
+ label = existing["chat_mode"] ? "chat mode tmp dir" : "thread worktree"
521
+ LOG.info "[Discord:#{agent_name}] Reusing #{label} at #{thread_worktree_path} (resume: #{should_resume})" if defined?(LOG)
522
+ elsif chat_mode
523
+ LOG.info "[Discord:#{agent_name}] Chat mode — skipping worktree creation" if defined?(LOG)
524
+ else
525
+ thread_worktree_path, thread_cli_provider, thread_model, thread_effort =
526
+ create_thread_worktree(agent_key, agent_name, effective_thread_id, project_config, clean_content, existing)
527
+ save_thread_map_entry(thread_map_key, thread_worktree_path,
528
+ branch: "discord-#{agent_key}-#{effective_thread_id[-8..]}",
529
+ project_config: project_config, effective_thread_id: effective_thread_id,
530
+ cli_provider: thread_cli_provider, model: thread_model, effort: thread_effort)
531
+ end
532
+ end
533
+
534
+ # Chat mode: create tmp dir if no worktree yet
535
+ if chat_mode && !thread_worktree_path && thread_map_key
536
+ thread_worktree_path, thread_cli_provider, thread_model, thread_effort =
537
+ create_chat_mode_dir(agent_key, agent_name, effective_thread_id, project_config, clean_content, thread_map_key)
538
+ end
539
+
540
+ [should_resume, thread_worktree_path, thread_cli_provider, thread_model, thread_effort]
541
+ end
542
+
543
+ def pre_create_thread(agent_key, agent_name, channel_id, message_id, clean_content, bot_token)
544
+ display_name = agent_display_name(agent_key)
545
+ thread = Api.create_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
546
+
547
+ unless thread && thread["id"]
548
+ LOG.warn "[Discord:#{agent_name}] Failed to pre-create thread — will run without worktree isolation" if defined?(LOG)
549
+ return nil
550
+ end
551
+
552
+ thread_id = thread["id"]
553
+ Delivery.shared_threads_mutex.synchronize { Delivery.shared_threads[message_id] = thread_id }
554
+
555
+ parent_depth_key = "discord-#{channel_id}"
556
+ thread_depth_key = "discord-#{thread_id}"
557
+ parent_info = AGENT_DISPATCH_DEPTH[parent_depth_key]
558
+ if parent_info
559
+ AGENT_DISPATCH_DEPTH[thread_depth_key] = { count: 0, last_human_at: parent_info[:last_human_at] }
560
+ else
561
+ record_human_comment(thread_depth_key)
562
+ end
563
+
564
+ LOG.info "[Discord:#{agent_name}] Pre-created thread #{thread_id} for worktree isolation" if defined?(LOG)
565
+ thread_id
566
+ end
567
+
568
+ def thread_resume?(project_config, clean_content, thread_cli_provider, agent_name)
569
+ effective_provider = detect_cli_provider(text: clean_content) || thread_cli_provider
570
+ resolved = resolve_project_cli_config(project_config, cli_provider_override: effective_provider, agent_name: agent_name)
571
+ resolved["resume_flag"] ? true : false
572
+ end
573
+
574
+ def detect_thread_overrides(project_config, clean_content, fallback_cli: nil, fallback_model: nil, fallback_effort: nil)
575
+ cli = detect_cli_provider(text: clean_content) || fallback_cli
576
+ model = (project_config ? detect_model(project_config, text: clean_content) : nil) || fallback_model
577
+ effort = (project_config ? detect_effort(project_config, text: clean_content) : nil) || fallback_effort
578
+ [cli, model, effort]
579
+ end
580
+
581
+ def create_thread_worktree(agent_key, agent_name, effective_thread_id, project_config, clean_content, existing)
582
+ repo_path = project_config["repo_path"]
583
+ branch = "discord-#{agent_key}-#{effective_thread_id[-8..]}"
584
+
585
+ debounced_repo_fetch(repo_path)
586
+ worktree_path = create_or_reuse_worktree(repo_path: repo_path, branch: branch)
587
+
588
+ cli, model, effort = detect_thread_overrides(
589
+ project_config, clean_content,
590
+ fallback_cli: existing&.dig("cli_provider"),
591
+ fallback_model: existing&.dig("model"),
592
+ fallback_effort: existing&.dig("effort")
593
+ )
594
+
595
+ LOG.info "[Discord:#{agent_name}] Created thread worktree at #{worktree_path}" if defined?(LOG)
596
+ [worktree_path, cli, model, effort]
597
+ end
598
+
599
+ def create_chat_mode_dir(agent_key, agent_name, effective_thread_id, project_config, clean_content, thread_map_key)
600
+ chat_tmp_dir = File.join(Delivery::BRAINIAC_DIR_PATH, "tmp", "chat", "#{agent_key}-#{effective_thread_id}")
601
+ FileUtils.mkdir_p(chat_tmp_dir)
602
+
603
+ cli, model, effort = detect_thread_overrides(project_config, clean_content)
604
+
605
+ save_thread_map_entry(thread_map_key, chat_tmp_dir,
606
+ chat_mode: true, project_config: project_config,
607
+ effective_thread_id: effective_thread_id,
608
+ cli_provider: cli, model: model, effort: effort)
609
+
610
+ LOG.info "[Discord:#{agent_name}] Created chat mode tmp dir at #{chat_tmp_dir}" if defined?(LOG)
611
+ [chat_tmp_dir, cli, model, effort]
612
+ end
613
+
614
+ def save_thread_map_entry(thread_map_key, worktree_path, project_config:, effective_thread_id:,
615
+ cli_provider: nil, model: nil, effort: nil, branch: nil, chat_mode: false)
616
+ Config.thread_map_mutex.synchronize do
617
+ map = Config.load_thread_map
618
+ entry = { "worktree" => worktree_path,
619
+ "project" => PROJECTS.find { |_k, v| v == project_config }&.first,
620
+ "channel_id" => effective_thread_id, "cli_provider" => cli_provider,
621
+ "model" => model, "effort" => effort, "created_at" => Time.now.iso8601 }
622
+ entry["branch"] = branch if branch
623
+ entry["chat_mode"] = true if chat_mode
624
+ map[thread_map_key] = entry
625
+ Config.save_thread_map(map)
626
+ end
627
+ end
628
+
629
+ def build_prompt(should_resume:, thread_worktree_path:, clean_content_for_prompt:,
630
+ discord_user:, channel_name:, reply_context:, channel_history:, thread_root_context:,
631
+ project_context:, response_file:, card_id:, brain_context:, agent_name:)
632
+ if should_resume && thread_worktree_path
633
+ return render_discord_resume_prompt(
634
+ message_body: clean_content_for_prompt, discord_user: discord_user,
635
+ response_file: response_file, agent_name: agent_name, card_id: card_id
636
+ )
637
+ end
638
+
639
+ template_vars = {
640
+ "DISCORD_USER" => discord_user, "CHANNEL_NAME" => channel_name,
641
+ "MESSAGE_BODY" => clean_content_for_prompt, "REPLY_CONTEXT" => reply_context,
642
+ "CHANNEL_HISTORY" => channel_history, "THREAD_ROOT_CONTEXT" => thread_root_context,
643
+ "PROJECT_CONTEXT" => project_context, "RESPONSE_FILE" => response_file,
644
+ "COMMENT_CREATOR" => discord_user, "DISCORD_MENTION_ROSTER" => Api.mention_roster
645
+ }
646
+
647
+ template_vars["CARD_ID"] = card_id
648
+ render_prompt(Prompts::SITUATION, template_vars,
649
+ brain_context: brain_context, agent_name: agent_name, channel: :discord)
650
+ end
651
+
652
+ def spawn_agent(agent_key:, agent_name:, bot_token:, channel_id:, message_id:, discord_user:,
653
+ work_dir:, prompt_file:, response_file:, meta_file:, model:, effort:,
654
+ should_resume:, cli_provider_override:, project_config:,
655
+ session_key:, supersede_key:, attachment_paths:, timestamp:, response_dir:)
656
+ agent_config_name = agent_key.downcase.gsub(/[^a-z0-9-]/, "-")
657
+ log_file = File.join(response_dir, "discord-agent-#{timestamp}-#{agent_key}-#{message_id}.log")
658
+
659
+ resolved = resolve_project_cli_config(project_config || DEFAULT_PROJECT,
660
+ cli_provider_override: cli_provider_override, agent_name: agent_name)
661
+ cmd = build_agent_cmd(resolved, agent_config_name: agent_config_name, model: model, effort: effort,
662
+ prompt_file: prompt_file, resume: should_resume)
663
+
664
+ resume_note = should_resume ? ", resuming" : ""
665
+ if defined?(LOG)
666
+ LOG.info "[Discord:#{agent_name}] Dispatching for #{discord_user} " \
667
+ "(model: #{model || "default"}, effort: #{effort || "default"}, cli: #{resolved["agent_cli"]}#{resume_note}), tail -f #{log_file}"
668
+ end
669
+ LOG.info "[Discord:#{agent_name}] Command: #{cmd.join(" ")}" if defined?(LOG)
670
+
671
+ spawn_env = {}
672
+ agent_env = agent_env_for(agent_name)
673
+ unless agent_env.empty?
674
+ spawn_env.merge!(agent_env)
675
+ LOG.info "[Discord:#{agent_name}] Injecting #{agent_env.size} env var(s): #{agent_env.keys.join(", ")}" if defined?(LOG)
676
+ end
677
+
678
+ head_before, status_before = capture_brainiac_state(project_config, work_dir)
679
+ prompt_mode = resolved["prompt_mode"] || "stdin"
680
+
681
+ pid = spawn(spawn_env, *cmd,
682
+ chdir: work_dir,
683
+ **(prompt_mode == "stdin" ? { in: prompt_file } : {}),
684
+ out: [log_file, "w"],
685
+ err: %i[child out])
686
+
687
+ register_session(session_key, pid, log_file: log_file,
688
+ message_id: message_id, channel_id: channel_id,
689
+ supersede_key: supersede_key,
690
+ draft_files: [response_file, meta_file],
691
+ agent_name: agent_name)
692
+
693
+ monitor_agent(
694
+ pid: pid, session_key: session_key, agent_name: agent_name,
695
+ agent_config_name: agent_config_name, channel_id: channel_id,
696
+ message_id: message_id, bot_token: bot_token, response_file: response_file,
697
+ meta_file: meta_file, prompt_file: prompt_file, log_file: log_file,
698
+ attachment_paths: attachment_paths, project_config: project_config,
699
+ head_before: head_before, status_before: status_before
700
+ )
701
+ end
702
+
703
+ def capture_brainiac_state(project_config, work_dir)
704
+ return [nil, nil] unless project_config
705
+
706
+ pk = PROJECTS.find { |_k, v| v == project_config }&.first
707
+ pk == "brainiac" ? capture_git_state(work_dir) : [nil, nil]
708
+ end
709
+
710
+ def monitor_agent(pid:, session_key:, agent_name:, agent_config_name:, channel_id:, message_id:,
711
+ bot_token:, response_file:, meta_file:, prompt_file:, log_file:,
712
+ attachment_paths:, project_config:, head_before:, status_before:)
713
+ Thread.new do
714
+ Process.wait(pid)
715
+ exit_status = $CHILD_STATUS
716
+
717
+ session_cancelled = ACTIVE_SESSIONS_MUTEX.synchronize { !ACTIVE_SESSIONS.key?(session_key) }
718
+
719
+ if exit_status.signaled? || session_cancelled
720
+ handle_cancelled(exit_status, session_cancelled, agent_name, message_id,
721
+ response_file, meta_file, prompt_file, attachment_paths)
722
+ next
723
+ end
724
+
725
+ handle_completed(
726
+ exit_status: exit_status, agent_name: agent_name, agent_config_name: agent_config_name,
727
+ channel_id: channel_id, message_id: message_id, bot_token: bot_token,
728
+ response_file: response_file, meta_file: meta_file, log_file: log_file,
729
+ project_config: project_config, head_before: head_before, status_before: status_before
730
+ )
731
+
732
+ schedule_temp_cleanup(prompt_file, attachment_paths)
733
+ rescue StandardError => e
734
+ LOG.error "[Discord:#{agent_name}] Error monitoring agent: #{e.message}" if defined?(LOG)
735
+ Api.add_reaction(channel_id, message_id, "❌", token: bot_token)
736
+ end
737
+ end
738
+
739
+ def handle_cancelled(exit_status, session_cancelled, agent_name, message_id,
740
+ response_file, meta_file, prompt_file, attachment_paths)
741
+ reason = session_cancelled ? "cancelled" : "superseded (signal: #{exit_status.termsig})"
742
+ LOG.info "[Discord:#{agent_name}] Agent was #{reason} for message #{message_id}" if defined?(LOG)
743
+ [response_file, meta_file].each { |f| FileUtils.rm_f(f) }
744
+ schedule_temp_cleanup(prompt_file, attachment_paths)
745
+ end
746
+
747
+ def handle_completed(exit_status:, agent_name:, agent_config_name:, channel_id:, message_id:,
748
+ bot_token:, response_file:, meta_file:, log_file:,
749
+ project_config:, head_before:, status_before:)
750
+ LOG.info "[Discord:#{agent_name}] Agent finished for message #{message_id} (exit: #{exit_status.exitstatus})" if defined?(LOG)
751
+
752
+ if exit_status.exitstatus && exit_status.exitstatus != 0 && !File.exist?(response_file)
753
+ notify_agent_crash(
754
+ exit_status: exit_status.exitstatus, log_file: log_file,
755
+ agent_name: agent_name, source: :discord,
756
+ source_context: { channel_id: channel_id, message_id: message_id, bot_token: bot_token },
757
+ project_config: project_config
758
+ )
759
+ elsif exit_status.exitstatus && exit_status.exitstatus != 0
760
+ if defined?(LOG)
761
+ LOG.warn "[Discord:#{agent_name}] Agent exited with code #{exit_status.exitstatus} but response file exists — " \
762
+ "likely crashed during post-task reflection."
763
+ end
764
+ log_post_task_crash_diagnostics(log_file, agent_name)
765
+ end
766
+
767
+ extract_response_from_log(response_file, meta_file, log_file, exit_status, agent_name, agent_config_name, message_id)
768
+ deliver_response(agent_name, channel_id, message_id, bot_token, response_file, meta_file)
769
+ update_brain_after_session(agent_name, message_id)
770
+
771
+ project_key = PROJECTS.find { |_k, v| v == project_config }&.first
772
+ check_brainiac_restart(head_before, status_before, project_config&.dig("repo_path"), project_key, agent_config_name)
773
+ end
774
+
775
+ def deliver_response(agent_name, channel_id, message_id, bot_token, response_file, meta_file)
776
+ Api.remove_reaction(channel_id, message_id, "👀", token: bot_token)
777
+ sleep 0.5
778
+
779
+ delivered = Delivery.deliver_draft(response_file, meta_file)
780
+ return if delivered
781
+
782
+ response_basename_check = File.basename(response_file)
783
+ already_posted = File.exist?(File.join(Delivery::POSTED_DIR, response_basename_check))
784
+ return if already_posted
785
+
786
+ LOG.warn "[Discord:#{agent_name}] No response produced for message #{message_id}" if defined?(LOG)
787
+ Api.add_reaction(channel_id, message_id, "😶", token: bot_token)
788
+ end
789
+
790
+ def update_brain_after_session(agent_name, message_id)
791
+ qmd_out, qmd_status = Open3.capture2e("qmd", "update")
792
+ if qmd_status.success?
793
+ LOG.info "[Brain] qmd update completed after #{agent_name} Discord session" if defined?(LOG)
794
+ elsif defined?(LOG)
795
+ LOG.warn "[Brain] qmd update failed: #{qmd_out.strip}"
796
+ end
797
+
798
+ brain_push(message: "#{agent_name}: discord-#{message_id}")
799
+ end
800
+
801
+ def schedule_temp_cleanup(prompt_file, attachment_paths)
802
+ Thread.new do
803
+ sleep 300
804
+ [prompt_file, *attachment_paths].each { |f| FileUtils.rm_f(f) }
805
+ end
806
+ end
807
+
808
+ def extract_response_from_log(response_file, meta_file, log_file, exit_status, agent_name, agent_config_name, message_id)
809
+ return if File.exist?(response_file) || !File.exist?(log_file)
810
+
811
+ log_content = File.read(log_file)
812
+
813
+ if exit_status.exitstatus != 0 && log_content.match?(/InternalServerError|Encountered an unexpected error|Failed to receive the next message/i)
814
+ LOG.warn "[Discord:#{agent_name}] Agent hit an upstream error for message #{message_id}" if defined?(LOG)
815
+ File.write(response_file, "_Sorry, I hit a temporary error on the backend. Please try again._")
816
+ elsif log_content.match?(/Opening browser\.\.\.|Press \(\^\) \+ C to cancel/)
817
+ if defined?(LOG)
818
+ LOG.error "[Discord:#{agent_name}] Auth failure detected — re-authenticate with: kiro-cli --agent #{agent_config_name} chat"
819
+ end
820
+ FileUtils.rm_f(meta_file)
821
+ else
822
+ clean_output = log_content
823
+ .gsub(/\e\[[0-9;]*[a-zA-Z]|\e\[\?[0-9;]*[a-zA-Z]/, "")
824
+ .gsub(/\e\][^\a]*\a/, "")
825
+ .delete("\r")
826
+ .gsub(/^.*?(using tool:.*?)$/m, "")
827
+ .gsub(/^.*?✓.*?$/m, "")
828
+ .gsub(/^.*?▸.*?$/m, "")
829
+ .gsub(/^.*?Loading\.\.\..*?$/m, "")
830
+ .gsub(/^.*?Completed in.*?$/m, "")
831
+ .strip
832
+
833
+ if !clean_output.empty? && clean_output.length > 20
834
+ File.write(response_file, clean_output)
835
+ LOG.info "[Discord:#{agent_name}] Extracted response from log (#{clean_output.length} chars)" if defined?(LOG)
836
+ end
837
+ end
838
+ end
839
+
840
+ def log_post_task_crash_diagnostics(log_file, agent_name)
841
+ return unless log_file && File.exist?(log_file)
842
+
843
+ content = File.read(log_file)
844
+ lines = content.lines.map { |l| l.gsub(/\e\[[0-9;]*[a-zA-Z]/, "").rstrip }
845
+
846
+ last_tool_idx = lines.rindex { |l| l.include?("using tool:") }
847
+ last_tool = last_tool_idx ? lines[last_tool_idx].strip : "unknown"
848
+ error_lines = lines.grep(/^error:|Tool approval required|Failed to receive/i)
849
+ tool_count = lines.count { |l| l.include?("using tool:") }
850
+ log_size_kb = (File.size(log_file) / 1024.0).round(1)
851
+
852
+ LOG.warn "[Discord:#{agent_name}] Post-task crash diagnostics:" if defined?(LOG)
853
+ LOG.warn "[Discord:#{agent_name}] Log size: #{log_size_kb}KB, tool calls: #{tool_count}" if defined?(LOG)
854
+ LOG.warn "[Discord:#{agent_name}] Last successful tool: #{last_tool[0..120]}" if defined?(LOG)
855
+ error_lines.each { |l| LOG.warn "[Discord:#{agent_name}] Error: #{l.strip[0..200]}" } if defined?(LOG)
856
+ rescue StandardError => e
857
+ LOG.warn "[Discord:#{agent_name}] Could not read crash diagnostics: #{e.message}" if defined?(LOG)
858
+ end
859
+ end
860
+ end
861
+ end
862
+ end
863
+ end