brainiac 0.0.9 → 0.0.11
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 -11
- data/bin/brainiac +1039 -316
- data/bin/brainiac-completion.bash +93 -18
- data/brainiac.gemspec +2 -2
- data/lib/brainiac/config.rb +9 -21
- data/lib/brainiac/cron.rb +41 -26
- data/lib/brainiac/handlers/github.rb +3 -28
- data/lib/brainiac/handlers/zoho.rb +25 -33
- data/lib/brainiac/helpers.rb +6 -16
- data/lib/brainiac/notifications.rb +96 -0
- data/lib/brainiac/prompts.rb +2 -102
- data/lib/brainiac/restart.rb +2 -10
- data/lib/brainiac/routes/api.rb +2 -14
- data/lib/brainiac/version.rb +1 -1
- data/receiver.rb +9 -60
- data/skills/brainiac-plugins/SKILL.md +220 -0
- metadata +5 -25
- data/lib/brainiac/handlers/discord/api.rb +0 -196
- data/lib/brainiac/handlers/discord/config.rb +0 -134
- data/lib/brainiac/handlers/discord/delivery.rb +0 -196
- data/lib/brainiac/handlers/discord/gateway.rb +0 -212
- data/lib/brainiac/handlers/discord/message.rb +0 -921
- data/lib/brainiac/handlers/discord/reactions.rb +0 -215
- data/lib/brainiac/handlers/discord.rb +0 -24
- data/templates/discord.json.example +0 -17
|
@@ -1,921 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Discord message handler — the main dispatch function.
|
|
4
|
-
#
|
|
5
|
-
# Handles incoming messages: authorization, cross-agent routing, project detection,
|
|
6
|
-
# worktree management, prompt building, agent spawning, and response monitoring.
|
|
7
|
-
|
|
8
|
-
# Handle an incoming Discord message for a specific agent bot.
|
|
9
|
-
# agent_key: the lowercase agent key (e.g. "galen")
|
|
10
|
-
# bot_token: the Discord bot token for this agent
|
|
11
|
-
# bot_user_id: the Discord user ID of this bot
|
|
12
|
-
def handle_discord_message(message, agent_key, bot_token, bot_user_id)
|
|
13
|
-
channel_id = message["channel_id"]
|
|
14
|
-
message_id = message["id"]
|
|
15
|
-
author = message["author"]
|
|
16
|
-
content = message["content"] || ""
|
|
17
|
-
is_bot = !author["bot"].nil?
|
|
18
|
-
|
|
19
|
-
sender_agent_key = detect_sender_agent(author, agent_key) if is_bot
|
|
20
|
-
return if is_bot && !sender_agent_key
|
|
21
|
-
|
|
22
|
-
mentions = message["mentions"] || []
|
|
23
|
-
mentioned = mentions.any? { |m| m["id"].to_s == bot_user_id.to_s } ||
|
|
24
|
-
content.match?(/<@!?#{Regexp.escape(bot_user_id.to_s)}>/)
|
|
25
|
-
return if sender_agent_key && !validate_cross_agent_dispatch(sender_agent_key, agent_key, mentioned, content, channel_id)
|
|
26
|
-
|
|
27
|
-
is_reply_to_bot, referenced_message = detect_reply_to_bot(message, channel_id, mentioned, bot_token, bot_user_id)
|
|
28
|
-
channel_info, is_thread, is_dm, in_own_thread = detect_channel_context(message, channel_id, mentioned, is_reply_to_bot, bot_token, bot_user_id)
|
|
29
|
-
return if should_stand_down?(in_own_thread, mentioned, is_reply_to_bot, is_bot, agent_key, mentions, content)
|
|
30
|
-
return unless mentioned || in_own_thread || is_dm || is_reply_to_bot
|
|
31
|
-
|
|
32
|
-
record_human_comment("discord-#{channel_id}") unless is_bot
|
|
33
|
-
|
|
34
|
-
clean_content = prepare_discord_content(content, bot_user_id, message, message_id, agent_key)
|
|
35
|
-
attachment_paths = download_attachments(message, message_id, agent_key)
|
|
36
|
-
clean_content = append_attachments(clean_content, attachment_paths)
|
|
37
|
-
return if clean_content.empty? && attachment_paths.empty?
|
|
38
|
-
|
|
39
|
-
reply_context = build_reply_context(referenced_message)
|
|
40
|
-
discord_user = author["username"]
|
|
41
|
-
discord_user_id = author["id"]
|
|
42
|
-
agent_name = agent_display_name(agent_key) || agent_key.capitalize
|
|
43
|
-
|
|
44
|
-
channel_info, is_thread, is_dm = ensure_channel_info(channel_info, is_thread, is_dm, channel_id, bot_token)
|
|
45
|
-
parent_channel_id = is_thread ? channel_info&.dig("parent_id") || channel_id : channel_id
|
|
46
|
-
channel_history = fetch_discord_channel_history(channel_id, message_id, token: bot_token, limit: is_thread ? 25 : 10)
|
|
47
|
-
location = is_dm ? "DM" : (is_thread ? "thread" : "channel") # rubocop:disable Style/NestedTernaryOperator
|
|
48
|
-
LOG.info "[Discord:#{agent_name}] Message from #{discord_user} in #{location} #{channel_id}: #{clean_content[0..100]}"
|
|
49
|
-
|
|
50
|
-
reload_projects!
|
|
51
|
-
reload_agent_registry!
|
|
52
|
-
reload_discord_config!
|
|
53
|
-
return unless authorize_discord_user(discord_user, discord_user_id, message, channel_id, message_id, agent_name, bot_token)
|
|
54
|
-
|
|
55
|
-
tags = parse_inline_tags(clean_content)
|
|
56
|
-
project_key, project_config = resolve_discord_project(tags[:project], parent_channel_id, agent_name, channel_id, message_id, bot_token)
|
|
57
|
-
|
|
58
|
-
route_discord_dispatch(
|
|
59
|
-
agent_key: agent_key, agent_name: agent_name, bot_token: bot_token, is_bot: is_bot,
|
|
60
|
-
channel_id: channel_id, message_id: message_id, message: message,
|
|
61
|
-
clean_content: clean_content, clean_content_for_prompt: tags[:clean_text],
|
|
62
|
-
chat_mode: tags[:chat_mode], is_thread: is_thread, is_dm: is_dm,
|
|
63
|
-
channel_info: channel_info, parent_channel_id: parent_channel_id,
|
|
64
|
-
discord_user: discord_user, reply_context: reply_context,
|
|
65
|
-
channel_history: channel_history, project_key: project_key,
|
|
66
|
-
project_config: project_config, attachment_paths: attachment_paths
|
|
67
|
-
)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def route_discord_dispatch(agent_key:, agent_name:, bot_token:, is_bot:, channel_id:, message_id:, message:,
|
|
71
|
-
clean_content:, clean_content_for_prompt:, chat_mode:, is_thread:, is_dm:,
|
|
72
|
-
channel_info:, parent_channel_id:, discord_user:, reply_context:,
|
|
73
|
-
channel_history:, project_key:, project_config:, attachment_paths:)
|
|
74
|
-
session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
|
|
75
|
-
supersede_key = "discord-#{agent_key}-#{channel_id}"
|
|
76
|
-
if session_active?(session_key)
|
|
77
|
-
add_discord_reaction(channel_id, message_id, "⏳", token: bot_token)
|
|
78
|
-
return
|
|
79
|
-
end
|
|
80
|
-
handle_supersede(is_bot, supersede_key, discord_user, agent_name, bot_token)
|
|
81
|
-
Thread.new do
|
|
82
|
-
remove_discord_reaction(channel_id, message_id, "🛑", token: bot_token)
|
|
83
|
-
add_discord_reaction(channel_id, message_id, "👀", token: bot_token)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
begin
|
|
87
|
-
project_context = build_discord_project_context(project_key, project_config, agent_name)
|
|
88
|
-
|
|
89
|
-
dispatch_discord_session(
|
|
90
|
-
agent_key: agent_key, agent_name: agent_name, bot_token: bot_token,
|
|
91
|
-
channel_id: channel_id, message_id: message_id, message: message,
|
|
92
|
-
clean_content: clean_content, clean_content_for_prompt: clean_content_for_prompt,
|
|
93
|
-
chat_mode: chat_mode, is_thread: is_thread, is_dm: is_dm,
|
|
94
|
-
channel_info: channel_info, parent_channel_id: parent_channel_id,
|
|
95
|
-
discord_user: discord_user, reply_context: reply_context,
|
|
96
|
-
channel_history: channel_history, project_key: project_key,
|
|
97
|
-
project_config: project_config, project_context: project_context,
|
|
98
|
-
session_key: session_key, supersede_key: supersede_key,
|
|
99
|
-
attachment_paths: attachment_paths, is_bot: is_bot
|
|
100
|
-
)
|
|
101
|
-
rescue StandardError => e
|
|
102
|
-
LOG.error "[Discord:#{agent_name}] Dispatch failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
|
|
103
|
-
Thread.new do
|
|
104
|
-
remove_discord_reaction(channel_id, message_id, "👀", token: bot_token)
|
|
105
|
-
add_discord_reaction(channel_id, message_id, "❌", token: bot_token)
|
|
106
|
-
end
|
|
107
|
-
error_msg = "⚠️ Something went wrong while preparing your request:\n```\n#{e.message.lines.first(3).join.strip}\n```"
|
|
108
|
-
send_discord_message(channel_id, error_msg, token: bot_token, reply_to: message_id)
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def ensure_channel_info(channel_info, is_thread, is_dm, channel_id, bot_token)
|
|
113
|
-
return [channel_info, is_thread, is_dm] if channel_info
|
|
114
|
-
|
|
115
|
-
channel_info = discord_api(:get, "/channels/#{channel_id}", token: bot_token)
|
|
116
|
-
is_thread = channel_info && [11, 12].include?(channel_info["type"])
|
|
117
|
-
is_dm = channel_info && channel_info["type"] == 1
|
|
118
|
-
[channel_info, is_thread, is_dm]
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def prepare_discord_content(content, bot_user_id, _message, _message_id, _agent_key)
|
|
122
|
-
content.gsub(/<@!?#{bot_user_id}>/, "").strip
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def append_attachments(clean_content, attachment_paths)
|
|
126
|
-
return clean_content if attachment_paths.empty?
|
|
127
|
-
|
|
128
|
-
clean_content += "\n\n" unless clean_content.empty?
|
|
129
|
-
clean_content + attachment_paths.join("\n")
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def dispatch_discord_session(agent_key:, agent_name:, bot_token:, channel_id:, message_id:, message:,
|
|
133
|
-
clean_content:, clean_content_for_prompt:, chat_mode:, is_thread:, is_dm:,
|
|
134
|
-
channel_info:, parent_channel_id:, discord_user:, reply_context:,
|
|
135
|
-
channel_history:, project_key:, project_config:, project_context:,
|
|
136
|
-
session_key:, supersede_key:, attachment_paths:, is_bot:)
|
|
137
|
-
timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
138
|
-
response_dir = File.join(BRAINIAC_DIR, "tmp")
|
|
139
|
-
response_basename = "discord-response-#{timestamp}-#{agent_key}-#{message_id}"
|
|
140
|
-
response_file = File.join(DISCORD_DRAFT_DIR, "#{response_basename}.md")
|
|
141
|
-
|
|
142
|
-
root_message = find_root_message(message, channel_id, bot_token)
|
|
143
|
-
card_id = is_thread ? "discord-#{parent_channel_id}-#{channel_id}" : "discord-#{channel_id}-#{root_message[:id]}"
|
|
144
|
-
thread_root_context = build_thread_root_context(is_thread, root_message, parent_channel_id, channel_id, bot_token)
|
|
145
|
-
planning_info = false
|
|
146
|
-
brain_context = build_brain_context(agent_name: agent_name, card_title: clean_content, comment_body: clean_content)
|
|
147
|
-
|
|
148
|
-
should_resume, thread_worktree_path, thread_cli_provider, thread_model, thread_effort = manage_discord_worktree(
|
|
149
|
-
agent_key: agent_key, agent_name: agent_name, channel_id: channel_id, message_id: message_id,
|
|
150
|
-
is_thread: is_thread, is_dm: is_dm, project_config: project_config, clean_content: clean_content,
|
|
151
|
-
chat_mode: chat_mode, bot_token: bot_token
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
prompt = build_discord_prompt(
|
|
155
|
-
should_resume: should_resume, thread_worktree_path: thread_worktree_path,
|
|
156
|
-
planning_info: planning_info, clean_content_for_prompt: clean_content_for_prompt,
|
|
157
|
-
discord_user: discord_user, channel_name: channel_info&.dig("name") || channel_id, reply_context: reply_context,
|
|
158
|
-
channel_history: channel_history, thread_root_context: thread_root_context,
|
|
159
|
-
project_context: project_context, response_file: response_file, card_id: card_id,
|
|
160
|
-
brain_context: brain_context, agent_name: agent_name
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
work_dir = chat_mode_fallback(agent_key, agent_name, message_id, chat_mode, thread_worktree_path) ||
|
|
164
|
-
(project_config ? project_config["repo_path"] : Dir.pwd)
|
|
165
|
-
prompt_file = File.join(response_dir, "discord-prompt-#{timestamp}-#{agent_key}-#{message_id}.md")
|
|
166
|
-
File.write(prompt_file, prompt)
|
|
167
|
-
|
|
168
|
-
model, effort, cli_provider_override = resolve_discord_overrides(
|
|
169
|
-
clean_content, project_config, thread_model, thread_effort, thread_cli_provider
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
meta_file = File.join(DISCORD_DRAFT_DIR, "#{response_basename}.meta.json")
|
|
173
|
-
write_discord_meta(meta_file,
|
|
174
|
-
channel_id: channel_id, message_id: message_id, agent_key: agent_key,
|
|
175
|
-
agent_name: agent_name, is_dm: is_dm, is_thread: is_thread,
|
|
176
|
-
clean_content: clean_content,
|
|
177
|
-
explicit_model: explicit_model_tag?(clean_content, project_config) ? model : nil,
|
|
178
|
-
explicit_effort: clean_content.match?(/\[effort:\w+\]/i) ? effort : nil)
|
|
179
|
-
|
|
180
|
-
spawn_discord_agent(
|
|
181
|
-
agent_key: agent_key, agent_name: agent_name, bot_token: bot_token,
|
|
182
|
-
channel_id: channel_id, message_id: message_id, discord_user: discord_user,
|
|
183
|
-
work_dir: work_dir, prompt_file: prompt_file, response_file: response_file,
|
|
184
|
-
meta_file: meta_file, model: model, effort: effort, should_resume: should_resume,
|
|
185
|
-
cli_provider_override: cli_provider_override, project_config: project_config,
|
|
186
|
-
session_key: session_key, supersede_key: supersede_key,
|
|
187
|
-
attachment_paths: attachment_paths, timestamp: timestamp, response_dir: response_dir
|
|
188
|
-
)
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def chat_mode_fallback(agent_key, agent_name, message_id, chat_mode, thread_worktree_path)
|
|
192
|
-
return thread_worktree_path unless chat_mode && !thread_worktree_path
|
|
193
|
-
|
|
194
|
-
chat_tmp_dir = File.join(BRAINIAC_DIR, "tmp", "chat", "#{agent_key}-#{message_id}")
|
|
195
|
-
FileUtils.mkdir_p(chat_tmp_dir)
|
|
196
|
-
LOG.info "[Discord:#{agent_name}] Chat mode fallback tmp dir at #{chat_tmp_dir}"
|
|
197
|
-
chat_tmp_dir
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
def resolve_discord_overrides(clean_content, project_config, thread_model, thread_effort, thread_cli_provider)
|
|
201
|
-
cli_provider = detect_cli_provider(text: clean_content) || thread_cli_provider
|
|
202
|
-
has_explicit_model = explicit_model_tag?(clean_content, project_config)
|
|
203
|
-
has_explicit_effort = clean_content.match?(/\[effort:\w+\]/i)
|
|
204
|
-
|
|
205
|
-
model = if has_explicit_model
|
|
206
|
-
detect_model(project_config, text: clean_content)
|
|
207
|
-
elsif thread_model
|
|
208
|
-
thread_model
|
|
209
|
-
else
|
|
210
|
-
project_config ? detect_model(project_config, text: clean_content) : nil
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
effort = if has_explicit_effort
|
|
214
|
-
detect_effort(project_config, text: clean_content)
|
|
215
|
-
elsif thread_effort
|
|
216
|
-
thread_effort
|
|
217
|
-
else
|
|
218
|
-
project_config ? detect_effort(project_config, text: clean_content) : nil
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
[model, effort, cli_provider]
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
def explicit_model_tag?(clean_content, project_config)
|
|
225
|
-
return false unless project_config
|
|
226
|
-
|
|
227
|
-
allowed_models = resolve_project_cli_config(project_config)["allowed_models"] || {}
|
|
228
|
-
model_tag_match = clean_content.match(/\[(\w+)\]/i)
|
|
229
|
-
model_tag_match && allowed_models.key?(model_tag_match[1].downcase)
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
def write_discord_meta(meta_file, channel_id:, message_id:, agent_key:, agent_name:, is_dm:, is_thread:,
|
|
233
|
-
clean_content:, explicit_model:, explicit_effort:)
|
|
234
|
-
File.write(meta_file, JSON.pretty_generate({
|
|
235
|
-
channel_id: channel_id, message_id: message_id,
|
|
236
|
-
agent_key: agent_key, agent_name: agent_name,
|
|
237
|
-
is_dm: is_dm, is_thread: is_thread,
|
|
238
|
-
clean_content: clean_content[0..80],
|
|
239
|
-
cli_provider: detect_cli_provider(text: clean_content),
|
|
240
|
-
model: explicit_model, effort: explicit_effort,
|
|
241
|
-
created_at: Time.now.iso8601
|
|
242
|
-
}))
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
def spawn_discord_agent(agent_key:, agent_name:, bot_token:, channel_id:, message_id:, discord_user:,
|
|
246
|
-
work_dir:, prompt_file:, response_file:, meta_file:, model:, effort:,
|
|
247
|
-
should_resume:, cli_provider_override:, project_config:,
|
|
248
|
-
session_key:, supersede_key:, attachment_paths:, timestamp:, response_dir:)
|
|
249
|
-
agent_config_name = agent_key.downcase.gsub(/[^a-z0-9-]/, "-")
|
|
250
|
-
log_file = File.join(response_dir, "discord-agent-#{timestamp}-#{agent_key}-#{message_id}.log")
|
|
251
|
-
|
|
252
|
-
resolved = resolve_project_cli_config(project_config || DEFAULT_PROJECT,
|
|
253
|
-
cli_provider_override: cli_provider_override, agent_name: agent_name)
|
|
254
|
-
cmd = build_agent_cli_cmd(resolved, agent_config_name, model, effort, should_resume, prompt_file)
|
|
255
|
-
|
|
256
|
-
resume_note = should_resume ? ", resuming" : ""
|
|
257
|
-
LOG.info "[Discord:#{agent_name}] Dispatching for #{discord_user} " \
|
|
258
|
-
"(model: #{model || "default"}, effort: #{effort || "default"}, cli: #{resolved["agent_cli"]}#{resume_note}), tail -f #{log_file}"
|
|
259
|
-
LOG.info "[Discord:#{agent_name}] Command: #{cmd.join(" ")}"
|
|
260
|
-
|
|
261
|
-
spawn_env = {}
|
|
262
|
-
agent_env = agent_env_for(agent_name)
|
|
263
|
-
unless agent_env.empty?
|
|
264
|
-
spawn_env.merge!(agent_env)
|
|
265
|
-
LOG.info "[Discord:#{agent_name}] Injecting #{agent_env.size} env var(s): #{agent_env.keys.join(", ")}"
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
head_before, status_before = capture_brainiac_state(project_config, work_dir)
|
|
269
|
-
prompt_mode = resolved["prompt_mode"] || "stdin"
|
|
270
|
-
|
|
271
|
-
pid = spawn(spawn_env, *cmd,
|
|
272
|
-
chdir: work_dir,
|
|
273
|
-
**(prompt_mode == "stdin" ? { in: prompt_file } : {}),
|
|
274
|
-
out: [log_file, "w"],
|
|
275
|
-
err: %i[child out])
|
|
276
|
-
|
|
277
|
-
register_session(session_key, pid, log_file: log_file,
|
|
278
|
-
message_id: message_id, channel_id: channel_id,
|
|
279
|
-
supersede_key: supersede_key,
|
|
280
|
-
draft_files: [response_file, meta_file],
|
|
281
|
-
agent_name: agent_name)
|
|
282
|
-
|
|
283
|
-
monitor_discord_agent(
|
|
284
|
-
pid: pid, session_key: session_key, agent_name: agent_name,
|
|
285
|
-
agent_config_name: agent_config_name, channel_id: channel_id,
|
|
286
|
-
message_id: message_id, bot_token: bot_token, response_file: response_file,
|
|
287
|
-
meta_file: meta_file, prompt_file: prompt_file, log_file: log_file,
|
|
288
|
-
attachment_paths: attachment_paths, project_config: project_config,
|
|
289
|
-
head_before: head_before, status_before: status_before
|
|
290
|
-
)
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
def build_agent_cli_cmd(resolved, agent_config_name, model, effort, should_resume, prompt_file)
|
|
294
|
-
agent_flag = resolved.key?("agent_flag") ? resolved["agent_flag"] : "--agent"
|
|
295
|
-
prompt_mode = resolved["prompt_mode"] || "stdin"
|
|
296
|
-
|
|
297
|
-
cmd = [resolved["agent_cli"]]
|
|
298
|
-
cmd.push(agent_flag, agent_config_name) if agent_flag
|
|
299
|
-
cmd.concat(resolved["agent_cli_args"].split)
|
|
300
|
-
if model && resolved["agent_model_flag"] && !resolved["agent_model_flag"].empty?
|
|
301
|
-
allowed = resolved["allowed_models"] || {}
|
|
302
|
-
cmd.push(resolved["agent_model_flag"], model) if allowed.value?(model) || allowed.key?(model)
|
|
303
|
-
end
|
|
304
|
-
cmd.push(resolved["agent_effort_flag"], effort) if resolved["agent_effort_flag"] && !resolved["agent_effort_flag"].empty? && effort
|
|
305
|
-
cmd.push(resolved["resume_flag"]) if should_resume && resolved["resume_flag"]
|
|
306
|
-
cmd.push(resolved["prompt_flag"], prompt_file) if prompt_mode == "flag" && resolved["prompt_flag"]
|
|
307
|
-
cmd
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
def capture_brainiac_state(project_config, work_dir)
|
|
311
|
-
return [nil, nil] unless project_config
|
|
312
|
-
|
|
313
|
-
pk = PROJECTS.find { |_k, v| v == project_config }&.first
|
|
314
|
-
pk == "brainiac" ? capture_git_state(work_dir) : [nil, nil]
|
|
315
|
-
end
|
|
316
|
-
|
|
317
|
-
# --- Private helpers for handle_discord_message ---
|
|
318
|
-
|
|
319
|
-
def detect_sender_agent(author, agent_key)
|
|
320
|
-
sender_id = author["id"]
|
|
321
|
-
sender_agent_key = nil
|
|
322
|
-
|
|
323
|
-
DISCORD_BOTS_MUTEX.synchronize do
|
|
324
|
-
DISCORD_BOTS.each do |key, info|
|
|
325
|
-
if info[:user_id] == sender_id && key != agent_key
|
|
326
|
-
sender_agent_key = key
|
|
327
|
-
break
|
|
328
|
-
end
|
|
329
|
-
end
|
|
330
|
-
end
|
|
331
|
-
|
|
332
|
-
unless sender_agent_key
|
|
333
|
-
user_mappings = DISCORD_CONFIG["user_mappings"] || {}
|
|
334
|
-
user_mappings.each do |name, discord_id|
|
|
335
|
-
if discord_id == sender_id
|
|
336
|
-
sender_agent_key = name.downcase
|
|
337
|
-
break
|
|
338
|
-
end
|
|
339
|
-
end
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
LOG.info "[Discord:#{agent_key}] Ignoring unknown bot: id=#{sender_id}, username=#{author["username"]}" unless sender_agent_key
|
|
343
|
-
|
|
344
|
-
sender_agent_key
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
def validate_cross_agent_dispatch(sender_agent_key, agent_key, mentioned, content, channel_id) # rubocop:disable Naming/PredicateMethod
|
|
348
|
-
return false unless mentioned
|
|
349
|
-
|
|
350
|
-
if content.match?(/created\s+card\s+#?\d+/i) || content.match?(/assigned\s+.*card\s+#?\d+/i) || content.match?(/card\s+#?\d+.*assigned/i)
|
|
351
|
-
sender_display = agent_display_name(sender_agent_key) || sender_agent_key.capitalize
|
|
352
|
-
agent_display = agent_display_name(agent_key) || agent_key.capitalize
|
|
353
|
-
LOG.info "[Discord:#{agent_display}] Ignoring cross-agent mention from #{sender_display} — card creation/assignment (handled by webhook)"
|
|
354
|
-
return false
|
|
355
|
-
end
|
|
356
|
-
|
|
357
|
-
depth_key = "discord-#{channel_id}"
|
|
358
|
-
unless agent_dispatch_allowed?(depth_key)
|
|
359
|
-
sender_display = agent_display_name(sender_agent_key) || sender_agent_key.capitalize
|
|
360
|
-
agent_display = agent_display_name(agent_key) || agent_key.capitalize
|
|
361
|
-
LOG.info "[Discord:#{agent_display}] Blocking cross-agent dispatch from #{sender_display} — depth limit reached"
|
|
362
|
-
return false
|
|
363
|
-
end
|
|
364
|
-
record_agent_dispatch(depth_key)
|
|
365
|
-
true
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
def detect_reply_to_bot(message, channel_id, mentioned, bot_token, bot_user_id)
|
|
369
|
-
is_reply_to_bot = false
|
|
370
|
-
referenced_message = nil
|
|
371
|
-
|
|
372
|
-
if message["message_reference"]
|
|
373
|
-
ref_msg_id = message.dig("message_reference", "message_id")
|
|
374
|
-
ref_channel = message.dig("message_reference", "channel_id") || channel_id
|
|
375
|
-
if ref_msg_id
|
|
376
|
-
referenced_message = discord_api(:get, "/channels/#{ref_channel}/messages/#{ref_msg_id}", token: bot_token)
|
|
377
|
-
is_reply_to_bot = !mentioned && referenced_message && referenced_message.dig("author", "id") == bot_user_id
|
|
378
|
-
end
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
[is_reply_to_bot, referenced_message]
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
def detect_channel_context(_message, channel_id, mentioned, is_reply_to_bot, bot_token, bot_user_id)
|
|
385
|
-
channel_info = nil
|
|
386
|
-
is_thread = false
|
|
387
|
-
is_dm = false
|
|
388
|
-
in_own_thread = false
|
|
389
|
-
|
|
390
|
-
if !mentioned && !is_reply_to_bot
|
|
391
|
-
channel_info = discord_api(:get, "/channels/#{channel_id}", token: bot_token)
|
|
392
|
-
is_thread = channel_info && [11, 12].include?(channel_info["type"])
|
|
393
|
-
is_dm = channel_info && channel_info["type"] == 1
|
|
394
|
-
in_own_thread = is_thread && channel_info["owner_id"] == bot_user_id
|
|
395
|
-
end
|
|
396
|
-
|
|
397
|
-
[channel_info, is_thread, is_dm, in_own_thread]
|
|
398
|
-
end
|
|
399
|
-
|
|
400
|
-
def should_stand_down?(in_own_thread, mentioned, is_reply_to_bot, is_bot, agent_key, mentions, content)
|
|
401
|
-
return false unless in_own_thread && !mentioned && !is_reply_to_bot && !is_bot
|
|
402
|
-
|
|
403
|
-
other_bot_mentioned = false
|
|
404
|
-
DISCORD_BOTS_MUTEX.synchronize do
|
|
405
|
-
DISCORD_BOTS.each do |key, info|
|
|
406
|
-
next if key == agent_key
|
|
407
|
-
next unless info[:user_id]
|
|
408
|
-
next unless mentions.any? { |m| m["id"].to_s == info[:user_id].to_s } ||
|
|
409
|
-
content.match?(/<@!?#{Regexp.escape(info[:user_id].to_s)}>/)
|
|
410
|
-
|
|
411
|
-
other_bot_mentioned = true
|
|
412
|
-
break
|
|
413
|
-
end
|
|
414
|
-
end
|
|
415
|
-
|
|
416
|
-
unless other_bot_mentioned
|
|
417
|
-
user_mappings = DISCORD_CONFIG["user_mappings"] || {}
|
|
418
|
-
user_mappings.each_value do |discord_id|
|
|
419
|
-
next unless mentions.any? { |m| m["id"].to_s == discord_id.to_s } ||
|
|
420
|
-
content.match?(/<@!?#{Regexp.escape(discord_id.to_s)}>/)
|
|
421
|
-
|
|
422
|
-
other_bot_mentioned = true
|
|
423
|
-
break
|
|
424
|
-
end
|
|
425
|
-
end
|
|
426
|
-
|
|
427
|
-
if other_bot_mentioned
|
|
428
|
-
agent_display = agent_display_name(agent_key) || agent_key.capitalize
|
|
429
|
-
LOG.info "[Discord:#{agent_display}] Standing down in own thread — human is directing message to another agent"
|
|
430
|
-
end
|
|
431
|
-
|
|
432
|
-
other_bot_mentioned
|
|
433
|
-
end
|
|
434
|
-
|
|
435
|
-
def download_attachments(message, message_id, agent_key)
|
|
436
|
-
attachments = message["attachments"] || []
|
|
437
|
-
paths = []
|
|
438
|
-
agent_display = agent_display_name(agent_key) || agent_key.capitalize
|
|
439
|
-
|
|
440
|
-
attachments.each do |att|
|
|
441
|
-
url = att["url"]
|
|
442
|
-
filename = att["filename"]
|
|
443
|
-
content_type = att["content_type"] || ""
|
|
444
|
-
next unless content_type.start_with?("image/")
|
|
445
|
-
|
|
446
|
-
temp_dir = File.join(BRAINIAC_DIR, "tmp", "discord", "attachments")
|
|
447
|
-
FileUtils.mkdir_p(temp_dir)
|
|
448
|
-
temp_path = File.join(temp_dir, "#{message_id}-#{filename}")
|
|
449
|
-
|
|
450
|
-
begin
|
|
451
|
-
uri = URI(url)
|
|
452
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
453
|
-
http.use_ssl = true
|
|
454
|
-
response = http.get(uri.path + (uri.query ? "?#{uri.query}" : ""))
|
|
455
|
-
|
|
456
|
-
if response.code.to_i == 200
|
|
457
|
-
File.binwrite(temp_path, response.body)
|
|
458
|
-
paths << temp_path
|
|
459
|
-
LOG.info "[Discord:#{agent_display}] Downloaded attachment: #{filename} (#{content_type})"
|
|
460
|
-
else
|
|
461
|
-
LOG.warn "[Discord:#{agent_display}] Failed to download attachment #{filename}: HTTP #{response.code}"
|
|
462
|
-
end
|
|
463
|
-
rescue StandardError => e
|
|
464
|
-
LOG.error "[Discord:#{agent_display}] Error downloading attachment #{filename}: #{e.message}"
|
|
465
|
-
end
|
|
466
|
-
end
|
|
467
|
-
paths
|
|
468
|
-
end
|
|
469
|
-
|
|
470
|
-
def build_reply_context(referenced_message)
|
|
471
|
-
return "" unless referenced_message && referenced_message["content"]
|
|
472
|
-
|
|
473
|
-
ref_author = referenced_message.dig("author", "username") || "unknown"
|
|
474
|
-
ref_text = referenced_message["content"].strip
|
|
475
|
-
return "" if ref_text.empty?
|
|
476
|
-
|
|
477
|
-
"**Replying to #{ref_author}:**\n> #{ref_text}\n\n"
|
|
478
|
-
end
|
|
479
|
-
|
|
480
|
-
def authorize_discord_user(discord_user, discord_user_id, message, channel_id, message_id, agent_name, bot_token) # rubocop:disable Naming/PredicateMethod
|
|
481
|
-
authorized_users = DISCORD_CONFIG["authorized_user_ids"] || []
|
|
482
|
-
|
|
483
|
-
authorized_roles = if DISCORD_CONFIG["role_mappings"]
|
|
484
|
-
DISCORD_CONFIG["role_mappings"].values
|
|
485
|
-
elsif DISCORD_CONFIG["authorized_role_ids"].is_a?(Hash)
|
|
486
|
-
DISCORD_CONFIG["authorized_role_ids"].values
|
|
487
|
-
else
|
|
488
|
-
DISCORD_CONFIG["authorized_role_ids"] || []
|
|
489
|
-
end
|
|
490
|
-
authorized_roles = authorized_roles.map(&:to_s)
|
|
491
|
-
|
|
492
|
-
return true if authorized_users.empty? && authorized_roles.empty?
|
|
493
|
-
|
|
494
|
-
user_authorized = authorized_users.include?(discord_user_id)
|
|
495
|
-
member_roles = message.dig("member", "roles") || []
|
|
496
|
-
|
|
497
|
-
if member_roles.empty? && message["guild_id"]
|
|
498
|
-
guild_member = fetch_guild_member(message["guild_id"], discord_user_id, token: bot_token)
|
|
499
|
-
member_roles = guild_member["roles"] || [] if guild_member
|
|
500
|
-
end
|
|
501
|
-
|
|
502
|
-
role_authorized = member_roles.intersect?(authorized_roles)
|
|
503
|
-
|
|
504
|
-
unless user_authorized || role_authorized
|
|
505
|
-
LOG.info "[Discord:#{agent_name}] Unauthorized user #{discord_user} (#{discord_user_id}), roles: #{member_roles.inspect}"
|
|
506
|
-
add_discord_reaction(channel_id, message_id, "🚫", token: bot_token)
|
|
507
|
-
return false
|
|
508
|
-
end
|
|
509
|
-
|
|
510
|
-
true
|
|
511
|
-
end
|
|
512
|
-
|
|
513
|
-
def resolve_discord_project(inline_project_key, parent_channel_id, agent_name, channel_id, message_id, bot_token)
|
|
514
|
-
if inline_project_key && PROJECTS.key?(inline_project_key)
|
|
515
|
-
project_key = inline_project_key
|
|
516
|
-
project_config = PROJECTS[inline_project_key]
|
|
517
|
-
LOG.info "[Discord:#{agent_name}] Using inline project: #{project_key} (#{project_config["repo_path"]})"
|
|
518
|
-
else
|
|
519
|
-
if inline_project_key && !PROJECTS.key?(inline_project_key)
|
|
520
|
-
LOG.warn "[Discord:#{agent_name}] Unknown inline project '#{inline_project_key}', " \
|
|
521
|
-
"falling back to channel mapping. Available: #{PROJECTS.keys.join(", ")}"
|
|
522
|
-
Thread.new { add_discord_reaction(channel_id, message_id, "⚠️", token: bot_token) }
|
|
523
|
-
end
|
|
524
|
-
project_key, project_config, _mapping = find_project_for_discord_channel(parent_channel_id)
|
|
525
|
-
if project_key
|
|
526
|
-
LOG.info "[Discord:#{agent_name}] Using channel-mapped project: #{project_key}"
|
|
527
|
-
else
|
|
528
|
-
LOG.info "[Discord:#{agent_name}] No project context (no inline tag or channel mapping)"
|
|
529
|
-
end
|
|
530
|
-
end
|
|
531
|
-
[project_key, project_config]
|
|
532
|
-
end
|
|
533
|
-
|
|
534
|
-
def handle_supersede(is_bot, supersede_key, discord_user, agent_name, bot_token)
|
|
535
|
-
return if is_bot
|
|
536
|
-
|
|
537
|
-
prev = find_supersedable_session(supersede_key)
|
|
538
|
-
return unless prev
|
|
539
|
-
|
|
540
|
-
LOG.info "[Discord:#{agent_name}] Superseding previous session #{prev[:session_key]} (pid: #{prev[:pid]}) for follow-up from #{discord_user}"
|
|
541
|
-
kill_session(prev[:session_key])
|
|
542
|
-
if prev[:message_id] && prev[:channel_id]
|
|
543
|
-
Thread.new do
|
|
544
|
-
remove_discord_reaction(prev[:channel_id], prev[:message_id], "👀", token: bot_token)
|
|
545
|
-
add_discord_reaction(prev[:channel_id], prev[:message_id], "❌", token: bot_token)
|
|
546
|
-
end
|
|
547
|
-
end
|
|
548
|
-
(prev[:draft_files] || []).each { |f| FileUtils.rm_f(f) }
|
|
549
|
-
end
|
|
550
|
-
|
|
551
|
-
def build_discord_project_context(project_key, project_config, agent_name)
|
|
552
|
-
if project_config
|
|
553
|
-
repo_path = project_config["repo_path"]
|
|
554
|
-
debounced_repo_fetch(repo_path)
|
|
555
|
-
default_branch = get_default_branch(repo_path)
|
|
556
|
-
lines = ["## Project Context", "Project: #{project_key}", "Source directory: `#{repo_path}`",
|
|
557
|
-
"Default branch: `#{default_branch}`"]
|
|
558
|
-
lines << "GitHub: #{project_config["github_repo"]}" if project_config["github_repo"]
|
|
559
|
-
lines << ""
|
|
560
|
-
lines << "This is the project's source code directory. When asked to modify, inspect, or work on this project, " \
|
|
561
|
-
"go directly to `#{repo_path}` — do NOT search for it."
|
|
562
|
-
lines << ""
|
|
563
|
-
lines << "### All registered projects"
|
|
564
|
-
PROJECTS.each { |key, cfg| lines << "- **#{key}**: `#{cfg["repo_path"]}`" }
|
|
565
|
-
LOG.info "[Discord:#{agent_name}] Built project context for #{project_key} (#{repo_path})"
|
|
566
|
-
else
|
|
567
|
-
lines = ["## Project Context", "No specific project mapped to this channel.", "",
|
|
568
|
-
"### Registered projects (use `[project:name]` to target one)"]
|
|
569
|
-
PROJECTS.each { |key, cfg| lines << "- **#{key}**: `#{cfg["repo_path"]}`" }
|
|
570
|
-
LOG.info "[Discord:#{agent_name}] No project context - showing available projects"
|
|
571
|
-
end
|
|
572
|
-
lines.join("\n")
|
|
573
|
-
end
|
|
574
|
-
|
|
575
|
-
def build_thread_root_context(is_thread, root_message, parent_channel_id, channel_id, bot_token)
|
|
576
|
-
return "" unless is_thread
|
|
577
|
-
|
|
578
|
-
root_content = root_message[:content]
|
|
579
|
-
root_author = root_message[:author]
|
|
580
|
-
|
|
581
|
-
if root_content.nil? || root_content.empty?
|
|
582
|
-
parent_msg = fetch_discord_message(parent_channel_id, channel_id, token: bot_token, log_errors: false)
|
|
583
|
-
if parent_msg && parent_msg["content"] && !parent_msg["content"].strip.empty?
|
|
584
|
-
root_content = parent_msg["content"].strip
|
|
585
|
-
root_author = parent_msg.dig("author", "username") || "unknown"
|
|
586
|
-
end
|
|
587
|
-
end
|
|
588
|
-
|
|
589
|
-
return "" unless root_content && !root_content.empty?
|
|
590
|
-
|
|
591
|
-
"### Original Message (thread starter)\n#{root_author || "unknown"}: #{root_content}\n\n"
|
|
592
|
-
end
|
|
593
|
-
|
|
594
|
-
def manage_discord_worktree(agent_key:, agent_name:, channel_id:, message_id:, is_thread:, is_dm:, project_config:, clean_content:, chat_mode:,
|
|
595
|
-
bot_token:)
|
|
596
|
-
should_resume = false
|
|
597
|
-
thread_worktree_path = nil
|
|
598
|
-
thread_cli_provider = nil
|
|
599
|
-
thread_model = nil
|
|
600
|
-
thread_effort = nil
|
|
601
|
-
|
|
602
|
-
# Pre-create thread for channel messages with a project
|
|
603
|
-
pre_created_thread_id = nil
|
|
604
|
-
if !is_thread && !is_dm && project_config
|
|
605
|
-
pre_created_thread_id = pre_create_discord_thread(agent_key, agent_name, channel_id, message_id, clean_content, bot_token)
|
|
606
|
-
end
|
|
607
|
-
|
|
608
|
-
effective_thread_id = is_thread ? channel_id : pre_created_thread_id
|
|
609
|
-
thread_map_key = "#{agent_key}:#{effective_thread_id}" if effective_thread_id
|
|
610
|
-
|
|
611
|
-
if project_config && thread_map_key
|
|
612
|
-
thread_map = DISCORD_THREAD_MAP_MUTEX.synchronize { load_discord_thread_map }
|
|
613
|
-
existing = thread_map[thread_map_key]
|
|
614
|
-
|
|
615
|
-
if existing && (existing["chat_mode"] || (existing["worktree"] && File.directory?(existing["worktree"])))
|
|
616
|
-
# Reuse existing thread worktree or chat dir
|
|
617
|
-
thread_worktree_path = existing["worktree"]
|
|
618
|
-
thread_cli_provider = existing["cli_provider"]
|
|
619
|
-
thread_model = existing["model"]
|
|
620
|
-
thread_effort = existing["effort"]
|
|
621
|
-
chat_mode = true if existing["chat_mode"]
|
|
622
|
-
should_resume = thread_resume?(project_config, clean_content, thread_cli_provider, agent_name)
|
|
623
|
-
label = existing["chat_mode"] ? "chat mode tmp dir" : "thread worktree"
|
|
624
|
-
LOG.info "[Discord:#{agent_name}] Reusing #{label} at #{thread_worktree_path} (resume: #{should_resume})"
|
|
625
|
-
elsif chat_mode
|
|
626
|
-
LOG.info "[Discord:#{agent_name}] Chat mode — skipping worktree creation"
|
|
627
|
-
else
|
|
628
|
-
# First worktree creation for this thread
|
|
629
|
-
thread_worktree_path, thread_cli_provider, thread_model, thread_effort =
|
|
630
|
-
create_thread_worktree(agent_key, agent_name, effective_thread_id, project_config, clean_content, existing)
|
|
631
|
-
save_thread_map_entry(thread_map_key, thread_worktree_path,
|
|
632
|
-
branch: "discord-#{agent_key}-#{effective_thread_id[-8..]}",
|
|
633
|
-
project_config: project_config, effective_thread_id: effective_thread_id,
|
|
634
|
-
cli_provider: thread_cli_provider, model: thread_model, effort: thread_effort)
|
|
635
|
-
end
|
|
636
|
-
end
|
|
637
|
-
|
|
638
|
-
# Chat mode: create tmp dir if no worktree yet
|
|
639
|
-
if chat_mode && !thread_worktree_path && thread_map_key
|
|
640
|
-
thread_worktree_path, thread_cli_provider, thread_model, thread_effort =
|
|
641
|
-
create_chat_mode_dir(agent_key, agent_name, effective_thread_id, project_config, clean_content, thread_map_key)
|
|
642
|
-
end
|
|
643
|
-
|
|
644
|
-
[should_resume, thread_worktree_path, thread_cli_provider, thread_model, thread_effort]
|
|
645
|
-
end
|
|
646
|
-
|
|
647
|
-
def pre_create_discord_thread(agent_key, agent_name, channel_id, message_id, clean_content, bot_token)
|
|
648
|
-
display_name = agent_display_name(agent_key)
|
|
649
|
-
thread = create_discord_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
|
|
650
|
-
|
|
651
|
-
unless thread && thread["id"]
|
|
652
|
-
LOG.warn "[Discord:#{agent_name}] Failed to pre-create thread — will run without worktree isolation"
|
|
653
|
-
return nil
|
|
654
|
-
end
|
|
655
|
-
|
|
656
|
-
thread_id = thread["id"]
|
|
657
|
-
DISCORD_SHARED_THREADS_MUTEX.synchronize { DISCORD_SHARED_THREADS[message_id] = thread_id }
|
|
658
|
-
|
|
659
|
-
parent_depth_key = "discord-#{channel_id}"
|
|
660
|
-
thread_depth_key = "discord-#{thread_id}"
|
|
661
|
-
parent_info = AGENT_DISPATCH_DEPTH[parent_depth_key]
|
|
662
|
-
if parent_info
|
|
663
|
-
AGENT_DISPATCH_DEPTH[thread_depth_key] = { count: 0, last_human_at: parent_info[:last_human_at] }
|
|
664
|
-
else
|
|
665
|
-
record_human_comment(thread_depth_key)
|
|
666
|
-
end
|
|
667
|
-
|
|
668
|
-
LOG.info "[Discord:#{agent_name}] Pre-created thread #{thread_id} for worktree isolation"
|
|
669
|
-
thread_id
|
|
670
|
-
end
|
|
671
|
-
|
|
672
|
-
def thread_resume?(project_config, clean_content, thread_cli_provider, agent_name)
|
|
673
|
-
effective_provider = detect_cli_provider(text: clean_content) || thread_cli_provider
|
|
674
|
-
resolved = resolve_project_cli_config(project_config, cli_provider_override: effective_provider, agent_name: agent_name)
|
|
675
|
-
resolved["resume_flag"] ? true : false
|
|
676
|
-
end
|
|
677
|
-
|
|
678
|
-
def detect_thread_overrides(project_config, clean_content, fallback_cli: nil, fallback_model: nil, fallback_effort: nil)
|
|
679
|
-
cli = detect_cli_provider(text: clean_content) || fallback_cli
|
|
680
|
-
model = (project_config ? detect_model(project_config, text: clean_content) : nil) || fallback_model
|
|
681
|
-
effort = (project_config ? detect_effort(project_config, text: clean_content) : nil) || fallback_effort
|
|
682
|
-
[cli, model, effort]
|
|
683
|
-
end
|
|
684
|
-
|
|
685
|
-
def create_thread_worktree(agent_key, agent_name, effective_thread_id, project_config, clean_content, existing)
|
|
686
|
-
repo_path = project_config["repo_path"]
|
|
687
|
-
thread_slug = effective_thread_id[-8..]
|
|
688
|
-
branch = "discord-#{agent_key}-#{thread_slug}"
|
|
689
|
-
|
|
690
|
-
debounced_repo_fetch(repo_path)
|
|
691
|
-
worktree_path = create_or_reuse_worktree(repo_path: repo_path, branch: branch)
|
|
692
|
-
|
|
693
|
-
cli, model, effort = detect_thread_overrides(
|
|
694
|
-
project_config, clean_content,
|
|
695
|
-
fallback_cli: existing&.dig("cli_provider"),
|
|
696
|
-
fallback_model: existing&.dig("model"),
|
|
697
|
-
fallback_effort: existing&.dig("effort")
|
|
698
|
-
)
|
|
699
|
-
|
|
700
|
-
LOG.info "[Discord:#{agent_name}] Created thread worktree at #{worktree_path}"
|
|
701
|
-
[worktree_path, cli, model, effort]
|
|
702
|
-
end
|
|
703
|
-
|
|
704
|
-
def create_chat_mode_dir(agent_key, agent_name, effective_thread_id, project_config, clean_content, thread_map_key)
|
|
705
|
-
chat_tmp_dir = File.join(BRAINIAC_DIR, "tmp", "chat", "#{agent_key}-#{effective_thread_id}")
|
|
706
|
-
FileUtils.mkdir_p(chat_tmp_dir)
|
|
707
|
-
|
|
708
|
-
cli, model, effort = detect_thread_overrides(project_config, clean_content)
|
|
709
|
-
|
|
710
|
-
save_thread_map_entry(thread_map_key, chat_tmp_dir,
|
|
711
|
-
chat_mode: true, project_config: project_config,
|
|
712
|
-
effective_thread_id: effective_thread_id,
|
|
713
|
-
cli_provider: cli, model: model, effort: effort)
|
|
714
|
-
|
|
715
|
-
LOG.info "[Discord:#{agent_name}] Created chat mode tmp dir at #{chat_tmp_dir}"
|
|
716
|
-
[chat_tmp_dir, cli, model, effort]
|
|
717
|
-
end
|
|
718
|
-
|
|
719
|
-
def save_thread_map_entry(thread_map_key, worktree_path, project_config:, effective_thread_id:,
|
|
720
|
-
cli_provider: nil, model: nil, effort: nil, branch: nil, chat_mode: false)
|
|
721
|
-
DISCORD_THREAD_MAP_MUTEX.synchronize do
|
|
722
|
-
map = load_discord_thread_map
|
|
723
|
-
entry = { "worktree" => worktree_path,
|
|
724
|
-
"project" => PROJECTS.find { |_k, v| v == project_config }&.first,
|
|
725
|
-
"channel_id" => effective_thread_id, "cli_provider" => cli_provider,
|
|
726
|
-
"model" => model, "effort" => effort, "created_at" => Time.now.iso8601 }
|
|
727
|
-
entry["branch"] = branch if branch
|
|
728
|
-
entry["chat_mode"] = true if chat_mode
|
|
729
|
-
map[thread_map_key] = entry
|
|
730
|
-
save_discord_thread_map(map)
|
|
731
|
-
end
|
|
732
|
-
end
|
|
733
|
-
|
|
734
|
-
def build_discord_prompt(should_resume:, thread_worktree_path:, planning_info:, clean_content_for_prompt:,
|
|
735
|
-
discord_user:, channel_name:, reply_context:, channel_history:, thread_root_context:,
|
|
736
|
-
project_context:, response_file:, card_id:, brain_context:, agent_name:)
|
|
737
|
-
if should_resume && thread_worktree_path
|
|
738
|
-
return render_discord_resume_prompt(
|
|
739
|
-
message_body: clean_content_for_prompt, discord_user: discord_user,
|
|
740
|
-
response_file: response_file, agent_name: agent_name, card_id: card_id
|
|
741
|
-
)
|
|
742
|
-
end
|
|
743
|
-
|
|
744
|
-
template_vars = {
|
|
745
|
-
"DISCORD_USER" => discord_user, "CHANNEL_NAME" => channel_name,
|
|
746
|
-
"MESSAGE_BODY" => clean_content_for_prompt, "REPLY_CONTEXT" => reply_context,
|
|
747
|
-
"CHANNEL_HISTORY" => channel_history, "THREAD_ROOT_CONTEXT" => thread_root_context,
|
|
748
|
-
"PROJECT_CONTEXT" => project_context, "RESPONSE_FILE" => response_file,
|
|
749
|
-
"COMMENT_CREATOR" => discord_user, "DISCORD_MENTION_ROSTER" => discord_mention_roster
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
if planning_info
|
|
753
|
-
LOG.info "[Discord:#{agent_name}] Planning mode detected for #{discord_user}"
|
|
754
|
-
template_vars["CARD_ID"] = planning_info[:card_id]
|
|
755
|
-
template_vars["MESSAGE_BODY"] = clean_content_for_prompt.sub(/\[plan\]/i, "").strip
|
|
756
|
-
render_planning_prompt(PROMPT_DISCORD, template_vars,
|
|
757
|
-
brain_context: brain_context, agent_name: agent_name, channel: :discord)
|
|
758
|
-
else
|
|
759
|
-
template_vars["CARD_ID"] = card_id
|
|
760
|
-
render_prompt(PROMPT_DISCORD, template_vars,
|
|
761
|
-
brain_context: brain_context, agent_name: agent_name, channel: :discord)
|
|
762
|
-
end
|
|
763
|
-
end
|
|
764
|
-
|
|
765
|
-
def monitor_discord_agent(pid:, session_key:, agent_name:, agent_config_name:, channel_id:, message_id:,
|
|
766
|
-
bot_token:, response_file:, meta_file:, prompt_file:, log_file:,
|
|
767
|
-
attachment_paths:, project_config:, head_before:, status_before:)
|
|
768
|
-
Thread.new do
|
|
769
|
-
Process.wait(pid)
|
|
770
|
-
exit_status = $CHILD_STATUS
|
|
771
|
-
|
|
772
|
-
session_cancelled = ACTIVE_SESSIONS_MUTEX.synchronize { !ACTIVE_SESSIONS.key?(session_key) }
|
|
773
|
-
|
|
774
|
-
if exit_status.signaled? || session_cancelled
|
|
775
|
-
handle_cancelled_session(exit_status, session_cancelled, agent_name, message_id,
|
|
776
|
-
response_file, meta_file, prompt_file, attachment_paths)
|
|
777
|
-
next
|
|
778
|
-
end
|
|
779
|
-
|
|
780
|
-
handle_completed_session(
|
|
781
|
-
exit_status: exit_status, agent_name: agent_name, agent_config_name: agent_config_name,
|
|
782
|
-
channel_id: channel_id, message_id: message_id, bot_token: bot_token,
|
|
783
|
-
response_file: response_file, meta_file: meta_file, log_file: log_file,
|
|
784
|
-
project_config: project_config, head_before: head_before, status_before: status_before
|
|
785
|
-
)
|
|
786
|
-
|
|
787
|
-
schedule_temp_cleanup(prompt_file, attachment_paths)
|
|
788
|
-
rescue StandardError => e
|
|
789
|
-
LOG.error "[Discord:#{agent_name}] Error monitoring agent: #{e.message}"
|
|
790
|
-
add_discord_reaction(channel_id, message_id, "❌", token: bot_token)
|
|
791
|
-
end
|
|
792
|
-
end
|
|
793
|
-
|
|
794
|
-
def handle_cancelled_session(exit_status, session_cancelled, agent_name, message_id,
|
|
795
|
-
response_file, meta_file, prompt_file, attachment_paths)
|
|
796
|
-
reason = session_cancelled ? "cancelled" : "superseded (signal: #{exit_status.termsig})"
|
|
797
|
-
LOG.info "[Discord:#{agent_name}] Agent was #{reason} for message #{message_id}"
|
|
798
|
-
[response_file, meta_file].each { |f| FileUtils.rm_f(f) }
|
|
799
|
-
schedule_temp_cleanup(prompt_file, attachment_paths)
|
|
800
|
-
end
|
|
801
|
-
|
|
802
|
-
def handle_completed_session(exit_status:, agent_name:, agent_config_name:, channel_id:, message_id:,
|
|
803
|
-
bot_token:, response_file:, meta_file:, log_file:,
|
|
804
|
-
project_config:, head_before:, status_before:)
|
|
805
|
-
LOG.info "[Discord:#{agent_name}] Agent finished for message #{message_id} (exit: #{exit_status.exitstatus})"
|
|
806
|
-
|
|
807
|
-
# Only report a crash if the agent didn't produce a response. If the response file exists,
|
|
808
|
-
# the agent completed its primary task — the crash happened during post-task reflection
|
|
809
|
-
# (e.g. kiro-cli hitting "Tool approval required" after an API error in the reflection phase).
|
|
810
|
-
if exit_status.exitstatus && exit_status.exitstatus != 0 && !File.exist?(response_file)
|
|
811
|
-
notify_agent_crash(
|
|
812
|
-
exit_status: exit_status.exitstatus, log_file: log_file,
|
|
813
|
-
agent_name: agent_name, source: :discord,
|
|
814
|
-
source_context: { channel_id: channel_id, message_id: message_id, bot_token: bot_token },
|
|
815
|
-
project_config: project_config
|
|
816
|
-
)
|
|
817
|
-
elsif exit_status.exitstatus && exit_status.exitstatus != 0
|
|
818
|
-
LOG.warn "[Discord:#{agent_name}] Agent exited with code #{exit_status.exitstatus} but response file exists — " \
|
|
819
|
-
"likely crashed during post-task reflection. Suppressing crash notification."
|
|
820
|
-
log_post_task_crash_diagnostics(log_file, agent_name)
|
|
821
|
-
end
|
|
822
|
-
|
|
823
|
-
extract_response_from_log(response_file, meta_file, log_file, exit_status, agent_name, agent_config_name, message_id)
|
|
824
|
-
deliver_discord_response(agent_name, channel_id, message_id, bot_token, response_file, meta_file)
|
|
825
|
-
update_brain_after_session(agent_name, message_id)
|
|
826
|
-
|
|
827
|
-
project_key = PROJECTS.find { |_k, v| v == project_config }&.first
|
|
828
|
-
check_brainiac_restart(head_before, status_before, project_config&.dig("repo_path"), project_key, agent_config_name)
|
|
829
|
-
end
|
|
830
|
-
|
|
831
|
-
def deliver_discord_response(agent_name, channel_id, message_id, bot_token, response_file, meta_file)
|
|
832
|
-
remove_discord_reaction(channel_id, message_id, "👀", token: bot_token)
|
|
833
|
-
sleep 0.5
|
|
834
|
-
|
|
835
|
-
delivered = deliver_discord_draft(response_file, meta_file)
|
|
836
|
-
return if delivered
|
|
837
|
-
|
|
838
|
-
response_basename_check = File.basename(response_file)
|
|
839
|
-
already_posted = File.exist?(File.join(DISCORD_POSTED_DIR, response_basename_check))
|
|
840
|
-
return if already_posted
|
|
841
|
-
|
|
842
|
-
LOG.warn "[Discord:#{agent_name}] No response produced for message #{message_id}"
|
|
843
|
-
add_discord_reaction(channel_id, message_id, "😶", token: bot_token)
|
|
844
|
-
end
|
|
845
|
-
|
|
846
|
-
def update_brain_after_session(agent_name, message_id)
|
|
847
|
-
qmd_out, qmd_status = Open3.capture2e("qmd", "update")
|
|
848
|
-
if qmd_status.success?
|
|
849
|
-
LOG.info "[Brain] qmd update completed after #{agent_name} Discord session"
|
|
850
|
-
else
|
|
851
|
-
LOG.warn "[Brain] qmd update failed: #{qmd_out.strip}"
|
|
852
|
-
end
|
|
853
|
-
|
|
854
|
-
brain_push(message: "#{agent_name}: discord-#{message_id}")
|
|
855
|
-
end
|
|
856
|
-
|
|
857
|
-
def schedule_temp_cleanup(prompt_file, attachment_paths)
|
|
858
|
-
Thread.new do
|
|
859
|
-
sleep 300
|
|
860
|
-
[prompt_file, *attachment_paths].each { |f| FileUtils.rm_f(f) }
|
|
861
|
-
end
|
|
862
|
-
end
|
|
863
|
-
|
|
864
|
-
def extract_response_from_log(response_file, meta_file, log_file, exit_status, agent_name, agent_config_name, message_id)
|
|
865
|
-
return if File.exist?(response_file) || !File.exist?(log_file)
|
|
866
|
-
|
|
867
|
-
log_content = File.read(log_file)
|
|
868
|
-
|
|
869
|
-
if exit_status.exitstatus != 0 && log_content.match?(/InternalServerError|Encountered an unexpected error|Failed to receive the next message/i)
|
|
870
|
-
LOG.warn "[Discord:#{agent_name}] Agent hit an upstream error for message #{message_id}"
|
|
871
|
-
File.write(response_file, "_Sorry, I hit a temporary error on the backend. Please try again._")
|
|
872
|
-
elsif log_content.match?(/Opening browser\.\.\.|Press \(\^\) \+ C to cancel/)
|
|
873
|
-
LOG.error "[Discord:#{agent_name}] Auth failure detected — re-authenticate with: kiro-cli --agent #{agent_config_name} chat"
|
|
874
|
-
FileUtils.rm_f(meta_file)
|
|
875
|
-
else
|
|
876
|
-
clean_output = log_content
|
|
877
|
-
.gsub(/\e\[[0-9;]*[a-zA-Z]|\e\[\?[0-9;]*[a-zA-Z]/, "")
|
|
878
|
-
.gsub(/\e\][^\a]*\a/, "")
|
|
879
|
-
.delete("\r")
|
|
880
|
-
.gsub(/^.*?(using tool:.*?)$/m, "")
|
|
881
|
-
.gsub(/^.*?✓.*?$/m, "")
|
|
882
|
-
.gsub(/^.*?▸.*?$/m, "")
|
|
883
|
-
.gsub(/^.*?Loading\.\.\..*?$/m, "")
|
|
884
|
-
.gsub(/^.*?Completed in.*?$/m, "")
|
|
885
|
-
.strip
|
|
886
|
-
|
|
887
|
-
if !clean_output.empty? && clean_output.length > 20
|
|
888
|
-
File.write(response_file, clean_output)
|
|
889
|
-
LOG.info "[Discord:#{agent_name}] Extracted response from log (#{clean_output.length} chars)"
|
|
890
|
-
end
|
|
891
|
-
end
|
|
892
|
-
end
|
|
893
|
-
|
|
894
|
-
# Diagnostic logging for when an agent exits non-zero but already wrote its response.
|
|
895
|
-
# Extracts the last tool call, the error messages, and context from the log tail.
|
|
896
|
-
def log_post_task_crash_diagnostics(log_file, agent_name)
|
|
897
|
-
return unless log_file && File.exist?(log_file)
|
|
898
|
-
|
|
899
|
-
content = File.read(log_file)
|
|
900
|
-
lines = content.lines.map { |l| l.gsub(/\e\[[0-9;]*[a-zA-Z]/, "").rstrip }
|
|
901
|
-
|
|
902
|
-
# Find the last tool call that succeeded
|
|
903
|
-
last_tool_idx = lines.rindex { |l| l.include?("using tool:") }
|
|
904
|
-
last_tool = last_tool_idx ? lines[last_tool_idx].strip : "unknown"
|
|
905
|
-
|
|
906
|
-
# Find error lines
|
|
907
|
-
error_lines = lines.grep(/^error:|Tool approval required|Failed to receive/i)
|
|
908
|
-
|
|
909
|
-
# Count total tool calls
|
|
910
|
-
tool_count = lines.count { |l| l.include?("using tool:") }
|
|
911
|
-
|
|
912
|
-
# Log size as a proxy for conversation length
|
|
913
|
-
log_size_kb = (File.size(log_file) / 1024.0).round(1)
|
|
914
|
-
|
|
915
|
-
LOG.warn "[Discord:#{agent_name}] Post-task crash diagnostics:"
|
|
916
|
-
LOG.warn "[Discord:#{agent_name}] Log size: #{log_size_kb}KB, tool calls: #{tool_count}"
|
|
917
|
-
LOG.warn "[Discord:#{agent_name}] Last successful tool: #{last_tool[0..120]}"
|
|
918
|
-
error_lines.each { |l| LOG.warn "[Discord:#{agent_name}] Error: #{l.strip[0..200]}" }
|
|
919
|
-
rescue StandardError => e
|
|
920
|
-
LOG.warn "[Discord:#{agent_name}] Could not read crash diagnostics: #{e.message}"
|
|
921
|
-
end
|