brainiac-discord 0.0.17 → 0.0.19

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b1fa3c52f3226376895626cbf3ba0d97963dcd8ea9f5ae227f4615797e48a6c
4
- data.tar.gz: 99b890d0c8ccb29079b2617e0cd661aef8c703040b90db58a148938a73fc2990
3
+ metadata.gz: '039a353d5ca3679a9ac10b849382d8df6df5080c89a15f6eda1a843585c60beb'
4
+ data.tar.gz: 0a8944ada2fa21e0ece41680930b773620b8b68c7c6cc003847d21ac86beda9f
5
5
  SHA512:
6
- metadata.gz: 5754062ffb91909ec05451e84b3b03fca71754cbe9cb4434e154eb9e70df34654ffcd7c37c28be4adea593628944bc2d9b19a46d7d60fc08c15b7ebb40848520
7
- data.tar.gz: ac00ebaa2b82042fb2fc7abbf3dcf94d1f674b0b9ded9a1e3fd7cd3974f0256ff0162440935acaa9b334e3eb2f081ed1393aaf2de87b99e61aa53fed8a0c4bcf
6
+ metadata.gz: b119932ccf37b19d4bdeaebefc49538ba1d560060103064e8931804c1827ef7656de9903a8231b866872d425043e638254061d5358c4d2bc771e451749f206b4
7
+ data.tar.gz: ca37078a141d0829cfbfb26602155b91962616a9b0882801844b63fbd045f3a0a0de1c07ef18577599de20e09282a9a01746b96e67c1ed3238ce0a96786e45a1
@@ -16,7 +16,10 @@ module Brainiac
16
16
  DISCORD_API_BASE = "https://discord.com/api/v10"
17
17
 
18
18
  # Emojis reserved for brainiac functionality — not treated as feedback
19
- RESERVED_EMOJIS = %w[👀 ❌ 🛑 🚫 ⚠️ ⏳ 😶 ❔ ❓ 🧠].freeze
19
+ RESERVED_EMOJIS = %w[👀 ❌ 🛑 🚫 ⚠️ ⏳ 😶 ❔ ❓ 🧠 🌿 🪾 🍴 🍽️].freeze
20
+
21
+ # All emojis that trigger conversation forking
22
+ FORK_EMOJIS = %w[🌿 🪾 🍴 🍽️].freeze
20
23
 
21
24
  class << self
22
25
  def request(method, path, token:, body: nil, log_errors: true)
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brainiac
4
+ module Plugins
5
+ module Discord
6
+ # Conversation branching — fork a conversation into a new thread.
7
+ #
8
+ # Supports two triggers:
9
+ # - Fork emoji reactions (🌿, 🪾, 🍴, 🍽️) on any message → creates thread from that message
10
+ # - [fork] or [fork:topic] inline tag → creates thread from the tagged message
11
+ #
12
+ # Branched threads start "unbound" — no worktree, no project lock-in.
13
+ # The agent can discuss, search brain, and read files across any registered project.
14
+ # When implementation work is requested, the worktree is created at that point.
15
+ #
16
+ # Threading limitations:
17
+ # - You can create a thread from a message in a regular text channel
18
+ # - You CANNOT create a thread from a message inside an existing thread
19
+ # - When the source message is in a thread, we create a standalone thread in the parent channel
20
+ module Branching
21
+ class << self
22
+ # Fork a conversation from a message into a new thread.
23
+ #
24
+ # @param source_channel_id [String] The channel where the source message lives
25
+ # @param source_message_id [String] The message to branch from
26
+ # @param source_message [Hash, nil] The source message object (avoids re-fetch if already have it)
27
+ # @param source_is_thread [Boolean] Whether the source channel is a thread
28
+ # @param parent_channel_id [String] The parent text channel (same as source if not in a thread)
29
+ # @param topic [String, nil] Optional thread title (defaults to message content snippet)
30
+ # @param agent_key [String] The agent handling this branch
31
+ # @param agent_name [String] Display name of the agent
32
+ # @param bot_token [String] Discord bot token
33
+ # @param project_key [String, nil] Optional project to inherit (not locked until implementation)
34
+ # @param fork_emoji [String] The emoji that triggered the fork (default: 🌿)
35
+ # @return [Hash, nil] The created thread object, or nil on failure
36
+ def fork_conversation(source_channel_id:, source_message_id:, agent_key:, agent_name:, bot_token:,
37
+ source_message: nil, source_is_thread: false, parent_channel_id: nil,
38
+ topic: nil, project_key: nil, fork_emoji: "🌿")
39
+ # Fetch the source message if not provided
40
+ source_message ||= Api.fetch_message(source_channel_id, source_message_id, token: bot_token)
41
+ unless source_message
42
+ LOG.warn "[Discord:#{agent_name}] Cannot fork — failed to fetch message #{source_message_id}" if defined?(LOG)
43
+ return nil
44
+ end
45
+
46
+ # Build thread title
47
+ content = source_message["content"]&.strip || ""
48
+ content = strip_inline_tags(content)
49
+ thread_title = build_thread_title(topic, content, agent_name)
50
+
51
+ # Create the thread
52
+ # If the source message is inside a thread, we can't create a sub-thread from it.
53
+ # Instead, create a standalone thread in the parent channel.
54
+ thread = if source_is_thread && parent_channel_id
55
+ create_standalone_thread(parent_channel_id, thread_title, agent_name, bot_token)
56
+ else
57
+ Api.create_thread(source_channel_id, source_message_id, name: thread_title, token: bot_token)
58
+ end
59
+
60
+ unless thread && thread["id"]
61
+ LOG.error "[Discord:#{agent_name}] Failed to create branch thread from message #{source_message_id}" if defined?(LOG)
62
+ return nil
63
+ end
64
+
65
+ thread_id = thread["id"]
66
+ LOG.info "[Discord:#{agent_name}] Created branch thread #{thread_id}: \"#{thread_title}\"" if defined?(LOG)
67
+
68
+ # Register as an unbound branch (no worktree yet)
69
+ register_unbound_branch(
70
+ agent_key: agent_key, agent_name: agent_name,
71
+ thread_id: thread_id, project_key: project_key,
72
+ source_channel_id: source_channel_id, source_message_id: source_message_id
73
+ )
74
+
75
+ # React on the source message to indicate branching
76
+ Thread.new { Api.add_reaction(source_channel_id, source_message_id, fork_emoji, token: bot_token) }
77
+
78
+ # Post an intro message in the new thread
79
+ # Include a reference to the original message when created as a standalone thread
80
+ if source_is_thread
81
+ post_branch_intro_with_reference(thread_id, agent_name, source_channel_id, source_message_id, bot_token)
82
+ else
83
+ post_branch_intro(thread_id, agent_name, bot_token)
84
+ end
85
+
86
+ thread
87
+ end
88
+
89
+ # Legacy method signature for backward compatibility with [fork] tag handling.
90
+ # The message.rb handler uses the old parameter names.
91
+ def fork_conversation_legacy(channel_id:, message_id:, agent_key:, agent_name:, bot_token:, topic: nil, project_key: nil)
92
+ fork_conversation(
93
+ source_channel_id: channel_id,
94
+ source_message_id: message_id,
95
+ source_message: nil,
96
+ source_is_thread: false,
97
+ parent_channel_id: channel_id,
98
+ topic: topic,
99
+ agent_key: agent_key,
100
+ agent_name: agent_name,
101
+ bot_token: bot_token,
102
+ project_key: project_key
103
+ )
104
+ end
105
+
106
+ private
107
+
108
+ # Create a standalone thread (not connected to a message).
109
+ # Used when forking from inside an existing thread.
110
+ # Discord API: POST /channels/<channel_id>/threads
111
+ def create_standalone_thread(channel_id, name, agent_name, bot_token)
112
+ thread_name = name.length > 100 ? "#{name[0..96]}..." : name
113
+ result = Api.request(:post, "/channels/#{channel_id}/threads", token: bot_token, body: {
114
+ name: thread_name,
115
+ type: 11, # PUBLIC_THREAD
116
+ auto_archive_duration: 1440
117
+ })
118
+
119
+ if result && result["id"]
120
+ LOG.info "[Discord:#{agent_name}] Created standalone thread #{result["id"]} in channel #{channel_id}" if defined?(LOG)
121
+ elsif defined?(LOG)
122
+ LOG.error "[Discord:#{agent_name}] Failed to create standalone thread: #{result.inspect}"
123
+ end
124
+
125
+ result
126
+ end
127
+
128
+ # Strip inline tags from content for cleaner thread titles.
129
+ #
130
+ # The bracket-tag regex uses an atomic group ((?>...)) around the tag
131
+ # name so the engine can't backtrack into overlapping alternatives on
132
+ # malformed input like "[p:[?:[?:...". Without it, the "p"/"?"
133
+ # alternatives combined with the unbounded [^\]]+ argument create
134
+ # polynomial-time backtracking (ReDoS) on adversarial strings.
135
+ def strip_inline_tags(content)
136
+ content
137
+ .gsub(/\[(?>fork|branch|project|effort|cli|profile|p|deploy|chat|question|\?|fresh|plan|worktree|workitem)(?::[^\]]*)?\]/i, "")
138
+ .gsub(/\[\w+\]/i, "") # Model tags and others
139
+ .gsub(/<@!?\d+>/, "") # Discord mentions
140
+ .strip
141
+ end
142
+
143
+ def build_thread_title(topic, content, agent_name)
144
+ if topic && topic != true
145
+ "#{agent_name}: #{topic}"
146
+ elsif !content.empty?
147
+ snippet = content[0..60]
148
+ snippet = "#{snippet}..." if content.length > 60
149
+ "#{agent_name}: #{snippet}"
150
+ else
151
+ "#{agent_name}: Branch"
152
+ end
153
+ end
154
+
155
+ def register_unbound_branch(agent_key:, agent_name:, thread_id:, project_key:, source_channel_id:, source_message_id:)
156
+ thread_map_key = "#{agent_key}:#{thread_id}"
157
+
158
+ Config.thread_map_mutex.synchronize do
159
+ map = Config.load_thread_map
160
+ map[thread_map_key] = {
161
+ "channel_id" => thread_id,
162
+ "project" => project_key,
163
+ "unbound" => true, # No worktree yet — created on demand
164
+ "source_channel_id" => source_channel_id,
165
+ "source_message_id" => source_message_id,
166
+ "created_at" => Time.now.iso8601
167
+ }
168
+ Config.save_thread_map(map)
169
+ end
170
+
171
+ LOG.info "[Discord:#{agent_name}] Registered unbound branch #{thread_map_key} (project: #{project_key || "none"})" if defined?(LOG)
172
+ end
173
+
174
+ def post_branch_intro(thread_id, agent_name, bot_token)
175
+ intro = "_Branched conversation. I'll work in this thread — the original stays undisturbed._"
176
+ Api.send_message(thread_id, intro, token: bot_token)
177
+ rescue StandardError => e
178
+ LOG.warn "[Discord:#{agent_name}] Failed to post branch intro: #{e.message}" if defined?(LOG)
179
+ end
180
+
181
+ # Post intro with a reference to the original message (for standalone threads).
182
+ def post_branch_intro_with_reference(thread_id, agent_name, source_channel_id, source_message_id, bot_token)
183
+ # Discord message link format: https://discord.com/channels/guild_id/channel_id/message_id
184
+ # For within the same server, we can use a relative link
185
+ message_link = "https://discord.com/channels/@me/#{source_channel_id}/#{source_message_id}"
186
+ intro = "_Branched from [this message](#{message_link}). I'll work in this thread — the original stays undisturbed._"
187
+ Api.send_message(thread_id, intro, token: bot_token)
188
+ rescue StandardError => e
189
+ LOG.warn "[Discord:#{agent_name}] Failed to post branch intro with reference: #{e.message}" if defined?(LOG)
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -66,6 +66,18 @@ module Brainiac
66
66
  return unless authorize_user(discord_user, discord_user_id, message, channel_id, message_id, agent_name, bot_token)
67
67
 
68
68
  tags = parse_inline_tags(clean_content)
69
+
70
+ # [fork] or [fork:topic] — branch the conversation before dispatching
71
+ if tags[:fork_branch]
72
+ return handle_fork_branch(
73
+ tags: tags, agent_key: agent_key, agent_name: agent_name, bot_token: bot_token,
74
+ channel_id: channel_id, message_id: message_id, message: message,
75
+ clean_content: clean_content, parent_channel_id: parent_channel_id,
76
+ discord_user: discord_user, attachment_paths: attachment_paths,
77
+ is_thread: is_thread
78
+ )
79
+ end
80
+
69
81
  project_key, project_config = resolve_project(tags[:project], parent_channel_id, agent_name, channel_id, message_id, bot_token)
70
82
 
71
83
  # Thread owners bypass intent checking ONLY when they're the sole agent in
@@ -91,6 +103,67 @@ module Brainiac
91
103
 
92
104
  private
93
105
 
106
+ # Handle [fork] or [fork:topic] tag — create a branch thread and dispatch to it.
107
+ # The branched thread starts unbound (no worktree) and inherits the parent's project.
108
+ def handle_fork_branch(tags:, agent_key:, agent_name:, bot_token:, channel_id:, message_id:,
109
+ message:, clean_content:, parent_channel_id:, discord_user:, attachment_paths:, is_thread:)
110
+ topic = tags[:fork_branch] == true ? nil : tags[:fork_branch]
111
+
112
+ # Resolve project from channel mapping (inherited, but not locked until implementation)
113
+ project_key, _project_config, _mapping = Config.find_project_for_channel(parent_channel_id)
114
+
115
+ # Create the branch thread
116
+ thread = Branching.fork_conversation(
117
+ source_channel_id: channel_id,
118
+ source_message_id: message_id,
119
+ source_message: nil, # Will be fetched by Branching
120
+ source_is_thread: is_thread,
121
+ parent_channel_id: parent_channel_id,
122
+ topic: topic,
123
+ agent_key: agent_key,
124
+ agent_name: agent_name,
125
+ bot_token: bot_token,
126
+ project_key: project_key
127
+ )
128
+
129
+ unless thread
130
+ LOG.error "[Discord:#{agent_name}] Failed to create fork branch" if defined?(LOG)
131
+ Thread.new { Api.add_reaction(channel_id, message_id, "⚠️", token: bot_token) }
132
+ return
133
+ end
134
+
135
+ branch_thread_id = thread["id"]
136
+ LOG.info "[Discord:#{agent_name}] Forked to thread #{branch_thread_id}, dispatching there" if defined?(LOG)
137
+
138
+ # Now dispatch to the new thread
139
+ # Re-synthesize a message event as if it came from the new thread
140
+ branch_message = message.dup
141
+ branch_message["channel_id"] = branch_thread_id
142
+
143
+ # Fetch fresh channel info for the new thread
144
+ branch_channel_info = Api.fetch_channel_info(branch_thread_id, token: bot_token)
145
+
146
+ # Build context for the branched thread (no prior history since it's new)
147
+ branch_channel_history = ""
148
+
149
+ # Resolve project again (same result, but for clarity)
150
+ branch_project_key, branch_project_config = resolve_project(tags[:project], parent_channel_id, agent_name,
151
+ branch_thread_id, message_id, bot_token)
152
+
153
+ # Dispatch to the branch
154
+ route_dispatch(
155
+ agent_key: agent_key, agent_name: agent_name, bot_token: bot_token, is_bot: false,
156
+ channel_id: branch_thread_id, message_id: message_id, message: branch_message,
157
+ clean_content: clean_content, clean_content_for_prompt: tags[:clean_text],
158
+ chat_mode: tags[:chat_mode], fresh: tags[:fresh], is_thread: true, is_dm: false,
159
+ channel_info: branch_channel_info, parent_channel_id: parent_channel_id,
160
+ discord_user: discord_user, reply_context: "",
161
+ channel_history: branch_channel_history, project_key: branch_project_key,
162
+ project_config: branch_project_config, attachment_paths: attachment_paths,
163
+ directly_addressed: true # User explicitly forked to us
164
+ )
165
+ end
166
+
94
167
  # Check if the agent's Discord role was @mentioned in this message.
95
168
  # Discord role mentions use <@&ROLE_ID> syntax and populate mention_roles array.
96
169
  def role_mentioned?(message, content, agent_key)
@@ -1239,6 +1312,13 @@ module Brainiac
1239
1312
  exit_status: exit_status, profile: profile
1240
1313
  )
1241
1314
 
1315
+ # Capture and store the CLI session id for future --resume-id dispatches.
1316
+ # This allows resuming the specific session for this worktree rather than
1317
+ # whichever session was globally last. The session id gets stored on the
1318
+ # work item (keyed by agent_cli and worktree), and cli_session_id_for will
1319
+ # retrieve it when resolve_resume is called.
1320
+ capture_discord_session_id(resolved: resolved, work_dir: work_dir, agent_name: agent_name)
1321
+
1242
1322
  if exit_status.signaled? || session_cancelled
1243
1323
  handle_cancelled(exit_status, session_cancelled, agent_name, message_id,
1244
1324
  response_file, meta_file, prompt_file, attachment_paths)
@@ -1307,6 +1387,25 @@ module Brainiac
1307
1387
  LOG.warn "[Discord:#{agent_name}] Failed to archive session history: #{e.message}" if defined?(LOG)
1308
1388
  end
1309
1389
 
1390
+ # Capture and store the CLI session id after a Discord dispatch completes.
1391
+ #
1392
+ # Discord threads get worktrees registered as work items (via register_work_item
1393
+ # in create_thread_worktree), so we can use the core's capture_cli_session_id
1394
+ # to store the session id on the work item. On future dispatches, resolve_resume
1395
+ # calls cli_session_id_for which retrieves this stored id, enabling accurate
1396
+ # --resume-id targeting instead of resuming whichever session was globally last.
1397
+ #
1398
+ # Fully best-effort: capture_cli_session_id already logs failures internally,
1399
+ # but we guard here too so a session capture failure never breaks Discord's flow.
1400
+ def capture_discord_session_id(resolved:, work_dir:, agent_name:)
1401
+ return unless defined?(capture_cli_session_id)
1402
+ return unless resolved && work_dir
1403
+
1404
+ capture_cli_session_id(resolved: resolved, chdir: work_dir)
1405
+ rescue StandardError => e
1406
+ LOG.warn "[Discord:#{agent_name}] Failed to capture session id: #{e.message}" if defined?(LOG)
1407
+ end
1408
+
1310
1409
  def handle_completed(exit_status:, agent_name:, agent_config_name:, channel_id:, message_id:,
1311
1410
  bot_token:, response_file:, meta_file:, log_file:,
1312
1411
  project_config:, head_before:, status_before:)
@@ -11,6 +11,16 @@ module Brainiac
11
11
  # - 🧠 to stream the full thinking log to a thread
12
12
  # - Non-reserved emojis logged as feedback to the agent's persona
13
13
  module Reactions
14
+ # Global deduplication for fork reactions. Tracks which messages have
15
+ # already been forked to prevent duplicates from multiple agents or
16
+ # repeated Discord events.
17
+ #
18
+ # Key: "channel_id:message_id" -> Time the fork was handled
19
+ # Entries expire after 60 seconds (well beyond Discord's duplicate window).
20
+ FORK_DEDUP = {}
21
+ FORK_DEDUP_MUTEX = Mutex.new
22
+ FORK_DEDUP_TTL = 60 # seconds
23
+
14
24
  class << self
15
25
  def handle(reaction_data, agent_key, bot_token, bot_user_id)
16
26
  channel_id = reaction_data["channel_id"]
@@ -31,6 +41,8 @@ module Brainiac
31
41
  handle_thinking_stream(agent_key, agent_name, channel_id, message_id, bot_token)
32
42
  when "❌"
33
43
  handle_cancel(agent_key, agent_name, channel_id, message_id, bot_token)
44
+ when "🌿", "🪾", "🍴", "🍽️"
45
+ handle_branch(agent_key, agent_name, channel_id, message_id, bot_token, emoji: emoji_name)
34
46
  else
35
47
  unless Api::RESERVED_EMOJIS.include?(emoji_name)
36
48
  Thread.new do
@@ -171,6 +183,116 @@ module Brainiac
171
183
  end
172
184
  end
173
185
 
186
+ # Handle fork reactions (🌿, 🪾, 🍴, 🍽️) — fork the conversation into a new thread.
187
+ # The reacted message becomes the root of the new branch.
188
+ #
189
+ # Multi-agent coordination uses a two-phase approach:
190
+ # 1. Global deduplication — atomic check-and-claim ensures only one agent proceeds
191
+ # 2. Ownership check — determines which specific agent should handle it
192
+ #
193
+ # This prevents both the race condition (multiple agents passing ownership check
194
+ # simultaneously) and duplicate events (Discord sending the same reaction multiple times).
195
+ def handle_branch(agent_key, agent_name, channel_id, message_id, bot_token, emoji: "🌿")
196
+ LOG.info "[Discord:#{agent_name}] #{emoji} fork reaction on message #{message_id} in channel #{channel_id}" if defined?(LOG)
197
+
198
+ dedup_key = "#{channel_id}:#{message_id}"
199
+
200
+ # Fetch source message to determine ownership
201
+ source_message = Api.fetch_message(channel_id, message_id, token: bot_token)
202
+ unless source_message
203
+ LOG.warn "[Discord:#{agent_name}] #{emoji} skipping — couldn't fetch message #{message_id}" if defined?(LOG)
204
+ return
205
+ end
206
+
207
+ # Check if this agent should handle the fork (based on mentions/authorship/fallback)
208
+ unless should_handle_fork?(agent_key, source_message)
209
+ LOG.info "[Discord:#{agent_name}] #{emoji} skipping — another agent should handle this fork" if defined?(LOG)
210
+ return
211
+ end
212
+
213
+ # Atomic claim — only one agent gets past this point
214
+ unless claim_fork!(dedup_key, agent_key)
215
+ LOG.info "[Discord:#{agent_name}] #{emoji} skipping — fork already claimed by another process" if defined?(LOG)
216
+ return
217
+ end
218
+
219
+ LOG.info "[Discord:#{agent_name}] #{emoji} handling fork for message #{message_id}" if defined?(LOG)
220
+
221
+ # Check if we're in a thread — get parent channel for project resolution
222
+ channel_info = Api.fetch_channel_info(channel_id, token: bot_token)
223
+ is_thread = channel_info && [11, 12].include?(channel_info["type"])
224
+ parent_channel_id = is_thread ? channel_info["parent_id"] : channel_id
225
+
226
+ # Resolve project from channel mapping (inherited, but not locked)
227
+ project_key, _project_config, _mapping = Config.find_project_for_channel(parent_channel_id)
228
+
229
+ thread = Branching.fork_conversation(
230
+ source_channel_id: channel_id,
231
+ source_message_id: message_id,
232
+ source_message: source_message,
233
+ source_is_thread: is_thread,
234
+ parent_channel_id: parent_channel_id,
235
+ topic: nil, # Auto-generate from message content
236
+ agent_key: agent_key,
237
+ agent_name: agent_name,
238
+ bot_token: bot_token,
239
+ project_key: project_key,
240
+ fork_emoji: emoji
241
+ )
242
+
243
+ Api.add_reaction(channel_id, message_id, "⚠️", token: bot_token) unless thread
244
+ end
245
+
246
+ # Atomically claim a fork operation. Returns true if this caller wins,
247
+ # false if already claimed. Cleans up expired entries while holding the lock.
248
+ def claim_fork!(dedup_key, agent_key)
249
+ FORK_DEDUP_MUTEX.synchronize do
250
+ now = Time.now
251
+
252
+ # Clean up expired entries
253
+ FORK_DEDUP.delete_if { |_, claimed_at| now - claimed_at > FORK_DEDUP_TTL }
254
+
255
+ # Check if already claimed
256
+ return false if FORK_DEDUP.key?(dedup_key)
257
+
258
+ # Claim it
259
+ FORK_DEDUP[dedup_key] = now
260
+ LOG.debug "[Discord] Fork claimed: #{dedup_key} by #{agent_key}" if defined?(LOG) && LOG.respond_to?(:debug)
261
+ true
262
+ end
263
+ end
264
+
265
+ # Determine if this agent should handle a fork reaction.
266
+ # Only one agent should handle each fork to avoid duplicates.
267
+ def should_handle_fork?(agent_key, source_message)
268
+ bot_user_id = Gateway.bot_user_id(agent_key)&.to_s
269
+ return false unless bot_user_id
270
+
271
+ # 1. If this bot authored the message → handle it
272
+ return true if source_message.dig("author", "id").to_s == bot_user_id
273
+
274
+ # 2. If this bot was @mentioned in the message → handle it
275
+ mentions = source_message["mentions"] || []
276
+ content = source_message["content"] || ""
277
+ if mentions.any? { |m| m["id"].to_s == bot_user_id } ||
278
+ content.match?(/<@!?#{Regexp.escape(bot_user_id)}>/)
279
+ return true
280
+ end
281
+
282
+ # 3. Fallback: deterministic selection among all active bots
283
+ # Pick the bot with the lexicographically smallest agent_key
284
+ active_bot_keys = []
285
+ Gateway.each_bot do |key, info|
286
+ active_bot_keys << key if info[:user_id]
287
+ end
288
+
289
+ return false if active_bot_keys.empty?
290
+
291
+ # Pick the smallest key to ensure deterministic selection
292
+ designated_handler = active_bot_keys.min
293
+ agent_key == designated_handler
294
+ end
295
+
174
296
  def log_emoji_feedback(channel_id, message_id, user_id, emoji_name, agent_key, agent_name, bot_token)
175
297
  msg = Api.fetch_message(channel_id, message_id, token: bot_token, log_errors: false)
176
298
  return unless msg&.dig("author", "bot")
@@ -3,7 +3,7 @@
3
3
  module Brainiac
4
4
  module Plugins
5
5
  module Discord
6
- VERSION = "0.0.17"
6
+ VERSION = "0.0.19"
7
7
  end
8
8
  end
9
9
  end
@@ -8,6 +8,7 @@ require_relative "discord/prompts"
8
8
  require_relative "discord/api"
9
9
  require_relative "discord/delivery"
10
10
  require_relative "discord/reactions"
11
+ require_relative "discord/branching"
11
12
  require_relative "discord/message"
12
13
  require_relative "discord/thread_cleanup"
13
14
  require_relative "discord/gateway"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brainiac-discord
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.17
4
+ version: 0.0.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis
@@ -104,6 +104,7 @@ files:
104
104
  - README.md
105
105
  - lib/brainiac/plugins/discord.rb
106
106
  - lib/brainiac/plugins/discord/api.rb
107
+ - lib/brainiac/plugins/discord/branching.rb
107
108
  - lib/brainiac/plugins/discord/cli.rb
108
109
  - lib/brainiac/plugins/discord/config.rb
109
110
  - lib/brainiac/plugins/discord/delivery.rb