brainiac 0.0.11 → 0.0.12

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.
@@ -125,15 +125,6 @@ def slugify(title, max_length: 40)
125
125
  title.downcase.gsub(/[^a-z0-9\s-]/, "").strip.gsub(/\s+/, "-").slice(0, max_length).chomp("-")
126
126
  end
127
127
 
128
- def verify_github_signature!(request, payload_body)
129
- signature = request.env["HTTP_X_HUB_SIGNATURE_256"]
130
- halt 403, { error: "Missing GitHub signature" }.to_json unless signature
131
- secret = github_webhook_secret
132
- halt 500, { error: "GitHub webhook secret not configured" }.to_json unless secret
133
- computed = "sha256=#{OpenSSL::HMAC.hexdigest("sha256", secret, payload_body)}"
134
- halt 403, { error: "Invalid GitHub signature" }.to_json unless Rack::Utils.secure_compare(signature, computed)
135
- end
136
-
137
128
  def run_cmd(*cmd, chdir:, env: {})
138
129
  LOG.info "Running: #{cmd.join(" ")} (in #{chdir})"
139
130
  stdout, stderr, status = Open3.capture3(env, *cmd, chdir: chdir)
@@ -184,7 +175,6 @@ end
184
175
  def notify_agent_crash(exit_status:, log_file:, agent_name:, source:, source_context:, project_config:)
185
176
  agent_display = agent_name || "Agent"
186
177
  snippet = extract_crash_snippet(log_file)
187
- snippet_block = snippet ? "\n```\n#{snippet[-1500..]}\n```" : ""
188
178
 
189
179
  # Emit to plugins — they handle their own channel-specific delivery
190
180
  handled = Brainiac.emit(:agent_crashed,
@@ -192,28 +182,66 @@ def notify_agent_crash(exit_status:, log_file:, agent_name:, source:, source_con
192
182
  source: source, source_context: source_context, project_config: project_config,
193
183
  snippet: snippet)
194
184
 
195
- # If a plugin handled it, we're done
196
- return if handled.any?
197
-
198
- # Built-in: GitHub crash comment (doesn't need a plugin)
199
- if source == :github
200
- pr_number = source_context[:pr_number]
201
- repo_name = source_context[:repo_name]
202
- return unless pr_number && repo_name
203
-
204
- work_dir = source_context[:work_dir] || Dir.pwd
205
- comment_body = "💥 **#{agent_display} crashed** (exit code #{exit_status})\n\nLog: `#{log_file}`#{snippet_block}"
206
- begin
207
- run_cmd("gh", "pr", "comment", pr_number.to_s, "--repo", repo_name, "--body", comment_body, chdir: work_dir)
208
- LOG.info "[CrashNotify] Posted crash comment on GitHub PR ##{pr_number}"
209
- rescue StandardError => e
210
- LOG.error "[CrashNotify] Failed to post GitHub crash comment: #{e.message}"
211
- end
212
- end
185
+ # If no plugin handled it, log a warning
186
+ LOG.warn "[CrashNotify] Agent crashed but no plugin handled notification (source: #{source})" unless handled.any?
213
187
  rescue StandardError => e
214
188
  LOG.error "[CrashNotify] Unexpected error: #{e.message}"
215
189
  end
216
190
 
191
+ # Check if a prior CLI session exists in the given directory for the specified CLI binary.
192
+ # This prevents resume attempts when the CLI provider changed (e.g., [cli:grok] in a thread
193
+ # started by kiro-cli) or when the session was started on a different machine.
194
+ def prior_session_exists?(chdir, agent_cli)
195
+ return false unless chdir && agent_cli
196
+
197
+ cli_name = File.basename(agent_cli)
198
+
199
+ # Check for CLI-specific session markers:
200
+ # - grok uses .grok/ directory for session state
201
+ # - kiro-cli uses .kiro-cli/ or similar
202
+ # - Generic fallback: check tmp/ for agent logs from this CLI
203
+ session_dir = File.join(chdir, ".#{cli_name}")
204
+ return true if File.directory?(session_dir)
205
+
206
+ # Fallback: look for recent session logs in tmp/ that suggest this CLI ran here before.
207
+ # This covers CLIs that don't leave a dotdir but do leave logs via brainiac.
208
+ tmp_dir = File.join(chdir, "tmp")
209
+ return false unless File.directory?(tmp_dir)
210
+
211
+ Dir.glob(File.join(tmp_dir, "agent-*.log")).any? do |log|
212
+ # Only count logs from the last 24 hours as valid "resumable" sessions
213
+ File.mtime(log) > Time.now - 86_400
214
+ end
215
+ rescue StandardError
216
+ false
217
+ end
218
+
219
+ # Public helper: check if resume is viable for a given project + CLI provider combo.
220
+ # Plugins should call this BEFORE building the prompt to decide between
221
+ # render_resume_prompt (lean) and render_prompt (full context).
222
+ #
223
+ # Returns true if the CLI supports resume AND a prior session exists in the working directory.
224
+ # When this returns false, plugins should use render_prompt with thread history as card_context
225
+ # so the agent gets full context even though this is a follow-up message.
226
+ def resume_viable?(project_config:, cli_provider: nil, agent_name: nil, chdir: nil)
227
+ resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
228
+ chdir ||= resolved["repo_path"]
229
+ return false unless resolved["resume_flag"]
230
+
231
+ prior_session_exists?(chdir, resolved["agent_cli"])
232
+ end
233
+
234
+ # Determine whether a session resume should actually happen.
235
+ # Returns truthy (the resume flag string) if viable, false otherwise.
236
+ # Logs a message when resume was requested but isn't possible.
237
+ def resolve_resume(resume, resolved, chdir)
238
+ return false unless resume && resolved["resume_flag"]
239
+ return resolved["resume_flag"] if prior_session_exists?(chdir, resolved["agent_cli"])
240
+
241
+ LOG.info "[Dispatch] Resume requested but not viable for #{resolved["agent_cli"]} in #{chdir} — starting fresh session"
242
+ false
243
+ end
244
+
217
245
  def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil,
218
246
  source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false)
219
247
  resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
@@ -222,9 +250,8 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
222
250
  effort ||= resolved["agent_effort"]
223
251
  agent_config_name = agent_name&.downcase&.gsub(/[^a-z0-9-]/, "-")
224
252
 
225
- # Auto-resume: if the provider supports session resume and we're in a worktree
226
- # that has had a previous session, resume it. Only applies to follow-ups (not first dispatch).
227
- should_resume = resume && resolved["resume_flag"]
253
+ # Auto-resume: only if the provider supports it AND a prior session exists for this CLI here.
254
+ should_resume = resolve_resume(resume, resolved, chdir)
228
255
 
229
256
  # Pre-dispatch hook — plugins can prep the working directory (e.g., copy config files, clean up)
230
257
  Brainiac.emit(:pre_dispatch, chdir: chdir, project_config: project_config, agent_name: agent_name)
@@ -22,7 +22,7 @@
22
22
  # end
23
23
  #
24
24
  # Usage (in core):
25
- # Brainiac.emit(:agent_completed, card_number: 42, agent_name: "Galen", ...)
25
+ # Brainiac.emit(:agent_completed, card_number: 42, agent_name: "Sherlock", ...)
26
26
 
27
27
  module Brainiac
28
28
  @hooks = Hash.new { |h, k| h[k] = [] }
@@ -77,8 +77,8 @@ end
77
77
  # Convenience: send a notification for cron job output.
78
78
  def notify_cron_output(job, message, agent_name: nil)
79
79
  send_notification(:cron, message,
80
- target: job[:notify_target] || job[:discord_channel_id],
81
- channel: job[:notify_channel] || (job[:discord_channel_id] ? :discord : nil),
80
+ target: job[:notify_target],
81
+ channel: job[:notify_channel],
82
82
  agent: agent_name || job[:agent],
83
83
  job_id: job[:id],
84
84
  forum_title: job[:forum_title],
@@ -4,7 +4,7 @@
4
4
  #
5
5
  # Prompts are layered:
6
6
  # PROMPT_CORE — universal (identity, memory, brain, reflection)
7
- # PROMPT_GITHUB_CHANNEL — GitHub-specific rules (GFM, PR conventions)
7
+ # (GitHub prompts extracted to brainiac-github plugin)
8
8
  #
9
9
  # Each handler composes: PROMPT_CORE + channel rules + situation template.
10
10
  # Channel-specific prompts (Discord, Fizzy, etc.) are registered by plugins.
@@ -112,7 +112,7 @@ PROMPT_CORE = <<~PROMPT
112
112
  - Be specific in your query — tell the subagent exactly what to find and where to look
113
113
  - Include relevant file paths and repo locations in the query
114
114
  - Use `relevant_context` to pass information the subagent needs
115
- - You can specify `agent_name` to use a specialized agent (e.g., "sheogorath" for Android research)
115
+ - You can specify `agent_name` to use a specialized agent (e.g., "robin" for Android research)
116
116
  - Run `ListAgents` first if you want to see available specialized agents
117
117
  - Up to 4 subagents can run in parallel
118
118
  - To discover project locations for cross-repo work, run: `brainiac list`
@@ -122,37 +122,11 @@ PROMPT_CORE = <<~PROMPT
122
122
  They're excellent researchers — use them as such.
123
123
 
124
124
  ## Image Reading Limits
125
- Read at most 45 images per tool call. Summarize what you saw before reading more.
125
+ Read at most 4-5 images per tool call. Summarize what you saw before reading more.
126
126
  Loading too many images at once can exceed the API request size limit and crash your session.
127
127
 
128
128
  PROMPT
129
129
 
130
- # ---------------------------------------------------------------------------
131
- # PROMPT_PRE_POST_CHECK — inserted before PROMPT_REFLECTION so the agent
132
- # re-checks for new comments/messages before posting its response.
133
- # Channel-specific: plugins register pre-post checks, Discord skips.
134
- # ---------------------------------------------------------------------------
135
-
136
- PROMPT_PRE_POST_CHECK_GITHUB = <<~PROMPT
137
- ## Pre-Post Comment Check (MANDATORY — do this BEFORE posting your comment)
138
-
139
- Your session may have been running for a while. Before you post your final comment,
140
- re-check the PR for new comments that arrived while you were working:
141
-
142
- ```bash
143
- gh pr view {{PR_NUMBER}} --comments --json comments
144
- ```
145
-
146
- If there are **new comments** that weren't in your original context:
147
-
148
- 1. **Read them carefully** — a reviewer may have added feedback or changed direction
149
- 2. **Adjust your work or response** to account for the new information
150
- 3. **Do NOT ignore new comments** — avoid posting a response that's already outdated
151
-
152
- If no new comments appeared, proceed normally.
153
-
154
- PROMPT
155
-
156
130
  # ---------------------------------------------------------------------------
157
131
  # PROMPT_REFLECTION — appended AFTER the situation template so the agent
158
132
  # sees its task first and reflects only after completing it.
@@ -186,78 +160,13 @@ PROMPT_REFLECTION = <<~PROMPT
186
160
 
187
161
  PROMPT
188
162
 
189
- # ---------------------------------------------------------------------------
190
- # PROMPT_GITHUB_CHANNEL — GitHub-specific rules, prepended to GitHub templates
191
- # ---------------------------------------------------------------------------
192
- PROMPT_GITHUB_CHANNEL = <<~PROMPT
193
- ## GitHub Channel Rules
194
-
195
- ### Formatting
196
- Use GitHub-Flavored Markdown for all comments:
197
- - `## Heading` for sections
198
- - `**bold**` for emphasis
199
- - ``` ```language ``` for code blocks
200
- - `- item` for lists
201
-
202
- ### Scope
203
- You are responding to activity on a GitHub PR. Focus on the code changes and review feedback.
204
- When posting comments, post on the PR unless specifically asked to update the card.
205
-
206
- PROMPT
207
-
208
- PROMPT_GITHUB_PR_COMMENT = <<~'PROMPT'
209
- There's a new comment from @{{COMMENT_CREATOR}} on your PR #{{PR_NUMBER}} for card #{{CARD_NUMBER}}.
210
-
211
- Comment:
212
- {{COMMENT_BODY}}
213
-
214
- Please:
215
- 1. Read the comment and understand what's being requested
216
- 2. Make any necessary changes
217
- 3. Commit and push your updates
218
- 4. Reply on the PR summarizing what you changed
219
-
220
- You are in the worktree at {{WORKTREE_PATH}}.
221
- PROMPT
222
-
223
- PROMPT_GITHUB_PR_REVIEW = <<~'PROMPT'
224
- A code review has been submitted on your PR #{{PR_NUMBER}} for card #{{CARD_NUMBER}}.
225
-
226
- {{REVIEW_CONTEXT}}
227
-
228
- Please:
229
- 1. Read the review comments carefully
230
- 2. Address each piece of feedback
231
- 3. Make the necessary code changes
232
- 4. Commit and push your updates
233
- 5. Post a comment on the PR summarizing the changes
234
-
235
- You are in the worktree at {{WORKTREE_PATH}}.
236
- PROMPT
237
-
238
163
  # ---------------------------------------------------------------------------
239
164
  # Channel constant mapping for render_prompt
165
+ # NOTE: GitHub prompts have been extracted to brainiac-github plugin.
166
+ # The plugin registers via Brainiac.register_channel_prompt(:github, ...)
240
167
  # ---------------------------------------------------------------------------
241
- PROMPT_GITHUB_UAT = <<~'PROMPT'
242
- PR #{{PR_NUMBER}} has been merged into main for card #{{CARD_NUMBER}}: "{{CARD_TITLE}}"
243
168
 
244
- The card has been moved to the UAT column. The changes are now deployed to the UAT environment.
245
-
246
- Your job: post a comment on card #{{CARD_NUMBER}} with clear, specific steps for how to manually test this feature in UAT. Include:
247
- 1. What URL(s) or screen(s) to visit
248
- 2. Step-by-step actions to verify the feature works
249
- 3. What the expected behavior should be
250
- 4. Any edge cases worth checking
251
- 5. Links to relevant pages if applicable (use the UAT/staging URL, not localhost)
252
-
253
- Base your testing steps on the card title, the PR diff, and any card context provided. Be specific — "verify it works" is not a testing step.
254
-
255
- Do NOT make any code changes. This is a read-only review task.
256
- PROMPT
257
-
258
- CHANNEL_PROMPTS = {
259
- github: PROMPT_GITHUB_CHANNEL
260
- }.freeze
169
+ CHANNEL_PROMPTS = {}.freeze
261
170
 
262
171
  # ---------------------------------------------------------------------------
263
172
  # render_prompt — composes PROMPT_CORE + channel rules + situation template
@@ -266,7 +175,7 @@ CHANNEL_PROMPTS = {
266
175
  # ---------------------------------------------------------------------------
267
176
 
268
177
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
269
- def render_prompt(template, vars = {}, brain_context: "", card_context: "", agent_name: AI_AGENT_NAME, channel: :discord, board_key: nil)
178
+ def render_prompt(template, vars = {}, brain_context: "", card_context: "", agent_name: AI_AGENT_NAME, channel: nil, board_key: nil)
270
179
  result = ""
271
180
  result += "#{brain_context}\n" unless brain_context.empty?
272
181
  result += card_context unless card_context.empty?
@@ -278,16 +187,12 @@ def render_prompt(template, vars = {}, brain_context: "", card_context: "", agen
278
187
 
279
188
  result += template
280
189
 
281
- # Pre-post comment check: plugin-registered or built-in
190
+ # Pre-post comment check: plugin-registered
282
191
  plugin_pre_post = Brainiac.channel_pre_post_checks[channel]
283
- if plugin_pre_post
284
- result += plugin_pre_post
285
- elsif channel == :github
286
- result += PROMPT_PRE_POST_CHECK_GITHUB
287
- end
192
+ result += plugin_pre_post if plugin_pre_post
288
193
 
289
- # Reflection prompt — skip for Discord (causes crashes in post-task phase)
290
- result += PROMPT_REFLECTION unless channel == :discord
194
+ # Reflection prompt — skip for now
195
+ # result += PROMPT_REFLECTION
291
196
 
292
197
  vars["KNOWLEDGE_DIR"] ||= KNOWLEDGE_DIR
293
198
  vars["MEMORY_DIR"] ||= memory_dir_for(agent_name)
@@ -345,30 +250,3 @@ def render_resume_prompt(comment_body:, comment_creator:, comment_id:, card_numb
345
250
 
346
251
  lines.join("\n")
347
252
  end
348
-
349
- # Lean resume prompt for Discord threads. The previous session has full context
350
- # (role, persona, knowledge, instructions). We only send the new message + channel history.
351
- def render_discord_resume_prompt(message_body:, discord_user:, response_file:, agent_name: AI_AGENT_NAME, card_id: nil)
352
- memory_dir = memory_dir_for(agent_name)
353
- if card_id
354
- memory_file = File.join(memory_dir, "card-#{card_id}.md")
355
- FileUtils.mkdir_p(memory_dir)
356
- FileUtils.touch(memory_file)
357
- end
358
-
359
- lines = []
360
- lines << "## Resumed Session — New Discord Message"
361
- lines << ""
362
- lines << "This is a continuation of your previous session in this thread."
363
- lines << "All prior context, instructions, and your previous work are still in this conversation."
364
- lines << ""
365
- lines << "### New Message from #{discord_user}"
366
- lines << ""
367
- lines << message_body
368
- lines << ""
369
- lines << "---"
370
- lines << "**IMPORTANT: Write your response to `#{response_file}`. Do NOT reply via stdout.**"
371
- lines << "All your previous instructions still apply (memory, persona, one message per session, etc.)."
372
-
373
- lines.join("\n")
374
- end
@@ -5,9 +5,6 @@
5
5
  # When an agent works on brainiac itself (modifies code), a restart is queued.
6
6
  # A background thread checks every 30s and only restarts when no other agents
7
7
  # are running, preventing mid-session kills.
8
- #
9
- # This is NOT Discord-specific — it was previously in the Discord handler
10
- # because Discord agents trigger restarts most often, but any source can queue one.
11
8
 
12
9
  BRAINIAC_RESTART_STATE = { queued: false, triggered_by: nil }
13
10
  BRAINIAC_RESTART_MUTEX = Mutex.new
@@ -30,16 +30,10 @@ def format_active_sessions
30
30
  end
31
31
  end
32
32
 
33
- def resolve_session_agent_name(card_key, info)
33
+ def resolve_session_agent_name(_card_key, info)
34
34
  return info[:agent_name] if info[:agent_name]
35
35
 
36
- parts = card_key.split("-")
37
- agent_key = if parts[0] == "discord" && parts.size >= 4
38
- parts[1]
39
- else
40
- "Unknown"
41
- end
42
- agent_display_name(agent_key)
36
+ agent_display_name("Unknown")
43
37
  end
44
38
 
45
39
  def format_recent_sessions
@@ -70,20 +64,6 @@ rescue Errno::EPERM
70
64
  halt 403, { error: "permission denied" }.to_json
71
65
  end
72
66
 
73
- def search_giphy(query, api_key)
74
- uri = URI("https://api.giphy.com/v1/gifs/search")
75
- uri.query = URI.encode_www_form(api_key: api_key, q: query, limit: 5, rating: "pg-13")
76
- response = Net::HTTP.get_response(uri)
77
-
78
- if response.code.to_i == 200
79
- results = JSON.parse(response.body)["data"] || []
80
- results.map { |g| { url: g.dig("images", "original", "url") || g["url"], title: g["title"] } }
81
- else
82
- LOG.warn "[GIF] Giphy API returned #{response.code}: #{response.body[0..200]}"
83
- nil
84
- end
85
- end
86
-
87
67
  # --- Projects ---
88
68
 
89
69
  get "/api/projects" do
@@ -108,7 +88,6 @@ post "/api/reload" do
108
88
  reload_projects!(force: true)
109
89
  reload_agent_registry!(force: true)
110
90
  reload_user_registry!(force: true)
111
- reload_github_config!(force: true)
112
91
  ReloadHooks.run_all!
113
92
  { status: "reloaded", projects: PROJECTS.keys, agents: all_agent_names.to_a, registry: AGENT_REGISTRY.keys,
114
93
  users: USER_REGISTRY["users"].size }.to_json
@@ -283,13 +262,6 @@ get "/api/logs" do
283
262
  all_lines.join.gsub(/\e\[[\d;]*[a-zA-Z]/, "").gsub(/\e\[\?[\d;]*[a-zA-Z]/, "")
284
263
  end
285
264
 
286
- # --- GIF (handled by discord plugin when installed) ---
287
-
288
- get "/api/gif" do
289
- content_type :json
290
- halt 503, { error: "brainiac-discord plugin not installed (provides GIF API)" }.to_json
291
- end
292
-
293
265
  # --- Cron ---
294
266
 
295
267
  get "/api/cron/script" do
@@ -332,7 +304,8 @@ post "/api/cron/add" do
332
304
  script: payload["script"],
333
305
  model: payload["model"],
334
306
  effort: payload["effort"],
335
- discord_channel_id: payload["discord_channel_id"],
307
+ notify_channel: payload["notify_channel"],
308
+ notify_target: payload["notify_target"],
336
309
  forum_title: payload["forum_title"],
337
310
  forum_reply_to_latest: payload["forum_reply_to_latest"] || false,
338
311
  repeat_count: payload["repeat_count"]
@@ -367,7 +340,8 @@ post "/api/cron/update" do
367
340
  result = update_cron_job(
368
341
  payload["id"],
369
342
  schedule: payload["schedule"],
370
- discord_channel_id: payload["discord_channel_id"],
343
+ notify_target: payload["notify_target"],
344
+ notify_channel: payload["notify_channel"],
371
345
  forum_title: payload["forum_title"],
372
346
  forum_reply_to_latest: payload["forum_reply_to_latest"]
373
347
  )
@@ -179,8 +179,7 @@ def register_session(card_key, pid, log_file: nil, message_id: nil, channel_id:
179
179
  end
180
180
  end
181
181
 
182
- # --- Session supersede (Discord follow-up within window kills previous run) ---
183
-
182
+ # --- Session supersede (Channel follow-up within window kills previous run) ---
184
183
  SUPERSEDE_WINDOW = 60 # seconds
185
184
 
186
185
  # Find an active session for the same supersede key (agent+channel) started within the window.
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.0.11"
5
+ VERSION = "0.0.12"
6
6
  end
data/monitor/shared.rb CHANGED
@@ -21,7 +21,7 @@ BRAINIAC_DIR = File.expand_path("~/.brainiac")
21
21
  # --- Agent Config ---
22
22
 
23
23
  # Load agent configuration from ~/.brainiac/waybar.json.
24
- # Returns { "galen" => { emoji: "🤖", color: "blue" }, ... }
24
+ # Returns { "sherlock" => { emoji: "🤖", color: "blue" }, ... }
25
25
  def load_agent_config
26
26
  config = JSON.parse(File.read(CONFIG_PATH, encoding: "utf-8"))
27
27
  agents = {}