brainiac 0.0.9 → 0.0.11

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