brainiac-discord 0.0.9 → 0.0.11
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/README.md +3 -2
- data/lib/brainiac/plugins/discord/api.rb +7 -4
- data/lib/brainiac/plugins/discord/config.rb +2 -2
- data/lib/brainiac/plugins/discord/delivery.rb +4 -2
- data/lib/brainiac/plugins/discord/message.rb +71 -26
- data/lib/brainiac/plugins/discord/prompts.rb +13 -0
- data/lib/brainiac/plugins/discord/version.rb +1 -1
- data/lib/brainiac/plugins/discord.rb +5 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e8d2bbbf0efaa83da8904137abf18a256f79e5073fe5896390c0afa1f880856f
|
|
4
|
+
data.tar.gz: 9b9a19c746dd57d13b232a6864918b99acf9a54128213dac2ff1c0328b90e242
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8af3d015077e894932bf05ae2940b8bc9d69895874b88ed548e51c01469a2257bc2bda5dd162a601c775c4745567c257a16c0091b52f5ae4854f4ebb42fecfd3
|
|
7
|
+
data.tar.gz: bbda431894c7453f4e6a8f9b8c6747884cc9cf39c01a0b812dd7f9a812a5afff63fbbc0f2171ccee88c7deabfd3eeac7216910efbb01b24def1d4d3270ad4db0
|
data/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Each agent gets its own Discord bot. Users @mention @Galen or @GLaDOS directly
|
|
|
14
14
|
- **Emoji feedback** — non-reserved emoji reactions are logged as feedback to the agent's persona
|
|
15
15
|
- **Thread isolation** — conversations get their own threads with worktree persistence
|
|
16
16
|
- **Forum support** — cron jobs can post to forum channels
|
|
17
|
-
- **GIF support** — agents can search and embed GIFs via
|
|
17
|
+
- **GIF support** — agents can search and embed GIFs via Klipy API (formerly GIPHY)
|
|
18
18
|
- **Draft delivery** — file-based response delivery survives server restarts
|
|
19
19
|
|
|
20
20
|
## Installation
|
|
@@ -92,7 +92,8 @@ Stored in `~/.brainiac/discord.json`:
|
|
|
92
92
|
},
|
|
93
93
|
"authorized_role_ids": [],
|
|
94
94
|
"authorized_user_ids": [],
|
|
95
|
-
"giphy_api_key":
|
|
95
|
+
"giphy_api_key": null,
|
|
96
|
+
"klipy_api_key": "your-klipy-api-key"
|
|
96
97
|
}
|
|
97
98
|
```
|
|
98
99
|
|
|
@@ -192,18 +192,21 @@ module Brainiac
|
|
|
192
192
|
# --- GIF Search ---
|
|
193
193
|
|
|
194
194
|
def search_gif(query)
|
|
195
|
-
api_key = Config.
|
|
195
|
+
api_key = Config.klipy_api_key
|
|
196
196
|
return [] unless api_key
|
|
197
197
|
|
|
198
|
-
uri = URI("https://api.
|
|
199
|
-
uri.query = URI.encode_www_form(
|
|
198
|
+
uri = URI("https://api.klipy.com/v2/search")
|
|
199
|
+
uri.query = URI.encode_www_form(key: api_key, q: query, limit: 5, contentfilter: "medium")
|
|
200
200
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
201
201
|
http.use_ssl = true
|
|
202
202
|
response = http.get(uri)
|
|
203
203
|
return [] unless response.code.to_i == 200
|
|
204
204
|
|
|
205
205
|
data = JSON.parse(response.body)
|
|
206
|
-
(data["
|
|
206
|
+
(data["results"] || []).filter_map do |r|
|
|
207
|
+
url = r.dig("media_formats", "gif", "url") || r.dig("media_formats", "mediumgif", "url") || r["url"]
|
|
208
|
+
{ "url" => url } if url
|
|
209
|
+
end
|
|
207
210
|
rescue StandardError => e
|
|
208
211
|
LOG.warn "[Discord] GIF search error: #{e.message}" if defined?(LOG)
|
|
209
212
|
[]
|
|
@@ -12,8 +12,9 @@ module Brainiac
|
|
|
12
12
|
# A poller thread recovers orphaned drafts (e.g. after a server restart).
|
|
13
13
|
module Delivery
|
|
14
14
|
BRAINIAC_DIR_PATH = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
|
|
15
|
-
DRAFT_DIR
|
|
16
|
-
POSTED_DIR
|
|
15
|
+
DRAFT_DIR = File.join(BRAINIAC_DIR_PATH, "tmp", "discord", "draft")
|
|
16
|
+
POSTED_DIR = File.join(BRAINIAC_DIR_PATH, "tmp", "discord", "posted")
|
|
17
|
+
PENDING_DIR = File.join(BRAINIAC_DIR_PATH, "tmp", "discord", "pending")
|
|
17
18
|
|
|
18
19
|
POLLER_INTERVAL = 5 # seconds
|
|
19
20
|
DRAFT_MIN_AGE = 30 # seconds — don't race the monitoring thread
|
|
@@ -30,6 +31,7 @@ module Brainiac
|
|
|
30
31
|
def ensure_dirs!
|
|
31
32
|
FileUtils.mkdir_p(DRAFT_DIR)
|
|
32
33
|
FileUtils.mkdir_p(POSTED_DIR)
|
|
34
|
+
FileUtils.mkdir_p(PENDING_DIR)
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
def start_poller!
|
|
@@ -254,7 +254,7 @@ module Brainiac
|
|
|
254
254
|
# Also check user_mappings for remote agent bots not connected locally
|
|
255
255
|
agent_keys = []
|
|
256
256
|
Gateway.each_bot { |key, _| agent_keys << key }
|
|
257
|
-
Config.user_mappings.
|
|
257
|
+
Config.user_mappings.each_key do |name|
|
|
258
258
|
normalized = name.downcase.gsub(/[^a-z0-9]/, "-")
|
|
259
259
|
next if normalized == agent_key
|
|
260
260
|
next if agent_keys.include?(normalized)
|
|
@@ -389,9 +389,7 @@ module Brainiac
|
|
|
389
389
|
end
|
|
390
390
|
|
|
391
391
|
def route_dispatch(agent_key:, agent_name:, bot_token:, is_bot:, channel_id:, message_id:, message:,
|
|
392
|
-
clean_content:, clean_content_for_prompt:, chat_mode:, fresh: false,
|
|
393
|
-
channel_info:, parent_channel_id:, discord_user:, reply_context:,
|
|
394
|
-
channel_history:, project_key:, project_config:, attachment_paths:,
|
|
392
|
+
clean_content:, clean_content_for_prompt:, chat_mode:, is_thread:, is_dm:, channel_info:, parent_channel_id:, discord_user:, reply_context:, channel_history:, project_key:, project_config:, attachment_paths:, fresh: false,
|
|
395
393
|
directly_addressed: false)
|
|
396
394
|
session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
|
|
397
395
|
supersede_key = "discord-#{agent_key}-#{channel_id}"
|
|
@@ -401,14 +399,24 @@ module Brainiac
|
|
|
401
399
|
end
|
|
402
400
|
handle_supersede(is_bot, supersede_key, discord_user, agent_name, bot_token)
|
|
403
401
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
402
|
+
# If an agent is still running (in supersede window), queue the message
|
|
403
|
+
# for mid-session context injection instead of spawning a second agent.
|
|
404
|
+
unless is_bot
|
|
405
|
+
active = find_supersedable_session(supersede_key)
|
|
406
|
+
if active
|
|
407
|
+
queue_pending_message(supersede_key, discord_user, clean_content, attachment_paths)
|
|
408
|
+
Thread.new { Api.add_reaction(channel_id, message_id, "📎", token: bot_token) }
|
|
409
|
+
LOG.info "[Discord:#{agent_name}] Queued follow-up from #{discord_user} for active session #{active[:session_key]}" if defined?(LOG)
|
|
408
410
|
return
|
|
409
411
|
end
|
|
410
412
|
end
|
|
411
413
|
|
|
414
|
+
if !directly_addressed && intent_skip?(clean_content, agent_name: agent_name, source: :discord,
|
|
415
|
+
channel: "Discord #{is_thread ? "thread" : "channel"}", context: channel_history)
|
|
416
|
+
LOG.info "[Discord:#{agent_name}] Intent skip — not dispatching for: #{clean_content[0..80]}" if defined?(LOG)
|
|
417
|
+
return
|
|
418
|
+
end
|
|
419
|
+
|
|
412
420
|
Thread.new do
|
|
413
421
|
Api.remove_reaction(channel_id, message_id, "🛑", token: bot_token)
|
|
414
422
|
Api.add_reaction(channel_id, message_id, "👀", token: bot_token)
|
|
@@ -459,6 +467,37 @@ module Brainiac
|
|
|
459
467
|
(prev[:draft_files] || []).each { |f| FileUtils.rm_f(f) }
|
|
460
468
|
end
|
|
461
469
|
|
|
470
|
+
def queue_pending_message(supersede_key, discord_user, content, attachment_paths)
|
|
471
|
+
FileUtils.mkdir_p(Delivery::PENDING_DIR)
|
|
472
|
+
safe_key = supersede_key.gsub(/[^a-zA-Z0-9-]/, "_")
|
|
473
|
+
timestamp = Time.now.strftime("%Y%m%d-%H%M%S-%L")
|
|
474
|
+
pending_file = File.join(Delivery::PENDING_DIR, "#{safe_key}-#{timestamp}.json")
|
|
475
|
+
payload = {
|
|
476
|
+
user: discord_user,
|
|
477
|
+
content: content,
|
|
478
|
+
timestamp: Time.now.iso8601,
|
|
479
|
+
attachments: attachment_paths || []
|
|
480
|
+
}
|
|
481
|
+
File.write(pending_file, JSON.pretty_generate(payload))
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def pending_messages_for(supersede_key)
|
|
485
|
+
safe_key = supersede_key.gsub(/[^a-zA-Z0-9-]/, "_")
|
|
486
|
+
pattern = File.join(Delivery::PENDING_DIR, "#{safe_key}-*.json")
|
|
487
|
+
files = Dir.glob(pattern)
|
|
488
|
+
messages = files.filter_map do |f|
|
|
489
|
+
JSON.parse(File.read(f))
|
|
490
|
+
rescue JSON::ParserError
|
|
491
|
+
nil
|
|
492
|
+
end
|
|
493
|
+
[messages, files]
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def clear_pending_messages(supersede_key)
|
|
497
|
+
_, files = pending_messages_for(supersede_key)
|
|
498
|
+
files.each { |f| FileUtils.rm_f(f) }
|
|
499
|
+
end
|
|
500
|
+
|
|
462
501
|
def build_project_context(project_key, project_config, agent_name)
|
|
463
502
|
if project_config
|
|
464
503
|
repo_path = project_config["repo_path"]
|
|
@@ -484,10 +523,7 @@ module Brainiac
|
|
|
484
523
|
end
|
|
485
524
|
|
|
486
525
|
def dispatch_session(agent_key:, agent_name:, bot_token:, channel_id:, message_id:, message:,
|
|
487
|
-
clean_content:, clean_content_for_prompt:, chat_mode:,
|
|
488
|
-
channel_info:, parent_channel_id:, discord_user:, reply_context:,
|
|
489
|
-
channel_history:, project_key:, project_config:, project_context:,
|
|
490
|
-
session_key:, supersede_key:, attachment_paths:, is_bot:)
|
|
526
|
+
clean_content:, clean_content_for_prompt:, chat_mode:, is_thread:, is_dm:, channel_info:, parent_channel_id:, discord_user:, reply_context:, channel_history:, project_key:, project_config:, project_context:, session_key:, supersede_key:, attachment_paths:, is_bot:, fresh: false)
|
|
491
527
|
timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
492
528
|
response_dir = File.join(Delivery::BRAINIAC_DIR_PATH, "tmp")
|
|
493
529
|
response_basename = "discord-response-#{timestamp}-#{agent_key}-#{message_id}"
|
|
@@ -528,7 +564,7 @@ module Brainiac
|
|
|
528
564
|
discord_user: discord_user, channel_name: channel_info&.dig("name") || channel_id, reply_context: reply_context,
|
|
529
565
|
channel_history: channel_history, thread_root_context: thread_root_context,
|
|
530
566
|
project_context: project_context, response_file: response_file, card_id: card_id,
|
|
531
|
-
brain_context: brain_context, agent_name: agent_name
|
|
567
|
+
brain_context: brain_context, agent_name: agent_name, supersede_key: supersede_key
|
|
532
568
|
)
|
|
533
569
|
|
|
534
570
|
work_dir = chat_mode_fallback(agent_key, agent_name, message_id, chat_mode, thread_worktree_path) ||
|
|
@@ -823,7 +859,7 @@ module Brainiac
|
|
|
823
859
|
|
|
824
860
|
def build_prompt(should_resume:, thread_worktree_path:, clean_content_for_prompt:,
|
|
825
861
|
discord_user:, channel_name:, reply_context:, channel_history:, thread_root_context:,
|
|
826
|
-
project_context:, response_file:, card_id:, brain_context:, agent_name:)
|
|
862
|
+
project_context:, response_file:, card_id:, brain_context:, agent_name:, supersede_key: nil)
|
|
827
863
|
if should_resume && thread_worktree_path
|
|
828
864
|
return Brainiac::Plugins::Discord::Prompts.render_resume(
|
|
829
865
|
message_body: clean_content_for_prompt, discord_user: discord_user,
|
|
@@ -831,12 +867,16 @@ module Brainiac
|
|
|
831
867
|
)
|
|
832
868
|
end
|
|
833
869
|
|
|
870
|
+
safe_key = supersede_key&.gsub(/[^a-zA-Z0-9-]/, "_") || "unknown"
|
|
871
|
+
pending_glob = File.join(Delivery::PENDING_DIR, "#{safe_key}-*.json")
|
|
872
|
+
|
|
834
873
|
template_vars = {
|
|
835
874
|
"DISCORD_USER" => discord_user, "CHANNEL_NAME" => channel_name,
|
|
836
875
|
"MESSAGE_BODY" => clean_content_for_prompt, "REPLY_CONTEXT" => reply_context,
|
|
837
876
|
"CHANNEL_HISTORY" => channel_history, "THREAD_ROOT_CONTEXT" => thread_root_context,
|
|
838
877
|
"PROJECT_CONTEXT" => project_context, "RESPONSE_FILE" => response_file,
|
|
839
|
-
"COMMENT_CREATOR" => discord_user, "DISCORD_MENTION_ROSTER" => Api.mention_roster
|
|
878
|
+
"COMMENT_CREATOR" => discord_user, "DISCORD_MENTION_ROSTER" => Api.mention_roster,
|
|
879
|
+
"PENDING_MESSAGES_GLOB" => pending_glob
|
|
840
880
|
}
|
|
841
881
|
|
|
842
882
|
template_vars["CARD_ID"] = card_id
|
|
@@ -891,7 +931,8 @@ module Brainiac
|
|
|
891
931
|
message_id: message_id, bot_token: bot_token, response_file: response_file,
|
|
892
932
|
meta_file: meta_file, prompt_file: prompt_file, log_file: log_file,
|
|
893
933
|
attachment_paths: attachment_paths, project_config: project_config,
|
|
894
|
-
head_before: head_before, status_before: status_before
|
|
934
|
+
head_before: head_before, status_before: status_before,
|
|
935
|
+
supersede_key: supersede_key
|
|
895
936
|
)
|
|
896
937
|
end
|
|
897
938
|
|
|
@@ -985,10 +1026,10 @@ module Brainiac
|
|
|
985
1026
|
Process.wait(pid)
|
|
986
1027
|
exit_status = $CHILD_STATUS.exitstatus
|
|
987
1028
|
|
|
988
|
-
if exit_status
|
|
1029
|
+
if exit_status.zero?
|
|
989
1030
|
LOG.info "[Discord:#{agent_name}] [fresh] Memory refresh completed successfully" if defined?(LOG)
|
|
990
|
-
|
|
991
|
-
LOG.warn "[Discord:#{agent_name}] [fresh] Memory refresh failed (exit: #{exit_status}), proceeding anyway"
|
|
1031
|
+
elsif defined?(LOG)
|
|
1032
|
+
LOG.warn "[Discord:#{agent_name}] [fresh] Memory refresh failed (exit: #{exit_status}), proceeding anyway"
|
|
992
1033
|
end
|
|
993
1034
|
|
|
994
1035
|
Api.remove_reaction(channel_id, message_id, "📖", token: bot_token)
|
|
@@ -1034,11 +1075,14 @@ module Brainiac
|
|
|
1034
1075
|
|
|
1035
1076
|
def monitor_agent(pid:, session_key:, agent_name:, agent_config_name:, channel_id:, message_id:,
|
|
1036
1077
|
bot_token:, response_file:, meta_file:, prompt_file:, log_file:,
|
|
1037
|
-
attachment_paths:, project_config:, head_before:, status_before:)
|
|
1078
|
+
attachment_paths:, project_config:, head_before:, status_before:, supersede_key: nil)
|
|
1038
1079
|
Thread.new do
|
|
1039
1080
|
Process.wait(pid)
|
|
1040
1081
|
exit_status = $CHILD_STATUS
|
|
1041
1082
|
|
|
1083
|
+
# Clean up any pending messages that the agent may not have consumed
|
|
1084
|
+
clear_pending_messages(supersede_key) if supersede_key
|
|
1085
|
+
|
|
1042
1086
|
session_cancelled = ACTIVE_SESSIONS_MUTEX.synchronize { !ACTIVE_SESSIONS.key?(session_key) }
|
|
1043
1087
|
|
|
1044
1088
|
if exit_status.signaled? || session_cancelled
|
|
@@ -1172,12 +1216,13 @@ module Brainiac
|
|
|
1172
1216
|
# Detect transient CLI errors that don't warrant a full crash notification.
|
|
1173
1217
|
# These are random upstream failures (model timeouts, tool approval glitches)
|
|
1174
1218
|
# that resolve on retry — reacting with an emoji is sufficient.
|
|
1175
|
-
TRANSIENT_CLI_ERROR_PATTERN = /
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1219
|
+
TRANSIENT_CLI_ERROR_PATTERN = # rubocop:disable Lint/UselessConstantScoping
|
|
1220
|
+
/
|
|
1221
|
+
Failed\sto\sreceive\sthe\snext\smessage|
|
|
1222
|
+
Kiro\sfailed\sto\sgenerate\sa\sresponse|
|
|
1223
|
+
Tool\sapproval\srequired\sbut\s--no-interactive|
|
|
1224
|
+
Kiro\sis\shaving\strouble\sresponding
|
|
1225
|
+
/ix # rubocop:enable Lint/UselessConstantScoping
|
|
1181
1226
|
|
|
1182
1227
|
def transient_cli_error?(log_file)
|
|
1183
1228
|
return false unless log_file && File.exist?(log_file)
|
|
@@ -84,6 +84,19 @@ module Brainiac
|
|
|
84
84
|
- The current topic/focus as of this session
|
|
85
85
|
This is the ONLY way future sessions will know what happened in the middle of the conversation.
|
|
86
86
|
|
|
87
|
+
### Pending Follow-Up Messages (check BEFORE writing your response)
|
|
88
|
+
While you're working, the user may send additional messages that get queued for you.
|
|
89
|
+
Before writing your response file, check for pending messages:
|
|
90
|
+
```bash
|
|
91
|
+
ls {{PENDING_MESSAGES_GLOB}} 2>/dev/null
|
|
92
|
+
```
|
|
93
|
+
If any files exist, read them — they contain JSON with `user`, `content`, and `timestamp`.
|
|
94
|
+
Incorporate the follow-up context into your response. Address anything new the user said.
|
|
95
|
+
After reading them, delete the pending files:
|
|
96
|
+
```bash
|
|
97
|
+
rm -f {{PENDING_MESSAGES_GLOB}}
|
|
98
|
+
```
|
|
99
|
+
|
|
87
100
|
PROMPT
|
|
88
101
|
|
|
89
102
|
SITUATION = <<~'PROMPT'
|
|
@@ -89,7 +89,8 @@ module Brainiac
|
|
|
89
89
|
|
|
90
90
|
# Handle forum posts
|
|
91
91
|
if ctx[:forum_title] && Api.forum_channel?(target, token: token)
|
|
92
|
-
|
|
92
|
+
title = "#{ctx[:forum_title]} — #{Time.now.strftime("%b %d, %Y")}"
|
|
93
|
+
Api.create_forum_post(target, title: title, content: message, token: token)
|
|
93
94
|
elsif ctx[:forum_reply_to_latest] && Api.forum_channel?(target, token: token)
|
|
94
95
|
latest = Api.find_latest_forum_thread(target, token: token)
|
|
95
96
|
if latest
|
|
@@ -97,6 +98,9 @@ module Brainiac
|
|
|
97
98
|
else
|
|
98
99
|
Api.send_long_message(target, message, token: token)
|
|
99
100
|
end
|
|
101
|
+
elsif Api.forum_channel?(target, token: token)
|
|
102
|
+
title = "#{agent || "Notification"} — #{Time.now.strftime("%b %d, %Y")}"
|
|
103
|
+
Api.create_forum_post(target, title: title, content: message, token: token)
|
|
100
104
|
else
|
|
101
105
|
Api.send_long_message(target, message, token: token)
|
|
102
106
|
end
|