brainiac-discord 0.0.1 → 0.0.4

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: 223dd60e819a0b61ff8b4f82e27cf88774d431b2a2673f82e5e5d7724ca6ed9b
4
- data.tar.gz: 1777674edaec7db3e0772bdc3fa55d3f7438b1c0fba4c2450c50cd78ffa46442
3
+ metadata.gz: cdd8beafe4070560e4caaaa465d2b1e9dde33defa1b7fdc429467864776d3c21
4
+ data.tar.gz: 8714538569c7c1211a93ffa567401fe54197ce975fb3946e1524550f67cacb97
5
5
  SHA512:
6
- metadata.gz: 3a2f08e64fa903bd185a7c4a09a279a5dd23425b744e59f0e5e4bd17bc7d9adb841bef15d7f1c09b614ff89587fac1ed01a11bf38edb10059e06956e21816358
7
- data.tar.gz: 3dd471e951c05a947837a55ab5f9c6510e798eb41d2d86c65ab13c0264b29804e48de6cf430654639d5076db912c1093bee6578ad307a0bde56ad51a51309cb8
6
+ metadata.gz: a072c85e99b507dc4cdf65ec04cb9ed1d83e3ef22ef6ba77bd2866b7e04d947920cd607e087be3c93b06d362ef2e94127903e5087b8e9b7a85c456be72684b6f
7
+ data.tar.gz: 481b0308e707c86fddc515ba26a079bb23c31bb1f0f0f42da00cc7d798af52a24f6f8ea40f75356675f856f037b4e7dafa4174e1d62589569379bdc36e26f401
@@ -269,6 +269,45 @@ module Brainiac
269
269
  def self.completions
270
270
  %w[setup config default map owner token status]
271
271
  end
272
+
273
+ # Called by brainiac CLI after `agent create` — prompts for Discord user ID.
274
+ def self.on_agent_created(agent_key, entry)
275
+ return unless $stdin.tty?
276
+
277
+ display_name = entry["display_name"] || agent_key.capitalize
278
+ puts ""
279
+ puts " [Discord] Configure Discord for #{display_name}?"
280
+ print " Discord user ID (or blank to skip): "
281
+ user_id = $stdin.gets&.chomp
282
+ return if user_id.nil? || user_id.empty?
283
+
284
+ config_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "discord.json")
285
+ config = File.exist?(config_file) ? JSON.parse(File.read(config_file)) : {}
286
+ config["user_mappings"] ||= {}
287
+ config["user_mappings"][display_name] = user_id
288
+ File.write(config_file, JSON.pretty_generate(config))
289
+ puts " ✓ Added #{display_name} → #{user_id} to discord.json user_mappings"
290
+ rescue JSON::ParserError => e
291
+ puts " ⚠ Could not update discord.json: #{e.message}"
292
+ end
293
+
294
+ # Called by brainiac CLI after `agent remove` — removes from Discord user_mappings.
295
+ def self.on_agent_removed(agent_key, display_name)
296
+ config_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "discord.json")
297
+ return unless File.exist?(config_file)
298
+
299
+ config = JSON.parse(File.read(config_file))
300
+ mappings = config["user_mappings"]
301
+ return unless mappings
302
+
303
+ removed = mappings.delete(display_name) || mappings.delete(agent_key)
304
+ return unless removed
305
+
306
+ File.write(config_file, JSON.pretty_generate(config))
307
+ puts " [Discord] Removed #{display_name} from discord.json user_mappings"
308
+ rescue JSON::ParserError
309
+ nil
310
+ end
272
311
  end
273
312
  end
274
313
  end
@@ -79,6 +79,27 @@ module Brainiac
79
79
  end
80
80
  end
81
81
 
82
+ # Start a single bot gateway connection dynamically (e.g. after agent is added).
83
+ # No-op if the bot is already running.
84
+ def start_bot!(agent_key, token)
85
+ @bots_mutex.synchronize do
86
+ return if @bots[agent_key]
87
+
88
+ @bots[agent_key] = { token: token, status: "starting", user_id: nil }
89
+ end
90
+ start_gateway_for(agent_key, token)
91
+ end
92
+
93
+ # Stop a bot gateway connection (e.g. after agent is removed).
94
+ # Removes it from the bots hash so it won't be matched in detect_sender_agent.
95
+ def stop_bot!(agent_key)
96
+ info = @bots_mutex.synchronize { @bots.delete(agent_key) }
97
+ return unless info
98
+
99
+ info[:thread]&.kill
100
+ LOG.info "[Discord] Gateway stopped for #{agent_key}" if defined?(LOG)
101
+ end
102
+
82
103
  # Summary of all bot statuses for the API endpoint.
83
104
  def bots_status
84
105
  @bots_mutex.synchronize do
@@ -122,7 +143,7 @@ module Brainiac
122
143
  private
123
144
 
124
145
  def start_gateway_for(agent_key, bot_token)
125
- Thread.new do
146
+ thread = Thread.new do
126
147
  agent_display = agent_display_name(agent_key) || agent_key.capitalize
127
148
  bot_user_id = nil
128
149
 
@@ -136,13 +157,19 @@ module Brainiac
136
157
  sleep 5
137
158
  end
138
159
  end
160
+ @bots_mutex.synchronize { @bots[agent_key][:thread] = thread if @bots[agent_key] }
139
161
  end
140
162
 
141
163
  def run_gateway_connection(agent_key, agent_display, bot_token, bot_user_id)
142
- @bots_mutex.synchronize do
143
- @bots[agent_key] ||= {}
144
- @bots[agent_key][:status] = "connecting"
145
- @bots[agent_key][:token] = bot_token
164
+ # Capture module-level ivars in local variables so the event_emitter
165
+ # blocks (which run via instance_exec on the WS client) can access them.
166
+ bots = @bots
167
+ bots_mutex = @bots_mutex
168
+
169
+ bots_mutex.synchronize do
170
+ bots[agent_key] ||= {}
171
+ bots[agent_key][:status] = "connecting"
172
+ bots[agent_key][:token] = bot_token
146
173
  end
147
174
 
148
175
  LOG.debug "[Discord:#{agent_display}] Connecting to Gateway..." if defined?(LOG) && LOG.respond_to?(:debug)
@@ -168,8 +195,8 @@ module Brainiac
168
195
  end
169
196
 
170
197
  ws.on :close do |_e|
171
- @bots_mutex.synchronize do
172
- @bots[agent_key][:status] = "disconnected" if @bots[agent_key]
198
+ bots_mutex.synchronize do
199
+ bots[agent_key][:status] = "disconnected" if bots[agent_key]
173
200
  end
174
201
  LOG.warn "[Discord:#{agent_display}] WebSocket closed" if defined?(LOG)
175
202
  heartbeat_thread&.kill
@@ -31,7 +31,10 @@ module Brainiac
31
31
  channel_info, is_thread, is_dm, in_own_thread = detect_channel_context(message, channel_id, mentioned, is_reply_to_bot, bot_token,
32
32
  bot_user_id)
33
33
  return if should_stand_down?(in_own_thread, mentioned, is_reply_to_bot, is_bot, agent_key, mentions, content)
34
- return unless mentioned || in_own_thread || is_dm || is_reply_to_bot
34
+
35
+ is_thread_participant = !mentioned && !in_own_thread && is_thread &&
36
+ thread_participant?(channel_id, message_id, bot_user_id, bot_token)
37
+ return unless mentioned || in_own_thread || is_dm || is_reply_to_bot || is_thread_participant
35
38
 
36
39
  record_human_comment("discord-#{channel_id}") unless is_bot
37
40
 
@@ -158,6 +161,24 @@ module Brainiac
158
161
  other_bot_mentioned
159
162
  end
160
163
 
164
+ # Check if this bot was previously mentioned in the thread (making it a participant).
165
+ # Uses a small history fetch to avoid excessive API calls.
166
+ def thread_participant?(channel_id, before_message_id, bot_user_id, bot_token)
167
+ messages = Api.request(:get, "/channels/#{channel_id}/messages?before=#{before_message_id}&limit=50", token: bot_token)
168
+ return false unless messages.is_a?(Array)
169
+
170
+ bot_id_str = bot_user_id.to_s
171
+ messages.any? do |msg|
172
+ mentions = msg["mentions"] || []
173
+ mentions.any? { |m| m["id"].to_s == bot_id_str } ||
174
+ (msg["content"] || "").match?(/<@!?#{Regexp.escape(bot_id_str)}>/) ||
175
+ msg.dig("author", "id").to_s == bot_id_str
176
+ end
177
+ rescue StandardError => e
178
+ LOG.warn "[Discord] Failed to check thread participation: #{e.message}" if defined?(LOG)
179
+ false
180
+ end
181
+
161
182
  def prepare_content(content, bot_user_id)
162
183
  content.gsub(/<@!?#{bot_user_id}>/, "").strip
163
184
  end
@@ -284,6 +305,12 @@ module Brainiac
284
305
  return
285
306
  end
286
307
  handle_supersede(is_bot, supersede_key, discord_user, agent_name, bot_token)
308
+
309
+ if intent_skip?(clean_content, agent_name: agent_name, source: :discord, channel: "Discord #{is_thread ? "thread" : "channel"}")
310
+ LOG.info "[Discord:#{agent_name}] Intent skip — not dispatching for: #{clean_content[0..80]}" if defined?(LOG)
311
+ return
312
+ end
313
+
287
314
  Thread.new do
288
315
  Api.remove_reaction(channel_id, message_id, "🛑", token: bot_token)
289
316
  Api.add_reaction(channel_id, message_id, "👀", token: bot_token)
@@ -567,8 +594,7 @@ module Brainiac
567
594
 
568
595
  def thread_resume?(project_config, clean_content, thread_cli_provider, agent_name)
569
596
  effective_provider = detect_cli_provider(text: clean_content) || thread_cli_provider
570
- resolved = resolve_project_cli_config(project_config, cli_provider_override: effective_provider, agent_name: agent_name)
571
- resolved["resume_flag"] ? true : false
597
+ resume_viable?(project_config: project_config, cli_provider: effective_provider, agent_name: agent_name)
572
598
  end
573
599
 
574
600
  def detect_thread_overrides(project_config, clean_content, fallback_cli: nil, fallback_model: nil, fallback_effort: nil)
@@ -630,7 +656,7 @@ module Brainiac
630
656
  discord_user:, channel_name:, reply_context:, channel_history:, thread_root_context:,
631
657
  project_context:, response_file:, card_id:, brain_context:, agent_name:)
632
658
  if should_resume && thread_worktree_path
633
- return render_discord_resume_prompt(
659
+ return Brainiac::Plugins::Discord::Prompts.render_resume(
634
660
  message_body: clean_content_for_prompt, discord_user: discord_user,
635
661
  response_file: response_file, agent_name: agent_name, card_id: card_id
636
662
  )
@@ -103,6 +103,33 @@ module Brainiac
103
103
 
104
104
  **IMPORTANT: Write your response to `{{RESPONSE_FILE}}`. Do NOT reply via stdout.**
105
105
  PROMPT
106
+
107
+ # Lean resume prompt for Discord threads. The previous session has full context
108
+ # (role, persona, knowledge, instructions). We only send the new message.
109
+ def self.render_resume(message_body:, discord_user:, response_file:, agent_name: AI_AGENT_NAME, card_id: nil)
110
+ memory_dir = memory_dir_for(agent_name)
111
+ if card_id
112
+ memory_file = File.join(memory_dir, "card-#{card_id}.md")
113
+ FileUtils.mkdir_p(memory_dir)
114
+ FileUtils.touch(memory_file)
115
+ end
116
+
117
+ lines = []
118
+ lines << "## Resumed Session — New Discord Message"
119
+ lines << ""
120
+ lines << "This is a continuation of your previous session in this thread."
121
+ lines << "All prior context, instructions, and your previous work are still in this conversation."
122
+ lines << ""
123
+ lines << "### New Message from #{discord_user}"
124
+ lines << ""
125
+ lines << message_body
126
+ lines << ""
127
+ lines << "---"
128
+ lines << "**IMPORTANT: Write your response to `#{response_file}`. Do NOT reply via stdout.**"
129
+ lines << "All your previous instructions still apply (memory, persona, one message per session, etc.)."
130
+
131
+ lines.join("\n")
132
+ end
106
133
  end
107
134
  end
108
135
  end
@@ -3,7 +3,7 @@
3
3
  module Brainiac
4
4
  module Plugins
5
5
  module Discord
6
- VERSION = "0.0.1"
6
+ VERSION = "0.0.4"
7
7
  end
8
8
  end
9
9
  end
@@ -31,6 +31,9 @@ module Brainiac
31
31
  # Register crash handler
32
32
  register_crash_handler!
33
33
 
34
+ # Register agent lifecycle hooks
35
+ register_agent_lifecycle_hooks!
36
+
34
37
  # Start all per-agent Discord bot gateway connections
35
38
  Brainiac::Plugins::Discord::Gateway.start_all!
36
39
 
@@ -45,6 +48,30 @@ module Brainiac
45
48
 
46
49
  private
47
50
 
51
+ def register_agent_lifecycle_hooks!
52
+ Brainiac.on(:agent_added) do |ctx|
53
+ agent_key = ctx[:agent_key]
54
+ entry = ctx[:entry]
55
+ display_name = ctx[:display_name]
56
+ next unless entry.is_a?(Hash)
57
+
58
+ token = (entry["env"] || {})["DISCORD_BOT_TOKEN"]
59
+ if token
60
+ # Start a gateway connection for the new agent's bot
61
+ Gateway.start_bot!(agent_key, token)
62
+ LOG.info "[Discord] Started bot for new agent: #{display_name}" if defined?(LOG)
63
+ end
64
+ end
65
+
66
+ Brainiac.on(:agent_removed) do |ctx|
67
+ agent_key = ctx[:agent_key]
68
+
69
+ # Stop the gateway connection if running
70
+ Gateway.stop_bot!(agent_key)
71
+ LOG.info "[Discord] Stopped bot for removed agent: #{agent_key}" if defined?(LOG)
72
+ end
73
+ end
74
+
48
75
  def register_notification_handler!
49
76
  Brainiac.on(:notify) do |ctx|
50
77
  next unless ctx[:channel].to_s == "discord"
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.1
4
+ version: 0.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.0.9
18
+ version: 0.0.14
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 0.0.9
25
+ version: 0.0.14
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: websocket-client-simple
28
28
  requirement: !ruby/object:Gem::Requirement