brainiac 0.0.7 → 0.0.9
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/bin/brainiac +132 -75
- data/lib/brainiac/agents.rb +12 -36
- data/lib/brainiac/brain.rb +6 -6
- data/lib/brainiac/config.rb +2 -67
- data/lib/brainiac/handlers/discord/config.rb +1 -1
- data/lib/brainiac/handlers/discord/delivery.rb +1 -1
- data/lib/brainiac/handlers/discord/gateway.rb +1 -1
- data/lib/brainiac/handlers/discord/message.rb +13 -25
- data/lib/brainiac/handlers/discord/reactions.rb +1 -1
- data/lib/brainiac/handlers/github.rb +49 -169
- data/lib/brainiac/handlers/shared/git.rb +4 -17
- data/lib/brainiac/handlers/shared/inline_tags.rb +1 -1
- data/lib/brainiac/handlers/zoho.rb +29 -82
- data/lib/brainiac/helpers.rb +43 -336
- data/lib/brainiac/hooks.rb +86 -0
- data/lib/brainiac/plugins.rb +27 -2
- data/lib/brainiac/prompts.rb +34 -172
- data/lib/brainiac/restart.rb +30 -12
- data/lib/brainiac/routes/api.rb +2 -18
- data/lib/brainiac/users.rb +1 -7
- data/lib/brainiac/version.rb +1 -1
- data/lib/brainiac.rb +1 -1
- data/monitor/waybar/deploy_env.rb +3 -3
- data/monitor/xbar/plugin.rb +11 -17
- data/receiver.rb +9 -157
- data/templates/agents.json.example +1 -2
- data/templates/brainiac.json.example +0 -1
- data/templates/users.json.example +0 -3
- metadata +2 -10
- data/lib/brainiac/handlers/fizzy/assignment.rb +0 -125
- data/lib/brainiac/handlers/fizzy/card_index.rb +0 -389
- data/lib/brainiac/handlers/fizzy/comments.rb +0 -730
- data/lib/brainiac/handlers/fizzy/dedup.rb +0 -74
- data/lib/brainiac/handlers/fizzy/deploy.rb +0 -152
- data/lib/brainiac/handlers/fizzy/deployments.rb +0 -260
- data/lib/brainiac/handlers/fizzy.rb +0 -14
- data/lib/brainiac/planning.rb +0 -237
- data/templates/fizzy.json.example +0 -24
|
@@ -195,7 +195,7 @@ ZOHO_TRIAGE_DIR = File.join(BRAINIAC_DIR, "tmp", "zoho", "triage")
|
|
|
195
195
|
FileUtils.mkdir_p(ZOHO_TRIAGE_DIR)
|
|
196
196
|
|
|
197
197
|
ZOHO_TRIAGE_PROMPT = <<~PROMPT
|
|
198
|
-
You are triaging a support email. Decide whether this email needs a
|
|
198
|
+
You are triaging a support email. Decide whether this email needs a card or not.
|
|
199
199
|
|
|
200
200
|
## Email
|
|
201
201
|
**From:** {{FROM}}
|
|
@@ -256,7 +256,7 @@ ZOHO_TRIAGE_PROMPT = <<~PROMPT
|
|
|
256
256
|
PROMPT
|
|
257
257
|
|
|
258
258
|
# Dispatch an agent to triage a support email. The agent decides whether to create
|
|
259
|
-
# a
|
|
259
|
+
# a card or just notify Discord.
|
|
260
260
|
def dispatch_zoho_triage(email, rule)
|
|
261
261
|
agent_name = rule["dispatch_agent"]
|
|
262
262
|
timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
@@ -390,7 +390,7 @@ def execute_zoho_triage_decision(decision, email, rule)
|
|
|
390
390
|
end
|
|
391
391
|
end
|
|
392
392
|
|
|
393
|
-
# Create a
|
|
393
|
+
# Create a card from the triage decision
|
|
394
394
|
def create_zoho_triage_card(decision, email, channel_id, token)
|
|
395
395
|
board_id = ZOHO_CONFIG["triage_board_id"]
|
|
396
396
|
unless board_id
|
|
@@ -404,87 +404,34 @@ def create_zoho_triage_card(decision, email, channel_id, token)
|
|
|
404
404
|
tags = ["support"]
|
|
405
405
|
tags << decision["project_tag"] if decision["project_tag"]
|
|
406
406
|
|
|
407
|
-
#
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
assign_zoho_triage_card(card_number, decision["assign_to"], spawn_env) if card_number && decision["assign_to"]
|
|
434
|
-
|
|
435
|
-
# Notify Discord
|
|
436
|
-
if channel_id && token
|
|
437
|
-
msg = "🎫 **Support Card Created: [##{card_number}](#{card_url})**\n"
|
|
438
|
-
msg += "**Title:** #{title}\n"
|
|
439
|
-
msg += "**Assigned to:** #{decision["assign_to"] || "unassigned"}\n"
|
|
440
|
-
msg += "**Tags:** #{tags.join(", ")}\n"
|
|
441
|
-
msg += "**From:** #{email["fromAddress"]}"
|
|
442
|
-
send_discord_message(channel_id, msg, token: token)
|
|
407
|
+
# Emit hook — work item plugin handles card creation (card management plugins)
|
|
408
|
+
results = Brainiac.emit(:create_work_item,
|
|
409
|
+
board_id: board_id,
|
|
410
|
+
title: title,
|
|
411
|
+
description: description,
|
|
412
|
+
tags: tags,
|
|
413
|
+
assign_to: decision["assign_to"])
|
|
414
|
+
|
|
415
|
+
card_info = results.compact.first
|
|
416
|
+
if card_info
|
|
417
|
+
card_number = card_info[:number]
|
|
418
|
+
card_url = card_info[:url]
|
|
419
|
+
LOG.info "[Zoho:Triage] Created card ##{card_number}: #{title}"
|
|
420
|
+
|
|
421
|
+
# Notify Discord
|
|
422
|
+
if channel_id && token
|
|
423
|
+
msg = "🎫 **Support Card Created: [##{card_number}](#{card_url})**\n"
|
|
424
|
+
msg += "**Title:** #{title}\n"
|
|
425
|
+
msg += "**Assigned to:** #{decision["assign_to"] || "unassigned"}\n"
|
|
426
|
+
msg += "**Tags:** #{tags.join(", ")}\n"
|
|
427
|
+
msg += "**From:** #{email["fromAddress"]}"
|
|
428
|
+
send_discord_message(channel_id, msg, token: token)
|
|
429
|
+
end
|
|
430
|
+
else
|
|
431
|
+
LOG.warn "[Zoho:Triage] No work item plugin handled card creation"
|
|
432
|
+
notify_zoho_match(email, { "label" => "Support Email (no card plugin)", "emoji" => "⚠️" }.merge(rule_defaults(nil)))
|
|
443
433
|
end
|
|
444
434
|
rescue StandardError => e
|
|
445
435
|
LOG.error "[Zoho:Triage] Error creating card: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
446
436
|
notify_zoho_match(email, { "label" => "Support Email", "emoji" => "🆘" }.merge(rule_defaults(nil)))
|
|
447
437
|
end
|
|
448
|
-
|
|
449
|
-
# Resolve tag names to IDs by querying Fizzy
|
|
450
|
-
def resolve_zoho_triage_tags(tag_names)
|
|
451
|
-
agent_env = agent_env_for("Threepio")
|
|
452
|
-
spawn_env = agent_env.empty? ? {} : agent_env
|
|
453
|
-
|
|
454
|
-
output, status = Open3.capture2e(spawn_env, "fizzy", "tag", "list", "--all")
|
|
455
|
-
return [] unless status.success?
|
|
456
|
-
|
|
457
|
-
all_tags = JSON.parse(output)["data"] || []
|
|
458
|
-
tag_names.filter_map do |name|
|
|
459
|
-
tag = all_tags.find { |t| t["title"].downcase == name.downcase }
|
|
460
|
-
tag&.dig("id")
|
|
461
|
-
end
|
|
462
|
-
rescue StandardError => e
|
|
463
|
-
LOG.warn "[Zoho:Triage] Failed to resolve tags: #{e.message}"
|
|
464
|
-
[]
|
|
465
|
-
end
|
|
466
|
-
|
|
467
|
-
# Assign a card to the appropriate agent
|
|
468
|
-
def assign_zoho_triage_card(card_number, agent_name, spawn_env)
|
|
469
|
-
# Resolve agent name to Fizzy user ID from fizzy.json authorized_users
|
|
470
|
-
users = FIZZY_CONFIG["authorized_users"] || []
|
|
471
|
-
user = users.find { |u| u["name"]&.downcase == agent_name.downcase }
|
|
472
|
-
user_id = user&.dig("id")
|
|
473
|
-
|
|
474
|
-
unless user_id
|
|
475
|
-
LOG.warn "[Zoho:Triage] Unknown agent for assignment: #{agent_name} (not found in fizzy.json authorized_users)"
|
|
476
|
-
return
|
|
477
|
-
end
|
|
478
|
-
|
|
479
|
-
output, status = Open3.capture2e(spawn_env, "fizzy", "card", "assign", card_number.to_s, "--user", user_id)
|
|
480
|
-
if status.success?
|
|
481
|
-
LOG.info "[Zoho:Triage] Assigned card ##{card_number} to #{agent_name}"
|
|
482
|
-
else
|
|
483
|
-
LOG.warn "[Zoho:Triage] Failed to assign card ##{card_number}: #{output}"
|
|
484
|
-
end
|
|
485
|
-
end
|
|
486
|
-
|
|
487
|
-
def rule_defaults(rule)
|
|
488
|
-
{ "discord_channel_id" => rule&.dig("discord_channel_id") || ZOHO_CONFIG["default_discord_channel_id"],
|
|
489
|
-
"notify_as" => rule&.dig("notify_as") || ZOHO_CONFIG["notify_as"] }
|
|
490
|
-
end
|
data/lib/brainiac/helpers.rb
CHANGED
|
@@ -70,19 +70,18 @@ def agent_cli_provider_for(agent_name)
|
|
|
70
70
|
entry["cli_provider"]
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
# Detect CLI provider override from inline [cli:X] tag or
|
|
73
|
+
# Detect CLI provider override from inline [cli:X] tag or card tags.
|
|
74
74
|
# Returns the provider name (e.g. "grok") or nil.
|
|
75
75
|
def detect_cli_provider(text: "", tags: [])
|
|
76
|
-
# Inline tag: [cli:grok]
|
|
76
|
+
# Inline tag: [cli:grok] — works in any channel
|
|
77
77
|
if (match = text.match(/\[cli:(\w+)\]/i))
|
|
78
78
|
return match[1].downcase
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
end
|
|
81
|
+
# Plugin hook: let plugins detect from their own metadata (e.g., card tags)
|
|
82
|
+
results = Brainiac.emit(:detect_cli_provider, text: text, tags: tags)
|
|
83
|
+
plugin_result = results.compact.first
|
|
84
|
+
return plugin_result if plugin_result
|
|
86
85
|
|
|
87
86
|
nil
|
|
88
87
|
end
|
|
@@ -93,26 +92,6 @@ def default_project_key
|
|
|
93
92
|
default ? default[0] : nil
|
|
94
93
|
end
|
|
95
94
|
|
|
96
|
-
def identify_project_by_tags(tags)
|
|
97
|
-
return nil if PROJECTS.empty?
|
|
98
|
-
|
|
99
|
-
tag_names = tags.map { |t| (t.is_a?(Hash) ? t["name"] : t).to_s.downcase }
|
|
100
|
-
|
|
101
|
-
PROJECTS.each do |project_key, config|
|
|
102
|
-
project_tags = (config["fizzy_tags"] || []).map(&:downcase)
|
|
103
|
-
return [project_key, config] if tag_names.intersect?(project_tags)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
# Fall back to default project if configured
|
|
107
|
-
default_key = default_project_key
|
|
108
|
-
if default_key
|
|
109
|
-
LOG.info "No project matched tags [#{tag_names.join(", ")}], falling back to default project '#{default_key}'"
|
|
110
|
-
return [default_key, PROJECTS[default_key]]
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
nil
|
|
114
|
-
end
|
|
115
|
-
|
|
116
95
|
def identify_project_by_repo(repo_full_name)
|
|
117
96
|
return nil if PROJECTS.empty?
|
|
118
97
|
|
|
@@ -130,54 +109,22 @@ def identify_project_by_repo(repo_full_name)
|
|
|
130
109
|
nil
|
|
131
110
|
end
|
|
132
111
|
|
|
133
|
-
def
|
|
134
|
-
|
|
135
|
-
[nil, "--indexed-by closed"].each do |extra_flag|
|
|
136
|
-
cmd = ["fizzy", "card", "list", "--all"]
|
|
137
|
-
cmd << extra_flag if extra_flag
|
|
138
|
-
output, status = Open3.capture2(env, *cmd, chdir: repo_path)
|
|
139
|
-
next unless status.success?
|
|
140
|
-
|
|
141
|
-
data = JSON.parse(output)["data"] || []
|
|
142
|
-
match = data.find { |c| c["id"] == internal_id }
|
|
143
|
-
if match
|
|
144
|
-
LOG.info "Resolved card number #{match["number"]} for internal_id #{internal_id}"
|
|
145
|
-
return match["number"]
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
LOG.warn "Could not resolve card number for internal_id #{internal_id}"
|
|
150
|
-
nil
|
|
151
|
-
rescue StandardError => e
|
|
152
|
-
LOG.warn "resolve_card_number failed for #{internal_id}: #{e.message}"
|
|
153
|
-
nil
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def load_card_map
|
|
157
|
-
return {} unless File.exist?(CARD_MAP_FILE)
|
|
112
|
+
def load_work_item_map
|
|
113
|
+
return {} unless File.exist?(WORK_ITEM_MAP_FILE)
|
|
158
114
|
|
|
159
|
-
JSON.parse(File.read(
|
|
115
|
+
JSON.parse(File.read(WORK_ITEM_MAP_FILE))
|
|
160
116
|
rescue JSON::ParserError
|
|
161
117
|
{}
|
|
162
118
|
end
|
|
163
119
|
|
|
164
|
-
def
|
|
165
|
-
File.write(
|
|
120
|
+
def save_work_item_map(map)
|
|
121
|
+
File.write(WORK_ITEM_MAP_FILE, JSON.pretty_generate(map))
|
|
166
122
|
end
|
|
167
123
|
|
|
168
124
|
def slugify(title, max_length: 40)
|
|
169
125
|
title.downcase.gsub(/[^a-z0-9\s-]/, "").strip.gsub(/\s+/, "-").slice(0, max_length).chomp("-")
|
|
170
126
|
end
|
|
171
127
|
|
|
172
|
-
def verify_signature!(request, payload_body, board_key: nil)
|
|
173
|
-
signature = request.env["HTTP_X_WEBHOOK_SIGNATURE"]
|
|
174
|
-
halt 403, { error: "Missing signature" }.to_json unless signature
|
|
175
|
-
secret = board_key ? board_webhook_secret(board_key) : FIZZY_WEBHOOK_SECRET
|
|
176
|
-
halt 403, { error: "No webhook secret configured" }.to_json unless secret
|
|
177
|
-
computed = OpenSSL::HMAC.hexdigest("sha256", secret, payload_body)
|
|
178
|
-
halt 403, { error: "Invalid signature" }.to_json unless Rack::Utils.secure_compare(signature, computed)
|
|
179
|
-
end
|
|
180
|
-
|
|
181
128
|
def verify_github_signature!(request, payload_body)
|
|
182
129
|
signature = request.env["HTTP_X_HUB_SIGNATURE_256"]
|
|
183
130
|
halt 403, { error: "Missing GitHub signature" }.to_json unless signature
|
|
@@ -200,18 +147,17 @@ end
|
|
|
200
147
|
MERGED_CARDS = {}
|
|
201
148
|
MERGED_CARDS_MUTEX = Mutex.new
|
|
202
149
|
|
|
203
|
-
def
|
|
150
|
+
def mark_work_item_merged(card_number)
|
|
204
151
|
MERGED_CARDS_MUTEX.synchronize { MERGED_CARDS[card_number.to_s] = Time.now }
|
|
205
152
|
end
|
|
206
153
|
|
|
207
|
-
def
|
|
154
|
+
def work_item_merged?(card_number)
|
|
208
155
|
MERGED_CARDS_MUTEX.synchronize do
|
|
209
156
|
ts = MERGED_CARDS[card_number.to_s]
|
|
210
157
|
ts && (Time.now - ts < 600)
|
|
211
158
|
end
|
|
212
159
|
end
|
|
213
160
|
|
|
214
|
-
# Pre-fetch a Fizzy card's body and comments so the agent doesn't have to.
|
|
215
161
|
# Returns a formatted string suitable for injection into the prompt, or ''
|
|
216
162
|
# if the fetch fails (agent can still fetch manually as a fallback).
|
|
217
163
|
PREFETCH_COMMENT_LIMIT = 15
|
|
@@ -219,106 +165,7 @@ COMMENT_BODY_TRUNCATE_LENGTH = 500
|
|
|
219
165
|
CARD_CONTEXT_CACHE = {}
|
|
220
166
|
CARD_CONTEXT_CACHE_TTL = 60 # seconds
|
|
221
167
|
|
|
222
|
-
def prefetch_card_context(card_number, repo_path:, agent_name: nil)
|
|
223
|
-
return "" unless card_number
|
|
224
|
-
|
|
225
|
-
# Return cached context if fresh enough
|
|
226
|
-
cache_key = "#{card_number}-#{agent_name}"
|
|
227
|
-
cached = CARD_CONTEXT_CACHE[cache_key]
|
|
228
|
-
if cached && (Time.now - cached[:at]) < CARD_CONTEXT_CACHE_TTL
|
|
229
|
-
LOG.info "Using cached card context for ##{card_number} (#{(Time.now - cached[:at]).to_i}s old)"
|
|
230
|
-
return cached[:context]
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
env = fizzy_env_for(agent_name)
|
|
234
|
-
parts = []
|
|
235
|
-
|
|
236
|
-
card_parts = fetch_card_details(card_number, repo_path: repo_path, env: env)
|
|
237
|
-
return "" if card_parts.nil?
|
|
238
|
-
|
|
239
|
-
parts.concat(card_parts)
|
|
240
|
-
parts.concat(fetch_card_comments(card_number, repo_path: repo_path, env: env))
|
|
241
|
-
return "" if parts.empty?
|
|
242
|
-
|
|
243
|
-
context = parts.join("\n")
|
|
244
|
-
result = <<~CARD_CONTEXT
|
|
245
|
-
## Card Context (pre-fetched — do NOT re-fetch this)
|
|
246
|
-
#{context}
|
|
247
|
-
|
|
248
|
-
CARD_CONTEXT
|
|
249
|
-
|
|
250
|
-
CARD_CONTEXT_CACHE[cache_key] = { context: result, at: Time.now }
|
|
251
|
-
CARD_CONTEXT_CACHE.delete_if { |_, v| (Time.now - v[:at]) > CARD_CONTEXT_CACHE_TTL * 5 } if CARD_CONTEXT_CACHE.size > 50
|
|
252
|
-
result
|
|
253
|
-
rescue StandardError => e
|
|
254
|
-
LOG.warn "prefetch_card_context failed for card ##{card_number}: #{e.message}"
|
|
255
|
-
""
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
# Fetch card details from Fizzy. Returns array of text parts, or nil on failure.
|
|
259
|
-
def fetch_card_details(card_number, repo_path:, env:)
|
|
260
|
-
card_output = run_cmd("fizzy", "card", "show", card_number.to_s, chdir: repo_path, env: env)
|
|
261
|
-
card_data = begin
|
|
262
|
-
JSON.parse(card_output)["data"]
|
|
263
|
-
rescue StandardError
|
|
264
|
-
nil
|
|
265
|
-
end
|
|
266
|
-
return [] unless card_data
|
|
267
|
-
|
|
268
|
-
parts = []
|
|
269
|
-
parts << "## Card ##{card_number}: #{card_data["title"]}"
|
|
270
|
-
parts << "Status: #{card_data["status"]}" if card_data["status"]
|
|
271
|
-
tags = (card_data["tags"] || []).map { |t| t.is_a?(Hash) ? t["name"] : t }
|
|
272
|
-
parts << "Tags: #{tags.join(", ")}" unless tags.empty?
|
|
273
|
-
body = card_data.dig("body", "plain_text") || card_data["body"]
|
|
274
|
-
parts << "\n#{body}" if body && !body.to_s.strip.empty?
|
|
275
|
-
parts
|
|
276
|
-
rescue StandardError => e
|
|
277
|
-
LOG.warn "Could not pre-fetch card ##{card_number}: #{e.message}"
|
|
278
|
-
nil
|
|
279
|
-
end
|
|
280
|
-
|
|
281
168
|
# Fetch recent comments for a card. Returns array of text parts.
|
|
282
|
-
def fetch_card_comments(card_number, repo_path:, env:)
|
|
283
|
-
comments_output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: repo_path, env: env)
|
|
284
|
-
comments_data = JSON.parse(comments_output)["data"] || []
|
|
285
|
-
return [] if comments_data.empty?
|
|
286
|
-
|
|
287
|
-
parts = []
|
|
288
|
-
total = comments_data.size
|
|
289
|
-
comments_data = comments_data.last(PREFETCH_COMMENT_LIMIT)
|
|
290
|
-
parts << "\n## Comments#{" (last #{PREFETCH_COMMENT_LIMIT} of #{total})" if total > PREFETCH_COMMENT_LIMIT}"
|
|
291
|
-
comments_data.each do |c|
|
|
292
|
-
author = c.dig("creator", "name") || "Unknown"
|
|
293
|
-
body = c.dig("body", "plain_text") || ""
|
|
294
|
-
cid = c["id"]
|
|
295
|
-
next if body.strip.empty?
|
|
296
|
-
|
|
297
|
-
body = "#{body[0...COMMENT_BODY_TRUNCATE_LENGTH]}… [truncated]" if body.length > COMMENT_BODY_TRUNCATE_LENGTH
|
|
298
|
-
parts << "\n### #{author} (comment ID: #{cid})\n#{body}"
|
|
299
|
-
end
|
|
300
|
-
parts
|
|
301
|
-
rescue StandardError => e
|
|
302
|
-
LOG.warn "Could not pre-fetch comments for card ##{card_number}: #{e.message}"
|
|
303
|
-
[]
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
def scrub_invalid_attachments!(dir)
|
|
307
|
-
attachments_dir = File.join(dir, ".fizzy-attachments")
|
|
308
|
-
return unless File.directory?(attachments_dir)
|
|
309
|
-
|
|
310
|
-
Dir.glob(File.join(attachments_dir, "*")).each do |file_path|
|
|
311
|
-
next unless File.file?(file_path)
|
|
312
|
-
|
|
313
|
-
file_type, _status = Open3.capture2("file", "--brief", "--mime-type", file_path)
|
|
314
|
-
unless file_type.strip.start_with?("image/")
|
|
315
|
-
LOG.warn "Removing invalid attachment #{file_path} (detected as: #{file_type.strip})"
|
|
316
|
-
FileUtils.rm_f(file_path)
|
|
317
|
-
end
|
|
318
|
-
end
|
|
319
|
-
rescue StandardError => e
|
|
320
|
-
LOG.error "Error scrubbing attachments in #{dir}: #{e.message}"
|
|
321
|
-
end
|
|
322
169
|
|
|
323
170
|
# Extract the last N meaningful lines from an agent log for crash reporting.
|
|
324
171
|
def extract_crash_snippet(log_file, max_lines: 20)
|
|
@@ -332,33 +179,23 @@ rescue StandardError => e
|
|
|
332
179
|
end
|
|
333
180
|
|
|
334
181
|
# Notify the originating channel that an agent crashed.
|
|
335
|
-
# source: :
|
|
182
|
+
# source: :github, :discord, or plugin-registered sources
|
|
336
183
|
# source_context: hash with channel-specific info needed to post the notification
|
|
337
184
|
def notify_agent_crash(exit_status:, log_file:, agent_name:, source:, source_context:, project_config:)
|
|
338
185
|
agent_display = agent_name || "Agent"
|
|
339
186
|
snippet = extract_crash_snippet(log_file)
|
|
340
187
|
snippet_block = snippet ? "\n```\n#{snippet[-1500..]}\n```" : ""
|
|
341
188
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if snippet
|
|
351
|
-
escaped = snippet[-1500..].gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
352
|
-
body += "<pre>#{escaped}</pre>"
|
|
353
|
-
end
|
|
354
|
-
begin
|
|
355
|
-
run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", body,
|
|
356
|
-
chdir: repo_path, env: fizzy_env_for(agent_display))
|
|
357
|
-
LOG.info "[CrashNotify] Posted crash comment on Fizzy card ##{card_number}"
|
|
358
|
-
rescue StandardError => e
|
|
359
|
-
LOG.error "[CrashNotify] Failed to post Fizzy crash comment: #{e.message}"
|
|
360
|
-
end
|
|
189
|
+
# Try plugin-registered crash handlers first
|
|
190
|
+
handled = Brainiac.emit(:agent_crashed,
|
|
191
|
+
exit_status: exit_status, log_file: log_file, agent_name: agent_display,
|
|
192
|
+
source: source, source_context: source_context, project_config: project_config,
|
|
193
|
+
snippet: snippet)
|
|
194
|
+
|
|
195
|
+
# If a plugin handled it for this source, we're done
|
|
196
|
+
return if handled.any?(source)
|
|
361
197
|
|
|
198
|
+
case source
|
|
362
199
|
when :github
|
|
363
200
|
pr_number = source_context[:pr_number]
|
|
364
201
|
repo_name = source_context[:repo_name]
|
|
@@ -387,67 +224,6 @@ rescue StandardError => e
|
|
|
387
224
|
LOG.error "[CrashNotify] Unexpected error: #{e.message}"
|
|
388
225
|
end
|
|
389
226
|
|
|
390
|
-
# Append an italic PR/branch footer to the agent's most recent Fizzy comment.
|
|
391
|
-
def append_fizzy_comment_footer(card_number, project_config:, agent_name: nil)
|
|
392
|
-
repo_path = project_config["repo_path"]
|
|
393
|
-
project_config["github_repo"]
|
|
394
|
-
env = fizzy_env_for(agent_name)
|
|
395
|
-
|
|
396
|
-
# Find branch and tracked PRs from card_map
|
|
397
|
-
card_map = load_card_map
|
|
398
|
-
card_info = card_map.values.find { |v| v["number"] == card_number }
|
|
399
|
-
branch = card_info&.dig("branch")
|
|
400
|
-
return unless branch
|
|
401
|
-
|
|
402
|
-
prs = card_info&.dig("prs") || []
|
|
403
|
-
|
|
404
|
-
# Build footer parts
|
|
405
|
-
parts = []
|
|
406
|
-
parts << "Branch: <code>#{branch}</code>"
|
|
407
|
-
prs.each { |pr| parts << "PR: <a href=\"#{pr["url"]}\">##{pr["number"]}</a>" }
|
|
408
|
-
return if parts.empty?
|
|
409
|
-
|
|
410
|
-
footer_html = "<p style=\"margin-top:12px;font-size:0.85em;color:#888;\"><em>#{parts.join(" · ")}</em></p>"
|
|
411
|
-
|
|
412
|
-
# Find agent's most recent comment
|
|
413
|
-
begin
|
|
414
|
-
output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: repo_path, env: env)
|
|
415
|
-
comments = (JSON.parse(output)["data"] || []).reverse
|
|
416
|
-
agent_display = fizzy_display_name(agent_name)
|
|
417
|
-
comment = comments.find { |c| c.dig("creator", "name") == agent_display && c.dig("body", "html")&.include?("<") }
|
|
418
|
-
return unless comment
|
|
419
|
-
|
|
420
|
-
existing_html = comment.dig("body", "html") || ""
|
|
421
|
-
# Don't double-append if footer already present
|
|
422
|
-
return if existing_html.include?("Branch: <code>#{branch}</code>")
|
|
423
|
-
|
|
424
|
-
# Strip Fizzy's outer wrapper — it re-wraps on update
|
|
425
|
-
inner = existing_html.sub(/\A\s*<div class="action-text-content">\s*/m, "").sub(%r{\s*</div>\s*\z}m, "")
|
|
426
|
-
updated_html = "#{inner}\n#{footer_html}"
|
|
427
|
-
run_cmd("fizzy", "comment", "update", comment["id"], "--card", card_number.to_s,
|
|
428
|
-
"--body", updated_html, chdir: repo_path, env: env)
|
|
429
|
-
LOG.info "[Footer] Appended PR/branch footer to comment #{comment["id"]} on card ##{card_number}"
|
|
430
|
-
rescue StandardError => e
|
|
431
|
-
LOG.warn "[Footer] Could not append footer to card ##{card_number}: #{e.message}"
|
|
432
|
-
end
|
|
433
|
-
end
|
|
434
|
-
|
|
435
|
-
def move_card_to_column(card_number, column_name, project_config:, agent_name: nil)
|
|
436
|
-
return unless card_number
|
|
437
|
-
|
|
438
|
-
board_key = board_key_for_project(project_config)
|
|
439
|
-
column_id = (board_key && board_column_id(board_key, column_name)) || DEFAULT_COLUMN_IDS[column_name]
|
|
440
|
-
return unless column_id
|
|
441
|
-
|
|
442
|
-
repo_path = project_config["repo_path"]
|
|
443
|
-
env = fizzy_env_for(agent_name || AI_AGENT_NAME)
|
|
444
|
-
run_cmd("fizzy", "card", "column", card_number.to_s, "--column", column_id, chdir: repo_path, env: env)
|
|
445
|
-
record_self_move(card_number)
|
|
446
|
-
LOG.info "[Column] Moved card ##{card_number} to #{column_name} (#{column_id})"
|
|
447
|
-
rescue StandardError => e
|
|
448
|
-
LOG.warn "[Column] Failed to move card ##{card_number} to #{column_name}: #{e.message}"
|
|
449
|
-
end
|
|
450
|
-
|
|
451
227
|
def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil,
|
|
452
228
|
source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false)
|
|
453
229
|
resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
|
|
@@ -460,8 +236,8 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
460
236
|
# that has had a previous session, resume it. Only applies to follow-ups (not first dispatch).
|
|
461
237
|
should_resume = resume && resolved["resume_flag"]
|
|
462
238
|
|
|
463
|
-
|
|
464
|
-
|
|
239
|
+
# Pre-dispatch hook — plugins can prep the working directory (e.g., copy config files, clean up)
|
|
240
|
+
Brainiac.emit(:pre_dispatch, chdir: chdir, project_config: project_config, agent_name: agent_name)
|
|
465
241
|
|
|
466
242
|
timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
467
243
|
log_file = File.join(chdir, "tmp/agent-#{log_name}-#{timestamp}.log")
|
|
@@ -506,18 +282,6 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
506
282
|
[pid, log_file]
|
|
507
283
|
end
|
|
508
284
|
|
|
509
|
-
# Ensure .fizzy.yaml is present in the working directory (worktrees need a copy).
|
|
510
|
-
def ensure_fizzy_yaml!(chdir, project_config)
|
|
511
|
-
fizzy_yaml_dest = File.join(chdir, ".fizzy.yaml")
|
|
512
|
-
return if File.exist?(fizzy_yaml_dest)
|
|
513
|
-
|
|
514
|
-
fizzy_yaml_src = File.join(project_config["repo_path"], ".fizzy.yaml")
|
|
515
|
-
return unless File.exist?(fizzy_yaml_src)
|
|
516
|
-
|
|
517
|
-
FileUtils.cp(fizzy_yaml_src, fizzy_yaml_dest)
|
|
518
|
-
LOG.info "Copied .fizzy.yaml to #{chdir}"
|
|
519
|
-
end
|
|
520
|
-
|
|
521
285
|
# Write agent prompt to a temp file, return path.
|
|
522
286
|
def write_agent_prompt_file(prompt, log_name, timestamp)
|
|
523
287
|
prompt_dir = File.join(BRAINIAC_DIR, "tmp")
|
|
@@ -566,10 +330,18 @@ def handle_agent_completion(**ctx)
|
|
|
566
330
|
)
|
|
567
331
|
end
|
|
568
332
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
333
|
+
# Emit lifecycle hook — plugins handle post-session actions (e.g., plugin moves card, appends footer)
|
|
334
|
+
Brainiac.emit(:agent_completed,
|
|
335
|
+
card_number: ctx[:card_number] || ctx[:source_context]&.dig(:card_number),
|
|
336
|
+
exit_status: agent_exit_status,
|
|
337
|
+
signaled: agent_signaled,
|
|
338
|
+
agent_name: ctx[:agent_name],
|
|
339
|
+
chdir: ctx[:chdir],
|
|
340
|
+
source: ctx[:source],
|
|
341
|
+
source_context: ctx[:source_context],
|
|
342
|
+
project_config: ctx[:project_config],
|
|
343
|
+
skip_column_move: ctx[:skip_column_move],
|
|
344
|
+
prompt_file: ctx[:prompt_file])
|
|
573
345
|
|
|
574
346
|
qmd_out, qmd_status = Open3.capture2e("qmd", "update")
|
|
575
347
|
if qmd_status.success?
|
|
@@ -589,55 +361,6 @@ def handle_agent_completion(**ctx)
|
|
|
589
361
|
check_brainiac_restart(ctx[:head_before], ctx[:status_before], ctx[:chdir], ctx[:project_key_for_restart], ctx[:agent_config_name])
|
|
590
362
|
end
|
|
591
363
|
|
|
592
|
-
def handle_fizzy_post_session(fizzy_card, exit_status, signaled, agent_name, chdir, source, source_context, project_config, skip_column_move)
|
|
593
|
-
return unless source == :fizzy && fizzy_card && exit_status&.zero? && !signaled
|
|
594
|
-
|
|
595
|
-
unless skip_column_move || card_merged?(fizzy_card)
|
|
596
|
-
move_card_to_column(fizzy_card, "needs_review", project_config: project_config,
|
|
597
|
-
agent_name: agent_name)
|
|
598
|
-
end
|
|
599
|
-
|
|
600
|
-
append_fizzy_comment_footer(fizzy_card, project_config: project_config, agent_name: agent_name)
|
|
601
|
-
|
|
602
|
-
return unless source_context[:deploy_intent]
|
|
603
|
-
|
|
604
|
-
auto_deploy_after_session(
|
|
605
|
-
deploy_intent: source_context[:deploy_intent],
|
|
606
|
-
card_internal_id: source_context[:card_internal_id] || load_card_map.find { |_, v| v["number"] == fizzy_card }&.first,
|
|
607
|
-
card_number: fizzy_card,
|
|
608
|
-
worktree_path: chdir,
|
|
609
|
-
agent_name: agent_name
|
|
610
|
-
)
|
|
611
|
-
end
|
|
612
|
-
|
|
613
|
-
def handle_plan_finalization(prompt_file, agent_name, project_config)
|
|
614
|
-
return unless File.exist?(prompt_file)
|
|
615
|
-
|
|
616
|
-
prompt_content = File.read(prompt_file)
|
|
617
|
-
card_id_match = prompt_content.match(/CARD_ID.*?(\d+|discord-[\w-]+)/)
|
|
618
|
-
return unless card_id_match
|
|
619
|
-
|
|
620
|
-
card_id = card_id_match[1]
|
|
621
|
-
plan_file = File.join(PLANS_DIR, "card-#{card_id}-plan.md")
|
|
622
|
-
return unless File.exist?(plan_file)
|
|
623
|
-
|
|
624
|
-
LOG.info "[Planning] Plan file detected for card #{card_id}, finalizing..."
|
|
625
|
-
card_num = card_id.match?(/^\d+$/) ? card_id.to_i : nil
|
|
626
|
-
project_key = PROJECTS.find { |_k, v| v == project_config }&.first
|
|
627
|
-
|
|
628
|
-
result = finalize_plan(
|
|
629
|
-
card_id: card_id, card_number: card_num,
|
|
630
|
-
agent_name: agent_name || AI_AGENT_NAME,
|
|
631
|
-
project_key: project_key, repo_path: project_config["repo_path"]
|
|
632
|
-
)
|
|
633
|
-
|
|
634
|
-
if result[:success]
|
|
635
|
-
LOG.info "[Planning] Plan finalized: #{result[:tasks].size} tasks created"
|
|
636
|
-
else
|
|
637
|
-
LOG.error "[Planning] Failed to finalize plan: #{result[:error]}"
|
|
638
|
-
end
|
|
639
|
-
end
|
|
640
|
-
|
|
641
364
|
def check_brainiac_restart(head_before, status_before, chdir, project_key_for_restart, agent_config_name)
|
|
642
365
|
return unless project_key_for_restart == "brainiac" && head_before
|
|
643
366
|
|
|
@@ -649,18 +372,6 @@ def check_brainiac_restart(head_before, status_before, chdir, project_key_for_re
|
|
|
649
372
|
end
|
|
650
373
|
end
|
|
651
374
|
|
|
652
|
-
def authorized?(payload)
|
|
653
|
-
creator_id = payload.dig("creator", "id")
|
|
654
|
-
AUTHORIZED_USER_IDS.include?(creator_id)
|
|
655
|
-
end
|
|
656
|
-
|
|
657
|
-
def human_mentioned?(user_id)
|
|
658
|
-
return false unless FIZZY_CONFIG["authorized_users"]
|
|
659
|
-
|
|
660
|
-
user = FIZZY_CONFIG["authorized_users"].find { |u| u["id"] == user_id }
|
|
661
|
-
user && user["human"]
|
|
662
|
-
end
|
|
663
|
-
|
|
664
375
|
def detect_model(project_config, tags: [], text: "")
|
|
665
376
|
resolved = resolve_project_cli_config(project_config)
|
|
666
377
|
allowed_models = resolved["allowed_models"] || {}
|
|
@@ -679,7 +390,7 @@ def detect_model(project_config, tags: [], text: "")
|
|
|
679
390
|
resolved["agent_model"]
|
|
680
391
|
end
|
|
681
392
|
|
|
682
|
-
# Detect effort level from inline tags [effort:high] or
|
|
393
|
+
# Detect effort level from inline tags [effort:high] or card tags (effort-high).
|
|
683
394
|
# Returns the effort level string (e.g. "high") or nil.
|
|
684
395
|
# If the requested level isn't supported by the current model, returns the closest
|
|
685
396
|
# lower level from allowed_efforts.
|
|
@@ -687,20 +398,16 @@ def detect_effort(project_config, tags: [], text: "")
|
|
|
687
398
|
resolved = resolve_project_cli_config(project_config)
|
|
688
399
|
allowed = resolved["allowed_efforts"] || %w[low medium high xhigh max]
|
|
689
400
|
|
|
690
|
-
# Inline tag: [effort:high]
|
|
401
|
+
# Inline tag: [effort:high] — works in any channel
|
|
691
402
|
if (match = text.match(/\[effort:(\w+)\]/i))
|
|
692
403
|
level = match[1].downcase
|
|
693
404
|
return resolve_effort_level(level, allowed) if allowed.include?(level)
|
|
694
405
|
end
|
|
695
406
|
|
|
696
|
-
#
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
level = name.sub("effort-", "")
|
|
701
|
-
return resolve_effort_level(level, allowed) if allowed.include?(level)
|
|
702
|
-
end
|
|
703
|
-
end
|
|
407
|
+
# Plugin hook: let plugins detect from their own metadata (e.g., card tags)
|
|
408
|
+
results = Brainiac.emit(:detect_effort, tags: tags, allowed: allowed)
|
|
409
|
+
plugin_result = results.compact.first
|
|
410
|
+
return resolve_effort_level(plugin_result, allowed) if plugin_result
|
|
704
411
|
|
|
705
412
|
resolved["agent_effort"]
|
|
706
413
|
end
|