brainiac 0.0.6 → 0.0.8

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +136 -6
  4. data/bin/brainiac +472 -44
  5. data/bin/brainiac-completion.bash +1 -1
  6. data/lib/brainiac/agents.rb +27 -74
  7. data/lib/brainiac/brain.rb +6 -6
  8. data/lib/brainiac/config.rb +40 -76
  9. data/lib/brainiac/handlers/discord/api.rb +196 -0
  10. data/lib/brainiac/handlers/discord/config.rb +134 -0
  11. data/lib/brainiac/handlers/discord/delivery.rb +196 -0
  12. data/lib/brainiac/handlers/discord/gateway.rb +212 -0
  13. data/lib/brainiac/handlers/discord/message.rb +933 -0
  14. data/lib/brainiac/handlers/discord/reactions.rb +215 -0
  15. data/lib/brainiac/handlers/discord.rb +14 -1892
  16. data/lib/brainiac/handlers/github.rb +134 -317
  17. data/lib/brainiac/handlers/shared/git.rb +190 -0
  18. data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
  19. data/lib/brainiac/handlers/zoho.rb +103 -153
  20. data/lib/brainiac/helpers.rb +43 -455
  21. data/lib/brainiac/hooks.rb +86 -0
  22. data/lib/brainiac/plugins.rb +154 -0
  23. data/lib/brainiac/prompts.rb +34 -172
  24. data/lib/brainiac/restart.rb +112 -0
  25. data/lib/brainiac/routes/api.rb +411 -0
  26. data/lib/brainiac/users.rb +1 -7
  27. data/lib/brainiac/version.rb +1 -1
  28. data/lib/brainiac/zoho_mail_api.rb +2 -1
  29. data/lib/brainiac.rb +8 -1
  30. data/monitor/daemon.rb +4 -27
  31. data/monitor/shared.rb +247 -0
  32. data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +17 -89
  33. data/monitor/waybar/setup.rb +232 -0
  34. data/monitor/waybar/status.rb +51 -0
  35. data/monitor/{view-logs-rofi.rb → waybar/view_logs.rb} +21 -88
  36. data/monitor/{deploy-env-macos.rb → xbar/deploy_env.rb} +3 -2
  37. data/monitor/xbar/plugin.rb +149 -0
  38. data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
  39. data/receiver.rb +44 -551
  40. data/templates/agents.json.example +1 -2
  41. data/templates/brainiac.json.example +8 -0
  42. data/templates/cli-providers/kiro.json.example +8 -2
  43. data/templates/plugins.json.example +3 -0
  44. data/templates/users.json.example +0 -3
  45. metadata +25 -23
  46. data/lib/brainiac/card_index.rb +0 -389
  47. data/lib/brainiac/deployments.rb +0 -258
  48. data/lib/brainiac/handlers/fizzy.rb +0 -1292
  49. data/lib/brainiac/planning.rb +0 -237
  50. data/lib/user_registry.rb +0 -159
  51. data/monitor/menubar.rb +0 -295
  52. data/monitor/setup-waybar-deploy-envs.rb +0 -121
  53. data/monitor/setup-waybar-deployments.rb +0 -96
  54. data/monitor/setup-waybar-module.rb +0 -113
  55. data/monitor/setup-xbar-plugin.rb +0 -35
  56. data/monitor/view-logs.rb +0 -119
  57. data/monitor/waybar-config-updater.rb +0 -56
  58. data/monitor/waybar-deployments.rb +0 -239
  59. data/monitor/waybar.rb +0 -146
  60. data/monitor/xbar.3s.rb +0 -179
  61. data/templates/fizzy.json.example +0 -24
  62. /data/monitor/{open-action.sh → xbar/open_action.sh} +0 -0
  63. /data/monitor/{view-logs-macos.rb → xbar/view_logs.rb} +0 -0
@@ -5,1898 +5,20 @@
5
5
  # Each agent with a `discord_bot_token` in the agent registry gets its own
6
6
  # Discord bot connection. Users @mention @Galen or @GLaDOS directly in Discord
7
7
  # rather than a single shared bot.
8
+ #
9
+ # This file loads all Discord sub-modules:
10
+ # discord/config.rb — Config loading, thread map, channel routing
11
+ # discord/api.rb — REST API helpers (also used by GitHub/Zoho for notifications)
12
+ # discord/delivery.rb — Draft file delivery, poller, shared thread management
13
+ # discord/reactions.rb — Reaction handler (cancel, thinking peek, feedback)
14
+ # discord/message.rb — Main message handler and agent dispatch
15
+ # discord/gateway.rb — WebSocket gateway connections per agent bot
8
16
 
9
17
  require "English"
10
- DISCORD_CONFIG_FILE = File.join(BRAINIAC_DIR, "discord.json")
11
- DISCORD_API_BASE = "https://discord.com/api/v10"
12
- DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"
13
-
14
- # Draft/posted directories for resilient Discord response delivery.
15
- # Response files land in draft/ with a .meta.json sidecar containing delivery info.
16
- # After successful posting, both files move to posted/.
17
- # A poller thread recovers orphaned drafts (e.g. after a server restart).
18
- DISCORD_DRAFT_DIR = File.join(BRAINIAC_DIR, "tmp", "discord", "draft")
19
- DISCORD_POSTED_DIR = File.join(BRAINIAC_DIR, "tmp", "discord", "posted")
20
- FileUtils.mkdir_p(DISCORD_DRAFT_DIR)
21
- FileUtils.mkdir_p(DISCORD_POSTED_DIR)
22
-
23
- # Per-bot state: { agent_key => { token:, user_id:, status:, thread: } }
24
- DISCORD_BOTS = {}
25
- DISCORD_BOTS_MUTEX = Mutex.new
26
- DISCORD_ALL_READY_LOGGED = { done: false }
27
-
28
- # Shared thread map: when multiple agents are mentioned in the same message,
29
- # the first to deliver creates the thread and stores its ID here so the rest
30
- # post into the same thread instead of creating duplicates.
31
- # Key: original message_id, Value: thread channel ID
32
- DISCORD_SHARED_THREADS = {}
33
- DISCORD_SHARED_THREADS_MUTEX = Mutex.new
34
-
35
- # Discord thread worktree map: tracks worktrees created for Discord thread conversations.
36
- # Keyed by "agent_key:channel_id" → { worktree, branch, project, created_at }
37
- # Persisted to disk so sessions survive restarts.
38
- DISCORD_THREAD_MAP_FILE = File.join(BRAINIAC_DIR, "discord_thread_map.json")
39
- DISCORD_THREAD_MAP_MUTEX = Mutex.new
40
-
41
- def load_discord_thread_map
42
- return {} unless File.exist?(DISCORD_THREAD_MAP_FILE)
43
-
44
- JSON.parse(File.read(DISCORD_THREAD_MAP_FILE))
45
- rescue JSON::ParserError
46
- {}
47
- end
48
-
49
- def save_discord_thread_map(map)
50
- File.write(DISCORD_THREAD_MAP_FILE, JSON.pretty_generate(map))
51
- end
52
-
53
- # Brainiac restart queue: when an agent works on brainiac itself, queue a restart
54
- # instead of doing it immediately. A background thread checks every 30s and only
55
- # restarts when no other agents are running, preventing mid-session kills.
56
- # Using a hash instead of a constant to allow mutation inside synchronize blocks
57
- BRAINIAC_RESTART_STATE = { queued: false, triggered_by: nil }
58
- BRAINIAC_RESTART_MUTEX = Mutex.new
59
-
60
- def queue_brainiac_restart(agent_name)
61
- BRAINIAC_RESTART_MUTEX.synchronize do
62
- unless BRAINIAC_RESTART_STATE[:queued]
63
- BRAINIAC_RESTART_STATE[:queued] = true
64
- BRAINIAC_RESTART_STATE[:triggered_by] = agent_name
65
- LOG.info "[Brainiac] #{agent_name} queued a restart — will execute when all agents finish"
66
- end
67
- end
68
- end
69
-
70
- # Send a Discord notification about brainiac restart/startup using any available bot token.
71
- def send_restart_notification(message)
72
- channel_id = DISCORD_CONFIG["notification_channel_id"]
73
- return unless channel_id
74
-
75
- tokens = discord_bot_tokens
76
- # Prefer the triggering agent's token, fall back to first available
77
- triggered_by = BRAINIAC_RESTART_MUTEX.synchronize { BRAINIAC_RESTART_STATE[:triggered_by] }
78
- token = tokens[triggered_by&.downcase] || tokens.values.first
79
- return unless token
80
-
81
- send_discord_message(channel_id, message, token: token)
82
- rescue StandardError => e
83
- LOG.warn "[Brainiac] Failed to send restart notification: #{e.message}"
84
- end
85
-
86
- def any_agents_running?
87
- ACTIVE_SESSIONS_MUTEX.synchronize do
88
- ACTIVE_SESSIONS.any? do |_key, info|
89
- Process.kill(0, info[:pid])
90
- true
91
- rescue Errno::ESRCH, Errno::EPERM
92
- false
93
- end
94
- end
95
- end
96
-
97
- def start_brainiac_restart_monitor
98
- Thread.new do
99
- LOG.info "[Brainiac] Restart monitor started, checking every 30s"
100
- loop do
101
- sleep 30
102
- restart_needed = BRAINIAC_RESTART_MUTEX.synchronize { BRAINIAC_RESTART_STATE[:queued] }
103
-
104
- if restart_needed && !any_agents_running?
105
- triggered_by = BRAINIAC_RESTART_MUTEX.synchronize { BRAINIAC_RESTART_STATE[:triggered_by] }
106
- LOG.info "[Brainiac] All agents finished, executing restart..."
107
- BRAINIAC_RESTART_MUTEX.synchronize { BRAINIAC_RESTART_STATE[:queued] = false }
108
-
109
- send_restart_notification("🔄 Restarting brainiac (triggered by #{triggered_by || "unknown"})...")
110
-
111
- # Schedule restart: stop now, start in 3 seconds
112
- # This ensures the current process fully exits before the new one starts
113
- Thread.new do
114
- sleep 1 # Give time for log to flush
115
-
116
- # Spawn a delayed restart command that will execute after we exit
117
- # Inherit current PATH so brainiac binary can be found regardless of install location
118
- # Process.detach ensures the spawned process survives when parent exits
119
- pid = spawn({ "PATH" => ENV.fetch("PATH", nil) }, "sh", "-c", "sleep 3 && brainiac server --daemon",
120
- out: "/dev/null", err: "/dev/null")
121
- Process.detach(pid)
122
-
123
- sleep 1
124
- LOG.info "[Brainiac] Stopping server, new instance will start in 3 seconds..."
125
- Sinatra::Application.quit!
126
- sleep 0.5 # Give Sinatra a moment to shut down gracefully
127
- exit! # Force exit to kill all threads immediately
128
- end
129
- elsif restart_needed
130
- active_count = ACTIVE_SESSIONS_MUTEX.synchronize { ACTIVE_SESSIONS.size }
131
- LOG.info "[Brainiac] Restart queued but #{active_count} agent(s) still running, waiting..."
132
- end
133
- end
134
- end
135
- end
136
-
137
- def load_discord_config
138
- default = { "channel_mappings" => {}, "authorized_role_ids" => [], "authorized_user_ids" => [] }
139
- return default unless File.exist?(DISCORD_CONFIG_FILE)
140
-
141
- JSON.parse(File.read(DISCORD_CONFIG_FILE))
142
- rescue JSON::ParserError => e
143
- LOG.error "Failed to parse discord config: #{e.message}"
144
- default
145
- end
146
-
147
- DISCORD_CONFIG = load_discord_config
148
-
149
- def reload_discord_config!
150
- DISCORD_CONFIG.replace(load_discord_config)
151
- end
152
-
153
- # Collect all agent Discord bot tokens from the registry.
154
- # Returns { "galen" => "token...", "glados" => "token..." }
155
- def discord_bot_tokens
156
- tokens = {}
157
- AGENT_REGISTRY.each do |key, entry|
158
- next unless entry.is_a?(Hash)
159
-
160
- token = (entry["env"] || {})["DISCORD_BOT_TOKEN"]
161
- next unless token
162
-
163
- tokens[key] = token
164
- end
165
- tokens
166
- end
167
-
168
- # --- Discord REST API ---
169
-
170
- def discord_api(method, path, token:, body: nil, log_errors: true)
171
- uri = URI("#{DISCORD_API_BASE}#{path}")
172
- http = Net::HTTP.new(uri.host, uri.port)
173
- http.use_ssl = true
174
-
175
- req = case method
176
- when :get then Net::HTTP::Get.new(uri)
177
- when :post then Net::HTTP::Post.new(uri)
178
- when :put then Net::HTTP::Put.new(uri)
179
- when :delete then Net::HTTP::Delete.new(uri)
180
- end
181
-
182
- req["Authorization"] = "Bot #{token}"
183
- req["Content-Type"] = "application/json"
184
- req.body = body.to_json if body
185
-
186
- response = http.request(req)
187
-
188
- if response.code.to_i == 429
189
- retry_after = JSON.parse(response.body)["retry_after"] || 1
190
- LOG.warn "Discord rate limited, waiting #{retry_after}s"
191
- sleep retry_after
192
- return discord_api(method, path, token: token, body: body, log_errors: log_errors)
193
- end
194
-
195
- LOG.error "Discord API error (#{method} #{path}): HTTP #{response.code} - #{response.body}" if response.code.to_i >= 400 && log_errors
196
-
197
- JSON.parse(response.body) unless response.body.nil? || response.body.empty?
198
- rescue StandardError => e
199
- LOG.error "Discord API error (#{method} #{path}): #{e.message}" if log_errors
200
- nil
201
- end
202
-
203
- def fetch_discord_channel_history(channel_id, before_message_id, token:, limit: 10)
204
- messages = discord_api(:get, "/channels/#{channel_id}/messages?before=#{before_message_id}&limit=#{limit}", token: token)
205
-
206
- all_messages = messages.is_a?(Array) ? messages : []
207
-
208
- # If we're in a thread, check if the oldest message is a THREAD_STARTER_MESSAGE (type 21).
209
- # These messages have no content but point to the original message via referenced_message.
210
- # We need to include that original message for full context.
211
- if all_messages.any?
212
- oldest = all_messages.last # API returns newest-first
213
- if oldest && oldest["type"] == 21 && oldest["referenced_message"]
214
- # Prepend the actual starter message content
215
- all_messages << oldest["referenced_message"]
216
- end
217
- end
218
-
219
- return "" if all_messages.empty?
220
-
221
- # Messages come newest-first from the API, reverse for chronological order
222
- lines = all_messages.reverse.filter_map do |msg|
223
- author = msg.dig("author", "username") || "unknown"
224
- content = msg["content"]&.strip || ""
225
- next if content.empty?
226
-
227
- "#{author}: #{content}"
228
- end
229
-
230
- return "" if lines.empty?
231
-
232
- lines.join("\n")
233
- rescue StandardError => e
234
- LOG.warn "Failed to fetch channel history: #{e.message}"
235
- ""
236
- end
237
-
238
- def fetch_channel_info(channel_id, token:)
239
- discord_api(:get, "/channels/#{channel_id}", token: token)
240
- end
241
-
242
- def forum_channel?(channel_id, token:)
243
- info = fetch_channel_info(channel_id, token: token)
244
- info && info["type"] == 15
245
- end
246
-
247
- def find_latest_forum_thread(channel_id, token:)
248
- # Get the guild ID from the channel info, then list active threads
249
- channel_info = fetch_channel_info(channel_id, token: token)
250
- return nil unless channel_info && channel_info["guild_id"]
251
-
252
- guild_id = channel_info["guild_id"]
253
- result = discord_api(:get, "/guilds/#{guild_id}/threads/active", token: token)
254
- return nil unless result && result["threads"]
255
-
256
- # Filter to threads in this forum channel, sort by creation (newest first)
257
- forum_threads = result["threads"]
258
- .select { |t| t["parent_id"] == channel_id }
259
- .sort_by { |t| t["id"].to_i }
260
- .reverse
261
-
262
- return nil if forum_threads.empty?
263
-
264
- latest = forum_threads.first
265
- LOG.info "[Discord] Found latest forum thread: #{latest["id"]} (#{latest["name"]}) in channel #{channel_id}"
266
- latest
267
- end
268
-
269
- def create_forum_post(channel_id, title:, content:, token:)
270
- thread_name = title.length > 100 ? "#{title[0..96]}..." : title
271
- result = discord_api(:post, "/channels/#{channel_id}/threads", token: token, body: {
272
- name: thread_name,
273
- message: { content: content },
274
- auto_archive_duration: 1440
275
- })
276
- if result && result["id"]
277
- LOG.info "[Discord] Forum post created in channel #{channel_id}, thread_id: #{result["id"]}"
278
- else
279
- LOG.error "[Discord] Failed to create forum post in channel #{channel_id}, result: #{result.inspect}"
280
- end
281
- result
282
- end
283
-
284
- def send_discord_message(channel_id, content, token:, reply_to: nil)
285
- body = { content: content }
286
- body[:message_reference] = { message_id: reply_to } if reply_to
287
- result = discord_api(:post, "/channels/#{channel_id}/messages", token: token, body: body)
288
- if result && result["id"]
289
- LOG.info "[Discord] Message posted successfully to channel #{channel_id}, message_id: #{result["id"]}"
290
- else
291
- LOG.error "[Discord] Failed to post message to channel #{channel_id}, result: #{result.inspect}"
292
- end
293
- result
294
- end
295
-
296
- def send_discord_typing(channel_id, token:)
297
- discord_api(:post, "/channels/#{channel_id}/typing", token: token)
298
- end
299
-
300
- def fetch_discord_message(channel_id, message_id, token:, log_errors: true)
301
- discord_api(:get, "/channels/#{channel_id}/messages/#{message_id}", token: token, log_errors: log_errors)
302
- end
303
-
304
- # Emojis reserved for brainiac functionality — not treated as feedback
305
- RESERVED_EMOJIS = %w[👀 ❌ 🛑 🚫 ⚠️ ⏳ 😶 ❔ ❓ 🧠].freeze
306
-
307
- def add_discord_reaction(channel_id, message_id, emoji, token:)
308
- encoded = URI.encode_www_form_component(emoji)
309
- discord_api(:put, "/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me", token: token)
310
- end
311
-
312
- def remove_discord_reaction(channel_id, message_id, emoji, token:)
313
- encoded = URI.encode_www_form_component(emoji)
314
- discord_api(:delete, "/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me", token: token)
315
- end
316
-
317
- def create_discord_thread(channel_id, message_id, name:, token:)
318
- thread_name = name.length > 100 ? "#{name[0..96]}..." : name
319
- discord_api(:post, "/channels/#{channel_id}/messages/#{message_id}/threads", token: token, body: {
320
- name: thread_name,
321
- auto_archive_duration: 1440
322
- })
323
- end
324
-
325
- def fetch_guild_member(guild_id, user_id, token:)
326
- discord_api(:get, "/guilds/#{guild_id}/members/#{user_id}", token: token)
327
- end
328
-
329
- def send_long_discord_message(channel_id, content, token:, reply_to: nil)
330
- if content.length <= 2000
331
- send_discord_message(channel_id, content, token: token, reply_to: reply_to)
332
- return
333
- end
334
-
335
- chunks = []
336
- remaining = content
337
- while remaining.length.positive?
338
- if remaining.length <= 2000
339
- chunks << remaining
340
- remaining = ""
341
- else
342
- split_at = remaining.rindex("\n", 1990) || 1990
343
- chunks << remaining[0...split_at]
344
- remaining = remaining[split_at..].lstrip
345
- end
346
- end
347
-
348
- chunks.each_with_index do |chunk, i|
349
- send_discord_message(channel_id, chunk, token: token, reply_to: i.zero? ? reply_to : nil)
350
- sleep 0.5
351
- end
352
- end
353
-
354
- def find_project_for_discord_channel(channel_id)
355
- mapping = DISCORD_CONFIG.dig("channel_mappings", channel_id)
356
-
357
- unless mapping
358
- default_project = DISCORD_CONFIG["default_project"]
359
- mapping = { "project" => default_project } if default_project
360
- end
361
-
362
- return nil unless mapping
363
-
364
- project_key = mapping["project"]
365
- project_config = PROJECTS[project_key]
366
- return nil unless project_config
367
-
368
- [project_key, project_config, mapping]
369
- end
370
-
371
- # Find the root message for a conversation thread.
372
- # Walks back through message_reference chain to find the original message.
373
- # Returns { id: root_message_id, content: root_message_text, author: username }
374
- # or { id: current_message_id, content: nil, author: nil } if already the root.
375
- def find_root_message(message, channel_id, bot_token)
376
- current_msg = message
377
- visited = Set.new
378
- max_depth = 20 # Prevent infinite loops
379
- walked = false
380
-
381
- max_depth.times do
382
- msg_id = current_msg["id"]
383
- return { id: msg_id, content: nil, author: nil } if visited.include?(msg_id) # Loop detected
384
-
385
- visited << msg_id
386
-
387
- ref = current_msg["message_reference"]
388
- break unless ref
389
-
390
- ref_msg_id = ref["message_id"]
391
- ref_channel = ref["channel_id"] || channel_id
392
- break unless ref_msg_id
393
-
394
- # Fetch the referenced message
395
- referenced = discord_api(:get, "/channels/#{ref_channel}/messages/#{ref_msg_id}", token: bot_token)
396
- break unless referenced
397
-
398
- current_msg = referenced
399
- walked = true
400
- end
401
-
402
- {
403
- id: current_msg["id"],
404
- content: walked ? current_msg["content"]&.strip : nil,
405
- author: walked ? current_msg.dig("author", "username") : nil
406
- }
407
- end
408
-
409
- # Build a Discord mention roster so the agent can @mention people and other bots.
410
- # Discord requires `<@USER_ID>` syntax — plain text "@Name" doesn't work.
411
- # Sources:
412
- # - Other agent bots: DISCORD_BOTS (populated at gateway READY)
413
- # - Human users: discord.json "user_mappings" (manually maintained)
414
- def discord_mention_roster
415
- lines = []
416
-
417
- # Agent bots
418
- DISCORD_BOTS_MUTEX.synchronize do
419
- DISCORD_BOTS.each do |agent_key, info|
420
- next unless info[:user_id]
421
-
422
- display = fizzy_display_name(agent_key) || agent_key.capitalize
423
- lines << " - #{display}: `<@#{info[:user_id]}>`"
424
- end
425
- end
426
-
427
- # Human users from config
428
- user_mappings = DISCORD_CONFIG["user_mappings"] || {}
429
- user_mappings.each do |name, discord_id|
430
- lines << " - #{name}: `<@#{discord_id}>`"
431
- end
432
-
433
- lines.join("\n")
434
- end
435
-
436
- # Handle an incoming Discord message for a specific agent bot.
437
- # agent_key: the lowercase agent key (e.g. "galen")
438
- # bot_token: the Discord bot token for this agent
439
- # bot_user_id: the Discord user ID of this bot
440
- def handle_discord_message(message, agent_key, bot_token, bot_user_id)
441
- channel_id = message["channel_id"]
442
- message_id = message["id"]
443
- author = message["author"]
444
- content = message["content"] || ""
445
-
446
- is_bot = !author["bot"].nil?
447
-
448
- # Identify if the author is a known agent bot (local or remote).
449
- # Local agents are in DISCORD_BOTS; remote agents (running on other machines)
450
- # are recognized via discord.json "user_mappings".
451
- sender_agent_key = nil
452
- if is_bot
453
- sender_id = author["id"]
454
-
455
- # Check local bots first
456
- DISCORD_BOTS_MUTEX.synchronize do
457
- DISCORD_BOTS.each do |key, info|
458
- if info[:user_id] == sender_id && key != agent_key
459
- sender_agent_key = key
460
- break
461
- end
462
- end
463
- end
464
-
465
- # Check user_mappings for remote agents
466
- unless sender_agent_key
467
- user_mappings = DISCORD_CONFIG["user_mappings"] || {}
468
- user_mappings.each do |name, discord_id|
469
- if discord_id == sender_id
470
- sender_agent_key = name.downcase
471
- break
472
- end
473
- end
474
- end
475
-
476
- # Unknown bot or self — ignore entirely
477
- unless sender_agent_key
478
- LOG.info "[Discord:#{agent_key}] Ignoring unknown bot: id=#{sender_id}, username=#{author["username"]}, bot=#{author["bot"]}"
479
- return
480
- end
481
- end
482
-
483
- mentions = message["mentions"] || []
484
- mentioned = mentions.any? { |m| m["id"].to_s == bot_user_id.to_s }
485
-
486
- # Discord doesn't always populate the mentions array for bot-to-bot mentions.
487
- # Check the raw content for mention patterns as a fallback.
488
- mentioned ||= content.match?(/<@!?#{Regexp.escape(bot_user_id.to_s)}>/)
489
-
490
- # Check for @everyone mention (DISABLED — agents need to cool it)
491
- # unless mentioned
492
- # mentioned = message['mention_everyone'] == true
493
- # end
494
-
495
- # Cross-agent bot mention: only proceed if this bot is explicitly @mentioned
496
- # and the dispatch depth hasn't been exceeded (prevents infinite loops).
497
- if sender_agent_key
498
- unless mentioned
499
- fizzy_display_name(sender_agent_key) || sender_agent_key.capitalize
500
- agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
501
- # LOG.info "[Discord:#{agent_display}] Ignoring cross-agent message from #{sender_display} — not mentioned (content: #{content[0..100]})"
502
- return
503
- end
504
-
505
- # Skip dispatch when the message is a Fizzy card creation/assignment
506
- # announcement. The Fizzy webhook handles card assignments — dispatching
507
- # here too causes the mentioned agent to respond in Discord instead of
508
- # (or in addition to) the new card.
509
- if content.match?(/created\s+card\s+#?\d+/i) || content.match?(/assigned\s+.*card\s+#?\d+/i) || content.match?(/card\s+#?\d+.*assigned/i)
510
- sender_display = fizzy_display_name(sender_agent_key) || sender_agent_key.capitalize
511
- agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
512
- LOG.info "[Discord:#{agent_display}] Ignoring cross-agent mention from #{sender_display} — Fizzy card creation/assignment (handled by webhook)"
513
- return
514
- end
515
-
516
- depth_key = "discord-#{channel_id}"
517
- unless agent_dispatch_allowed?(depth_key)
518
- sender_display = fizzy_display_name(sender_agent_key) || sender_agent_key.capitalize
519
- agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
520
- LOG.info "[Discord:#{agent_display}] Blocking cross-agent dispatch from #{sender_display} — depth limit reached"
521
- return
522
- end
523
- record_agent_dispatch(depth_key)
524
- end
525
-
526
- # Detect if the message is a reply to one of this bot's own messages.
527
- # Discord replies include a `message_reference` but don't automatically add
528
- # the referenced author to the `mentions` array, so we check explicitly.
529
- # We also cache the referenced message for later use as reply context.
530
- is_reply_to_bot = false
531
- referenced_message = nil
532
- if message["message_reference"]
533
- ref_msg_id = message.dig("message_reference", "message_id")
534
- ref_channel = message.dig("message_reference", "channel_id") || channel_id
535
- if ref_msg_id
536
- referenced_message = discord_api(:get, "/channels/#{ref_channel}/messages/#{ref_msg_id}", token: bot_token)
537
- is_reply_to_bot = !mentioned && referenced_message && referenced_message.dig("author", "id") == bot_user_id
538
- end
539
- end
540
-
541
- # Detect if inside a thread (follow-up conversation) or a DM.
542
- # Only call the API if the message doesn't already have an explicit @mention,
543
- # to avoid unnecessary API calls on every message.
544
- channel_info = nil
545
- is_thread = false
546
- is_dm = false
547
- in_own_thread = false
548
-
549
- if !mentioned && !is_reply_to_bot
550
- channel_info = discord_api(:get, "/channels/#{channel_id}", token: bot_token)
551
- is_thread = channel_info && [11, 12].include?(channel_info["type"])
552
- is_dm = channel_info && channel_info["type"] == 1
553
- in_own_thread = is_thread && channel_info["owner_id"] == bot_user_id
554
- end
555
-
556
- # If we'd respond only because we own the thread (not explicitly mentioned,
557
- # not a reply to us), check whether the human is explicitly talking to a
558
- # DIFFERENT agent. If so, stand down — they're directing the conversation
559
- # elsewhere and we shouldn't butt in.
560
- if in_own_thread && !mentioned && !is_reply_to_bot && !is_bot
561
- other_bot_mentioned = false
562
- DISCORD_BOTS_MUTEX.synchronize do
563
- DISCORD_BOTS.each do |key, info|
564
- next if key == agent_key # skip self
565
- next unless info[:user_id]
566
-
567
- next unless mentions.any? { |m| m["id"].to_s == info[:user_id].to_s } ||
568
- content.match?(/<@!?#{Regexp.escape(info[:user_id].to_s)}>/)
569
-
570
- other_bot_mentioned = true
571
- break
572
- end
573
- end
574
-
575
- # Also check user_mappings for remote agent bots
576
- unless other_bot_mentioned
577
- user_mappings = DISCORD_CONFIG["user_mappings"] || {}
578
- user_mappings.each_value do |discord_id|
579
- next unless mentions.any? { |m| m["id"].to_s == discord_id.to_s } ||
580
- content.match?(/<@!?#{Regexp.escape(discord_id.to_s)}>/)
581
-
582
- other_bot_mentioned = true
583
- break
584
- end
585
- end
586
-
587
- if other_bot_mentioned
588
- agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
589
- LOG.info "[Discord:#{agent_display}] Standing down in own thread — human is directing message to another agent"
590
- return
591
- end
592
- end
593
-
594
- # In DMs, threads the bot created, and replies to the bot's own messages,
595
- # respond without requiring an explicit @mention.
596
- # In guild channels, require an explicit @mention.
597
- return unless mentioned || in_own_thread || is_dm || is_reply_to_bot
598
-
599
- # Human message resets the cross-agent dispatch depth for this channel/thread
600
- record_human_comment("discord-#{channel_id}") unless is_bot
601
-
602
- clean_content = content.gsub(/<@!?#{bot_user_id}>/, "").strip
603
-
604
- # Handle attachments (images, gifs, etc.)
605
- attachments = message["attachments"] || []
606
- attachment_paths = []
607
- agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
608
- attachments.each do |att|
609
- url = att["url"]
610
- filename = att["filename"]
611
- content_type = att["content_type"] || ""
612
-
613
- # Only process image attachments
614
- next unless content_type.start_with?("image/")
615
-
616
- # Download to temp directory
617
- temp_dir = File.join(BRAINIAC_DIR, "tmp", "discord", "attachments")
618
- FileUtils.mkdir_p(temp_dir)
619
- temp_path = File.join(temp_dir, "#{message_id}-#{filename}")
620
-
621
- begin
622
- uri = URI(url)
623
- http = Net::HTTP.new(uri.host, uri.port)
624
- http.use_ssl = true
625
- response = http.get(uri.path + (uri.query ? "?#{uri.query}" : ""))
626
-
627
- if response.code.to_i == 200
628
- File.binwrite(temp_path, response.body)
629
- attachment_paths << temp_path
630
- LOG.info "[Discord:#{agent_display}] Downloaded attachment: #{filename} (#{content_type})"
631
- else
632
- LOG.warn "[Discord:#{agent_display}] Failed to download attachment #{filename}: HTTP #{response.code}"
633
- end
634
- rescue StandardError => e
635
- LOG.error "[Discord:#{agent_display}] Error downloading attachment #{filename}: #{e.message}"
636
- end
637
- end
638
-
639
- # Append attachment paths to the message content so kiro-cli can process them
640
- unless attachment_paths.empty?
641
- clean_content += "\n\n" unless clean_content.empty?
642
- clean_content += attachment_paths.join("\n")
643
- end
644
-
645
- return if clean_content.empty? && attachment_paths.empty?
646
-
647
- # Build reply context from the cached referenced message.
648
- reply_context = ""
649
- if referenced_message && referenced_message["content"]
650
- ref_author = referenced_message.dig("author", "username") || "unknown"
651
- ref_text = referenced_message["content"].strip
652
- reply_context = "**Replying to #{ref_author}:**\n> #{ref_text}\n\n" unless ref_text.empty?
653
- end
654
-
655
- # Fetch recent channel history so the agent has conversational context.
656
- # (Moved after is_thread detection below — needs thread status for limit)
657
-
658
- discord_user = author["username"]
659
- discord_user_id = author["id"]
660
-
661
- # The agent name comes directly from the bot identity — no detection needed
662
- agent_name = fizzy_display_name(agent_key) || agent_key.capitalize
663
-
664
- # Fetch channel_info if we haven't already (mentioned path skipped it)
665
- unless channel_info
666
- channel_info = discord_api(:get, "/channels/#{channel_id}", token: bot_token)
667
- is_thread = channel_info && [11, 12].include?(channel_info["type"])
668
- is_dm = channel_info && channel_info["type"] == 1
669
- end
670
- parent_channel_id = is_thread ? channel_info&.dig("parent_id") || channel_id : channel_id
671
-
672
- # Fetch recent channel history — threads get a larger window since they're bounded conversations.
673
- history_limit = is_thread ? 25 : 10
674
- channel_history = fetch_discord_channel_history(channel_id, message_id, token: bot_token, limit: history_limit)
675
-
676
- LOG.info "[Discord:#{agent_name}] Message from #{discord_user} in #{if is_dm
677
- "DM"
678
- else
679
- is_thread ? "thread" : "channel"
680
- end} #{channel_id}: #{clean_content[0..100]}"
681
-
682
- reload_projects!
683
- reload_agent_registry!
684
- reload_discord_config!
685
-
686
- # Authorization
687
- authorized_users = DISCORD_CONFIG["authorized_user_ids"] || []
688
-
689
- # Support both role_mappings (hash) and authorized_role_ids (array or hash)
690
- # If authorized_role_ids is a hash, treat it like role_mappings
691
- authorized_roles = if DISCORD_CONFIG["role_mappings"]
692
- DISCORD_CONFIG["role_mappings"].values
693
- elsif DISCORD_CONFIG["authorized_role_ids"].is_a?(Hash)
694
- DISCORD_CONFIG["authorized_role_ids"].values
695
- else
696
- DISCORD_CONFIG["authorized_role_ids"] || []
697
- end
698
-
699
- # Ensure all role IDs are strings (Discord API returns strings)
700
- authorized_roles = authorized_roles.map(&:to_s)
701
-
702
- unless authorized_users.empty? && authorized_roles.empty?
703
- user_authorized = authorized_users.include?(discord_user_id)
704
-
705
- # Fetch member roles — message.member is not always populated, so we need to
706
- # fetch guild member info separately if we have a guild_id
707
- member_roles = message.dig("member", "roles") || []
708
-
709
- # If member roles aren't in the message and we have a guild_id, fetch them
710
- if member_roles.empty? && message["guild_id"]
711
- guild_member = fetch_guild_member(message["guild_id"], discord_user_id, token: bot_token)
712
- member_roles = guild_member["roles"] || [] if guild_member
713
- end
714
-
715
- role_authorized = member_roles.intersect?(authorized_roles)
716
-
717
- unless user_authorized || role_authorized
718
- LOG.info "[Discord:#{agent_name}] Unauthorized user #{discord_user} (#{discord_user_id}), roles: #{member_roles.inspect}"
719
- add_discord_reaction(channel_id, message_id, "🚫", token: bot_token)
720
- return
721
- end
722
- end
723
-
724
- # Inline tags: [project:my-project] and [model] anywhere in the message.
725
- # Both are parsed for routing/config and stripped from the prompt content.
726
- inline_project_key = nil
727
- if (proj_match = clean_content.match(/\[project:(\S+)\]/i))
728
- inline_project_key = proj_match[1]
729
- clean_content = clean_content.sub(proj_match[0], "").strip
730
- LOG.info "[Discord:#{agent_name}] Detected inline project tag: #{inline_project_key}"
731
- end
732
-
733
- # Strip model tag (e.g. [opus], [sonnet]) from prompt content — detect_model
734
- # reads the original clean_content later, but we save the tag-free version
735
- # for the actual prompt so the bracket noise doesn't leak through.
736
- inline_model_tag = clean_content.match(/\[\w+\]/)
737
- clean_content_for_prompt = inline_model_tag ? clean_content.sub(inline_model_tag[0], "").strip : clean_content
738
-
739
- # Strip effort tag (e.g. [effort:high]) from prompt content
740
- clean_content_for_prompt = clean_content_for_prompt.sub(/\[effort:\w+\]/i, "").strip
741
-
742
- # Strip CLI provider tag (e.g. [cli:grok]) from prompt content
743
- clean_content_for_prompt = clean_content_for_prompt.sub(/\[cli:\w+\]/i, "").strip
744
-
745
- # Detect chat mode tag: [chat], [question], [?] — skips worktree, uses tmp dir instead
746
- chat_mode = clean_content.match?(/\[(chat|question|\?)\]/i)
747
- if chat_mode
748
- clean_content = clean_content.sub(/\[(chat|question|\?)\]/i, "").strip
749
- clean_content_for_prompt = clean_content_for_prompt.sub(/\[(chat|question|\?)\]/i, "").strip
750
- LOG.info "[Discord:#{agent_name}] Chat mode detected — will skip worktree creation"
751
- end
752
-
753
- # Find project: inline override > channel mapping > default_project
754
- if inline_project_key && PROJECTS.key?(inline_project_key)
755
- project_key = inline_project_key
756
- project_config = PROJECTS[inline_project_key]
757
- LOG.info "[Discord:#{agent_name}] Using inline project: #{project_key} (#{project_config["repo_path"]})"
758
- else
759
- if inline_project_key && !PROJECTS.key?(inline_project_key)
760
- LOG.warn "[Discord:#{agent_name}] Unknown inline project '#{inline_project_key}', falling back to channel mapping. Available: #{PROJECTS.keys.join(", ")}"
761
- Thread.new { add_discord_reaction(channel_id, message_id, "⚠️", token: bot_token) }
762
- end
763
- project_key, project_config, _mapping = find_project_for_discord_channel(parent_channel_id)
764
- if project_key
765
- LOG.info "[Discord:#{agent_name}] Using channel-mapped project: #{project_key}"
766
- else
767
- LOG.info "[Discord:#{agent_name}] No project context (no inline tag or channel mapping)"
768
- end
769
- end
770
-
771
- session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
772
- supersede_key = "discord-#{agent_key}-#{channel_id}"
773
-
774
- if session_active?(session_key)
775
- add_discord_reaction(channel_id, message_id, "⏳", token: bot_token)
776
- return
777
- end
778
-
779
- # Supersede: if a human sends a follow-up within 60s, kill the previous agent run
780
- if !is_bot && (prev = find_supersedable_session(supersede_key))
781
- LOG.info "[Discord:#{agent_name}] Superseding previous session #{prev[:session_key]} (pid: #{prev[:pid]}) for follow-up from #{discord_user}"
782
- kill_session(prev[:session_key])
783
- # React on the OLD message to show it was cancelled
784
- if prev[:message_id] && prev[:channel_id]
785
- Thread.new do
786
- remove_discord_reaction(prev[:channel_id], prev[:message_id], "👀", token: bot_token)
787
- add_discord_reaction(prev[:channel_id], prev[:message_id], "❌", token: bot_token)
788
- end
789
- end
790
- # Clean up draft files from the superseded session so the poller doesn't deliver stale responses
791
- (prev[:draft_files] || []).each { |f| FileUtils.rm_f(f) }
792
- end
793
-
794
- # React in background — don't block the dispatch path
795
- # Remove 🛑 if it exists (user may have cancelled and is now retrying via edit)
796
- Thread.new do
797
- remove_discord_reaction(channel_id, message_id, "🛑", token: bot_token)
798
- add_discord_reaction(channel_id, message_id, "👀", token: bot_token)
799
- end
800
-
801
- # Build project context
802
- if project_config
803
- repo_path = project_config["repo_path"]
804
- # Fetch latest from origin so worktrees branch from up-to-date main
805
- debounced_repo_fetch(repo_path)
806
- default_branch = get_default_branch(repo_path)
807
- lines = ["## Project Context"]
808
- lines << "Project: #{project_key}"
809
- lines << "Source directory: `#{repo_path}`"
810
- lines << "Default branch: `#{default_branch}`"
811
- lines << "GitHub: #{project_config["github_repo"]}" if project_config["github_repo"]
812
- lines << ""
813
- lines << "This is the project's source code directory. When asked to modify, inspect, or work on this project, go directly to `#{repo_path}` — do NOT search for it."
814
- lines << ""
815
- lines << "### All registered projects"
816
- PROJECTS.each do |key, cfg|
817
- lines << "- **#{key}**: `#{cfg["repo_path"]}`"
818
- end
819
- context = lines.join("\n")
820
- LOG.info "[Discord:#{agent_name}] Built project context for #{project_key} (#{repo_path})"
821
- else
822
- lines = ["## Project Context"]
823
- lines << "No specific project mapped to this channel."
824
- lines << ""
825
- lines << "### Registered projects (use `[project:name]` to target one)"
826
- PROJECTS.each do |key, cfg|
827
- lines << "- **#{key}**: `#{cfg["repo_path"]}`"
828
- end
829
- context = lines.join("\n")
830
- LOG.info "[Discord:#{agent_name}] No project context - showing available projects"
831
- end
832
- project_context = context
833
-
834
- # Prepare files — response goes to draft/ so the poller can recover it after restarts
835
- response_dir = File.join(BRAINIAC_DIR, "tmp")
836
- FileUtils.mkdir_p(response_dir)
837
- timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
838
- response_basename = "discord-response-#{timestamp}-#{agent_key}-#{message_id}"
839
- response_file = File.join(DISCORD_DRAFT_DIR, "#{response_basename}.md")
840
-
841
- channel_name = channel_info&.dig("name") || channel_id
842
-
843
- # Find the root message for this conversation thread.
844
- # All messages in a thread should share the same memory file.
845
- # Also captures root message content so the agent always has the original context.
846
- root_message = find_root_message(message, channel_id, bot_token)
847
- root_message_id = root_message[:id]
848
-
849
- # Build a stable card_id for memory files. Inside a thread, use parent_channel + thread_id
850
- # so all messages in the thread share one memory file. The thread's channel_id IS the thread
851
- # starter message ID, which is stable across all messages within it.
852
- card_id = if is_thread
853
- "discord-#{parent_channel_id}-#{channel_id}"
854
- else
855
- "discord-#{channel_id}-#{root_message_id}"
856
- end
857
-
858
- # Build thread root context — inject the original question/message that started
859
- # this thread so the agent never loses sight of it, even in long conversations.
860
- thread_root_context = ""
861
- if is_thread
862
- root_content = root_message[:content]
863
- root_author = root_message[:author]
864
-
865
- # Fallback: if find_root_message didn't capture content (no message_reference chain),
866
- # fetch the parent message directly. In Discord, a thread's channel_id equals the
867
- # message_id that the thread was created on in the parent channel.
868
- if root_content.nil? || root_content.empty?
869
- parent_msg = fetch_discord_message(parent_channel_id, channel_id, token: bot_token, log_errors: false)
870
- if parent_msg && parent_msg["content"] && !parent_msg["content"].strip.empty?
871
- root_content = parent_msg["content"].strip
872
- root_author = parent_msg.dig("author", "username") || "unknown"
873
- end
874
- end
875
-
876
- if root_content && !root_content.empty?
877
- thread_root_context = "### Original Message (thread starter)\n#{root_author || "unknown"}: #{root_content}\n\n"
878
- end
879
- end
880
-
881
- # Detect planning mode
882
- planning_info = detect_planning_mode(
883
- text: clean_content,
884
- tags: [],
885
- card_internal_id: card_id,
886
- card_number: nil
887
- )
888
-
889
- brain_context = build_brain_context(agent_name: agent_name, card_title: clean_content, comment_body: clean_content)
890
-
891
- # --- Worktree management & session resume ---
892
- # Every dispatch with a project gets a dedicated worktree per agent+thread/channel.
893
- # For channel messages, create a Discord thread upfront so we have a stable ID
894
- # for the worktree, then dispatch in it. This ensures grok sessions persist
895
- # across the thread conversation and can be resumed with -c.
896
- should_resume = false
897
- thread_worktree_path = nil
898
- thread_cli_provider = nil
899
- thread_model = nil
900
- thread_effort = nil
901
-
902
- # For channel messages (not DMs, not already in a thread), create a thread immediately
903
- # so we can use its ID for the worktree. This also means the response delivery
904
- # will find the pre-existing thread via the API lookup.
905
- pre_created_thread_id = nil
906
- if !is_thread && !is_dm && project_config
907
- display_name = fizzy_display_name(agent_key)
908
- thread = create_discord_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
909
- if thread && thread["id"]
910
- pre_created_thread_id = thread["id"]
911
- DISCORD_SHARED_THREADS_MUTEX.synchronize { DISCORD_SHARED_THREADS[message_id] = pre_created_thread_id }
912
-
913
- # Propagate dispatch depth to the new thread
914
- parent_depth_key = "discord-#{channel_id}"
915
- thread_depth_key = "discord-#{pre_created_thread_id}"
916
- parent_info = AGENT_DISPATCH_DEPTH[parent_depth_key]
917
- if parent_info
918
- AGENT_DISPATCH_DEPTH[thread_depth_key] = { count: 0, last_human_at: parent_info[:last_human_at] }
919
- else
920
- record_human_comment(thread_depth_key)
921
- end
922
-
923
- LOG.info "[Discord:#{agent_name}] Pre-created thread #{pre_created_thread_id} for worktree isolation"
924
- else
925
- LOG.warn "[Discord:#{agent_name}] Failed to pre-create thread — will run without worktree isolation"
926
- end
927
- end
928
-
929
- # Determine the thread map key: use the thread ID (existing thread or pre-created one)
930
- effective_thread_id = is_thread ? channel_id : pre_created_thread_id
931
- thread_map_key = "#{agent_key}:#{effective_thread_id}" if effective_thread_id
932
-
933
- if project_config && thread_map_key
934
- repo_path = project_config["repo_path"]
935
- thread_map = DISCORD_THREAD_MAP_MUTEX.synchronize { load_discord_thread_map }
936
- existing = thread_map[thread_map_key]
937
-
938
- if existing && existing["chat_mode"]
939
- # Thread was started in chat mode — reuse the tmp dir, don't create a worktree
940
- thread_worktree_path = existing["worktree"]
941
- thread_cli_provider = existing["cli_provider"]
942
- thread_model = existing["model"]
943
- thread_effort = existing["effort"]
944
- chat_mode = true
945
- effective_provider = detect_cli_provider(text: clean_content) || thread_cli_provider
946
- resolved_for_resume = resolve_project_cli_config(project_config, cli_provider_override: effective_provider, agent_name: agent_name)
947
- should_resume = resolved_for_resume["resume_flag"] ? true : false
948
- LOG.info "[Discord:#{agent_name}] Reusing chat mode tmp dir at #{thread_worktree_path} (resume: #{should_resume})"
949
- elsif chat_mode
950
- # New chat mode session — skip worktree, tmp dir will be created below
951
- LOG.info "[Discord:#{agent_name}] Chat mode — skipping worktree creation"
952
- elsif existing && existing["worktree"] && File.directory?(existing["worktree"])
953
- # Existing worktree — resume session. Inherit tags from the thread's first dispatch.
954
- thread_worktree_path = existing["worktree"]
955
- thread_cli_provider = existing["cli_provider"]
956
- thread_model = existing["model"]
957
- thread_effort = existing["effort"]
958
- effective_provider = detect_cli_provider(text: clean_content) || thread_cli_provider
959
- resolved_for_resume = resolve_project_cli_config(project_config, cli_provider_override: effective_provider, agent_name: agent_name)
960
- should_resume = resolved_for_resume["resume_flag"] ? true : false
961
- LOG.info "[Discord:#{agent_name}] Reusing thread worktree at #{thread_worktree_path} (resume: #{should_resume}, cli: #{effective_provider || "default"})"
962
- else
963
- # First worktree creation. Inherit tags from a seeded thread map entry if present.
964
- seeded_cli_provider = existing&.dig("cli_provider")
965
- seeded_model = existing&.dig("model")
966
- seeded_effort = existing&.dig("effort")
967
-
968
- thread_slug = effective_thread_id[-8..]
969
- branch = "discord-#{agent_key}-#{thread_slug}"
970
- thread_worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}")
971
-
972
- debounced_repo_fetch(repo_path)
973
- default_branch = get_default_branch(repo_path)
974
-
975
- branch_exists = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)
976
-
977
- if branch_exists
978
- worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
979
- has_worktree = worktree_list.lines.any? { |l| l.strip == "worktree #{thread_worktree_path}" }
980
- if has_worktree && File.directory?(thread_worktree_path)
981
- LOG.info "[Discord:#{agent_name}] Reusing existing worktree at #{thread_worktree_path}"
982
- else
983
- run_cmd("git", "worktree", "add", thread_worktree_path, branch, chdir: repo_path)
984
- end
985
- else
986
- run_cmd("git", "worktree", "add", "-b", branch, thread_worktree_path, "origin/#{default_branch}", chdir: repo_path)
987
- end
988
-
989
- trust_version_manager(thread_worktree_path, chdir: thread_worktree_path)
990
- apply_worktree_includes(repo_path, thread_worktree_path)
991
- run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => thread_worktree_path })
992
-
993
- # Store tags: prefer current message tag, then seeded value from initial dispatch
994
- first_cli_provider = detect_cli_provider(text: clean_content) || seeded_cli_provider
995
- first_model = (project_config ? detect_model(project_config, text: clean_content) : nil) || seeded_model
996
- first_effort = (project_config ? detect_effort(project_config, text: clean_content) : nil) || seeded_effort
997
- thread_cli_provider = first_cli_provider
998
- thread_model = first_model
999
- thread_effort = first_effort
1000
-
1001
- DISCORD_THREAD_MAP_MUTEX.synchronize do
1002
- map = load_discord_thread_map
1003
- map[thread_map_key] = { "worktree" => thread_worktree_path, "branch" => branch,
1004
- "project" => PROJECTS.find { |_k, v| v == project_config }&.first,
1005
- "channel_id" => effective_thread_id, "cli_provider" => first_cli_provider,
1006
- "model" => first_model, "effort" => first_effort,
1007
- "created_at" => Time.now.iso8601 }
1008
- save_discord_thread_map(map)
1009
- end
1010
- LOG.info "[Discord:#{agent_name}] Created thread worktree at #{thread_worktree_path}#{" (cli: #{first_cli_provider})" if first_cli_provider}#{" (model: #{first_model})" if first_model}#{" (effort: #{first_effort})" if first_effort}"
1011
- end
1012
- end
1013
-
1014
- # Chat mode: create a tmp directory instead of a worktree for lightweight conversational sessions
1015
- if chat_mode && !thread_worktree_path && thread_map_key
1016
- chat_tmp_dir = File.join(BRAINIAC_DIR, "tmp", "chat", "#{agent_key}-#{effective_thread_id}")
1017
- FileUtils.mkdir_p(chat_tmp_dir)
1018
- thread_worktree_path = chat_tmp_dir
1019
-
1020
- first_cli_provider = detect_cli_provider(text: clean_content)
1021
- first_model = project_config ? detect_model(project_config, text: clean_content) : nil
1022
- first_effort = project_config ? detect_effort(project_config, text: clean_content) : nil
1023
- thread_cli_provider = first_cli_provider
1024
- thread_model = first_model
1025
- thread_effort = first_effort
1026
-
1027
- DISCORD_THREAD_MAP_MUTEX.synchronize do
1028
- map = load_discord_thread_map
1029
- map[thread_map_key] = { "worktree" => chat_tmp_dir, "chat_mode" => true,
1030
- "project" => PROJECTS.find { |_k, v| v == project_config }&.first,
1031
- "channel_id" => effective_thread_id, "cli_provider" => first_cli_provider,
1032
- "model" => first_model, "effort" => first_effort,
1033
- "created_at" => Time.now.iso8601 }
1034
- save_discord_thread_map(map)
1035
- end
1036
- LOG.info "[Discord:#{agent_name}] Created chat mode tmp dir at #{chat_tmp_dir}"
1037
- end
1038
-
1039
- if should_resume && thread_worktree_path
1040
- # Lean resume prompt — prior session has full context
1041
- prompt = render_discord_resume_prompt(
1042
- message_body: clean_content_for_prompt,
1043
- discord_user: discord_user,
1044
- response_file: response_file,
1045
- agent_name: agent_name,
1046
- card_id: card_id
1047
- )
1048
- LOG.info "[Discord:#{agent_name}] Using resume prompt for thread #{channel_id}"
1049
- elsif planning_info
1050
- # Planning mode — use planning prompt
1051
- planning_card_id = planning_info[:card_id]
1052
- LOG.info "[Discord:#{agent_name}] Planning mode detected for #{discord_user}"
1053
-
1054
- prompt = render_planning_prompt(PROMPT_DISCORD,
1055
- { "DISCORD_USER" => discord_user,
1056
- "CHANNEL_NAME" => channel_name,
1057
- "MESSAGE_BODY" => clean_content_for_prompt.sub(/\[plan\]/i, "").strip,
1058
- "REPLY_CONTEXT" => reply_context,
1059
- "CHANNEL_HISTORY" => channel_history,
1060
- "THREAD_ROOT_CONTEXT" => thread_root_context,
1061
- "PROJECT_CONTEXT" => project_context,
1062
- "RESPONSE_FILE" => response_file,
1063
- "CARD_ID" => planning_card_id,
1064
- "COMMENT_CREATOR" => discord_user,
1065
- "DISCORD_MENTION_ROSTER" => discord_mention_roster },
1066
- brain_context: brain_context,
1067
- agent_name: agent_name,
1068
- channel: :discord)
1069
- else
1070
- # Normal mode
1071
- prompt = render_prompt(PROMPT_DISCORD,
1072
- { "DISCORD_USER" => discord_user,
1073
- "CHANNEL_NAME" => channel_name,
1074
- "MESSAGE_BODY" => clean_content_for_prompt,
1075
- "REPLY_CONTEXT" => reply_context,
1076
- "CHANNEL_HISTORY" => channel_history,
1077
- "THREAD_ROOT_CONTEXT" => thread_root_context,
1078
- "PROJECT_CONTEXT" => project_context,
1079
- "RESPONSE_FILE" => response_file,
1080
- "CARD_ID" => card_id,
1081
- "COMMENT_CREATOR" => discord_user,
1082
- "DISCORD_MENTION_ROSTER" => discord_mention_roster },
1083
- brain_context: brain_context,
1084
- agent_name: agent_name,
1085
- channel: :discord)
1086
- end
1087
-
1088
- # Chat mode fallback: if no thread_map_key was available (no thread created, e.g. DM or no project),
1089
- # still use a tmp dir so we don't fall back to the project repo_path.
1090
- if chat_mode && !thread_worktree_path
1091
- chat_tmp_dir = File.join(BRAINIAC_DIR, "tmp", "chat", "#{agent_key}-#{message_id}")
1092
- FileUtils.mkdir_p(chat_tmp_dir)
1093
- thread_worktree_path = chat_tmp_dir
1094
- LOG.info "[Discord:#{agent_name}] Chat mode fallback tmp dir at #{chat_tmp_dir}"
1095
- end
1096
-
1097
- work_dir = thread_worktree_path || (project_config ? project_config["repo_path"] : Dir.pwd)
1098
-
1099
- prompt_file = File.join(response_dir, "discord-prompt-#{timestamp}-#{agent_key}-#{message_id}.md")
1100
- File.write(prompt_file, prompt)
1101
-
1102
- # Detect overrides from inline tags — [opus], [effort:high], [cli:grok]
1103
- # Current message tags override thread defaults; thread defaults apply when no tag is present.
1104
- cli_provider_override = detect_cli_provider(text: clean_content) || thread_cli_provider
1105
-
1106
- # For model/effort: detect_model/detect_effort return the project default when no tag matches,
1107
- # so we check for explicit tag presence to know whether the thread default should apply.
1108
- has_explicit_model = false
1109
- if project_config
1110
- allowed_models = resolve_project_cli_config(project_config)["allowed_models"] || {}
1111
- model_tag_match = clean_content.match(/\[(\w+)\]/i)
1112
- has_explicit_model = model_tag_match && allowed_models.key?(model_tag_match[1].downcase)
1113
- end
1114
- has_explicit_effort = clean_content.match?(/\[effort:\w+\]/i)
1115
-
1116
- model = if has_explicit_model
1117
- detect_model(project_config, text: clean_content)
1118
- elsif thread_model
1119
- thread_model
1120
- else
1121
- project_config ? detect_model(project_config, text: clean_content) : nil
1122
- end
1123
-
1124
- effort = if has_explicit_effort
1125
- detect_effort(project_config, text: clean_content)
1126
- elsif thread_effort
1127
- thread_effort
1128
- else
1129
- project_config ? detect_effort(project_config, text: clean_content) : nil
1130
- end
1131
-
1132
- # Determine the "explicit" values from this message only (not thread defaults)
1133
- # for seeding into the thread map on initial dispatch.
1134
- explicit_model = has_explicit_model ? model : nil
1135
- explicit_effort = has_explicit_effort ? effort : nil
1136
-
1137
- # Write delivery metadata sidecar so the poller can post this response
1138
- # even if the monitoring thread dies (e.g. server restart).
1139
- meta_file = File.join(DISCORD_DRAFT_DIR, "#{response_basename}.meta.json")
1140
- File.write(meta_file, JSON.pretty_generate({
1141
- channel_id: channel_id,
1142
- message_id: message_id,
1143
- agent_key: agent_key,
1144
- agent_name: agent_name,
1145
- is_dm: is_dm,
1146
- is_thread: is_thread,
1147
- clean_content: clean_content[0..80],
1148
- cli_provider: detect_cli_provider(text: clean_content),
1149
- model: explicit_model,
1150
- effort: explicit_effort,
1151
- created_at: Time.now.iso8601
1152
- }))
1153
-
1154
- agent_config_name = agent_key.downcase.gsub(/[^a-z0-9-]/, "-")
1155
- log_file = File.join(response_dir, "discord-agent-#{timestamp}-#{agent_key}-#{message_id}.log")
1156
-
1157
- resolved = project_config ? resolve_project_cli_config(project_config, cli_provider_override: cli_provider_override, agent_name: agent_name) : {}
1158
- agent_cli = resolved["agent_cli"] || "kiro-cli"
1159
- agent_cli_args = resolved["agent_cli_args"] || "chat --trust-all-tools --no-interactive"
1160
- agent_model_flag = resolved["agent_model_flag"] || "--model"
1161
- agent_effort_flag = resolved["agent_effort_flag"] || "--effort"
1162
- agent_flag = resolved.key?("agent_flag") ? resolved["agent_flag"] : "--agent"
1163
- prompt_mode = resolved["prompt_mode"] || "stdin"
1164
-
1165
- cmd = [agent_cli]
1166
- cmd.push(agent_flag, agent_config_name) if agent_flag
1167
- cmd.concat(agent_cli_args.split)
1168
- if model && agent_model_flag && !agent_model_flag.empty?
1169
- allowed = resolved["allowed_models"] || {}
1170
- cmd.push(agent_model_flag, model) if allowed.value?(model) || allowed.key?(model)
1171
- end
1172
- cmd.push(agent_effort_flag, effort) if agent_effort_flag && !agent_effort_flag.empty? && effort
1173
- cmd.push(resolved["resume_flag"]) if should_resume && resolved["resume_flag"]
1174
- cmd.push(resolved["prompt_flag"], prompt_file) if prompt_mode == "flag" && resolved["prompt_flag"]
1175
-
1176
- LOG.info "[Discord:#{agent_name}] Dispatching for #{discord_user} (model: #{model || "default"}, effort: #{effort || "default"}, cli: #{agent_cli}#{", resuming" if should_resume}), tail -f #{log_file}"
1177
- LOG.info "[Discord:#{agent_name}] Command: #{cmd.join(" ")}"
1178
-
1179
- spawn_env = {}
1180
- agent_env = agent_env_for(agent_name)
1181
- unless agent_env.empty?
1182
- spawn_env.merge!(agent_env)
1183
- LOG.info "[Discord:#{agent_name}] Injecting #{agent_env.size} env var(s): #{agent_env.keys.join(", ")}"
1184
- end
1185
-
1186
- # Capture HEAD and dirty state before spawning so we can detect if THIS session made changes
1187
- head_before = nil
1188
- status_before = nil
1189
- if project_config
1190
- pk = PROJECTS.find { |_k, v| v == project_config }&.first
1191
- head_before, status_before = capture_git_state(work_dir) if pk == "brainiac"
1192
- end
1193
-
1194
- pid = spawn(spawn_env, *cmd,
1195
- chdir: work_dir,
1196
- **(prompt_mode == "stdin" ? { in: prompt_file } : {}),
1197
- out: [log_file, "w"],
1198
- err: %i[child out])
1199
-
1200
- register_session(session_key, pid, log_file: log_file,
1201
- message_id: message_id, channel_id: channel_id,
1202
- supersede_key: supersede_key,
1203
- draft_files: [response_file, meta_file],
1204
- agent_name: agent_name)
1205
-
1206
- Thread.new do
1207
- Process.wait(pid)
1208
- exit_status = $CHILD_STATUS
1209
-
1210
- # Check if session was cancelled (removed from ACTIVE_SESSIONS by reaction handler)
1211
- session_cancelled = ACTIVE_SESSIONS_MUTEX.synchronize { !ACTIVE_SESSIONS.key?(session_key) }
1212
-
1213
- # If the process was killed by a signal (superseded or cancelled), skip response delivery
1214
- if exit_status.signaled? || session_cancelled
1215
- reason = session_cancelled ? "cancelled" : "superseded (signal: #{exit_status.termsig})"
1216
- LOG.info "[Discord:#{agent_name}] Agent was #{reason} for message #{message_id}"
1217
- # Clean up draft/meta files so the poller doesn't deliver a stale response
1218
- [response_file, meta_file].each { |f| FileUtils.rm_f(f) }
1219
- Thread.new do
1220
- sleep 300
1221
- [prompt_file, *attachment_paths].each { |f| FileUtils.rm_f(f) }
1222
- end
1223
- next
1224
- end
1225
-
1226
- LOG.info "[Discord:#{agent_name}] Agent finished for message #{message_id} (exit: #{exit_status.exitstatus})"
1227
-
1228
- # Notify if the agent crashed (non-zero exit)
1229
- if exit_status.exitstatus && exit_status.exitstatus != 0
1230
- notify_agent_crash(
1231
- exit_status: exit_status.exitstatus, log_file: log_file,
1232
- agent_name: agent_name, source: :discord,
1233
- source_context: { channel_id: channel_id, message_id: message_id, bot_token: bot_token },
1234
- project_config: project_config
1235
- )
1236
- end
1237
-
1238
- # If the agent didn't write to the response file, extract it from the log.
1239
- # Agents should write to the file directly, but this is a fallback for when
1240
- # they respond via stdout instead.
1241
- if !File.exist?(response_file) && File.exist?(log_file)
1242
- log_content = File.read(log_file)
1243
-
1244
- # Detect known fatal error patterns from kiro-cli and write a clean
1245
- # user-facing message instead of leaking raw internal errors to Discord.
1246
- if exit_status.exitstatus != 0 && log_content.match?(/InternalServerError|Encountered an unexpected error|Failed to receive the next message/i)
1247
- LOG.warn "[Discord:#{agent_name}] Agent hit an upstream error for message #{message_id}"
1248
- File.write(response_file, "_Sorry, I hit a temporary error on the backend. Please try again._")
1249
- elsif log_content.match?(/Opening browser\.\.\.|Press \(\^\) \+ C to cancel/)
1250
- LOG.error "[Discord:#{agent_name}] Auth failure detected — re-authenticate with: kiro-cli --agent #{agent_config_name} chat"
1251
- FileUtils.rm_f(meta_file)
1252
- else
1253
- # Strip ANSI codes and kiro-cli UI noise
1254
- clean_output = log_content
1255
- .gsub(/\e\[[0-9;]*[a-zA-Z]|\e\[\?[0-9;]*[a-zA-Z]/, "") # ANSI escape codes (including cursor visibility)
1256
- .gsub(/\e\][^\a]*\a/, "") # OSC sequences
1257
- .delete("\r") # Carriage returns
1258
- .gsub(/^.*?(using tool:.*?)$/m, "") # Tool usage lines
1259
- .gsub(/^.*?✓.*?$/m, "") # Success checkmarks
1260
- .gsub(/^.*?▸.*?$/m, "") # Timing lines
1261
- .gsub(/^.*?Loading\.\.\..*?$/m, "") # Loading indicators
1262
- .gsub(/^.*?Completed in.*?$/m, "") # Completion messages
1263
- .strip
1264
-
1265
- # Only write if there's actual content
1266
- if !clean_output.empty? && clean_output.length > 20
1267
- File.write(response_file, clean_output)
1268
- LOG.info "[Discord:#{agent_name}] Extracted response from log (#{clean_output.length} chars)"
1269
- end
1270
- end
1271
- end
1272
-
1273
- # Deliver Discord response FIRST for faster human feedback
1274
- remove_discord_reaction(channel_id, message_id, "👀", token: bot_token)
1275
- sleep 0.5 # Breathing room to avoid Discord rate limits
1276
-
1277
- delivered = deliver_discord_draft(response_file, meta_file)
1278
-
1279
- # If deliver returned false, check whether the poller already handled it
1280
- # (files moved to posted/) or the response genuinely doesn't exist.
1281
- unless delivered
1282
- response_basename = File.basename(response_file)
1283
- already_posted = File.exist?(File.join(DISCORD_POSTED_DIR, response_basename))
1284
- unless already_posted
1285
- LOG.warn "[Discord:#{agent_name}] No response produced for message #{message_id}"
1286
- add_discord_reaction(channel_id, message_id, "😶", token: bot_token)
1287
- end
1288
- end
1289
-
1290
- # Re-index brain AFTER response delivery (Discord bypasses run_agent, so we handle it here)
1291
- qmd_out, qmd_status = Open3.capture2e("qmd", "update")
1292
- if qmd_status.success?
1293
- LOG.info "[Brain] qmd update completed after #{agent_name} Discord session"
1294
- else
1295
- LOG.warn "[Brain] qmd update failed: #{qmd_out.strip}"
1296
- end
1297
-
1298
- brain_push(message: "#{agent_name}: discord-#{message_id}")
1299
-
1300
- # Restart brainiac if THIS session actually changed code
1301
- # Compare HEAD and dirty state now vs before — only restart if the agent made commits or new dirty files
1302
- if project_config && head_before
1303
- project_key = PROJECTS.find { |_k, v| v == project_config }&.first
1304
- if project_key == "brainiac"
1305
- head_after, status_after = capture_git_state(project_config["repo_path"])
1306
- if head_after != head_before || status_after != status_before
1307
- queue_brainiac_restart(agent_name)
1308
- else
1309
- LOG.info "[Brainiac] #{agent_name} Discord session on brainiac had no changes — skipping restart"
1310
- end
1311
- end
1312
- end
1313
-
1314
- Thread.new do
1315
- sleep 300
1316
- [prompt_file, *attachment_paths].each { |f| FileUtils.rm_f(f) }
1317
- end
1318
- rescue StandardError => e
1319
- LOG.error "[Discord:#{agent_name}] Error monitoring agent: #{e.message}"
1320
- add_discord_reaction(channel_id, message_id, "❌", token: bot_token)
1321
- end
1322
- end
1323
-
1324
- # --- Discord Reaction Handler ---
1325
- # Handles MESSAGE_REACTION_ADD events. Currently supports:
1326
- # - ❌ reaction to cancel an active agent session
1327
- def handle_discord_reaction(reaction_data, agent_key, bot_token, bot_user_id)
1328
- channel_id = reaction_data["channel_id"]
1329
- message_id = reaction_data["message_id"]
1330
- user_id = reaction_data["user_id"]
1331
- emoji = reaction_data["emoji"]
1332
- emoji_name = emoji["name"]
1333
-
1334
- agent_name = fizzy_display_name(agent_key) || agent_key.capitalize
1335
-
1336
- # Ignore reactions from bots (including self)
1337
- return if user_id == bot_user_id
1338
-
1339
- # Handle ❔ or ❓ reactions (thinking file inspection)
1340
- if ["❔", "❓"].include?(emoji_name)
1341
- session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
1342
- line_count = emoji_name == "❔" ? 10 : 20
1343
-
1344
- ACTIVE_SESSIONS_MUTEX.synchronize do
1345
- session_info = ACTIVE_SESSIONS[session_key]
1346
-
1347
- unless session_info
1348
- LOG.info "[Discord:#{agent_name}] #{emoji_name} reaction on #{message_id} but no active session found"
1349
- return
1350
- end
1351
-
1352
- log_file = session_info[:log_file]
1353
- unless log_file && File.exist?(log_file)
1354
- LOG.warn "[Discord:#{agent_name}] No log file found for session #{session_key}"
1355
- send_discord_message(channel_id, "No thinking file found for this session.", token: bot_token, reply_to: message_id)
1356
- return
1357
- end
1358
-
1359
- LOG.info "[Discord:#{agent_name}] Reading last #{line_count} lines from #{log_file}"
1360
-
1361
- # Read last N lines from the log file
1362
- lines = File.readlines(log_file).last(line_count)
1363
- thinking_output = lines.join
1364
-
1365
- # Strip all ANSI escape codes and non-ASCII characters
1366
- thinking_output = thinking_output.gsub(/\e\[[0-9;]*[a-zA-Z]/, "") # All CSI sequences
1367
- .gsub(/\x1b\[[0-9;]*[a-zA-Z]/, "") # Alternative CSI notation
1368
- .gsub(/\e\][0-9;]*.*?(\x07|\e\\)/, "") # OSC sequences
1369
- .gsub(/\e[=>]/, "") # Other escape sequences
1370
- .gsub(/\[\?[0-9]+[lh]/, "") # Cursor visibility
1371
- .gsub("[K", "") # Clear line
1372
- .encode("ASCII", invalid: :replace, undef: :replace, replace: "") # Strip non-ASCII
1373
- .strip
1374
-
1375
- # Post to Discord as a code block
1376
- response = "**Last #{line_count} lines:**\n```\n#{thinking_output}\n```"
1377
- send_discord_message(channel_id, response, token: bot_token, reply_to: message_id)
1378
- end
1379
- return
1380
- end
1381
-
1382
- # Handle 🧠 reaction (stream full thinking to thread)
1383
- if emoji_name == "🧠"
1384
- session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
1385
-
1386
- ACTIVE_SESSIONS_MUTEX.synchronize do
1387
- session_info = ACTIVE_SESSIONS[session_key]
1388
-
1389
- unless session_info
1390
- LOG.info "[Discord:#{agent_name}] 🧠 reaction on #{message_id} but no active session found"
1391
- return
1392
- end
1393
-
1394
- log_file = session_info[:log_file]
1395
- unless log_file && File.exist?(log_file)
1396
- LOG.warn "[Discord:#{agent_name}] No log file found for session #{session_key}"
1397
- send_discord_message(channel_id, "No thinking file found for this session.", token: bot_token, reply_to: message_id)
1398
- return
1399
- end
1400
-
1401
- LOG.info "[Discord:#{agent_name}] Creating thread and streaming thinking from #{log_file}"
1402
-
1403
- # Create thread
1404
- thread_response = create_discord_thread(channel_id, message_id, name: "🧠 Thinking Stream", token: bot_token)
1405
- unless thread_response && thread_response["id"]
1406
- LOG.error "[Discord:#{agent_name}] Failed to create thread, response: #{thread_response.inspect}"
1407
- return
1408
- end
1409
-
1410
- thread_id = thread_response["id"]
1411
- LOG.info "[Discord:#{agent_name}] Thread created: #{thread_id}"
1412
-
1413
- # Read and clean full thinking file
1414
- thinking_content = File.read(log_file)
1415
- thinking_content = thinking_content.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
1416
- .gsub(/\x1b\[[0-9;]*[a-zA-Z]/, "")
1417
- .gsub(/\e\][0-9;]*.*?(\x07|\e\\)/, "")
1418
- .gsub(/\e[=>]/, "")
1419
- .gsub(/\[\?[0-9]+[lh]/, "")
1420
- .gsub("[K", "")
1421
- .encode("ASCII", invalid: :replace, undef: :replace, replace: "")
1422
- .strip
1423
-
1424
- # Split into 1900-char chunks (leave room for code blocks)
1425
- chunks = []
1426
- current_chunk = ""
1427
- thinking_content.lines.each do |line|
1428
- if current_chunk.length + line.length > 1900
1429
- chunks << current_chunk
1430
- current_chunk = line
1431
- else
1432
- current_chunk += line
1433
- end
1434
- end
1435
- chunks << current_chunk unless current_chunk.empty?
1436
-
1437
- # Post chunks to thread
1438
- chunks.each do |chunk|
1439
- send_discord_message(thread_id, "```\n#{chunk}\n```", token: bot_token)
1440
- sleep 0.5 # Rate limit protection
1441
- end
1442
- end
1443
- return
1444
- end
1445
-
1446
- # --- Feedback logging for non-reserved emojis ---
1447
- unless RESERVED_EMOJIS.include?(emoji_name)
1448
- Thread.new do
1449
- log_emoji_feedback(channel_id, message_id, user_id, emoji_name, agent_key, agent_name, bot_token)
1450
- rescue StandardError => e
1451
- LOG.warn "[Discord:#{agent_name}] Feedback logging failed: #{e.message}"
1452
- end
1453
- return
1454
- end
1455
-
1456
- # Only handle ❌ reactions beyond this point
1457
- return unless emoji_name == "❌"
1458
-
1459
- # Check if there's an active session for this message
1460
- session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
1461
-
1462
- ACTIVE_SESSIONS_MUTEX.synchronize do
1463
- session_info = ACTIVE_SESSIONS[session_key]
1464
-
1465
- unless session_info
1466
- LOG.info "[Discord:#{agent_name}] ❌ reaction on #{message_id} but no active session found"
1467
- return
1468
- end
1469
-
1470
- LOG.info "[Discord:#{agent_name}] Cancelling session for message #{message_id} (PID: #{session_info[:pid]})"
1471
-
1472
- # Kill the agent process
1473
- begin
1474
- Process.kill("KILL", session_info[:pid])
1475
- LOG.info "[Discord:#{agent_name}] Killed agent process #{session_info[:pid]}"
1476
- rescue Errno::ESRCH
1477
- LOG.warn "[Discord:#{agent_name}] Process #{session_info[:pid]} already exited"
1478
- rescue Errno::EPERM
1479
- LOG.error "[Discord:#{agent_name}] Permission denied killing process #{session_info[:pid]}"
1480
- end
1481
-
1482
- # Remove from active sessions
1483
- ACTIVE_SESSIONS.delete(session_key)
1484
-
1485
- # Update reactions: remove 👀, add 🛑
1486
- begin
1487
- remove_discord_reaction(channel_id, message_id, "👀", token: bot_token)
1488
- add_discord_reaction(channel_id, message_id, "🛑", token: bot_token)
1489
- rescue StandardError => e
1490
- LOG.warn "[Discord:#{agent_name}] Failed to update reactions: #{e.message}"
1491
- end
1492
-
1493
- # Clean up draft files if they exist
1494
- session_info[:draft_files]&.each do |file|
1495
- FileUtils.rm_f(file)
1496
- end
1497
- end
1498
- end
1499
-
1500
- # --- Emoji Feedback Logging ---
1501
- # Logs non-reserved emoji reactions on bot messages to the agent's persona feedback file.
1502
- # No LLM call, no dispatch — just a file append.
1503
-
1504
- def log_emoji_feedback(channel_id, message_id, user_id, emoji_name, agent_key, agent_name, bot_token)
1505
- # Verify the message was posted by this bot (quiet — bots get reactions from channels they can't access)
1506
- msg = fetch_discord_message(channel_id, message_id, token: bot_token, log_errors: false)
1507
- return unless msg&.dig("author", "bot")
1508
-
1509
- bot_user_id = DISCORD_BOTS_MUTEX.synchronize { DISCORD_BOTS.dig(agent_key, :user_id) }
1510
- return unless bot_user_id && msg.dig("author", "id") == bot_user_id
1511
-
1512
- # Resolve reactor to canonical name
1513
- reactor = find_user_by_discord_id(user_id)
1514
- reactor_name = reactor ? reactor["canonical_name"] : user_id
1515
-
1516
- # Build a brief context snippet from the message
1517
- snippet = (msg["content"] || "")[0, 80].tr("\n", " ").strip
1518
- snippet = "#{snippet}..." if (msg["content"] || "").length > 80
1519
-
1520
- # Append to persona feedback file
1521
- feedback_dir = File.join(persona_dir_for(agent_name), "people")
1522
- FileUtils.mkdir_p(feedback_dir)
1523
- feedback_file = File.join(feedback_dir, "#{reactor_name.downcase.gsub(/[^a-z0-9]/, "-")}-feedback.md")
1524
-
1525
- timestamp = Time.now.strftime("%Y-%m-%d %H:%M")
1526
- entry = "- #{timestamp} #{emoji_name} on: \"#{snippet}\" (channel: #{channel_id})\n"
1527
-
1528
- # Create file with header if new
1529
- if File.exist?(feedback_file)
1530
- File.open(feedback_file, "a") { |f| f.write(entry) }
1531
- else
1532
- File.write(feedback_file, "# Feedback from #{reactor_name}\n\n## Reaction Log\n#{entry}")
1533
- end
1534
-
1535
- LOG.info "[Discord:#{agent_name}] Logged #{emoji_name} feedback from #{reactor_name} on message #{message_id}"
1536
- end
1537
-
1538
- # --- Discord Draft Delivery ---
1539
- # Shared logic for posting a draft response file to Discord and moving it to posted/.
1540
- # Used by both the monitoring thread (happy path) and the poller (recovery path).
1541
-
1542
- def deliver_discord_draft(response_file, meta_file)
1543
- return false unless File.exist?(meta_file)
1544
-
1545
- # Simple file-based lock to prevent the monitoring thread and poller
1546
- # from delivering the same draft simultaneously.
1547
- lock_file = "#{meta_file}.lock"
1548
- begin
1549
- File.open(lock_file, File::CREAT | File::EXCL | File::WRONLY) {} # atomic create-or-fail
1550
- rescue Errno::EEXIST
1551
- return false # Another thread is already delivering this draft
1552
- end
1553
-
1554
- meta = JSON.parse(File.read(meta_file))
1555
- channel_id = meta["channel_id"]
1556
- message_id = meta["message_id"]
1557
- agent_key = meta["agent_key"]
1558
- agent_name = meta["agent_name"]
1559
- is_dm = meta["is_dm"]
1560
- is_thread = meta["is_thread"]
1561
- clean_content = meta["clean_content"] || ""
1562
-
1563
- # Look up the bot token from the current registry
1564
- bot_token = DISCORD_BOTS_MUTEX.synchronize { DISCORD_BOTS.dig(agent_key, :token) }
1565
- bot_token ||= (AGENT_REGISTRY.dig(agent_key, "env") || {})["DISCORD_BOT_TOKEN"]
1566
-
1567
- unless bot_token
1568
- LOG.warn "[Discord:#{agent_name}] No bot token found for #{agent_key}, cannot deliver draft"
1569
- FileUtils.rm_f(lock_file)
1570
- return false
1571
- end
1572
-
1573
- if File.exist?(response_file)
1574
- response = File.read(response_file).strip
1575
- if response.empty?
1576
- add_discord_reaction(channel_id, message_id, "😶", token: bot_token) if message_id
1577
- send_discord_message(channel_id, "_#{agent_name} had nothing to say._", token: bot_token)
1578
- elsif is_dm || is_thread || message_id.nil?
1579
- # DMs, threads, and cron jobs (no message_id) need special handling
1580
- # Check if this is a forum channel
1581
- if message_id.nil? && forum_channel?(channel_id, token: bot_token)
1582
- title = meta["forum_title"] || "#{agent_name} — #{Time.now.strftime("%b %d, %Y")}"
1583
- if meta["forum_reply_to_latest"]
1584
- latest_thread = find_latest_forum_thread(channel_id, token: bot_token)
1585
- if latest_thread
1586
- send_long_discord_message(latest_thread["id"], response, token: bot_token)
1587
- else
1588
- LOG.warn "[Discord:#{agent_name}] No existing thread found, creating new forum post"
1589
- create_forum_post(channel_id, title: title, content: response, token: bot_token)
1590
- end
1591
- else
1592
- create_forum_post(channel_id, title: title, content: response, token: bot_token)
1593
- end
1594
- else
1595
- # Regular DM, thread, or text channel
1596
- send_long_discord_message(channel_id, response, token: bot_token)
1597
- end
1598
- else
1599
- # Check if another agent (local OR remote) already created a thread
1600
- # for this message. Three-tier lookup:
1601
- # 1. Local in-memory cache (DISCORD_SHARED_THREADS) — fast, same-machine
1602
- # 2. Discord API — fetch the original message and check its `thread` field
1603
- # This is the cross-machine fix: if machine B's agent finishes after
1604
- # machine A already created a thread, the API will reveal it.
1605
- # 3. Create a new thread if neither found one.
1606
- # The mutex still covers the local check + create to prevent same-machine races.
1607
- thread_id = nil
1608
- created_thread = false
1609
- DISCORD_SHARED_THREADS_MUTEX.synchronize do
1610
- thread_id = DISCORD_SHARED_THREADS[message_id]
1611
-
1612
- # Tier 2: Ask Discord if a thread already exists on this message.
1613
- # This catches threads created by agents on other machines.
1614
- unless thread_id
1615
- original_msg = discord_api(:get, "/channels/#{channel_id}/messages/#{message_id}", token: bot_token)
1616
- if original_msg&.dig("thread", "id")
1617
- thread_id = original_msg["thread"]["id"]
1618
- DISCORD_SHARED_THREADS[message_id] = thread_id
1619
- LOG.info "[Discord:#{agent_name}] Discovered existing thread #{thread_id} on message #{message_id} via API"
1620
- end
1621
- end
1622
-
1623
- # Tier 3: No thread exists anywhere — create one.
1624
- unless thread_id
1625
- display_name = fizzy_display_name(agent_key)
1626
- thread = create_discord_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
1627
- if thread && thread["id"]
1628
- thread_id = thread["id"]
1629
- DISCORD_SHARED_THREADS[message_id] = thread_id
1630
- created_thread = true
1631
- LOG.info "[Discord:#{agent_name}] Created shared thread #{thread_id} for message #{message_id}"
1632
- end
1633
- end
1634
- end
1635
-
1636
- if thread_id
1637
- LOG.info "[Discord:#{agent_name}] Joining shared thread #{thread_id} for message #{message_id}" unless created_thread
1638
-
1639
- # Propagate the parent channel's dispatch depth to the thread so
1640
- # cross-agent mentions inside the thread aren't blocked immediately.
1641
- # The human's message was in the parent channel, but agent responses
1642
- # land in this thread (different channel_id). Without this, the
1643
- # thread's depth key has no entry and agent_dispatch_allowed? returns false.
1644
- parent_depth_key = "discord-#{channel_id}"
1645
- thread_depth_key = "discord-#{thread_id}"
1646
- parent_info = AGENT_DISPATCH_DEPTH[parent_depth_key]
1647
- unless AGENT_DISPATCH_DEPTH[thread_depth_key]
1648
- if parent_info
1649
- AGENT_DISPATCH_DEPTH[thread_depth_key] = { count: 0, last_human_at: parent_info[:last_human_at] }
1650
- LOG.info "[Discord:#{agent_name}] Propagated dispatch depth from channel #{channel_id} to thread #{thread_id}"
1651
- else
1652
- # No parent depth entry (edge case) — initialize with current time
1653
- record_human_comment(thread_depth_key)
1654
- end
1655
- end
1656
-
1657
- send_discord_typing(thread_id, token: bot_token)
1658
- send_long_discord_message(thread_id, response, token: bot_token)
1659
- else
1660
- LOG.warn "[Discord:#{agent_name}] Thread creation failed, falling back to reply"
1661
- send_long_discord_message(channel_id, response, token: bot_token, reply_to: message_id)
1662
- end
1663
- end
1664
- else
1665
- # Response file doesn't exist yet — agent may still be running
1666
- FileUtils.rm_f(lock_file)
1667
- return false
1668
- end
1669
-
1670
- # Move both files to posted/
1671
- basename = File.basename(response_file)
1672
- meta_basename = File.basename(meta_file)
1673
- FileUtils.mv(response_file, File.join(DISCORD_POSTED_DIR, basename)) if File.exist?(response_file)
1674
- FileUtils.mv(meta_file, File.join(DISCORD_POSTED_DIR, meta_basename))
1675
- FileUtils.rm_f(lock_file)
1676
- LOG.info "[Discord:#{agent_name}] Draft delivered and moved to posted/"
1677
- true
1678
- rescue StandardError => e
1679
- LOG.error "[Discord] Failed to deliver draft #{meta_file}: #{e.message}"
1680
- File.delete(lock_file) if lock_file && File.exist?(lock_file)
1681
- false
1682
- end
1683
-
1684
- # Poller thread: scans draft/ for orphaned response files and delivers them.
1685
- # Runs every 5 seconds. Only attempts delivery if the response file exists
1686
- # (meaning the agent finished) and the meta file is at least 30 seconds old
1687
- # (giving the monitoring thread a chance to handle it first).
1688
-
1689
- DISCORD_DRAFT_POLLER_INTERVAL = 5 # seconds
1690
- DISCORD_DRAFT_MIN_AGE = 30 # seconds — don't race the monitoring thread
1691
-
1692
- def start_discord_draft_poller
1693
- Thread.new do
1694
- LOG.info "[Discord] Draft poller started, checking #{DISCORD_DRAFT_DIR} every #{DISCORD_DRAFT_POLLER_INTERVAL}s"
1695
- loop do
1696
- sleep DISCORD_DRAFT_POLLER_INTERVAL
1697
- begin
1698
- # Clean up stale lock files (older than 60s) left by crashed deliveries
1699
- Dir.glob(File.join(DISCORD_DRAFT_DIR, "*.lock")).each do |lock_file|
1700
- File.delete(lock_file) if (Time.now - File.mtime(lock_file)) > 60
1701
- end
1702
-
1703
- Dir.glob(File.join(DISCORD_DRAFT_DIR, "*.meta.json")).each do |meta_file|
1704
- # Don't race the monitoring thread — wait for the file to age
1705
- next if (Time.now - File.mtime(meta_file)) < DISCORD_DRAFT_MIN_AGE
1706
-
1707
- # Cron metas: foo.md.meta.json → foo.md
1708
- # Discord metas: foo.meta.json → foo.md
1709
- response_file = if meta_file.end_with?(".md.meta.json")
1710
- meta_file.sub(".md.meta.json", ".md")
1711
- else
1712
- meta_file.sub(".meta.json", ".md")
1713
- end
1714
- next unless File.exist?(response_file)
1715
-
1716
- LOG.info "[Discord] Poller recovering orphaned draft: #{File.basename(meta_file)}"
1717
- deliver_discord_draft(response_file, meta_file)
1718
- end
1719
- rescue StandardError => e
1720
- LOG.error "[Discord] Draft poller error: #{e.message}"
1721
- end
1722
- end
1723
- end
1724
- end
1725
-
1726
- # --- Discord Gateway (one per agent bot) ---
1727
-
1728
- def start_discord_gateway_for(agent_key, bot_token)
1729
- Thread.new do
1730
- agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
1731
- bot_user_id = nil
1732
-
1733
- loop do
1734
- DISCORD_BOTS_MUTEX.synchronize do
1735
- DISCORD_BOTS[agent_key] ||= {}
1736
- DISCORD_BOTS[agent_key][:status] = "connecting"
1737
- DISCORD_BOTS[agent_key][:token] = bot_token
1738
- end
1739
-
1740
- LOG.debug "[Discord:#{agent_display}] Connecting to Gateway..."
1741
-
1742
- heartbeat_thread = nil
1743
- last_sequence = nil
1744
-
1745
- ws = WebSocket::Client::Simple.connect(DISCORD_GATEWAY_URL)
1746
-
1747
- ws.on :message do |msg|
1748
- next if msg.data.nil? || msg.data.empty?
1749
-
1750
- payload = JSON.parse(msg.data)
1751
- op = payload["op"]
1752
- data = payload["d"]
1753
- last_sequence = payload["s"] if payload["s"]
1754
-
1755
- case op
1756
- when 10 # Hello
1757
- heartbeat_interval = data["heartbeat_interval"]
1758
- LOG.debug "[Discord:#{agent_display}] Gateway connected, heartbeat: #{heartbeat_interval}ms"
1759
-
1760
- heartbeat_thread&.kill
1761
- heartbeat_thread = Thread.new do
1762
- loop do
1763
- sleep(heartbeat_interval / 1000.0)
1764
- ws.send({ op: 1, d: last_sequence }.to_json)
1765
- end
1766
- end
1767
-
1768
- ws.send({
1769
- op: 2,
1770
- d: {
1771
- token: bot_token,
1772
- intents: 46_593,
1773
- properties: { os: RUBY_PLATFORM, browser: "brainiac", device: "brainiac" }
1774
- }
1775
- }.to_json)
1776
-
1777
- when 0 # Dispatch
1778
- case payload["t"]
1779
- when "READY"
1780
- bot_user_id = data.dig("user", "id")
1781
- DISCORD_BOTS_MUTEX.synchronize do
1782
- DISCORD_BOTS[agent_key][:user_id] = bot_user_id
1783
- DISCORD_BOTS[agent_key][:status] = "ready"
1784
- end
1785
- guild_count = data["guilds"]&.size || 0
1786
- LOG.info "[Discord] #{agent_display} ready (#{guild_count} #{guild_count == 1 ? "guild" : "guilds"})"
1787
- LOG.debug "[Discord:#{agent_display}] user_id=#{bot_user_id}"
1788
-
1789
- # Check if all bots are now ready (log once)
1790
- DISCORD_BOTS_MUTEX.synchronize do
1791
- if !DISCORD_ALL_READY_LOGGED[:done] && DISCORD_BOTS.all? { |_, info| info[:status] == "ready" }
1792
- DISCORD_ALL_READY_LOGGED[:done] = true
1793
- LOG.info "[Discord] All bots connected."
1794
- end
1795
- end
1796
-
1797
- when "MESSAGE_CREATE"
1798
- Thread.new do
1799
- handle_discord_message(data, agent_key, bot_token, bot_user_id)
1800
- rescue StandardError => e
1801
- LOG.error "[Discord:#{agent_display}] Error handling message: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
1802
- end
1803
-
1804
- when "MESSAGE_UPDATE"
1805
- # Discord sends MESSAGE_UPDATE for embed/link preview resolution,
1806
- # not just human edits. Only dispatch if edited_timestamp is set
1807
- # (real edit) — otherwise it's just Discord enriching the message.
1808
- if data["edited_timestamp"]
1809
- Thread.new do
1810
- handle_discord_message(data, agent_key, bot_token, bot_user_id)
1811
- rescue StandardError => e
1812
- LOG.error "[Discord:#{agent_display}] Error handling message update: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
1813
- end
1814
- end
1815
-
1816
- when "MESSAGE_REACTION_ADD"
1817
- Thread.new do
1818
- handle_discord_reaction(data, agent_key, bot_token, bot_user_id)
1819
- rescue StandardError => e
1820
- LOG.error "[Discord:#{agent_display}] Error handling reaction: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
1821
- end
1822
- end
1823
-
1824
- when 1 then ws.send({ op: 1, d: last_sequence }.to_json)
1825
- when 7 then LOG.info "[Discord:#{agent_display}] Reconnect requested"
1826
- ws.close
1827
- when 9 then LOG.warn "[Discord:#{agent_display}] Invalid session, re-identifying in 5s"
1828
- sleep 5
1829
- ws.send({ op: 2, d: { token: bot_token, intents: 46_593,
1830
- properties: { os: RUBY_PLATFORM, browser: "brainiac", device: "brainiac" } } }.to_json)
1831
- when 11 then nil # Heartbeat ACK
1832
- end
1833
- rescue StandardError => e
1834
- LOG.error "[Discord:#{agent_display}] Gateway message error: #{e.message}"
1835
- end
1836
-
1837
- ws.on :open do
1838
- LOG.debug "[Discord:#{agent_display}] WebSocket connected"
1839
- end
1840
-
1841
- ws.on :close do |e|
1842
- DISCORD_BOTS_MUTEX.synchronize do
1843
- DISCORD_BOTS[agent_key][:status] = "disconnected" if DISCORD_BOTS[agent_key]
1844
- end
1845
- LOG.warn "[Discord:#{agent_display}] WebSocket closed: #{e&.inspect}"
1846
- heartbeat_thread&.kill
1847
- end
1848
-
1849
- ws.on :error do |e|
1850
- LOG.error "[Discord:#{agent_display}] WebSocket error: #{e.message}"
1851
- end
1852
-
1853
- loop do
1854
- sleep 1
1855
- next if ws.open?
1856
-
1857
- LOG.info "[Discord:#{agent_display}] Connection lost, reconnecting in 5s..."
1858
- sleep 5
1859
- break
1860
- end
1861
- rescue StandardError => e
1862
- DISCORD_BOTS_MUTEX.synchronize do
1863
- DISCORD_BOTS[agent_key][:status] = "error" if DISCORD_BOTS[agent_key]
1864
- end
1865
- LOG.error "[Discord:#{agent_display}] Gateway error: #{e.message}, reconnecting in 5s..."
1866
- sleep 5
1867
- end
1868
- end
1869
- end
1870
-
1871
- # Start all per-agent Discord bots.
1872
- def start_all_discord_gateways
1873
- tokens = discord_bot_tokens
1874
- if tokens.empty?
1875
- LOG.info "[Discord] No agents have DISCORD_BOT_TOKEN configured — Discord disabled"
1876
- return
1877
- end
1878
-
1879
- LOG.info "[Discord] Starting #{tokens.size} bot(s): #{tokens.keys.join(", ")}"
1880
-
1881
- # Register all bots upfront so the "all ready" check sees the full set
1882
- # before any individual READY event fires.
1883
- DISCORD_BOTS_MUTEX.synchronize do
1884
- tokens.each do |agent_key, token|
1885
- DISCORD_BOTS[agent_key] = { token: token, status: "starting", user_id: nil }
1886
- end
1887
- end
1888
-
1889
- tokens.each do |agent_key, token|
1890
- start_discord_gateway_for(agent_key, token)
1891
- sleep 1 # Stagger connections to avoid rate limits
1892
- end
1893
- end
1894
18
 
1895
- # Summary of all bot statuses for the API endpoint.
1896
- def discord_bots_status
1897
- DISCORD_BOTS_MUTEX.synchronize do
1898
- DISCORD_BOTS.transform_values do |info|
1899
- { status: info[:status], user_id: info[:user_id] }
1900
- end
1901
- end
1902
- end
19
+ require_relative "discord/config"
20
+ require_relative "discord/api"
21
+ require_relative "discord/delivery"
22
+ require_relative "discord/reactions"
23
+ require_relative "discord/message"
24
+ require_relative "discord/gateway"