brainiac-discord 0.0.15 → 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)
|
|
@@ -61,6 +61,7 @@ module Brainiac
|
|
|
61
61
|
|
|
62
62
|
reload_projects!
|
|
63
63
|
reload_agent_registry!
|
|
64
|
+
reload_profiles! if defined?(reload_profiles!)
|
|
64
65
|
Config.reload!
|
|
65
66
|
return unless authorize_user(discord_user, discord_user_id, message, channel_id, message_id, agent_name, bot_token)
|
|
66
67
|
|
|
@@ -605,7 +606,9 @@ module Brainiac
|
|
|
605
606
|
meta_file: meta_file, model: model, effort: effort, actual_resume: actual_resume,
|
|
606
607
|
resolved: resolved, project_config: project_config,
|
|
607
608
|
session_key: session_key, supersede_key: supersede_key,
|
|
608
|
-
attachment_paths: attachment_paths, timestamp: timestamp, response_dir: response_dir
|
|
609
|
+
attachment_paths: attachment_paths, timestamp: timestamp, response_dir: response_dir,
|
|
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)
|
|
609
612
|
)
|
|
610
613
|
end
|
|
611
614
|
|
|
@@ -710,6 +713,55 @@ module Brainiac
|
|
|
710
713
|
"cli=#{cli_provider}, model=#{model}, effort=#{effort}"
|
|
711
714
|
end
|
|
712
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
|
+
|
|
713
765
|
def explicit_model_tag?(clean_content, project_config, cli_provider_override: nil)
|
|
714
766
|
return false unless project_config
|
|
715
767
|
|
|
@@ -869,6 +921,8 @@ module Brainiac
|
|
|
869
921
|
"model" => model, "effort" => effort, "created_at" => Time.now.iso8601 }
|
|
870
922
|
entry["branch"] = branch if branch
|
|
871
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")
|
|
872
926
|
map[thread_map_key] = entry
|
|
873
927
|
Config.save_thread_map(map)
|
|
874
928
|
end
|
|
@@ -904,7 +958,8 @@ module Brainiac
|
|
|
904
958
|
def spawn_agent(agent_key:, agent_name:, bot_token:, channel_id:, message_id:, discord_user:,
|
|
905
959
|
work_dir:, prompt_file:, response_file:, meta_file:, model:, effort:,
|
|
906
960
|
actual_resume:, resolved:, project_config:,
|
|
907
|
-
session_key:, supersede_key:, attachment_paths:, timestamp:, response_dir:
|
|
961
|
+
session_key:, supersede_key:, attachment_paths:, timestamp:, response_dir:, profile: nil,
|
|
962
|
+
explicit_model: nil)
|
|
908
963
|
agent_config_name = agent_key.downcase.gsub(/[^a-z0-9-]/, "-")
|
|
909
964
|
log_file = File.join(response_dir, "discord-agent-#{timestamp}-#{agent_key}-#{message_id}.log")
|
|
910
965
|
|
|
@@ -920,6 +975,14 @@ module Brainiac
|
|
|
920
975
|
|
|
921
976
|
spawn_env = {}
|
|
922
977
|
agent_env = agent_env_for(agent_name)
|
|
978
|
+
# Profile env (named bundle, e.g. an alternate kiro-cli account via
|
|
979
|
+
# XDG_DATA_HOME). An explicitly-requested [profile:X] wins over agent
|
|
980
|
+
# env; the default profile is only a fallback. See core profiles.rb.
|
|
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
|
|
923
986
|
unless agent_env.empty?
|
|
924
987
|
spawn_env.merge!(agent_env)
|
|
925
988
|
LOG.info "[Discord:#{agent_name}] Injecting #{agent_env.size} env var(s): #{agent_env.keys.join(", ")}" if defined?(LOG)
|
|
@@ -936,6 +999,22 @@ module Brainiac
|
|
|
936
999
|
LOG.info "[Discord:#{agent_name}] Injecting GitHub App token (GH_TOKEN) for bot identity" if defined?(LOG)
|
|
937
1000
|
end
|
|
938
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
|
+
|
|
939
1018
|
head_before, status_before = capture_brainiac_state(project_config, work_dir)
|
|
940
1019
|
prompt_mode = resolved["prompt_mode"] || "stdin"
|
|
941
1020
|
|
|
@@ -960,7 +1039,8 @@ module Brainiac
|
|
|
960
1039
|
attachment_paths: attachment_paths, project_config: project_config,
|
|
961
1040
|
head_before: head_before, status_before: status_before,
|
|
962
1041
|
supersede_key: supersede_key,
|
|
963
|
-
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
|
|
964
1044
|
)
|
|
965
1045
|
end
|
|
966
1046
|
|
|
@@ -1139,7 +1219,7 @@ module Brainiac
|
|
|
1139
1219
|
def monitor_agent(pid:, session_key:, agent_name:, agent_config_name:, channel_id:, message_id:,
|
|
1140
1220
|
bot_token:, response_file:, meta_file:, prompt_file:, log_file:,
|
|
1141
1221
|
attachment_paths:, project_config:, head_before:, status_before:, supersede_key: nil,
|
|
1142
|
-
resolved: nil, model: nil, work_dir: nil, started_at: nil)
|
|
1222
|
+
resolved: nil, model: nil, work_dir: nil, started_at: nil, profile: nil)
|
|
1143
1223
|
Thread.new do
|
|
1144
1224
|
Process.wait(pid)
|
|
1145
1225
|
exit_status = $CHILD_STATUS
|
|
@@ -1156,7 +1236,7 @@ module Brainiac
|
|
|
1156
1236
|
agent_name: agent_name, agent_config_name: agent_config_name,
|
|
1157
1237
|
channel_id: channel_id, message_id: message_id, log_file: log_file,
|
|
1158
1238
|
resolved: resolved, model: model, work_dir: work_dir, started_at: started_at,
|
|
1159
|
-
exit_status: exit_status
|
|
1239
|
+
exit_status: exit_status, profile: profile
|
|
1160
1240
|
)
|
|
1161
1241
|
|
|
1162
1242
|
if exit_status.signaled? || session_cancelled
|
|
@@ -1199,7 +1279,8 @@ module Brainiac
|
|
|
1199
1279
|
# Fully best-effort: archive_session_history already rescues internally, but we
|
|
1200
1280
|
# guard here too so a history failure can never break Discord's completion path.
|
|
1201
1281
|
def archive_discord_session_history(agent_name:, agent_config_name:, channel_id:, message_id:,
|
|
1202
|
-
log_file:, resolved:, model:, work_dir:, started_at:, exit_status
|
|
1282
|
+
log_file:, resolved:, model:, work_dir:, started_at:, exit_status:,
|
|
1283
|
+
profile: nil)
|
|
1203
1284
|
return unless defined?(archive_session_history)
|
|
1204
1285
|
|
|
1205
1286
|
ctx = {
|
|
@@ -1213,6 +1294,7 @@ module Brainiac
|
|
|
1213
1294
|
chdir: work_dir,
|
|
1214
1295
|
agent_cli: resolved && resolved["agent_cli"],
|
|
1215
1296
|
model: model,
|
|
1297
|
+
profile: profile,
|
|
1216
1298
|
started_at: started_at
|
|
1217
1299
|
}
|
|
1218
1300
|
|