brainiac-discord 0.0.16 → 0.0.17
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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b1fa3c52f3226376895626cbf3ba0d97963dcd8ea9f5ae227f4615797e48a6c
|
|
4
|
+
data.tar.gz: 99b890d0c8ccb29079b2617e0cd661aef8c703040b90db58a148938a73fc2990
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5754062ffb91909ec05451e84b3b03fca71754cbe9cb4434e154eb9e70df34654ffcd7c37c28be4adea593628944bc2d9b19a46d7d60fc08c15b7ebb40848520
|
|
7
|
+
data.tar.gz: ac00ebaa2b82042fb2fc7abbf3dcf94d1f674b0b9ded9a1e3fd7cd3974f0256ff0162440935acaa9b334e3eb2f081ed1393aaf2de87b99e61aa53fed8a0c4bcf
|
|
@@ -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)
|
|
@@ -607,7 +607,8 @@ module Brainiac
|
|
|
607
607
|
resolved: resolved, project_config: project_config,
|
|
608
608
|
session_key: session_key, supersede_key: supersede_key,
|
|
609
609
|
attachment_paths: attachment_paths, timestamp: timestamp, response_dir: response_dir,
|
|
610
|
-
profile:
|
|
610
|
+
profile: resolve_thread_profile(thread_map_key, clean_content),
|
|
611
|
+
explicit_model: explicit_model_tag?(clean_content, project_config, cli_provider_override: cli_provider_override)
|
|
611
612
|
)
|
|
612
613
|
end
|
|
613
614
|
|
|
@@ -712,6 +713,55 @@ module Brainiac
|
|
|
712
713
|
"cli=#{cli_provider}, model=#{model}, effort=#{effort}"
|
|
713
714
|
end
|
|
714
715
|
|
|
716
|
+
# Resolve the profile ([profile:X]/[p:X]) for this dispatch and make it sticky.
|
|
717
|
+
#
|
|
718
|
+
# An inline tag wins and is saved on the thread map entry (and the core work item),
|
|
719
|
+
# so later untagged replies in the same thread keep running on that profile until a
|
|
720
|
+
# different [p:X] replaces it. Without a tag, the stored thread profile is used, then
|
|
721
|
+
# the work item's (e.g. set from a Fizzy card). nil means the default profile.
|
|
722
|
+
# Unknown profile names are passed through (core ignores them with a warning) but
|
|
723
|
+
# never saved, so a typo can't pin the thread to a nonexistent profile.
|
|
724
|
+
def resolve_thread_profile(thread_map_key, clean_content)
|
|
725
|
+
inline = parse_inline_tags(clean_content)[:profile]
|
|
726
|
+
return inline unless thread_map_key
|
|
727
|
+
|
|
728
|
+
if inline
|
|
729
|
+
persist_thread_profile(thread_map_key, inline) if !defined?(profile_entry) || profile_entry(inline)
|
|
730
|
+
return inline
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
entry = Config.thread_map_mutex.synchronize { Config.load_thread_map[thread_map_key] }
|
|
734
|
+
return nil unless entry
|
|
735
|
+
|
|
736
|
+
entry["profile"] || work_item_profile_for(entry["branch"])
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def persist_thread_profile(thread_map_key, profile)
|
|
740
|
+
branch = Config.thread_map_mutex.synchronize do
|
|
741
|
+
map = Config.load_thread_map
|
|
742
|
+
entry = map[thread_map_key]
|
|
743
|
+
next nil unless entry
|
|
744
|
+
|
|
745
|
+
if entry["profile"] != profile
|
|
746
|
+
entry["profile"] = profile
|
|
747
|
+
Config.save_thread_map(map)
|
|
748
|
+
LOG.info "[Discord] Persisted profile for #{thread_map_key}: #{profile}" if defined?(LOG)
|
|
749
|
+
end
|
|
750
|
+
entry["branch"]
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
return unless branch && defined?(update_work_item_overrides) &&
|
|
754
|
+
method(:update_work_item_overrides).parameters.any? { |t, n| t == :key && n == :profile }
|
|
755
|
+
|
|
756
|
+
update_work_item_overrides(branch: branch, profile: profile)
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def work_item_profile_for(branch)
|
|
760
|
+
return nil unless branch && defined?(work_item_overrides_for)
|
|
761
|
+
|
|
762
|
+
work_item_overrides_for(branch: branch)["profile"]
|
|
763
|
+
end
|
|
764
|
+
|
|
715
765
|
def explicit_model_tag?(clean_content, project_config, cli_provider_override: nil)
|
|
716
766
|
return false unless project_config
|
|
717
767
|
|
|
@@ -871,6 +921,8 @@ module Brainiac
|
|
|
871
921
|
"model" => model, "effort" => effort, "created_at" => Time.now.iso8601 }
|
|
872
922
|
entry["branch"] = branch if branch
|
|
873
923
|
entry["chat_mode"] = true if chat_mode
|
|
924
|
+
# Keep a sticky profile when an entry is rebuilt (e.g. its worktree was recreated).
|
|
925
|
+
entry["profile"] = map.dig(thread_map_key, "profile") if map.dig(thread_map_key, "profile")
|
|
874
926
|
map[thread_map_key] = entry
|
|
875
927
|
Config.save_thread_map(map)
|
|
876
928
|
end
|
|
@@ -906,7 +958,8 @@ module Brainiac
|
|
|
906
958
|
def spawn_agent(agent_key:, agent_name:, bot_token:, channel_id:, message_id:, discord_user:,
|
|
907
959
|
work_dir:, prompt_file:, response_file:, meta_file:, model:, effort:,
|
|
908
960
|
actual_resume:, resolved:, project_config:,
|
|
909
|
-
session_key:, supersede_key:, attachment_paths:, timestamp:, response_dir:, profile: nil
|
|
961
|
+
session_key:, supersede_key:, attachment_paths:, timestamp:, response_dir:, profile: nil,
|
|
962
|
+
explicit_model: nil)
|
|
910
963
|
agent_config_name = agent_key.downcase.gsub(/[^a-z0-9-]/, "-")
|
|
911
964
|
log_file = File.join(response_dir, "discord-agent-#{timestamp}-#{agent_key}-#{message_id}.log")
|
|
912
965
|
|
|
@@ -926,6 +979,10 @@ module Brainiac
|
|
|
926
979
|
# XDG_DATA_HOME). An explicitly-requested [profile:X] wins over agent
|
|
927
980
|
# env; the default profile is only a fallback. See core profiles.rb.
|
|
928
981
|
agent_env = profile_spawn_env(agent_env, profile) if defined?(profile_spawn_env)
|
|
982
|
+
# The *effective* profile name that actually ran (explicit if valid,
|
|
983
|
+
# else the default profile) — recorded in durable history so the
|
|
984
|
+
# monitor can show which account/profile the session used.
|
|
985
|
+
effective_profile = defined?(effective_profile_name) ? effective_profile_name(profile) : profile
|
|
929
986
|
unless agent_env.empty?
|
|
930
987
|
spawn_env.merge!(agent_env)
|
|
931
988
|
LOG.info "[Discord:#{agent_name}] Injecting #{agent_env.size} env var(s): #{agent_env.keys.join(", ")}" if defined?(LOG)
|
|
@@ -942,6 +999,22 @@ module Brainiac
|
|
|
942
999
|
LOG.info "[Discord:#{agent_name}] Injecting GitHub App token (GH_TOKEN) for bot identity" if defined?(LOG)
|
|
943
1000
|
end
|
|
944
1001
|
|
|
1002
|
+
# For providers with no runtime --model flag (e.g. kiro-cli, model_flag: ""),
|
|
1003
|
+
# persist the model to the provider's settings store before spawning. Discord
|
|
1004
|
+
# dispatches its own agents and never calls core's run_agent, so we invoke the
|
|
1005
|
+
# same helper here — running it under the fully-assembled spawn_env so the write
|
|
1006
|
+
# targets the correct account's KIRO_HOME. No-op when a model_flag is configured.
|
|
1007
|
+
# explicit_model tells the helper whether this came from an inline tag: an explicit
|
|
1008
|
+
# [auto] writes (a deliberate reset), a defaulted "auto" does not. Guard the kwarg
|
|
1009
|
+
# against older core builds whose apply_settings_model has no `explicit:` param.
|
|
1010
|
+
if defined?(apply_settings_model)
|
|
1011
|
+
if method(:apply_settings_model).parameters.any? { |t, n| t == :key && n == :explicit }
|
|
1012
|
+
apply_settings_model(model, resolved, spawn_env, explicit: explicit_model)
|
|
1013
|
+
else
|
|
1014
|
+
apply_settings_model(model, resolved, spawn_env)
|
|
1015
|
+
end
|
|
1016
|
+
end
|
|
1017
|
+
|
|
945
1018
|
head_before, status_before = capture_brainiac_state(project_config, work_dir)
|
|
946
1019
|
prompt_mode = resolved["prompt_mode"] || "stdin"
|
|
947
1020
|
|
|
@@ -966,7 +1039,8 @@ module Brainiac
|
|
|
966
1039
|
attachment_paths: attachment_paths, project_config: project_config,
|
|
967
1040
|
head_before: head_before, status_before: status_before,
|
|
968
1041
|
supersede_key: supersede_key,
|
|
969
|
-
resolved: resolved, model: model, work_dir: work_dir, started_at: started_at
|
|
1042
|
+
resolved: resolved, model: model, work_dir: work_dir, started_at: started_at,
|
|
1043
|
+
profile: effective_profile
|
|
970
1044
|
)
|
|
971
1045
|
end
|
|
972
1046
|
|
|
@@ -1145,7 +1219,7 @@ module Brainiac
|
|
|
1145
1219
|
def monitor_agent(pid:, session_key:, agent_name:, agent_config_name:, channel_id:, message_id:,
|
|
1146
1220
|
bot_token:, response_file:, meta_file:, prompt_file:, log_file:,
|
|
1147
1221
|
attachment_paths:, project_config:, head_before:, status_before:, supersede_key: nil,
|
|
1148
|
-
resolved: nil, model: nil, work_dir: nil, started_at: nil)
|
|
1222
|
+
resolved: nil, model: nil, work_dir: nil, started_at: nil, profile: nil)
|
|
1149
1223
|
Thread.new do
|
|
1150
1224
|
Process.wait(pid)
|
|
1151
1225
|
exit_status = $CHILD_STATUS
|
|
@@ -1162,7 +1236,7 @@ module Brainiac
|
|
|
1162
1236
|
agent_name: agent_name, agent_config_name: agent_config_name,
|
|
1163
1237
|
channel_id: channel_id, message_id: message_id, log_file: log_file,
|
|
1164
1238
|
resolved: resolved, model: model, work_dir: work_dir, started_at: started_at,
|
|
1165
|
-
exit_status: exit_status
|
|
1239
|
+
exit_status: exit_status, profile: profile
|
|
1166
1240
|
)
|
|
1167
1241
|
|
|
1168
1242
|
if exit_status.signaled? || session_cancelled
|
|
@@ -1205,7 +1279,8 @@ module Brainiac
|
|
|
1205
1279
|
# Fully best-effort: archive_session_history already rescues internally, but we
|
|
1206
1280
|
# guard here too so a history failure can never break Discord's completion path.
|
|
1207
1281
|
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
|
|
1282
|
+
log_file:, resolved:, model:, work_dir:, started_at:, exit_status:,
|
|
1283
|
+
profile: nil)
|
|
1209
1284
|
return unless defined?(archive_session_history)
|
|
1210
1285
|
|
|
1211
1286
|
ctx = {
|
|
@@ -1219,6 +1294,7 @@ module Brainiac
|
|
|
1219
1294
|
chdir: work_dir,
|
|
1220
1295
|
agent_cli: resolved && resolved["agent_cli"],
|
|
1221
1296
|
model: model,
|
|
1297
|
+
profile: profile,
|
|
1222
1298
|
started_at: started_at
|
|
1223
1299
|
}
|
|
1224
1300
|
|