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 +4 -4
- data/lib/brainiac/plugins/discord/api.rb +4 -1
- data/lib/brainiac/plugins/discord/branching.rb +195 -0
- data/lib/brainiac/plugins/discord/gateway.rb +106 -10
- data/lib/brainiac/plugins/discord/message.rb +155 -6
- data/lib/brainiac/plugins/discord/reactions.rb +122 -0
- data/lib/brainiac/plugins/discord/version.rb +1 -1
- data/lib/brainiac/plugins/discord.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 81f74969c95690e118d85fe590b2e2d35b39609937e807afaa2865ac58c8846d
|
|
4
|
+
data.tar.gz: cde31da7b3a0b2a4b84bff28b4b1e2fa99fe6ba7e7e8e372b572b1d0ea085c5c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
216
|
-
next if websocket.open?
|
|
310
|
+
return if block_given? && yield && !websocket.open?
|
|
217
311
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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:
|
|
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")
|
|
@@ -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.
|
|
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
|