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
@@ -9,7 +9,7 @@ _brainiac() {
9
9
  local brainiac_dir="${BRAINIAC_DIR:-$HOME/.brainiac}"
10
10
 
11
11
  # Top-level commands
12
- local commands="server stop restart logs status register unregister list show brain discord cron provider role agent config path version help setup projects card-map"
12
+ local commands="server stop restart logs status register unregister list show brain discord cron provider role agent config path version help setup projects card-map handler"
13
13
 
14
14
  # Helper: list agent keys from registry
15
15
  _brainiac_agents() {
@@ -7,10 +7,10 @@
7
7
  #
8
8
  # {
9
9
  # "galen": {
10
- # "fizzy_name": "Galen",
10
+ # "display_name": "Galen",
11
11
  # "local": true,
12
12
  # "env": {
13
- # "FIZZY_TOKEN": "fizzy_abc...",
13
+ # "SOME_TOKEN": "token_abc...",
14
14
  # "DISCORD_BOT_TOKEN": "Bot_abc..."
15
15
  # }
16
16
  # }
@@ -20,49 +20,26 @@
20
20
  # (card assignments). Agents without "local": true are still known for
21
21
  # mention detection, display names, tokens, and cross-agent interactions —
22
22
  # they just won't pick up card assignments on this machine.
23
- #
24
- # Legacy format with top-level `fizzy_token` / `discord_bot_token` keys is
25
- # auto-migrated into the `env` hash at load time.
26
-
27
23
  def load_agent_registry
28
- if File.exist?(AGENT_REGISTRY_FILE)
29
- raw_registry = JSON.parse(File.read(AGENT_REGISTRY_FILE))
30
- LOG.info "Loaded agent registry (#{raw_registry.size} agents) from #{AGENT_REGISTRY_FILE}"
31
-
32
- # Normalize keys: convert to lowercase, replace non-alphanumeric with hyphens
33
- registry = {}
34
- raw_registry.each do |key, entry|
35
- normalized_key = key.downcase.gsub(/[^a-z0-9-]/, "-")
36
- if registry.key?(normalized_key) && registry[normalized_key] != entry
37
- LOG.warn "Duplicate agent key after normalization: '#{key}' → '#{normalized_key}' (already exists)"
38
- end
39
- registry[normalized_key] = entry
40
- end
41
-
42
- # Migrate legacy keys into env hash
43
- registry.each_value do |entry|
44
- next unless entry.is_a?(Hash)
45
-
46
- entry["env"] ||= {}
47
- # Migrate fizzy_token → FIZZY_TOKEN
48
- if (ft = entry.delete("fizzy_token"))
49
- entry["env"]["FIZZY_TOKEN"] ||= ft
50
- end
51
- # Migrate discord_bot_token → DISCORD_BOT_TOKEN
52
- if (dt = entry.delete("discord_bot_token"))
53
- entry["env"]["DISCORD_BOT_TOKEN"] ||= dt
54
- end
55
- end
56
- return registry
24
+ unless File.exist?(AGENT_REGISTRY_FILE)
25
+ LOG.info "No agent registry found at #{AGENT_REGISTRY_FILE}"
26
+ return {}
57
27
  end
58
28
 
59
- if File.exist?(AGENT_TOKENS_FILE)
60
- tokens = JSON.parse(File.read(AGENT_TOKENS_FILE))
61
- LOG.info "Loaded legacy agent tokens (#{tokens.size} agents) from #{AGENT_TOKENS_FILE}"
62
- return tokens.transform_values { |token| { "env" => { "FIZZY_TOKEN" => token } } }
29
+ raw_registry = JSON.parse(File.read(AGENT_REGISTRY_FILE))
30
+ LOG.info "Loaded agent registry (#{raw_registry.size} agents) from #{AGENT_REGISTRY_FILE}"
31
+
32
+ # Normalize keys: convert to lowercase, replace non-alphanumeric with hyphens
33
+ registry = {}
34
+ raw_registry.each do |key, entry|
35
+ normalized_key = key.downcase.gsub(/[^a-z0-9-]/, "-")
36
+ if registry.key?(normalized_key) && registry[normalized_key] != entry
37
+ LOG.warn "Duplicate agent key after normalization: '#{key}' → '#{normalized_key}' (already exists)"
38
+ end
39
+ registry[normalized_key] = entry
63
40
  end
64
41
 
65
- {}
42
+ registry
66
43
  rescue JSON::ParserError => e
67
44
  LOG.error "Failed to parse agent registry: #{e.message}"
68
45
  {}
@@ -93,30 +70,17 @@ def agent_env_var(agent_name, var_name)
93
70
  agent_env_for(agent_name)[var_name]
94
71
  end
95
72
 
96
- # Convenience: get the Fizzy token for an agent.
97
- def fizzy_token_for(agent_name)
98
- agent_env_var(agent_name, "FIZZY_TOKEN")
99
- end
100
-
101
- # Convenience: build env hash for fizzy CLI calls (backward compat).
102
- # Falls back to default agent token when the given agent has no token.
103
- def fizzy_env_for(agent_name)
104
- token = fizzy_token_for(agent_name) || fizzy_token_for(AI_AGENT_NAME)
105
- token ? { "FIZZY_TOKEN" => token } : {}
106
- end
107
-
108
- def default_fizzy_env
109
- fizzy_env_for(AI_AGENT_NAME)
110
- end
111
-
112
- def fizzy_display_name(agent_name)
73
+ # Get the display name for an agent (from agents.json registry).
74
+ # This is the human-readable canonical name (e.g., "GLaDOS" not "glados").
75
+ # Core owns this — it's the identity used across all channels and plugins.
76
+ def agent_display_name(agent_name)
113
77
  return agent_name unless agent_name
114
78
 
115
79
  key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
116
80
  entry = AGENT_REGISTRY[key]
117
81
  return agent_name unless entry.is_a?(Hash)
118
82
 
119
- entry["fizzy_name"] || agent_name
83
+ entry["display_name"] || agent_name
120
84
  end
121
85
 
122
86
  # Get the role name(s) configured for an agent in agents.json.
@@ -155,7 +119,7 @@ end
155
119
 
156
120
  def agent_roster
157
121
  roster = {}
158
- all_agent_names.each { |name| roster[name.downcase] = fizzy_display_name(name) }
122
+ all_agent_names.each { |name| roster[name.downcase] = agent_display_name(name) }
159
123
  roster
160
124
  end
161
125
 
@@ -176,9 +140,9 @@ def all_agent_names
176
140
  names = Set.new([AI_AGENT_NAME])
177
141
  PROJECTS.each_value { |config| names << config["agent_name"] if config["agent_name"] }
178
142
  discover_kiro_agents.each { |name| names << name.capitalize }
179
- # Include agents from the registry (with their fizzy_name if specified)
143
+ # Include agents from the registry
180
144
  AGENT_REGISTRY.each do |key, entry|
181
- names << (entry["fizzy_name"] || key.capitalize)
145
+ names << (entry["display_name"] || key.capitalize)
182
146
  end
183
147
  names
184
148
  end
@@ -198,7 +162,7 @@ def local_agent_names
198
162
  AGENT_REGISTRY.each do |key, entry|
199
163
  next unless entry.is_a?(Hash) && entry["local"]
200
164
 
201
- names << (entry["fizzy_name"] || key.capitalize)
165
+ names << (entry["display_name"] || key.capitalize)
202
166
  end
203
167
  names
204
168
  end
@@ -209,7 +173,7 @@ def detect_mentioned_agent(text)
209
173
  all_agent_names.each do |name|
210
174
  return name if downcased.include?("@#{name.downcase}")
211
175
 
212
- # Fizzy renders mentions using first name only (e.g. "@Sleeper" not "@Sleeper Service").
176
+ # Some systems render mentions using first name only (e.g. "@Sleeper" not "@Sleeper Service").
213
177
  # Fall back to matching the first word of multi-word agent names.
214
178
  first_word = name.split.first.downcase
215
179
  next if first_word == name.downcase # already checked above
@@ -218,17 +182,6 @@ def detect_mentioned_agent(text)
218
182
  nil
219
183
  end
220
184
 
221
- def detect_mentioned_user_ids(text)
222
- return [] unless FIZZY_CONFIG["authorized_users"]
223
-
224
- mentioned_ids = []
225
- FIZZY_CONFIG["authorized_users"].each do |user|
226
- name = user["name"]
227
- mentioned_ids << user["id"] if text.downcase.include?("@#{name.downcase}")
228
- end
229
- mentioned_ids
230
- end
231
-
232
185
  def comment_from_agent?(name)
233
186
  return false unless name
234
187
 
@@ -162,9 +162,6 @@ def build_brain_context(agent_name: AI_AGENT_NAME, card_title: "", card_number:
162
162
  primary_query = topics.first(5).join(" ")
163
163
  primary_query = "project conventions" if primary_query.empty?
164
164
 
165
- fizzy_mentioned = [card_title, comment_body].any? { |s| s&.match?(/fizzy/i) }
166
- fizzy_originated = source == :fizzy
167
-
168
165
  search_queries = [primary_query]
169
166
 
170
167
  knowledge_threads = [
@@ -173,9 +170,12 @@ def build_brain_context(agent_name: AI_AGENT_NAME, card_title: "", card_number:
173
170
  ]
174
171
  search_queries << agent_name
175
172
 
176
- if fizzy_mentioned || fizzy_originated
177
- knowledge_threads << Thread.new { query_brain("fizzy CLI commands", scope: :knowledge, max_results: 2) }
178
- search_queries << "fizzy CLI commands"
173
+ # Plugin hook: source-specific brain queries (e.g., plugins inject source-specific knowledge)
174
+ plugin_queries = Brainiac.emit(:build_brain_context,
175
+ source: source, card_title: card_title, comment_body: comment_body)
176
+ plugin_queries.flatten.compact.each do |query|
177
+ knowledge_threads << Thread.new { query_brain(query, scope: :knowledge, max_results: 2) }
178
+ search_queries << query
179
179
  end
180
180
 
181
181
  persona_thread = Thread.new { query_brain("personality tone voice communication style", agent_name: agent_name, scope: :persona, max_results: 5) }
@@ -15,21 +15,46 @@ BRAINIAC_VERSION = Brainiac::VERSION
15
15
 
16
16
  # --- Environment & paths ---
17
17
 
18
- FIZZY_WEBHOOK_SECRET = ENV.fetch("FIZZY_WEBHOOK_SECRET", nil)
19
- AI_AGENT_NAME = ENV.fetch("AI_AGENT_NAME") do
20
- case RbConfig::CONFIG["host_os"]
21
- when /darwin/i then "Kaylee"
22
- else "Galen"
23
- end
24
- end
25
-
26
18
  BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
27
19
  PROJECTS_FILE = File.join(BRAINIAC_DIR, "projects.json")
28
20
  KIRO_AGENTS_DIR = File.join(Dir.home, ".kiro", "agents")
29
- CARD_MAP_FILE = File.join(BRAINIAC_DIR, "card_map.json")
30
- AGENT_TOKENS_FILE = File.join(BRAINIAC_DIR, "agent_tokens.json")
21
+ WORK_ITEM_MAP_FILE = File.join(BRAINIAC_DIR, "work_items.json")
31
22
  AGENT_REGISTRY_FILE = File.join(BRAINIAC_DIR, "agents.json")
32
23
 
24
+ # --- Master config (handler toggles, global settings) ---
25
+
26
+ BRAINIAC_CONFIG_FILE = File.join(BRAINIAC_DIR, "brainiac.json")
27
+
28
+ def load_brainiac_config
29
+ return {} unless File.exist?(BRAINIAC_CONFIG_FILE)
30
+
31
+ JSON.parse(File.read(BRAINIAC_CONFIG_FILE))
32
+ rescue JSON::ParserError => e
33
+ LOG.error "Failed to parse brainiac.json: #{e.message}" if defined?(LOG)
34
+ {}
35
+ end
36
+
37
+ BRAINIAC_CONFIG = load_brainiac_config
38
+
39
+ # --- Default agent name ---
40
+ # Priority: AI_AGENT_NAME env var → brainiac.json "default_agent" → error.
41
+ AI_AGENT_NAME = ENV.fetch("AI_AGENT_NAME", nil) || BRAINIAC_CONFIG["default_agent"] || begin
42
+ raise <<~MSG
43
+ No default agent configured. Set one of:
44
+ 1. Environment variable: export AI_AGENT_NAME="YourAgent"
45
+ 2. In ~/.brainiac/brainiac.json: { "default_agent": "YourAgent" }
46
+ MSG
47
+ end
48
+
49
+ # Check whether a handler is enabled. Defaults to true when brainiac.json
50
+ # doesn't exist or doesn't list the handler (backwards compatible — all on).
51
+ def handler_enabled?(name)
52
+ handlers = BRAINIAC_CONFIG["handlers"]
53
+ return true unless handlers # No config = everything enabled
54
+
55
+ handlers.fetch(name.to_s, true)
56
+ end
57
+
33
58
  LOG_LEVEL = ENV.fetch("LOG_LEVEL", "info").downcase
34
59
  LOG = Logger.new($stdout)
35
60
  LOG.level = case LOG_LEVEL
@@ -53,21 +78,6 @@ KNOWLEDGE_COLLECTION = "brainiac-knowledge"
53
78
 
54
79
  ROLES_DIR = File.join(BRAINIAC_DIR, "roles")
55
80
 
56
- # --- Fizzy auth ---
57
-
58
- FIZZY_CONFIG_FILE = File.join(BRAINIAC_DIR, "fizzy.json")
59
-
60
- def load_fizzy_config
61
- return {} unless File.exist?(FIZZY_CONFIG_FILE)
62
-
63
- JSON.parse(File.read(FIZZY_CONFIG_FILE))
64
- rescue JSON::ParserError => e
65
- LOG.error "Failed to parse Fizzy config: #{e.message}"
66
- {}
67
- end
68
-
69
- FIZZY_CONFIG = load_fizzy_config
70
-
71
81
  # --- GitHub auth ---
72
82
 
73
83
  GITHUB_CONFIG_FILE = File.join(BRAINIAC_DIR, "github.json")
@@ -84,57 +94,10 @@ end
84
94
  GITHUB_CONFIG = load_github_config
85
95
 
86
96
  def github_webhook_secret
87
- # Fallback to env var for backwards compatibility
97
+ # Fallback to env var
88
98
  GITHUB_CONFIG["webhook_secret"] || ENV.fetch("GITHUB_WEBHOOK_SECRET", nil)
89
99
  end
90
100
 
91
- # --- Board config ---
92
-
93
- FIZZY_BOARDS = FIZZY_CONFIG["boards"] || {}
94
-
95
- def board_config(board_key)
96
- FIZZY_BOARDS[board_key.to_s]
97
- end
98
-
99
- def board_webhook_secret(board_key)
100
- config = board_config(board_key)
101
- config&.dig("webhook_secret") || FIZZY_WEBHOOK_SECRET
102
- end
103
-
104
- def board_column_id(board_key, column_name)
105
- config = board_config(board_key)
106
- config&.dig("columns", column_name.to_s)
107
- end
108
-
109
- # Find board_key by board_id (from .fizzy.yaml or payload)
110
- def board_key_for_id(board_id)
111
- FIZZY_BOARDS.each do |key, config|
112
- return key if config["board_id"] == board_id
113
- end
114
- nil
115
- end
116
-
117
- # Determine board_key for a project by reading its .fizzy.yaml
118
- def board_key_for_project(project_config)
119
- fizzy_yaml = File.join(project_config["repo_path"], ".fizzy.yaml")
120
- return nil unless File.exist?(fizzy_yaml)
121
-
122
- require "yaml"
123
- data = YAML.safe_load_file(fizzy_yaml)
124
- board_id = data["board"]
125
- board_key_for_id(board_id)
126
- rescue StandardError => e
127
- LOG.warn "Could not read .fizzy.yaml for board lookup: #{e.message}"
128
- nil
129
- end
130
-
131
- # Build authorized user IDs from config or env var (env var overrides)
132
- AUTHORIZED_USER_IDS = if ENV["AUTHORIZED_USER_IDS"] && !ENV["AUTHORIZED_USER_IDS"].empty?
133
- ENV["AUTHORIZED_USER_IDS"].split(",").map(&:strip)
134
- else
135
- (FIZZY_CONFIG["authorized_users"] || []).map { |u| u["id"] }
136
- end
137
-
138
101
  NOTIFICATION_COMMAND = ENV.fetch("NOTIFICATION_COMMAND", nil)
139
102
 
140
103
  # --- Projects ---
@@ -185,8 +148,10 @@ PROJECTS = load_projects_config
185
148
 
186
149
  DEFAULT_PROJECT = {
187
150
  "repo_path" => ENV.fetch("REPO_PATH", Dir.pwd),
188
- "fizzy_tags" => [],
189
151
  "github_repo" => ENV.fetch("GITHUB_REPO", nil),
152
+ # CLI defaults below are overridden by ~/.brainiac/cli-providers/*.json when a
153
+ # cli_provider is configured on the project or agent. These only apply as a
154
+ # last-resort fallback when no provider config exists.
190
155
  "agent_cli" => ENV.fetch("AGENT_CLI", "kiro-cli"),
191
156
  "agent_cli_args" => ENV.fetch("AGENT_CLI_ARGS", "chat --trust-all-tools --no-interactive"),
192
157
  "agent_model_flag" => ENV["AGENT_MODEL_FLAG"] || "--model",
@@ -208,8 +173,7 @@ DEFAULT_PROJECT = {
208
173
  }.freeze
209
174
 
210
175
  # --- Discord (optional) ---
211
- # Discord is enabled when any agent in the registry has a discord_bot_token,
212
- # OR when the legacy DISCORD_BOT_TOKEN env var is set.
176
+ # Discord is enabled when any agent in the registry has a discord_bot_token
213
177
  # Requires the websocket-client-simple gem.
214
178
 
215
179
  DISCORD_ENABLED = begin
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Discord REST API helpers.
4
+ #
5
+ # Low-level HTTP methods and convenience wrappers for the Discord v10 API.
6
+ # Used by the Discord handler itself, but also by GitHub (deploy notifications)
7
+ # and Zoho (email notifications).
8
+
9
+ DISCORD_API_BASE = "https://discord.com/api/v10"
10
+
11
+ # Emojis reserved for brainiac functionality — not treated as feedback
12
+ RESERVED_EMOJIS = %w[👀 ❌ 🛑 🚫 ⚠️ ⏳ 😶 ❔ ❓ 🧠].freeze
13
+
14
+ def discord_api(method, path, token:, body: nil, log_errors: true)
15
+ uri = URI("#{DISCORD_API_BASE}#{path}")
16
+ http = Net::HTTP.new(uri.host, uri.port)
17
+ http.use_ssl = true
18
+
19
+ req = case method
20
+ when :get then Net::HTTP::Get.new(uri)
21
+ when :post then Net::HTTP::Post.new(uri)
22
+ when :put then Net::HTTP::Put.new(uri)
23
+ when :delete then Net::HTTP::Delete.new(uri)
24
+ end
25
+
26
+ req["Authorization"] = "Bot #{token}"
27
+ req["Content-Type"] = "application/json"
28
+ req.body = body.to_json if body
29
+
30
+ response = http.request(req)
31
+
32
+ if response.code.to_i == 429
33
+ retry_after = JSON.parse(response.body)["retry_after"] || 1
34
+ LOG.warn "Discord rate limited, waiting #{retry_after}s"
35
+ sleep retry_after
36
+ return discord_api(method, path, token: token, body: body, log_errors: log_errors)
37
+ end
38
+
39
+ LOG.error "Discord API error (#{method} #{path}): HTTP #{response.code} - #{response.body}" if response.code.to_i >= 400 && log_errors
40
+
41
+ JSON.parse(response.body) unless response.body.nil? || response.body.empty?
42
+ rescue StandardError => e
43
+ LOG.error "Discord API error (#{method} #{path}): #{e.message}" if log_errors
44
+ nil
45
+ end
46
+
47
+ # --- Channel & Message Operations ---
48
+
49
+ def fetch_discord_channel_history(channel_id, before_message_id, token:, limit: 10)
50
+ messages = discord_api(:get, "/channels/#{channel_id}/messages?before=#{before_message_id}&limit=#{limit}", token: token)
51
+
52
+ all_messages = messages.is_a?(Array) ? messages : []
53
+
54
+ # If we're in a thread, check if the oldest message is a THREAD_STARTER_MESSAGE (type 21).
55
+ # These messages have no content but point to the original message via referenced_message.
56
+ # We need to include that original message for full context.
57
+ if all_messages.any?
58
+ oldest = all_messages.last # API returns newest-first
59
+ all_messages << oldest["referenced_message"] if oldest && oldest["type"] == 21 && oldest["referenced_message"]
60
+ end
61
+
62
+ return "" if all_messages.empty?
63
+
64
+ # Messages come newest-first from the API, reverse for chronological order
65
+ lines = all_messages.reverse.filter_map do |msg|
66
+ author = msg.dig("author", "username") || "unknown"
67
+ content = msg["content"]&.strip || ""
68
+ next if content.empty?
69
+
70
+ "#{author}: #{content}"
71
+ end
72
+
73
+ return "" if lines.empty?
74
+
75
+ lines.join("\n")
76
+ rescue StandardError => e
77
+ LOG.warn "Failed to fetch channel history: #{e.message}"
78
+ ""
79
+ end
80
+
81
+ def fetch_channel_info(channel_id, token:)
82
+ discord_api(:get, "/channels/#{channel_id}", token: token)
83
+ end
84
+
85
+ def fetch_discord_message(channel_id, message_id, token:, log_errors: true)
86
+ discord_api(:get, "/channels/#{channel_id}/messages/#{message_id}", token: token, log_errors: log_errors)
87
+ end
88
+
89
+ def fetch_guild_member(guild_id, user_id, token:)
90
+ discord_api(:get, "/guilds/#{guild_id}/members/#{user_id}", token: token)
91
+ end
92
+
93
+ # --- Messaging ---
94
+
95
+ def send_discord_message(channel_id, content, token:, reply_to: nil)
96
+ body = { content: content }
97
+ body[:message_reference] = { message_id: reply_to } if reply_to
98
+ result = discord_api(:post, "/channels/#{channel_id}/messages", token: token, body: body)
99
+ if result && result["id"]
100
+ LOG.info "[Discord] Message posted successfully to channel #{channel_id}, message_id: #{result["id"]}"
101
+ else
102
+ LOG.error "[Discord] Failed to post message to channel #{channel_id}, result: #{result.inspect}"
103
+ end
104
+ result
105
+ end
106
+
107
+ def send_long_discord_message(channel_id, content, token:, reply_to: nil)
108
+ if content.length <= 2000
109
+ send_discord_message(channel_id, content, token: token, reply_to: reply_to)
110
+ return
111
+ end
112
+
113
+ chunks = []
114
+ remaining = content
115
+ while remaining.length.positive?
116
+ if remaining.length <= 2000
117
+ chunks << remaining
118
+ remaining = ""
119
+ else
120
+ split_at = remaining.rindex("\n", 1990) || 1990
121
+ chunks << remaining[0...split_at]
122
+ remaining = remaining[split_at..].lstrip
123
+ end
124
+ end
125
+
126
+ chunks.each_with_index do |chunk, i|
127
+ send_discord_message(channel_id, chunk, token: token, reply_to: i.zero? ? reply_to : nil)
128
+ sleep 0.5
129
+ end
130
+ end
131
+
132
+ def send_discord_typing(channel_id, token:)
133
+ discord_api(:post, "/channels/#{channel_id}/typing", token: token)
134
+ end
135
+
136
+ # --- Reactions ---
137
+
138
+ def add_discord_reaction(channel_id, message_id, emoji, token:)
139
+ encoded = URI.encode_www_form_component(emoji)
140
+ discord_api(:put, "/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me", token: token)
141
+ end
142
+
143
+ def remove_discord_reaction(channel_id, message_id, emoji, token:)
144
+ encoded = URI.encode_www_form_component(emoji)
145
+ discord_api(:delete, "/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me", token: token)
146
+ end
147
+
148
+ # --- Threads & Forums ---
149
+
150
+ def create_discord_thread(channel_id, message_id, name:, token:)
151
+ thread_name = name.length > 100 ? "#{name[0..96]}..." : name
152
+ discord_api(:post, "/channels/#{channel_id}/messages/#{message_id}/threads", token: token, body: {
153
+ name: thread_name,
154
+ auto_archive_duration: 1440
155
+ })
156
+ end
157
+
158
+ def forum_channel?(channel_id, token:)
159
+ info = fetch_channel_info(channel_id, token: token)
160
+ info && info["type"] == 15
161
+ end
162
+
163
+ def find_latest_forum_thread(channel_id, token:)
164
+ channel_info = fetch_channel_info(channel_id, token: token)
165
+ return nil unless channel_info && channel_info["guild_id"]
166
+
167
+ guild_id = channel_info["guild_id"]
168
+ result = discord_api(:get, "/guilds/#{guild_id}/threads/active", token: token)
169
+ return nil unless result && result["threads"]
170
+
171
+ forum_threads = result["threads"]
172
+ .select { |t| t["parent_id"] == channel_id }
173
+ .sort_by { |t| t["id"].to_i }
174
+ .reverse
175
+
176
+ return nil if forum_threads.empty?
177
+
178
+ latest = forum_threads.first
179
+ LOG.info "[Discord] Found latest forum thread: #{latest["id"]} (#{latest["name"]}) in channel #{channel_id}"
180
+ latest
181
+ end
182
+
183
+ def create_forum_post(channel_id, title:, content:, token:)
184
+ thread_name = title.length > 100 ? "#{title[0..96]}..." : title
185
+ result = discord_api(:post, "/channels/#{channel_id}/threads", token: token, body: {
186
+ name: thread_name,
187
+ message: { content: content },
188
+ auto_archive_duration: 1440
189
+ })
190
+ if result && result["id"]
191
+ LOG.info "[Discord] Forum post created in channel #{channel_id}, thread_id: #{result["id"]}"
192
+ else
193
+ LOG.error "[Discord] Failed to create forum post in channel #{channel_id}, result: #{result.inspect}"
194
+ end
195
+ result
196
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Discord configuration and shared state.
4
+ #
5
+ # Constants, config loading, and thread map persistence used across
6
+ # all Discord sub-modules.
7
+
8
+ DISCORD_CONFIG_FILE = File.join(BRAINIAC_DIR, "discord.json")
9
+
10
+ # Discord thread worktree map: tracks worktrees created for Discord thread conversations.
11
+ # Keyed by "agent_key:channel_id" → { worktree, branch, project, created_at }
12
+ # Persisted to disk so sessions survive restarts.
13
+ DISCORD_THREAD_MAP_FILE = File.join(BRAINIAC_DIR, "discord_thread_map.json")
14
+ DISCORD_THREAD_MAP_MUTEX = Mutex.new
15
+
16
+ def load_discord_config
17
+ default = { "channel_mappings" => {}, "authorized_role_ids" => [], "authorized_user_ids" => [] }
18
+ return default unless File.exist?(DISCORD_CONFIG_FILE)
19
+
20
+ JSON.parse(File.read(DISCORD_CONFIG_FILE))
21
+ rescue JSON::ParserError => e
22
+ LOG.error "Failed to parse discord config: #{e.message}"
23
+ default
24
+ end
25
+
26
+ DISCORD_CONFIG = load_discord_config
27
+
28
+ def reload_discord_config!
29
+ DISCORD_CONFIG.replace(load_discord_config)
30
+ end
31
+
32
+ # Collect all agent Discord bot tokens from the registry.
33
+ # Returns { "galen" => "token...", "glados" => "token..." }
34
+ def discord_bot_tokens
35
+ tokens = {}
36
+ AGENT_REGISTRY.each do |key, entry|
37
+ next unless entry.is_a?(Hash)
38
+
39
+ token = (entry["env"] || {})["DISCORD_BOT_TOKEN"]
40
+ next unless token
41
+
42
+ tokens[key] = token
43
+ end
44
+ tokens
45
+ end
46
+
47
+ # --- Thread Map Persistence ---
48
+
49
+ def load_discord_thread_map
50
+ return {} unless File.exist?(DISCORD_THREAD_MAP_FILE)
51
+
52
+ JSON.parse(File.read(DISCORD_THREAD_MAP_FILE))
53
+ rescue JSON::ParserError
54
+ {}
55
+ end
56
+
57
+ def save_discord_thread_map(map)
58
+ File.write(DISCORD_THREAD_MAP_FILE, JSON.pretty_generate(map))
59
+ end
60
+
61
+ # --- Channel/Project Routing ---
62
+
63
+ def find_project_for_discord_channel(channel_id)
64
+ mapping = DISCORD_CONFIG.dig("channel_mappings", channel_id)
65
+
66
+ unless mapping
67
+ default_project = DISCORD_CONFIG["default_project"]
68
+ mapping = { "project" => default_project } if default_project
69
+ end
70
+
71
+ return nil unless mapping
72
+
73
+ project_key = mapping["project"]
74
+ project_config = PROJECTS[project_key]
75
+ return nil unless project_config
76
+
77
+ [project_key, project_config, mapping]
78
+ end
79
+
80
+ # Find the root message for a conversation thread.
81
+ # Walks back through message_reference chain to find the original message.
82
+ def find_root_message(message, channel_id, bot_token)
83
+ current_msg = message
84
+ visited = Set.new
85
+ max_depth = 20
86
+ walked = false
87
+
88
+ max_depth.times do
89
+ msg_id = current_msg["id"]
90
+ return { id: msg_id, content: nil, author: nil } if visited.include?(msg_id)
91
+
92
+ visited << msg_id
93
+
94
+ ref = current_msg["message_reference"]
95
+ break unless ref
96
+
97
+ ref_msg_id = ref["message_id"]
98
+ ref_channel = ref["channel_id"] || channel_id
99
+ break unless ref_msg_id
100
+
101
+ referenced = discord_api(:get, "/channels/#{ref_channel}/messages/#{ref_msg_id}", token: bot_token)
102
+ break unless referenced
103
+
104
+ current_msg = referenced
105
+ walked = true
106
+ end
107
+
108
+ {
109
+ id: current_msg["id"],
110
+ content: walked ? current_msg["content"]&.strip : nil,
111
+ author: walked ? current_msg.dig("author", "username") : nil
112
+ }
113
+ end
114
+
115
+ # Build a Discord mention roster so the agent can @mention people and other bots.
116
+ def discord_mention_roster
117
+ lines = []
118
+
119
+ DISCORD_BOTS_MUTEX.synchronize do
120
+ DISCORD_BOTS.each do |agent_key, info|
121
+ next unless info[:user_id]
122
+
123
+ display = agent_display_name(agent_key) || agent_key.capitalize
124
+ lines << " - #{display}: `<@#{info[:user_id]}>`"
125
+ end
126
+ end
127
+
128
+ user_mappings = DISCORD_CONFIG["user_mappings"] || {}
129
+ user_mappings.each do |name, discord_id|
130
+ lines << " - #{name}: `<@#{discord_id}>`"
131
+ end
132
+
133
+ lines.join("\n")
134
+ end