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.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +136 -6
- data/bin/brainiac +472 -44
- data/bin/brainiac-completion.bash +1 -1
- data/lib/brainiac/agents.rb +27 -74
- data/lib/brainiac/brain.rb +6 -6
- data/lib/brainiac/config.rb +40 -76
- data/lib/brainiac/handlers/discord/api.rb +196 -0
- data/lib/brainiac/handlers/discord/config.rb +134 -0
- data/lib/brainiac/handlers/discord/delivery.rb +196 -0
- data/lib/brainiac/handlers/discord/gateway.rb +212 -0
- data/lib/brainiac/handlers/discord/message.rb +933 -0
- data/lib/brainiac/handlers/discord/reactions.rb +215 -0
- data/lib/brainiac/handlers/discord.rb +14 -1892
- data/lib/brainiac/handlers/github.rb +134 -317
- data/lib/brainiac/handlers/shared/git.rb +190 -0
- data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
- data/lib/brainiac/handlers/zoho.rb +103 -153
- data/lib/brainiac/helpers.rb +43 -455
- data/lib/brainiac/hooks.rb +86 -0
- data/lib/brainiac/plugins.rb +154 -0
- data/lib/brainiac/prompts.rb +34 -172
- data/lib/brainiac/restart.rb +112 -0
- data/lib/brainiac/routes/api.rb +411 -0
- data/lib/brainiac/users.rb +1 -7
- data/lib/brainiac/version.rb +1 -1
- data/lib/brainiac/zoho_mail_api.rb +2 -1
- data/lib/brainiac.rb +8 -1
- data/monitor/daemon.rb +4 -27
- data/monitor/shared.rb +247 -0
- data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +17 -89
- data/monitor/waybar/setup.rb +232 -0
- data/monitor/waybar/status.rb +51 -0
- data/monitor/{view-logs-rofi.rb → waybar/view_logs.rb} +21 -88
- data/monitor/{deploy-env-macos.rb → xbar/deploy_env.rb} +3 -2
- data/monitor/xbar/plugin.rb +149 -0
- data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
- data/receiver.rb +44 -551
- data/templates/agents.json.example +1 -2
- data/templates/brainiac.json.example +8 -0
- data/templates/cli-providers/kiro.json.example +8 -2
- data/templates/plugins.json.example +3 -0
- data/templates/users.json.example +0 -3
- metadata +25 -23
- data/lib/brainiac/card_index.rb +0 -389
- data/lib/brainiac/deployments.rb +0 -258
- data/lib/brainiac/handlers/fizzy.rb +0 -1292
- data/lib/brainiac/planning.rb +0 -237
- data/lib/user_registry.rb +0 -159
- data/monitor/menubar.rb +0 -295
- data/monitor/setup-waybar-deploy-envs.rb +0 -121
- data/monitor/setup-waybar-deployments.rb +0 -96
- data/monitor/setup-waybar-module.rb +0 -113
- data/monitor/setup-xbar-plugin.rb +0 -35
- data/monitor/view-logs.rb +0 -119
- data/monitor/waybar-config-updater.rb +0 -56
- data/monitor/waybar-deployments.rb +0 -239
- data/monitor/waybar.rb +0 -146
- data/monitor/xbar.3s.rb +0 -179
- data/templates/fizzy.json.example +0 -24
- /data/monitor/{open-action.sh → xbar/open_action.sh} +0 -0
- /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() {
|
data/lib/brainiac/agents.rb
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
#
|
|
8
8
|
# {
|
|
9
9
|
# "galen": {
|
|
10
|
-
# "
|
|
10
|
+
# "display_name": "Galen",
|
|
11
11
|
# "local": true,
|
|
12
12
|
# "env": {
|
|
13
|
-
# "
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
#
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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["
|
|
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] =
|
|
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
|
|
143
|
+
# Include agents from the registry
|
|
180
144
|
AGENT_REGISTRY.each do |key, entry|
|
|
181
|
-
names << (entry["
|
|
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["
|
|
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
|
-
#
|
|
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
|
|
data/lib/brainiac/brain.rb
CHANGED
|
@@ -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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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) }
|
data/lib/brainiac/config.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|