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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed2e8f74f05207e45944b37b43094298419d007ed897dac9987f97d86a03152b
4
- data.tar.gz: 9ac14bdbb6e5481d7b36a8734f8b4beb66eb7b8299caa1459e47c6389f186101
3
+ metadata.gz: e8d2bbbf0efaa83da8904137abf18a256f79e5073fe5896390c0afa1f880856f
4
+ data.tar.gz: 9b9a19c746dd57d13b232a6864918b99acf9a54128213dac2ff1c0328b90e242
5
5
  SHA512:
6
- metadata.gz: c74ff0656c3790f82604c8162c826758b5ca57ea0eecb71adc5e7fcf7d78ee6eccebb110dffada6a6ba4e74456c75c98fdcee88679cbd2716a52d73f53231cf6
7
- data.tar.gz: 9c5ffd03b96f76668d2cbfcfb54bebd93af6ca2501f7d952d635a9db75f2cd712d3e40274e23dee877fc835ac0a305264eb27b5383411e4b35288477a236e038
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 GIPHY API
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": "your-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.giphy_api_key
195
+ api_key = Config.klipy_api_key
196
196
  return [] unless api_key
197
197
 
198
- uri = URI("https://api.giphy.com/v1/gifs/search")
199
- uri.query = URI.encode_www_form(api_key: api_key, q: query, limit: 5, rating: "pg-13")
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["data"] || []).map { |g| { "url" => g.dig("images", "original", "url") || g["url"] } }
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
  []
@@ -53,8 +53,8 @@ module Brainiac
53
53
  @config["dashboard_token"]
54
54
  end
55
55
 
56
- def giphy_api_key
57
- @config["giphy_api_key"]
56
+ def klipy_api_key
57
+ @config["klipy_api_key"] || @config["giphy_api_key"]
58
58
  end
59
59
 
60
60
  def channel_mappings
@@ -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 = File.join(BRAINIAC_DIR_PATH, "tmp", "discord", "draft")
16
- POSTED_DIR = File.join(BRAINIAC_DIR_PATH, "tmp", "discord", "posted")
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.each do |name, _discord_id|
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, is_thread:, is_dm:,
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
- unless directly_addressed
405
- if intent_skip?(clean_content, agent_name: agent_name, source: :discord,
406
- channel: "Discord #{is_thread ? "thread" : "channel"}", context: channel_history)
407
- LOG.info "[Discord:#{agent_name}] Intent skip — not dispatching for: #{clean_content[0..80]}" if defined?(LOG)
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:, fresh: false, is_thread:, is_dm:,
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 == 0
1029
+ if exit_status.zero?
989
1030
  LOG.info "[Discord:#{agent_name}] [fresh] Memory refresh completed successfully" if defined?(LOG)
990
- else
991
- LOG.warn "[Discord:#{agent_name}] [fresh] Memory refresh failed (exit: #{exit_status}), proceeding anyway" if defined?(LOG)
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
- Failed\sto\sreceive\sthe\snext\smessage|
1177
- Kiro\sfailed\sto\sgenerate\sa\sresponse|
1178
- Tool\sapproval\srequired\sbut\s--no-interactive|
1179
- Kiro\sis\shaving\strouble\sresponding
1180
- /ix
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'
@@ -3,7 +3,7 @@
3
3
  module Brainiac
4
4
  module Plugins
5
5
  module Discord
6
- VERSION = "0.0.9"
6
+ VERSION = "0.0.11"
7
7
  end
8
8
  end
9
9
  end
@@ -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
- Api.create_forum_post(target, title: ctx[:forum_title], content: message, token: token)
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
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.9
4
+ version: 0.0.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis