brainiac 0.0.6 → 0.0.8

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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +136 -6
  4. data/bin/brainiac +472 -44
  5. data/bin/brainiac-completion.bash +1 -1
  6. data/lib/brainiac/agents.rb +27 -74
  7. data/lib/brainiac/brain.rb +6 -6
  8. data/lib/brainiac/config.rb +40 -76
  9. data/lib/brainiac/handlers/discord/api.rb +196 -0
  10. data/lib/brainiac/handlers/discord/config.rb +134 -0
  11. data/lib/brainiac/handlers/discord/delivery.rb +196 -0
  12. data/lib/brainiac/handlers/discord/gateway.rb +212 -0
  13. data/lib/brainiac/handlers/discord/message.rb +933 -0
  14. data/lib/brainiac/handlers/discord/reactions.rb +215 -0
  15. data/lib/brainiac/handlers/discord.rb +14 -1892
  16. data/lib/brainiac/handlers/github.rb +134 -317
  17. data/lib/brainiac/handlers/shared/git.rb +190 -0
  18. data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
  19. data/lib/brainiac/handlers/zoho.rb +103 -153
  20. data/lib/brainiac/helpers.rb +43 -455
  21. data/lib/brainiac/hooks.rb +86 -0
  22. data/lib/brainiac/plugins.rb +154 -0
  23. data/lib/brainiac/prompts.rb +34 -172
  24. data/lib/brainiac/restart.rb +112 -0
  25. data/lib/brainiac/routes/api.rb +411 -0
  26. data/lib/brainiac/users.rb +1 -7
  27. data/lib/brainiac/version.rb +1 -1
  28. data/lib/brainiac/zoho_mail_api.rb +2 -1
  29. data/lib/brainiac.rb +8 -1
  30. data/monitor/daemon.rb +4 -27
  31. data/monitor/shared.rb +247 -0
  32. data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +17 -89
  33. data/monitor/waybar/setup.rb +232 -0
  34. data/monitor/waybar/status.rb +51 -0
  35. data/monitor/{view-logs-rofi.rb → waybar/view_logs.rb} +21 -88
  36. data/monitor/{deploy-env-macos.rb → xbar/deploy_env.rb} +3 -2
  37. data/monitor/xbar/plugin.rb +149 -0
  38. data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
  39. data/receiver.rb +44 -551
  40. data/templates/agents.json.example +1 -2
  41. data/templates/brainiac.json.example +8 -0
  42. data/templates/cli-providers/kiro.json.example +8 -2
  43. data/templates/plugins.json.example +3 -0
  44. data/templates/users.json.example +0 -3
  45. metadata +25 -23
  46. data/lib/brainiac/card_index.rb +0 -389
  47. data/lib/brainiac/deployments.rb +0 -258
  48. data/lib/brainiac/handlers/fizzy.rb +0 -1292
  49. data/lib/brainiac/planning.rb +0 -237
  50. data/lib/user_registry.rb +0 -159
  51. data/monitor/menubar.rb +0 -295
  52. data/monitor/setup-waybar-deploy-envs.rb +0 -121
  53. data/monitor/setup-waybar-deployments.rb +0 -96
  54. data/monitor/setup-waybar-module.rb +0 -113
  55. data/monitor/setup-xbar-plugin.rb +0 -35
  56. data/monitor/view-logs.rb +0 -119
  57. data/monitor/waybar-config-updater.rb +0 -56
  58. data/monitor/waybar-deployments.rb +0 -239
  59. data/monitor/waybar.rb +0 -146
  60. data/monitor/xbar.3s.rb +0 -179
  61. data/templates/fizzy.json.example +0 -24
  62. /data/monitor/{open-action.sh → xbar/open_action.sh} +0 -0
  63. /data/monitor/{view-logs-macos.rb → xbar/view_logs.rb} +0 -0
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Discord draft delivery system.
4
+ #
5
+ # Response files land in draft/ with a .meta.json sidecar containing delivery info.
6
+ # After successful posting, both files move to posted/.
7
+ # A poller thread recovers orphaned drafts (e.g. after a server restart).
8
+
9
+ DISCORD_DRAFT_DIR = File.join(BRAINIAC_DIR, "tmp", "discord", "draft")
10
+ DISCORD_POSTED_DIR = File.join(BRAINIAC_DIR, "tmp", "discord", "posted")
11
+ FileUtils.mkdir_p(DISCORD_DRAFT_DIR)
12
+ FileUtils.mkdir_p(DISCORD_POSTED_DIR)
13
+
14
+ # Shared thread map: when multiple agents are mentioned in the same message,
15
+ # the first to deliver creates the thread and stores its ID here so the rest
16
+ # post into the same thread instead of creating duplicates.
17
+ DISCORD_SHARED_THREADS = {}
18
+ DISCORD_SHARED_THREADS_MUTEX = Mutex.new
19
+
20
+ # Shared logic for posting a draft response file to Discord and moving it to posted/.
21
+ # Used by both the monitoring thread (happy path) and the poller (recovery path).
22
+ def deliver_discord_draft(response_file, meta_file)
23
+ return false unless File.exist?(meta_file)
24
+
25
+ lock_file = "#{meta_file}.lock"
26
+ begin
27
+ File.open(lock_file, File::CREAT | File::EXCL | File::WRONLY) {} # rubocop:disable Lint/EmptyBlock
28
+ rescue Errno::EEXIST
29
+ return false
30
+ end
31
+
32
+ meta = JSON.parse(File.read(meta_file))
33
+ bot_token = resolve_bot_token(meta["agent_key"], meta["agent_name"])
34
+
35
+ unless bot_token
36
+ FileUtils.rm_f(lock_file)
37
+ return false
38
+ end
39
+
40
+ unless File.exist?(response_file)
41
+ FileUtils.rm_f(lock_file)
42
+ return false
43
+ end
44
+
45
+ deliver_response_content(response_file, meta, bot_token)
46
+ archive_delivered_draft(response_file, meta_file, lock_file, meta["agent_name"])
47
+ true
48
+ rescue StandardError => e
49
+ LOG.error "[Discord] Failed to deliver draft #{meta_file}: #{e.message}"
50
+ File.delete(lock_file) if lock_file && File.exist?(lock_file)
51
+ false
52
+ end
53
+
54
+ def resolve_bot_token(agent_key, agent_name)
55
+ token = DISCORD_BOTS_MUTEX.synchronize { DISCORD_BOTS.dig(agent_key, :token) }
56
+ token ||= (AGENT_REGISTRY.dig(agent_key, "env") || {})["DISCORD_BOT_TOKEN"]
57
+ LOG.warn "[Discord:#{agent_name}] No bot token found for #{agent_key}, cannot deliver draft" unless token
58
+ token
59
+ end
60
+
61
+ def deliver_response_content(response_file, meta, bot_token)
62
+ channel_id = meta["channel_id"]
63
+ message_id = meta["message_id"]
64
+ agent_key = meta["agent_key"]
65
+ agent_name = meta["agent_name"]
66
+ response = File.read(response_file).strip
67
+
68
+ if response.empty?
69
+ add_discord_reaction(channel_id, message_id, "😶", token: bot_token) if message_id
70
+ send_discord_message(channel_id, "_#{agent_name} had nothing to say._", token: bot_token)
71
+ elsif meta["is_dm"] || meta["is_thread"] || message_id.nil?
72
+ deliver_to_dm_or_forum(response, channel_id, message_id, agent_name, meta, bot_token)
73
+ else
74
+ deliver_to_channel_thread(response, channel_id, message_id, agent_key, agent_name, meta["clean_content"] || "", bot_token)
75
+ end
76
+ end
77
+
78
+ def archive_delivered_draft(response_file, meta_file, lock_file, agent_name)
79
+ FileUtils.mv(response_file, File.join(DISCORD_POSTED_DIR, File.basename(response_file))) if File.exist?(response_file)
80
+ FileUtils.mv(meta_file, File.join(DISCORD_POSTED_DIR, File.basename(meta_file)))
81
+ FileUtils.rm_f(lock_file)
82
+ LOG.info "[Discord:#{agent_name}] Draft delivered and moved to posted/"
83
+ end
84
+
85
+ # --- Delivery to DMs, threads, and forum channels ---
86
+
87
+ def deliver_to_dm_or_forum(response, channel_id, message_id, agent_name, meta, bot_token)
88
+ if message_id.nil? && forum_channel?(channel_id, token: bot_token)
89
+ title = meta["forum_title"] || "#{agent_name} — #{Time.now.strftime("%b %d, %Y")}"
90
+ if meta["forum_reply_to_latest"]
91
+ latest_thread = find_latest_forum_thread(channel_id, token: bot_token)
92
+ if latest_thread
93
+ send_long_discord_message(latest_thread["id"], response, token: bot_token)
94
+ else
95
+ LOG.warn "[Discord:#{agent_name}] No existing thread found, creating new forum post"
96
+ create_forum_post(channel_id, title: title, content: response, token: bot_token)
97
+ end
98
+ else
99
+ create_forum_post(channel_id, title: title, content: response, token: bot_token)
100
+ end
101
+ else
102
+ send_long_discord_message(channel_id, response, token: bot_token)
103
+ end
104
+ end
105
+
106
+ # --- Delivery to channel messages (creates or joins a thread) ---
107
+
108
+ def deliver_to_channel_thread(response, channel_id, message_id, agent_key, agent_name, clean_content, bot_token)
109
+ thread_id = nil
110
+ created_thread = false
111
+
112
+ DISCORD_SHARED_THREADS_MUTEX.synchronize do
113
+ thread_id = DISCORD_SHARED_THREADS[message_id]
114
+
115
+ # Tier 2: Ask Discord if a thread already exists on this message.
116
+ unless thread_id
117
+ original_msg = discord_api(:get, "/channels/#{channel_id}/messages/#{message_id}", token: bot_token)
118
+ if original_msg&.dig("thread", "id")
119
+ thread_id = original_msg["thread"]["id"]
120
+ DISCORD_SHARED_THREADS[message_id] = thread_id
121
+ LOG.info "[Discord:#{agent_name}] Discovered existing thread #{thread_id} on message #{message_id} via API"
122
+ end
123
+ end
124
+
125
+ # Tier 3: No thread exists anywhere — create one.
126
+ unless thread_id
127
+ display_name = agent_display_name(agent_key)
128
+ thread = create_discord_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
129
+ if thread && thread["id"]
130
+ thread_id = thread["id"]
131
+ DISCORD_SHARED_THREADS[message_id] = thread_id
132
+ created_thread = true
133
+ LOG.info "[Discord:#{agent_name}] Created shared thread #{thread_id} for message #{message_id}"
134
+ end
135
+ end
136
+ end
137
+
138
+ if thread_id
139
+ LOG.info "[Discord:#{agent_name}] Joining shared thread #{thread_id} for message #{message_id}" unless created_thread
140
+
141
+ # Propagate dispatch depth to the thread
142
+ parent_depth_key = "discord-#{channel_id}"
143
+ thread_depth_key = "discord-#{thread_id}"
144
+ parent_info = AGENT_DISPATCH_DEPTH[parent_depth_key]
145
+ unless AGENT_DISPATCH_DEPTH[thread_depth_key]
146
+ if parent_info
147
+ AGENT_DISPATCH_DEPTH[thread_depth_key] = { count: 0, last_human_at: parent_info[:last_human_at] }
148
+ LOG.info "[Discord:#{agent_name}] Propagated dispatch depth from channel #{channel_id} to thread #{thread_id}"
149
+ else
150
+ record_human_comment(thread_depth_key)
151
+ end
152
+ end
153
+
154
+ send_discord_typing(thread_id, token: bot_token)
155
+ send_long_discord_message(thread_id, response, token: bot_token)
156
+ else
157
+ LOG.warn "[Discord:#{agent_name}] Thread creation failed, falling back to reply"
158
+ send_long_discord_message(channel_id, response, token: bot_token, reply_to: message_id)
159
+ end
160
+ end
161
+
162
+ # --- Draft Poller ---
163
+
164
+ DISCORD_DRAFT_POLLER_INTERVAL = 5 # seconds
165
+ DISCORD_DRAFT_MIN_AGE = 30 # seconds — don't race the monitoring thread
166
+
167
+ def start_discord_draft_poller
168
+ Thread.new do
169
+ LOG.info "[Discord] Draft poller started, checking #{DISCORD_DRAFT_DIR} every #{DISCORD_DRAFT_POLLER_INTERVAL}s"
170
+ loop do
171
+ sleep DISCORD_DRAFT_POLLER_INTERVAL
172
+ begin
173
+ # Clean up stale lock files (older than 60s) left by crashed deliveries
174
+ Dir.glob(File.join(DISCORD_DRAFT_DIR, "*.lock")).each do |lock_file|
175
+ File.delete(lock_file) if (Time.now - File.mtime(lock_file)) > 60
176
+ end
177
+
178
+ Dir.glob(File.join(DISCORD_DRAFT_DIR, "*.meta.json")).each do |meta_file|
179
+ next if (Time.now - File.mtime(meta_file)) < DISCORD_DRAFT_MIN_AGE
180
+
181
+ response_file = if meta_file.end_with?(".md.meta.json")
182
+ meta_file.sub(".md.meta.json", ".md")
183
+ else
184
+ meta_file.sub(".meta.json", ".md")
185
+ end
186
+ next unless File.exist?(response_file)
187
+
188
+ LOG.info "[Discord] Poller recovering orphaned draft: #{File.basename(meta_file)}"
189
+ deliver_discord_draft(response_file, meta_file)
190
+ end
191
+ rescue StandardError => e
192
+ LOG.error "[Discord] Draft poller error: #{e.message}"
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Discord WebSocket gateway connections.
4
+ #
5
+ # Each agent with a DISCORD_BOT_TOKEN gets its own persistent WebSocket
6
+ # connection. The gateway dispatches MESSAGE_CREATE, MESSAGE_UPDATE,
7
+ # and MESSAGE_REACTION_ADD events to handler functions.
8
+
9
+ DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"
10
+
11
+ # Per-bot state: { agent_key => { token:, user_id:, status:, thread: } }
12
+ DISCORD_BOTS = {}
13
+ DISCORD_BOTS_MUTEX = Mutex.new
14
+ DISCORD_ALL_READY_LOGGED = { done: false }
15
+
16
+ def start_discord_gateway_for(agent_key, bot_token)
17
+ Thread.new do
18
+ agent_display = agent_display_name(agent_key) || agent_key.capitalize
19
+ bot_user_id = nil
20
+
21
+ loop do
22
+ bot_user_id = run_gateway_connection(agent_key, agent_display, bot_token, bot_user_id)
23
+ rescue StandardError => e
24
+ DISCORD_BOTS_MUTEX.synchronize do
25
+ DISCORD_BOTS[agent_key][:status] = "error" if DISCORD_BOTS[agent_key]
26
+ end
27
+ LOG.error "[Discord:#{agent_display}] Gateway error: #{e.message}, reconnecting in 5s..."
28
+ sleep 5
29
+ end
30
+ end
31
+ end
32
+
33
+ def run_gateway_connection(agent_key, agent_display, bot_token, bot_user_id)
34
+ DISCORD_BOTS_MUTEX.synchronize do
35
+ DISCORD_BOTS[agent_key] ||= {}
36
+ DISCORD_BOTS[agent_key][:status] = "connecting"
37
+ DISCORD_BOTS[agent_key][:token] = bot_token
38
+ end
39
+
40
+ LOG.debug "[Discord:#{agent_display}] Connecting to Gateway..."
41
+ heartbeat_thread = nil
42
+ last_sequence = nil
43
+ ws = WebSocket::Client::Simple.connect(DISCORD_GATEWAY_URL)
44
+
45
+ ws.on :message do |msg|
46
+ next if msg.data.nil? || msg.data.empty?
47
+
48
+ payload = JSON.parse(msg.data)
49
+ last_sequence = payload["s"] if payload["s"]
50
+ heartbeat_thread, bot_user_id = handle_gateway_op(
51
+ ws, payload, agent_key, agent_display, bot_token, bot_user_id, heartbeat_thread, last_sequence
52
+ )
53
+ rescue StandardError => e
54
+ LOG.error "[Discord:#{agent_display}] Gateway message error: #{e.message}"
55
+ end
56
+
57
+ ws.on :open do
58
+ LOG.debug "[Discord:#{agent_display}] WebSocket connected"
59
+ end
60
+
61
+ ws.on :close do |e|
62
+ DISCORD_BOTS_MUTEX.synchronize do
63
+ DISCORD_BOTS[agent_key][:status] = "disconnected" if DISCORD_BOTS[agent_key]
64
+ end
65
+ LOG.warn "[Discord:#{agent_display}] WebSocket closed: #{e&.inspect}"
66
+ heartbeat_thread&.kill
67
+ end
68
+
69
+ ws.on :error do |e|
70
+ LOG.error "[Discord:#{agent_display}] WebSocket error: #{e.message}"
71
+ end
72
+
73
+ wait_for_disconnect(ws, agent_display)
74
+ bot_user_id
75
+ end
76
+
77
+ def wait_for_disconnect(websocket, agent_display)
78
+ loop do
79
+ sleep 1
80
+ next if websocket.open?
81
+
82
+ LOG.info "[Discord:#{agent_display}] Connection lost, reconnecting in 5s..."
83
+ sleep 5
84
+ break
85
+ end
86
+ end
87
+
88
+ # Handle a single gateway opcode. Returns [heartbeat_thread, bot_user_id].
89
+ def handle_gateway_op(websocket, payload, agent_key, agent_display, bot_token, bot_user_id, heartbeat_thread, last_sequence)
90
+ op = payload["op"]
91
+ data = payload["d"]
92
+
93
+ case op
94
+ when 10
95
+ heartbeat_thread = start_gateway_heartbeat(websocket, data["heartbeat_interval"], agent_display, last_sequence)
96
+ send_identify(websocket, bot_token, agent_display)
97
+ when 0
98
+ bot_user_id = handle_gateway_dispatch(payload, data, agent_key, agent_display, bot_token, bot_user_id)
99
+ when 1
100
+ websocket.send({ op: 1, d: last_sequence }.to_json)
101
+ when 7
102
+ LOG.info "[Discord:#{agent_display}] Reconnect requested"
103
+ websocket.close
104
+ when 9
105
+ LOG.warn "[Discord:#{agent_display}] Invalid session, re-identifying in 5s"
106
+ sleep 5
107
+ send_identify(websocket, bot_token, agent_display)
108
+ when 11 then nil
109
+ end
110
+
111
+ [heartbeat_thread, bot_user_id]
112
+ end
113
+
114
+ def start_gateway_heartbeat(websocket, interval_ms, agent_display, last_sequence)
115
+ LOG.debug "[Discord:#{agent_display}] Gateway connected, heartbeat: #{interval_ms}ms"
116
+ Thread.new do
117
+ loop do
118
+ sleep(interval_ms / 1000.0)
119
+ websocket.send({ op: 1, d: last_sequence }.to_json)
120
+ end
121
+ end
122
+ end
123
+
124
+ def send_identify(websocket, bot_token, agent_display)
125
+ LOG.debug "[Discord:#{agent_display}] Sending IDENTIFY"
126
+ websocket.send({
127
+ op: 2,
128
+ d: {
129
+ token: bot_token,
130
+ intents: 46_593,
131
+ properties: { os: RUBY_PLATFORM, browser: "brainiac", device: "brainiac" }
132
+ }
133
+ }.to_json)
134
+ end
135
+
136
+ def handle_gateway_dispatch(payload, data, agent_key, agent_display, bot_token, bot_user_id)
137
+ case payload["t"]
138
+ when "READY"
139
+ bot_user_id = data.dig("user", "id")
140
+ mark_bot_ready(agent_key, agent_display, bot_user_id, data)
141
+ when "MESSAGE_CREATE"
142
+ Thread.new do
143
+ handle_discord_message(data, agent_key, bot_token, bot_user_id)
144
+ rescue StandardError => e
145
+ LOG.error "[Discord:#{agent_display}] Error handling message: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
146
+ end
147
+ when "MESSAGE_UPDATE"
148
+ if data["edited_timestamp"]
149
+ Thread.new do
150
+ handle_discord_message(data, agent_key, bot_token, bot_user_id)
151
+ rescue StandardError => e
152
+ LOG.error "[Discord:#{agent_display}] Error handling message update: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
153
+ end
154
+ end
155
+ when "MESSAGE_REACTION_ADD"
156
+ Thread.new do
157
+ handle_discord_reaction(data, agent_key, bot_token, bot_user_id)
158
+ rescue StandardError => e
159
+ LOG.error "[Discord:#{agent_display}] Error handling reaction: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
160
+ end
161
+ end
162
+
163
+ bot_user_id
164
+ end
165
+
166
+ def mark_bot_ready(agent_key, agent_display, bot_user_id, data)
167
+ DISCORD_BOTS_MUTEX.synchronize do
168
+ DISCORD_BOTS[agent_key][:user_id] = bot_user_id
169
+ DISCORD_BOTS[agent_key][:status] = "ready"
170
+ end
171
+ guild_count = data["guilds"]&.size || 0
172
+ LOG.info "[Discord] #{agent_display} ready (#{guild_count} #{guild_count == 1 ? "guild" : "guilds"})"
173
+ LOG.debug "[Discord:#{agent_display}] user_id=#{bot_user_id}"
174
+
175
+ DISCORD_BOTS_MUTEX.synchronize do
176
+ if !DISCORD_ALL_READY_LOGGED[:done] && DISCORD_BOTS.all? { |_, info| info[:status] == "ready" }
177
+ DISCORD_ALL_READY_LOGGED[:done] = true
178
+ LOG.info "[Discord] All bots connected."
179
+ end
180
+ end
181
+ end
182
+
183
+ # Start all per-agent Discord bots.
184
+ def start_all_discord_gateways
185
+ tokens = discord_bot_tokens
186
+ if tokens.empty?
187
+ LOG.info "[Discord] No agents have DISCORD_BOT_TOKEN configured — Discord disabled"
188
+ return
189
+ end
190
+
191
+ LOG.info "[Discord] Starting #{tokens.size} bot(s): #{tokens.keys.join(", ")}"
192
+
193
+ DISCORD_BOTS_MUTEX.synchronize do
194
+ tokens.each do |agent_key, token|
195
+ DISCORD_BOTS[agent_key] = { token: token, status: "starting", user_id: nil }
196
+ end
197
+ end
198
+
199
+ tokens.each do |agent_key, token|
200
+ start_discord_gateway_for(agent_key, token)
201
+ sleep 1 # Stagger connections to avoid rate limits
202
+ end
203
+ end
204
+
205
+ # Summary of all bot statuses for the API endpoint.
206
+ def discord_bots_status
207
+ DISCORD_BOTS_MUTEX.synchronize do
208
+ DISCORD_BOTS.transform_values do |info|
209
+ { status: info[:status], user_id: info[:user_id] }
210
+ end
211
+ end
212
+ end