brainiac-discord 0.0.16 → 0.0.18

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: afab8518a3314e7873e2bec99ba79f7efeedf1b45b80be5ccc9d0654d0b64b77
4
- data.tar.gz: a39348b184343d375313e1ca30e746165690562a729bac532e9880af4a9b540c
3
+ metadata.gz: 81f74969c95690e118d85fe590b2e2d35b39609937e807afaa2865ac58c8846d
4
+ data.tar.gz: cde31da7b3a0b2a4b84bff28b4b1e2fa99fe6ba7e7e8e372b572b1d0ea085c5c
5
5
  SHA512:
6
- metadata.gz: aecb0bff2e82096ba8ec67c7a862a9316025030fa999d87c323f023a95ab463807c0dcf1eac5b7055b1d6307f346acf80fd08ae0805bf21c7e248fc29e3f35b5
7
- data.tar.gz: aeb1f966c703cbf553d50535dcf6768ef1af4fc38443c47b0e0d4544dd11c42d5cf31049ccc86a7a8d6efdfd7e256e9f02c898cac9792ebc82732c243ba4692b
6
+ metadata.gz: 397af906d3f427dab7723fe20d3cc25840a35e109e4e054f9cf9689337cbcc8ccc376e0b14403d5dd38ede9fc25b3b2e2efa368371dae012b14c6dd5e9f0aa03
7
+ data.tar.gz: 8201ec6515468a4ed1a1f4e63ecc8bdf66c57e333a096c00c216559bb4a1466443928ea6c35542efdd77f80bea0c80a2555916cf053ffff41e6e721f329029ba
@@ -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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "websocket-client-simple"
4
+ require "websocket"
4
5
 
5
6
  module Brainiac
6
7
  module Plugins
@@ -12,6 +13,15 @@ module Brainiac
12
13
  # and MESSAGE_REACTION_ADD events to handler functions.
13
14
  module Gateway
14
15
  GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"
16
+ GATEWAY_QUERY = "?v=10&encoding=json"
17
+
18
+ # Reconnect backoff (seconds). A normal disconnect starts at BASE and
19
+ # doubles up to CAP; a handshake rejection (HTTP non-101, e.g. Discord
20
+ # returning 429 to the WebSocket upgrade) starts higher to avoid a
21
+ # reconnect storm across many bots hammering the gateway in lockstep.
22
+ BACKOFF_BASE = 5
23
+ BACKOFF_HANDSHAKE_BASE = 30
24
+ BACKOFF_CAP = 300
15
25
 
16
26
  # Per-bot state: { agent_key => { token:, user_id:, status:, thread: } }
17
27
  @bots = {}
@@ -142,19 +152,80 @@ module Brainiac
142
152
 
143
153
  private
144
154
 
155
+ # Resolve the gateway URL to connect to, and honor Discord's session
156
+ # start limits. Queries GET /gateway/bot (authenticated) which returns
157
+ # the recommended WebSocket URL plus session_start_limit. If the bot
158
+ # has exhausted its identify allowance, sleep until the window resets
159
+ # before returning so we don't burn reconnects against a hard limit.
160
+ # Falls back to the hardcoded GATEWAY_URL if the call fails.
161
+ def resolve_gateway_url(bot_token, agent_display)
162
+ info = Api.request(:get, "/gateway/bot", token: bot_token, log_errors: false)
163
+ unless info.is_a?(Hash) && info["url"]
164
+ LOG.warn "[Discord:#{agent_display}] Could not fetch /gateway/bot, using default URL" if defined?(LOG)
165
+ return GATEWAY_URL
166
+ end
167
+
168
+ honor_session_limit(info["session_start_limit"], agent_display)
169
+
170
+ "#{info["url"]}/#{GATEWAY_QUERY}"
171
+ rescue StandardError => e
172
+ LOG.warn "[Discord:#{agent_display}] /gateway/bot lookup failed: #{e.message}, using default URL" if defined?(LOG)
173
+ GATEWAY_URL
174
+ end
175
+
176
+ # If Discord reports no remaining identifies, wait out the reset window.
177
+ def honor_session_limit(limit, agent_display)
178
+ return unless limit.is_a?(Hash)
179
+
180
+ remaining = limit["remaining"].to_i
181
+ return if remaining.positive?
182
+
183
+ reset_after_s = (limit["reset_after"].to_i / 1000.0).ceil
184
+ reset_after_s = 1 if reset_after_s <= 0
185
+ LOG.warn "[Discord:#{agent_display}] Session start limit exhausted, waiting #{reset_after_s}s" if defined?(LOG)
186
+ sleep reset_after_s
187
+ end
188
+
189
+ # Exponential backoff with full jitter, capped. `attempt` is 0-based.
190
+ def backoff_delay(attempt, base)
191
+ capped = [base * (2**attempt), BACKOFF_CAP].min
192
+ # Full jitter: random between base and the capped ceiling.
193
+ rand(base.to_f..capped.to_f).round(1)
194
+ end
195
+
145
196
  def start_gateway_for(agent_key, bot_token)
146
197
  thread = Thread.new do
147
198
  agent_display = agent_display_name(agent_key) || agent_key.capitalize
148
199
  bot_user_id = nil
200
+ attempt = 0
149
201
 
150
202
  loop do
151
203
  bot_user_id = run_gateway_connection(agent_key, agent_display, bot_token, bot_user_id)
204
+ # A clean return means the connection was established at least
205
+ # once; reset backoff so a later transient drop recovers fast.
206
+ attempt = 0
207
+ delay = backoff_delay(attempt, BACKOFF_BASE)
208
+ LOG.info "[Discord:#{agent_display}] Reconnecting in #{delay}s..." if defined?(LOG)
209
+ sleep delay
210
+ rescue WebSocket::Error::Handshake::InvalidStatusCode => e
211
+ @bots_mutex.synchronize do
212
+ @bots[agent_key][:status] = "error" if @bots[agent_key]
213
+ end
214
+ delay = backoff_delay(attempt, BACKOFF_HANDSHAKE_BASE)
215
+ attempt += 1
216
+ if defined?(LOG)
217
+ LOG.error "[Discord:#{agent_display}] Gateway handshake rejected (#{e.message}) — " \
218
+ "likely rate limited; backing off #{delay}s"
219
+ end
220
+ sleep delay
152
221
  rescue StandardError => e
153
222
  @bots_mutex.synchronize do
154
223
  @bots[agent_key][:status] = "error" if @bots[agent_key]
155
224
  end
156
- LOG.error "[Discord:#{agent_display}] Gateway error: #{e.message}, reconnecting in 5s..." if defined?(LOG)
157
- sleep 5
225
+ delay = backoff_delay(attempt, BACKOFF_BASE)
226
+ attempt += 1
227
+ LOG.error "[Discord:#{agent_display}] Gateway error: #{e.message}, reconnecting in #{delay}s..." if defined?(LOG)
228
+ sleep delay
158
229
  end
159
230
  end
160
231
  @bots_mutex.synchronize { @bots[agent_key][:thread] = thread if @bots[agent_key] }
@@ -172,10 +243,17 @@ module Brainiac
172
243
  bots[agent_key][:token] = bot_token
173
244
  end
174
245
 
175
- LOG.debug "[Discord:#{agent_display}] Connecting to Gateway..." if defined?(LOG) && LOG.respond_to?(:debug)
246
+ # The WebSocket handshake runs in websocket-client-simple's own reader
247
+ # thread, so a non-101 response surfaces via the :error callback rather
248
+ # than raising here. Capture it and re-raise after the connection wait
249
+ # so the reconnect loop can apply handshake-specific backoff.
250
+ handshake_error = nil
251
+
252
+ gateway_url = resolve_gateway_url(bot_token, agent_display)
253
+ LOG.debug "[Discord:#{agent_display}] Connecting to #{gateway_url}" if defined?(LOG) && LOG.respond_to?(:debug)
176
254
  heartbeat_thread = nil
177
255
  last_sequence = nil
178
- ws = WebSocket::Client::Simple.connect(GATEWAY_URL)
256
+ ws = WebSocket::Client::Simple.connect(gateway_url)
179
257
 
180
258
  ws.on :message do |msg|
181
259
  next if msg.data.nil? || msg.data.empty?
@@ -203,22 +281,40 @@ module Brainiac
203
281
  end
204
282
 
205
283
  ws.on :error do |e|
284
+ handshake_error = e if e.is_a?(WebSocket::Error::Handshake::InvalidStatusCode)
206
285
  LOG.error "[Discord:#{agent_display}] WebSocket error: #{e.message}" if defined?(LOG)
207
286
  end
208
287
 
209
- wait_for_disconnect(ws, agent_display)
288
+ wait_for_disconnect(ws, agent_display) { handshake_error }
289
+
290
+ # If the connection never opened because Discord rejected the upgrade,
291
+ # propagate so the caller backs off harder instead of tight-looping.
292
+ raise handshake_error if handshake_error && !ws.open?
293
+
210
294
  bot_user_id
211
295
  end
212
296
 
297
+ # Block until the socket closes. Returns promptly if a handshake error
298
+ # is reported (the socket never opens in that case). Backoff between
299
+ # reconnects is owned by the caller (start_gateway_for).
213
300
  def wait_for_disconnect(websocket, agent_display)
301
+ # Give the handshake a brief window to complete before deciding the
302
+ # socket is dead, so we don't treat a still-connecting socket as lost.
303
+ grace = 10
304
+ grace.times do
305
+ sleep 0.5
306
+ break if websocket.open? || (block_given? && yield)
307
+ end
308
+
214
309
  loop do
215
- sleep 1
216
- next if websocket.open?
310
+ return if block_given? && yield && !websocket.open?
217
311
 
218
- LOG.info "[Discord:#{agent_display}] Connection lost, reconnecting in 5s..." if defined?(LOG)
219
- sleep 5
220
- break
312
+ break unless websocket.open?
313
+
314
+ sleep 1
221
315
  end
316
+
317
+ LOG.info "[Discord:#{agent_display}] Connection lost" if defined?(LOG)
222
318
  end
223
319
 
224
320
  def handle_gateway_op(websocket, payload, agent_key, agent_display, bot_token, bot_user_id, heartbeat_thread, last_sequence)
@@ -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)
@@ -607,7 +680,8 @@ module Brainiac
607
680
  resolved: resolved, project_config: project_config,
608
681
  session_key: session_key, supersede_key: supersede_key,
609
682
  attachment_paths: attachment_paths, timestamp: timestamp, response_dir: response_dir,
610
- profile: parse_inline_tags(clean_content)[:profile]
683
+ profile: resolve_thread_profile(thread_map_key, clean_content),
684
+ explicit_model: explicit_model_tag?(clean_content, project_config, cli_provider_override: cli_provider_override)
611
685
  )
612
686
  end
613
687
 
@@ -712,6 +786,55 @@ module Brainiac
712
786
  "cli=#{cli_provider}, model=#{model}, effort=#{effort}"
713
787
  end
714
788
 
789
+ # Resolve the profile ([profile:X]/[p:X]) for this dispatch and make it sticky.
790
+ #
791
+ # An inline tag wins and is saved on the thread map entry (and the core work item),
792
+ # so later untagged replies in the same thread keep running on that profile until a
793
+ # different [p:X] replaces it. Without a tag, the stored thread profile is used, then
794
+ # the work item's (e.g. set from a Fizzy card). nil means the default profile.
795
+ # Unknown profile names are passed through (core ignores them with a warning) but
796
+ # never saved, so a typo can't pin the thread to a nonexistent profile.
797
+ def resolve_thread_profile(thread_map_key, clean_content)
798
+ inline = parse_inline_tags(clean_content)[:profile]
799
+ return inline unless thread_map_key
800
+
801
+ if inline
802
+ persist_thread_profile(thread_map_key, inline) if !defined?(profile_entry) || profile_entry(inline)
803
+ return inline
804
+ end
805
+
806
+ entry = Config.thread_map_mutex.synchronize { Config.load_thread_map[thread_map_key] }
807
+ return nil unless entry
808
+
809
+ entry["profile"] || work_item_profile_for(entry["branch"])
810
+ end
811
+
812
+ def persist_thread_profile(thread_map_key, profile)
813
+ branch = Config.thread_map_mutex.synchronize do
814
+ map = Config.load_thread_map
815
+ entry = map[thread_map_key]
816
+ next nil unless entry
817
+
818
+ if entry["profile"] != profile
819
+ entry["profile"] = profile
820
+ Config.save_thread_map(map)
821
+ LOG.info "[Discord] Persisted profile for #{thread_map_key}: #{profile}" if defined?(LOG)
822
+ end
823
+ entry["branch"]
824
+ end
825
+
826
+ return unless branch && defined?(update_work_item_overrides) &&
827
+ method(:update_work_item_overrides).parameters.any? { |t, n| t == :key && n == :profile }
828
+
829
+ update_work_item_overrides(branch: branch, profile: profile)
830
+ end
831
+
832
+ def work_item_profile_for(branch)
833
+ return nil unless branch && defined?(work_item_overrides_for)
834
+
835
+ work_item_overrides_for(branch: branch)["profile"]
836
+ end
837
+
715
838
  def explicit_model_tag?(clean_content, project_config, cli_provider_override: nil)
716
839
  return false unless project_config
717
840
 
@@ -871,6 +994,8 @@ module Brainiac
871
994
  "model" => model, "effort" => effort, "created_at" => Time.now.iso8601 }
872
995
  entry["branch"] = branch if branch
873
996
  entry["chat_mode"] = true if chat_mode
997
+ # Keep a sticky profile when an entry is rebuilt (e.g. its worktree was recreated).
998
+ entry["profile"] = map.dig(thread_map_key, "profile") if map.dig(thread_map_key, "profile")
874
999
  map[thread_map_key] = entry
875
1000
  Config.save_thread_map(map)
876
1001
  end
@@ -906,7 +1031,8 @@ module Brainiac
906
1031
  def spawn_agent(agent_key:, agent_name:, bot_token:, channel_id:, message_id:, discord_user:,
907
1032
  work_dir:, prompt_file:, response_file:, meta_file:, model:, effort:,
908
1033
  actual_resume:, resolved:, project_config:,
909
- session_key:, supersede_key:, attachment_paths:, timestamp:, response_dir:, profile: nil)
1034
+ session_key:, supersede_key:, attachment_paths:, timestamp:, response_dir:, profile: nil,
1035
+ explicit_model: nil)
910
1036
  agent_config_name = agent_key.downcase.gsub(/[^a-z0-9-]/, "-")
911
1037
  log_file = File.join(response_dir, "discord-agent-#{timestamp}-#{agent_key}-#{message_id}.log")
912
1038
 
@@ -926,6 +1052,10 @@ module Brainiac
926
1052
  # XDG_DATA_HOME). An explicitly-requested [profile:X] wins over agent
927
1053
  # env; the default profile is only a fallback. See core profiles.rb.
928
1054
  agent_env = profile_spawn_env(agent_env, profile) if defined?(profile_spawn_env)
1055
+ # The *effective* profile name that actually ran (explicit if valid,
1056
+ # else the default profile) — recorded in durable history so the
1057
+ # monitor can show which account/profile the session used.
1058
+ effective_profile = defined?(effective_profile_name) ? effective_profile_name(profile) : profile
929
1059
  unless agent_env.empty?
930
1060
  spawn_env.merge!(agent_env)
931
1061
  LOG.info "[Discord:#{agent_name}] Injecting #{agent_env.size} env var(s): #{agent_env.keys.join(", ")}" if defined?(LOG)
@@ -942,6 +1072,22 @@ module Brainiac
942
1072
  LOG.info "[Discord:#{agent_name}] Injecting GitHub App token (GH_TOKEN) for bot identity" if defined?(LOG)
943
1073
  end
944
1074
 
1075
+ # For providers with no runtime --model flag (e.g. kiro-cli, model_flag: ""),
1076
+ # persist the model to the provider's settings store before spawning. Discord
1077
+ # dispatches its own agents and never calls core's run_agent, so we invoke the
1078
+ # same helper here — running it under the fully-assembled spawn_env so the write
1079
+ # targets the correct account's KIRO_HOME. No-op when a model_flag is configured.
1080
+ # explicit_model tells the helper whether this came from an inline tag: an explicit
1081
+ # [auto] writes (a deliberate reset), a defaulted "auto" does not. Guard the kwarg
1082
+ # against older core builds whose apply_settings_model has no `explicit:` param.
1083
+ if defined?(apply_settings_model)
1084
+ if method(:apply_settings_model).parameters.any? { |t, n| t == :key && n == :explicit }
1085
+ apply_settings_model(model, resolved, spawn_env, explicit: explicit_model)
1086
+ else
1087
+ apply_settings_model(model, resolved, spawn_env)
1088
+ end
1089
+ end
1090
+
945
1091
  head_before, status_before = capture_brainiac_state(project_config, work_dir)
946
1092
  prompt_mode = resolved["prompt_mode"] || "stdin"
947
1093
 
@@ -966,7 +1112,8 @@ module Brainiac
966
1112
  attachment_paths: attachment_paths, project_config: project_config,
967
1113
  head_before: head_before, status_before: status_before,
968
1114
  supersede_key: supersede_key,
969
- resolved: resolved, model: model, work_dir: work_dir, started_at: started_at
1115
+ resolved: resolved, model: model, work_dir: work_dir, started_at: started_at,
1116
+ profile: effective_profile
970
1117
  )
971
1118
  end
972
1119
 
@@ -1145,7 +1292,7 @@ module Brainiac
1145
1292
  def monitor_agent(pid:, session_key:, agent_name:, agent_config_name:, channel_id:, message_id:,
1146
1293
  bot_token:, response_file:, meta_file:, prompt_file:, log_file:,
1147
1294
  attachment_paths:, project_config:, head_before:, status_before:, supersede_key: nil,
1148
- resolved: nil, model: nil, work_dir: nil, started_at: nil)
1295
+ resolved: nil, model: nil, work_dir: nil, started_at: nil, profile: nil)
1149
1296
  Thread.new do
1150
1297
  Process.wait(pid)
1151
1298
  exit_status = $CHILD_STATUS
@@ -1162,7 +1309,7 @@ module Brainiac
1162
1309
  agent_name: agent_name, agent_config_name: agent_config_name,
1163
1310
  channel_id: channel_id, message_id: message_id, log_file: log_file,
1164
1311
  resolved: resolved, model: model, work_dir: work_dir, started_at: started_at,
1165
- exit_status: exit_status
1312
+ exit_status: exit_status, profile: profile
1166
1313
  )
1167
1314
 
1168
1315
  if exit_status.signaled? || session_cancelled
@@ -1205,7 +1352,8 @@ module Brainiac
1205
1352
  # Fully best-effort: archive_session_history already rescues internally, but we
1206
1353
  # guard here too so a history failure can never break Discord's completion path.
1207
1354
  def archive_discord_session_history(agent_name:, agent_config_name:, channel_id:, message_id:,
1208
- log_file:, resolved:, model:, work_dir:, started_at:, exit_status:)
1355
+ log_file:, resolved:, model:, work_dir:, started_at:, exit_status:,
1356
+ profile: nil)
1209
1357
  return unless defined?(archive_session_history)
1210
1358
 
1211
1359
  ctx = {
@@ -1219,6 +1367,7 @@ module Brainiac
1219
1367
  chdir: work_dir,
1220
1368
  agent_cli: resolved && resolved["agent_cli"],
1221
1369
  model: model,
1370
+ profile: profile,
1222
1371
  started_at: started_at
1223
1372
  }
1224
1373
 
@@ -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.16"
6
+ VERSION = "0.0.18"
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.16
4
+ version: 0.0.18
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