brainiac 0.2.1 → 0.4.0
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/CHANGELOG.md +4 -0
- data/README.md +9 -0
- data/bin/brainiac +344 -28
- data/bin/brainiac-completion.bash +3 -3
- data/lib/brainiac/helpers.rb +37 -3
- data/lib/brainiac/prompts.rb +12 -0
- data/lib/brainiac/stream_formatter.rb +132 -0
- data/lib/brainiac/version.rb +1 -1
- data/skills/brainiac-plugins/SKILL.md +62 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1e2fc609dfdad411c1262f53783c3d6d88de4dbb75c83ccb9cedf6b935555a42
|
|
4
|
+
data.tar.gz: 6c7f6aed1727d45ba3bd953554598a20222c3ed47a81879b66108fbe2e7abb98
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 68db7910a0cca76489408685a40f1fe549eaa6462ddefad4b532160b2ef8bd80c798a79b84c4daaeb538e52cc4df3412fca8f61862c39d94a49e9c080cb83e95
|
|
7
|
+
data.tar.gz: ab36e6f636e4ac76b28bfa55a418d1c00f2251adc36663e9fe21786150d3a27aa7d870bf4c225ffc47303873af38c76ed7601166be9d9ce88a2f30e372770e3b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.2 — 2026-11-02
|
|
4
|
+
|
|
5
|
+
- Add filesystem-search guardrail to PROMPT_CORE — forbids `find /`, `find ~`, `grep -r /`, and `locate`, which hang indefinitely on macOS (autofs, iCloud, sealed sim volumes). Teaches agents to use `gem which` / `Gem.loaded_specs` and scope `find` to known gem/repo roots with a depth limit.
|
|
6
|
+
|
|
3
7
|
## 0.9.1 — 2026-04-21
|
|
4
8
|
|
|
5
9
|
- Add subagent delegation section to PROMPT_CORE — teaches agents when/how to use `use_subagent` tool, with `brainiac list` for on-demand project discovery
|
data/README.md
CHANGED
|
@@ -271,6 +271,15 @@ Set `agent_flag` to `null` to suppress passing the agent name entirely (for CLIs
|
|
|
271
271
|
|
|
272
272
|
A provider without `readonly_args` can't be used read-only; the call fails instead of falling back to `default_args`.
|
|
273
273
|
|
|
274
|
+
`stream_args` and `stream_format` are optional and give you a live agent log for CLIs that otherwise print nothing until they exit. `claude -p` is one: by default it prints only the final answer. `stream_args` is added only to background dispatches that write to a log (never `ask_agent`, which needs plain text). `stream_format` sends the output through a formatter that turns the JSON events into readable lines: assistant text, `▸ Bash: git status` for each tool call, and a closing `--- done · 14 turns · 6m12s · $0.82` line.
|
|
275
|
+
|
|
276
|
+
```json
|
|
277
|
+
"stream_args": "--output-format stream-json --verbose",
|
|
278
|
+
"stream_format": "claude"
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`claude` is the only `stream_format` so far. Lines that aren't JSON pass through unchanged.
|
|
282
|
+
|
|
274
283
|
Which provider runs is decided in this order: a `[cli:X]` tag, the agent's `cli_provider` in `agents.json`, the project's `cli_provider`, then `default_cli_provider` in `brainiac.json`. With none of those set, brainiac uses the `AGENT_CLI` env var, or kiro-cli.
|
|
275
284
|
|
|
276
285
|
#### Asking an agent from a plugin
|
data/bin/brainiac
CHANGED
|
@@ -15,7 +15,7 @@ BRAINIAC_ROOT = File.expand_path("..", __dir__)
|
|
|
15
15
|
require_relative "../lib/brainiac/version"
|
|
16
16
|
BRAINIAC_VERSION = Brainiac::VERSION
|
|
17
17
|
|
|
18
|
-
BRAINIAC_DIR = File.join(Dir.home, ".brainiac")
|
|
18
|
+
BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
|
|
19
19
|
PROJECTS_FILE = File.join(BRAINIAC_DIR, "projects.json")
|
|
20
20
|
CONFIG_FILE = File.join(BRAINIAC_DIR, "config.json")
|
|
21
21
|
PID_FILE = File.join(BRAINIAC_DIR, "server.pid")
|
|
@@ -193,27 +193,135 @@ rescue StandardError
|
|
|
193
193
|
nil
|
|
194
194
|
end
|
|
195
195
|
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
|
|
199
|
-
def notify_plugins_agent_created(agent_key, entry)
|
|
196
|
+
# Installed plugins whose CLI module implements on_agent_created, as
|
|
197
|
+
# [name, module] pairs in plugins.json order.
|
|
198
|
+
def agent_setup_plugins
|
|
200
199
|
plugins_file = File.join(BRAINIAC_DIR, "plugins.json")
|
|
201
|
-
return unless File.exist?(plugins_file)
|
|
200
|
+
return [] unless File.exist?(plugins_file)
|
|
202
201
|
|
|
203
|
-
|
|
204
|
-
(plugins_config["plugins"] || []).each do |pentry|
|
|
202
|
+
(JSON.parse(File.read(plugins_file))["plugins"] || []).filter_map do |pentry|
|
|
205
203
|
pname = pentry.is_a?(Hash) ? pentry["name"] : pentry.to_s
|
|
206
204
|
try_require_metadata(pname, pentry)
|
|
207
205
|
try_require_plugin_cli(pname, pentry)
|
|
208
206
|
mod = resolve_plugin_module_for(pname)
|
|
209
|
-
|
|
207
|
+
[pname, mod] if mod.respond_to?(:on_agent_created)
|
|
208
|
+
rescue StandardError, LoadError => e
|
|
209
|
+
puts " ⚠ Plugin '#{pname}' couldn't load: #{e.message}"
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
rescue JSON::ParserError
|
|
213
|
+
[]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Run each plugin's on_agent_created hook (Discord bot, Fizzy user, ...).
|
|
217
|
+
# Runs synchronously and can prompt for interactive input. Plugins in `skip`
|
|
218
|
+
# are left alone; when `only` is given, just those plugins run. With
|
|
219
|
+
# `confirm`, each plugin is asked about first and a "no" is added to `skip`
|
|
220
|
+
# (the caller persists it so `agent setup` won't ask again).
|
|
221
|
+
def notify_plugins_agent_created(agent_key, entry, skip: [], only: nil, confirm: false)
|
|
222
|
+
agent_setup_plugins.each do |pname, mod|
|
|
223
|
+
if only ? !only.include?(pname) : skip.include?(pname)
|
|
224
|
+
puts " [#{pname}] skipped"
|
|
225
|
+
next
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
if confirm
|
|
229
|
+
print "\nSet up #{pname} for #{entry["display_name"] || agent_key}? [Y/n]: "
|
|
230
|
+
if $stdin.gets.to_s.strip.downcase.start_with?("n")
|
|
231
|
+
skip << pname
|
|
232
|
+
next
|
|
233
|
+
end
|
|
234
|
+
end
|
|
210
235
|
|
|
211
236
|
mod.on_agent_created(agent_key, entry)
|
|
212
237
|
rescue StandardError => e
|
|
213
238
|
puts " ⚠ Plugin '#{pname}' hook failed: #{e.message}"
|
|
214
239
|
end
|
|
215
|
-
|
|
216
|
-
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# "--skip basecamp,fizzy" style values → ["basecamp", "fizzy"]
|
|
243
|
+
def plugin_list_arg(value)
|
|
244
|
+
value.to_s.split(",").map { |p| p.strip.downcase.delete_prefix("brainiac-") }.reject(&:empty?)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
AVATAR_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp].freeze
|
|
248
|
+
|
|
249
|
+
# Resolve a description flag value: "-" reads stdin, "@path" reads a file,
|
|
250
|
+
# anything else is the literal text.
|
|
251
|
+
def read_description_arg(value)
|
|
252
|
+
return $stdin.read.strip if value == "-"
|
|
253
|
+
return File.read(File.expand_path(value[1..])).strip if value.start_with?("@")
|
|
254
|
+
|
|
255
|
+
value
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Open $VISUAL/$EDITOR on a scratch file so a multi-paragraph persona can be
|
|
259
|
+
# written comfortably. HTML comment lines are instructions and get stripped.
|
|
260
|
+
# Returns nil when the result is blank.
|
|
261
|
+
def edit_description_in_editor(display_name, initial = nil)
|
|
262
|
+
require "tempfile"
|
|
263
|
+
require "shellwords"
|
|
264
|
+
editor = ENV["VISUAL"] || ENV["EDITOR"] || "vi"
|
|
265
|
+
Tempfile.create(["#{display_name.downcase}-persona", ".md"]) do |f|
|
|
266
|
+
f.write(initial || <<~MD)
|
|
267
|
+
<!-- Describe #{display_name}: personality, tone, voice, what they focus on. -->
|
|
268
|
+
<!-- Paragraphs and markdown are fine. Lines like this one are removed. Save and quit when done. -->
|
|
269
|
+
|
|
270
|
+
MD
|
|
271
|
+
f.flush
|
|
272
|
+
system(*Shellwords.split(editor), f.path)
|
|
273
|
+
text = File.read(f.path).gsub(/^\s*<!--.*?-->\s*\n?/, "").strip
|
|
274
|
+
text.empty? ? nil : text
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Default agent email from brainiac.json "agent_email_template",
|
|
279
|
+
# e.g. "agent+{key}@example.com". Returns nil when no template is configured.
|
|
280
|
+
def default_agent_email(agent_key, display_name)
|
|
281
|
+
config_path = File.join(BRAINIAC_DIR, "brainiac.json")
|
|
282
|
+
template = File.exist?(config_path) ? JSON.parse(File.read(config_path))["agent_email_template"] : nil
|
|
283
|
+
return nil if template.nil? || template.strip.empty?
|
|
284
|
+
|
|
285
|
+
template.gsub("{key}", agent_key).gsub("{name}", display_name)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Copy an avatar image into the agent's persona dir (synced with the brain)
|
|
289
|
+
# and return the stored path.
|
|
290
|
+
def store_agent_avatar(agent_key, source)
|
|
291
|
+
path = File.expand_path(source)
|
|
292
|
+
ext = File.extname(path).downcase
|
|
293
|
+
raise ArgumentError, "Avatar not found: #{source}" unless File.file?(path)
|
|
294
|
+
raise ArgumentError, "Avatar must be one of #{AVATAR_EXTENSIONS.join(", ")}" unless AVATAR_EXTENSIONS.include?(ext)
|
|
295
|
+
|
|
296
|
+
persona_dir = File.join(BRAINIAC_DIR, "brain", "persona", agent_key)
|
|
297
|
+
FileUtils.mkdir_p(persona_dir)
|
|
298
|
+
dest = File.join(persona_dir, "avatar#{ext}")
|
|
299
|
+
FileUtils.cp(path, dest) unless File.identical?(path, dest)
|
|
300
|
+
dest
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# The persona text for an agent, read back from its style.md (frontmatter and
|
|
304
|
+
# the generated "# Name — Persona" heading removed).
|
|
305
|
+
def agent_persona_description(agent_key)
|
|
306
|
+
file = File.join(BRAINIAC_DIR, "brain", "persona", agent_key, "style.md")
|
|
307
|
+
return nil unless File.exist?(file)
|
|
308
|
+
|
|
309
|
+
body = File.read(file).sub(/\A---\n.*?\n---\n/m, "").sub(/\A\s*# .*— Persona\n/, "").strip
|
|
310
|
+
body.empty? ? nil : body
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Payload handed to plugins' on_agent_created: the registry entry plus the
|
|
314
|
+
# setup inputs plugins need to provision accounts (avatar, description, email).
|
|
315
|
+
# "open_browser" tells plugins whether they may launch a browser for their
|
|
316
|
+
# manual steps (false with --no-browser, BRAINIAC_NO_BROWSER, or no terminal).
|
|
317
|
+
def agent_hook_payload(agent_key, entry, open_browser: true)
|
|
318
|
+
entry.merge(
|
|
319
|
+
"agent_key" => agent_key,
|
|
320
|
+
"avatar_path" => entry["avatar"],
|
|
321
|
+
"description" => agent_persona_description(agent_key),
|
|
322
|
+
"email" => entry["email"],
|
|
323
|
+
"open_browser" => open_browser && $stdin.tty? && !ENV["BRAINIAC_NO_BROWSER"]
|
|
324
|
+
).compact
|
|
217
325
|
end
|
|
218
326
|
|
|
219
327
|
# Run CLI-time plugin hooks for agent removal.
|
|
@@ -561,6 +669,68 @@ def generate_plugin_skill_md(plugin_name, pascal_name)
|
|
|
561
669
|
| `:detect_cli_provider` | Detect CLI provider |
|
|
562
670
|
| `:detect_effort` | Detect effort level |
|
|
563
671
|
|
|
672
|
+
## Agent Onboarding (`on_agent_created`)
|
|
673
|
+
|
|
674
|
+
`brainiac agent create` writes the brainiac files (agents.json, persona, brain),
|
|
675
|
+
then asks each plugin to provision the new agent on its service: a Discord bot,
|
|
676
|
+
a Fizzy user, a Basecamp person. `brainiac agent setup <name>` runs the same hook
|
|
677
|
+
again. If your plugin gives agents an identity on some service, implement it.
|
|
678
|
+
|
|
679
|
+
Define it in `cli.rb` (or a file `cli.rb` requires), since it runs in the CLI context:
|
|
680
|
+
|
|
681
|
+
```ruby
|
|
682
|
+
# cli.rb
|
|
683
|
+
require_relative "agent_setup"
|
|
684
|
+
|
|
685
|
+
module Brainiac
|
|
686
|
+
module Plugins
|
|
687
|
+
module MyService
|
|
688
|
+
# Called by `brainiac agent create` / `brainiac agent setup`.
|
|
689
|
+
def self.on_agent_created(agent_key, entry)
|
|
690
|
+
AgentSetup.run(agent_key, entry)
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def self.on_agent_removed(agent_key, display_name) # optional: clean up config
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
end
|
|
697
|
+
end
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
`entry` is the agents.json entry plus:
|
|
701
|
+
|
|
702
|
+
| Key | Value |
|
|
703
|
+
|-----|-------|
|
|
704
|
+
| `agent_key` | Registry key, e.g. `quill` |
|
|
705
|
+
| `display_name` | e.g. `Quill` |
|
|
706
|
+
| `avatar_path` | Profile image (png/jpg/gif/webp) or absent |
|
|
707
|
+
| `description` | Persona text (can be several paragraphs) or absent |
|
|
708
|
+
| `email` | Email for the agent's accounts (from `--email` / `agent_email_template`) or absent |
|
|
709
|
+
| `open_browser` | `true` when you may launch a browser for a manual step |
|
|
710
|
+
|
|
711
|
+
Rules:
|
|
712
|
+
|
|
713
|
+
1. **Idempotent.** `agent setup` re-runs every hook. Check your config first and
|
|
714
|
+
print `✓ <Name> already ... (skipping)` when the work is done.
|
|
715
|
+
2. **Automate everything the API allows.** Only stop for steps the service truly
|
|
716
|
+
can't do by API (e.g. Discord has no create-application endpoint). Then print
|
|
717
|
+
numbered steps and the URL, and read the result with `$stdin.noecho(&:gets)`
|
|
718
|
+
for secrets.
|
|
719
|
+
3. **Browser.** If `entry["open_browser"]` is true, open the URL as well as printing
|
|
720
|
+
it (`xdg-open` on Linux, `open` on macOS, spawned and detached). Don't open links
|
|
721
|
+
that must be used while signed in as someone else (e.g. a join link for the bot's
|
|
722
|
+
own account), because they'd land in the user's own session. Say "open in a
|
|
723
|
+
private window" instead.
|
|
724
|
+
4. **Optional inputs.** Skip quietly if `avatar_path`, `description` or `email` is missing.
|
|
725
|
+
Return without error when `$stdin` isn't a TTY and you need input.
|
|
726
|
+
5. **Never raise for expected failures.** Print `✗ ... re-run: brainiac agent setup <key>`.
|
|
727
|
+
Uncaught exceptions are reported as "hook failed" and the next plugin runs.
|
|
728
|
+
6. **Prefix output** with ` [MyService]` so it's clear whose step is running.
|
|
729
|
+
|
|
730
|
+
Users choose which plugins run with `--skip` / `--only` (or by answering "n" when
|
|
731
|
+
asked during create). Core filters on the plugin name before calling you, so
|
|
732
|
+
plugins don't need to handle this.
|
|
733
|
+
|
|
564
734
|
## Channel Prompts
|
|
565
735
|
|
|
566
736
|
For communication channel plugins:
|
|
@@ -2254,18 +2424,28 @@ when "agent"
|
|
|
2254
2424
|
puts "Usage: brainiac agent create <name> [options]"
|
|
2255
2425
|
puts ""
|
|
2256
2426
|
puts "Options:"
|
|
2257
|
-
puts " --
|
|
2258
|
-
puts " --
|
|
2259
|
-
puts " --
|
|
2260
|
-
puts " --
|
|
2427
|
+
puts " --avatar <path> Profile picture (png/jpg/gif/webp), used by plugins (Discord, Fizzy)"
|
|
2428
|
+
puts " --description <text> Persona description (\"-\" reads stdin, \"@file\" reads a file)"
|
|
2429
|
+
puts " --description-file <path> Read the persona description from a file"
|
|
2430
|
+
puts " --email <address> Email for the agent's accounts (default: agent_email_template)"
|
|
2431
|
+
puts " --local Mark agent as local (dispatches on this machine)"
|
|
2432
|
+
puts " --role <role> Assign a role (repeatable)"
|
|
2433
|
+
puts " --cli <provider> Set CLI provider (default: kiro)"
|
|
2434
|
+
puts " --skip <plugins> Don't set up these plugins for this agent, e.g. --skip basecamp"
|
|
2435
|
+
puts " --only <plugins> Set up just these plugins, e.g. --only discord,fizzy"
|
|
2436
|
+
puts " --no-browser Print setup links instead of opening them"
|
|
2261
2437
|
puts ""
|
|
2262
|
-
puts "
|
|
2438
|
+
puts "With no flags and a terminal, interactive mode is used. If no description is"
|
|
2439
|
+
puts "given on a terminal, $EDITOR opens so you can write a full persona."
|
|
2440
|
+
puts "After the brainiac files are written, each installed plugin sets up its part"
|
|
2441
|
+
puts "(Discord bot, Fizzy user, Basecamp person), asking first unless --skip/--only"
|
|
2442
|
+
puts "is given. Re-run with: brainiac agent setup <name>"
|
|
2263
2443
|
puts ""
|
|
2264
2444
|
puts "Examples:"
|
|
2265
|
-
puts " brainiac agent create
|
|
2445
|
+
puts " brainiac agent create Quill --local --avatar quill.png # opens $EDITOR for the persona"
|
|
2446
|
+
puts " brainiac agent create Quill --local --avatar quill.png --description @quill.md"
|
|
2447
|
+
puts " brainiac agent create MyAgent # interactive"
|
|
2266
2448
|
puts " brainiac agent create SecurityBot --local --role code-reviewer"
|
|
2267
|
-
puts " brainiac agent create Jane --role general-engineer --cli grok"
|
|
2268
|
-
puts " brainiac agent create Merlin --persona \"Wise, enigmatic, helpful\""
|
|
2269
2449
|
exit 1
|
|
2270
2450
|
end
|
|
2271
2451
|
|
|
@@ -2273,7 +2453,8 @@ when "agent"
|
|
|
2273
2453
|
registry = File.exist?(agent_registry_file) ? JSON.parse(File.read(agent_registry_file)) : {}
|
|
2274
2454
|
|
|
2275
2455
|
if registry[agent_key]
|
|
2276
|
-
puts "Agent '#{name}' already exists. Use 'brainiac agent #{agent_key} show' to view
|
|
2456
|
+
puts "Agent '#{name}' already exists. Use 'brainiac agent #{agent_key} show' to view,"
|
|
2457
|
+
puts "or 'brainiac agent setup #{agent_key}' to finish plugin setup."
|
|
2277
2458
|
exit 1
|
|
2278
2459
|
end
|
|
2279
2460
|
|
|
@@ -2281,6 +2462,11 @@ when "agent"
|
|
|
2281
2462
|
roles = []
|
|
2282
2463
|
cli_provider = nil
|
|
2283
2464
|
persona_text = nil
|
|
2465
|
+
avatar = nil
|
|
2466
|
+
email = nil
|
|
2467
|
+
skip_plugins = []
|
|
2468
|
+
only_plugins = nil
|
|
2469
|
+
open_browser = true
|
|
2284
2470
|
|
|
2285
2471
|
if ARGV.any?
|
|
2286
2472
|
# Flag mode
|
|
@@ -2295,9 +2481,27 @@ when "agent"
|
|
|
2295
2481
|
when "--cli"
|
|
2296
2482
|
ARGV.shift
|
|
2297
2483
|
cli_provider = ARGV.shift
|
|
2298
|
-
when "--persona"
|
|
2484
|
+
when "--description", "--persona"
|
|
2485
|
+
ARGV.shift
|
|
2486
|
+
persona_text = read_description_arg(ARGV.shift.to_s)
|
|
2487
|
+
when "--description-file"
|
|
2488
|
+
ARGV.shift
|
|
2489
|
+
persona_text = File.read(File.expand_path(ARGV.shift.to_s)).strip
|
|
2490
|
+
when "--avatar"
|
|
2299
2491
|
ARGV.shift
|
|
2300
|
-
|
|
2492
|
+
avatar = ARGV.shift
|
|
2493
|
+
when "--email"
|
|
2494
|
+
ARGV.shift
|
|
2495
|
+
email = ARGV.shift
|
|
2496
|
+
when "--skip"
|
|
2497
|
+
ARGV.shift
|
|
2498
|
+
skip_plugins.concat(plugin_list_arg(ARGV.shift))
|
|
2499
|
+
when "--only"
|
|
2500
|
+
ARGV.shift
|
|
2501
|
+
only_plugins = plugin_list_arg(ARGV.shift)
|
|
2502
|
+
when "--no-browser"
|
|
2503
|
+
ARGV.shift
|
|
2504
|
+
open_browser = false
|
|
2301
2505
|
else
|
|
2302
2506
|
puts "Unknown option: #{ARGV[0]}"
|
|
2303
2507
|
exit 1
|
|
@@ -2327,15 +2531,39 @@ when "agent"
|
|
|
2327
2531
|
input = $stdin.gets.chomp
|
|
2328
2532
|
roles = input.empty? ? [] : input.split(",").map(&:strip)
|
|
2329
2533
|
|
|
2330
|
-
print "
|
|
2534
|
+
print "Avatar image path (or blank to skip) []: "
|
|
2535
|
+
input = $stdin.gets.chomp
|
|
2536
|
+
avatar = input.empty? ? nil : input
|
|
2537
|
+
|
|
2538
|
+
default_email = default_agent_email(agent_key, name)
|
|
2539
|
+
print "Email for the agent's accounts#{" [#{default_email}]" if default_email}: "
|
|
2331
2540
|
input = $stdin.gets.chomp
|
|
2332
|
-
|
|
2541
|
+
email = input.empty? ? default_email : input
|
|
2333
2542
|
end
|
|
2334
2543
|
|
|
2544
|
+
# A persona is usually a paragraph or more — give it a real editor.
|
|
2545
|
+
if persona_text.nil? && $stdin.tty?
|
|
2546
|
+
print "Write #{name}'s persona description in #{ENV["VISUAL"] || ENV["EDITOR"] || "vi"}? [Y/n]: "
|
|
2547
|
+
persona_text = edit_description_in_editor(name) unless $stdin.gets.to_s.strip.downcase.start_with?("n")
|
|
2548
|
+
end
|
|
2549
|
+
|
|
2550
|
+
email ||= default_agent_email(agent_key, name)
|
|
2551
|
+
|
|
2335
2552
|
entry = { "display_name" => name }
|
|
2336
2553
|
entry["local"] = true if local
|
|
2337
2554
|
entry["cli_provider"] = cli_provider if cli_provider
|
|
2338
2555
|
entry["role"] = roles.size == 1 ? roles.first : roles if roles.any?
|
|
2556
|
+
entry["email"] = email if email
|
|
2557
|
+
entry["skip_plugins"] = skip_plugins.uniq if skip_plugins.any?
|
|
2558
|
+
|
|
2559
|
+
if avatar
|
|
2560
|
+
begin
|
|
2561
|
+
entry["avatar"] = store_agent_avatar(agent_key, avatar)
|
|
2562
|
+
rescue ArgumentError => e
|
|
2563
|
+
puts "❌ #{e.message}"
|
|
2564
|
+
exit 1
|
|
2565
|
+
end
|
|
2566
|
+
end
|
|
2339
2567
|
|
|
2340
2568
|
registry[agent_key] = entry
|
|
2341
2569
|
File.write(agent_registry_file, JSON.pretty_generate(registry))
|
|
@@ -2343,6 +2571,8 @@ when "agent"
|
|
|
2343
2571
|
puts " Local: #{local}" if local
|
|
2344
2572
|
puts " Role: #{roles.join(", ")}" if roles.any?
|
|
2345
2573
|
puts " CLI: #{cli_provider}" if cli_provider
|
|
2574
|
+
puts " Email: #{email}" if email
|
|
2575
|
+
puts " Avatar: #{entry["avatar"]}" if entry["avatar"]
|
|
2346
2576
|
|
|
2347
2577
|
# Create persona directory and style file if requested
|
|
2348
2578
|
if persona_text
|
|
@@ -2356,6 +2586,7 @@ when "agent"
|
|
|
2356
2586
|
description: Persona voice for #{name}.
|
|
2357
2587
|
---
|
|
2358
2588
|
# #{name} — Persona
|
|
2589
|
+
|
|
2359
2590
|
#{persona_text}
|
|
2360
2591
|
MD
|
|
2361
2592
|
puts " Persona: #{persona_file}"
|
|
@@ -2368,12 +2599,82 @@ when "agent"
|
|
|
2368
2599
|
brain_init(name)
|
|
2369
2600
|
end
|
|
2370
2601
|
|
|
2371
|
-
#
|
|
2372
|
-
|
|
2602
|
+
# Each plugin provisions its side (Discord bot, Fizzy user, Basecamp person, ...).
|
|
2603
|
+
# Without --skip/--only, a terminal user is asked per plugin; "no" answers
|
|
2604
|
+
# are saved so `agent setup` leaves those plugins alone too.
|
|
2605
|
+
confirm = $stdin.tty? && skip_plugins.empty? && only_plugins.nil?
|
|
2606
|
+
notify_plugins_agent_created(agent_key, agent_hook_payload(agent_key, entry, open_browser: open_browser),
|
|
2607
|
+
skip: skip_plugins, only: only_plugins, confirm: confirm)
|
|
2608
|
+
if skip_plugins.any? && entry["skip_plugins"] != skip_plugins.uniq
|
|
2609
|
+
entry["skip_plugins"] = skip_plugins.uniq
|
|
2610
|
+
registry[agent_key] = entry
|
|
2611
|
+
File.write(agent_registry_file, JSON.pretty_generate(registry))
|
|
2612
|
+
puts " Skipping #{skip_plugins.uniq.join(", ")} for #{name} (undo: brainiac agent setup #{agent_key} --only <plugin>)"
|
|
2613
|
+
end
|
|
2373
2614
|
|
|
2374
2615
|
# Notify running server to reload and trigger :agent_added hook
|
|
2375
2616
|
notify_server_reload
|
|
2376
2617
|
|
|
2618
|
+
when "setup"
|
|
2619
|
+
name = ARGV.shift
|
|
2620
|
+
unless name
|
|
2621
|
+
puts "Usage: brainiac agent setup <name> [--avatar <path>] [--email <address>] [--skip <plugins>] [--only <plugins>] [--no-browser]"
|
|
2622
|
+
puts ""
|
|
2623
|
+
puts "Re-runs every plugin's agent setup (Discord bot, Fizzy user, Basecamp person)."
|
|
2624
|
+
puts "Steps that are already done are skipped, so it's safe to run after an"
|
|
2625
|
+
puts "interrupted 'agent create' or to set up a newly installed plugin."
|
|
2626
|
+
puts ""
|
|
2627
|
+
puts " --skip a,b Never set up these plugins for this agent (saved)"
|
|
2628
|
+
puts " --only a,b Run just these plugins (also un-skips them)"
|
|
2629
|
+
puts " --no-browser Print links instead of opening them"
|
|
2630
|
+
exit 1
|
|
2631
|
+
end
|
|
2632
|
+
|
|
2633
|
+
agent_key = name.downcase.gsub(/[^a-z0-9-]/, "-")
|
|
2634
|
+
registry = File.exist?(agent_registry_file) ? JSON.parse(File.read(agent_registry_file)) : {}
|
|
2635
|
+
entry = registry[agent_key]
|
|
2636
|
+
unless entry
|
|
2637
|
+
puts "Agent '#{name}' not found in registry."
|
|
2638
|
+
exit 1
|
|
2639
|
+
end
|
|
2640
|
+
|
|
2641
|
+
only_plugins = nil
|
|
2642
|
+
open_browser = true
|
|
2643
|
+
while ARGV.any?
|
|
2644
|
+
case ARGV.shift
|
|
2645
|
+
when "--skip"
|
|
2646
|
+
entry["skip_plugins"] = ((entry["skip_plugins"] || []) + plugin_list_arg(ARGV.shift)).uniq
|
|
2647
|
+
when "--only"
|
|
2648
|
+
only_plugins = plugin_list_arg(ARGV.shift)
|
|
2649
|
+
when "--no-browser"
|
|
2650
|
+
open_browser = false
|
|
2651
|
+
when "--avatar"
|
|
2652
|
+
begin
|
|
2653
|
+
entry["avatar"] = store_agent_avatar(agent_key, ARGV.shift.to_s)
|
|
2654
|
+
rescue ArgumentError => e
|
|
2655
|
+
puts "❌ #{e.message}"
|
|
2656
|
+
exit 1
|
|
2657
|
+
end
|
|
2658
|
+
when "--email"
|
|
2659
|
+
entry["email"] = ARGV.shift
|
|
2660
|
+
else
|
|
2661
|
+
puts "Unknown option. Run 'brainiac agent setup' for usage."
|
|
2662
|
+
exit 1
|
|
2663
|
+
end
|
|
2664
|
+
end
|
|
2665
|
+
entry["email"] ||= default_agent_email(agent_key, entry["display_name"] || name)
|
|
2666
|
+
entry.delete("email") unless entry["email"]
|
|
2667
|
+
# Explicitly running a plugin with --only un-skips it for good.
|
|
2668
|
+
entry["skip_plugins"] -= only_plugins if only_plugins && entry["skip_plugins"]
|
|
2669
|
+
entry.delete("skip_plugins") if entry["skip_plugins"] == []
|
|
2670
|
+
registry[agent_key] = entry
|
|
2671
|
+
File.write(agent_registry_file, JSON.pretty_generate(registry))
|
|
2672
|
+
|
|
2673
|
+
puts "Setting up #{entry["display_name"] || name} (#{agent_key})"
|
|
2674
|
+
notify_plugins_agent_created(agent_key, agent_hook_payload(agent_key, entry, open_browser: open_browser),
|
|
2675
|
+
skip: entry["skip_plugins"] || [], only: only_plugins)
|
|
2676
|
+
notify_server_reload
|
|
2677
|
+
|
|
2377
2678
|
when "remove", "delete", "rm"
|
|
2378
2679
|
name = ARGV.shift
|
|
2379
2680
|
unless name
|
|
@@ -2407,12 +2708,14 @@ when "agent"
|
|
|
2407
2708
|
brainiac agent list
|
|
2408
2709
|
brainiac agent default [name]
|
|
2409
2710
|
brainiac agent create <name> [options]
|
|
2711
|
+
brainiac agent setup <name> [--skip|--only <plugins>]
|
|
2410
2712
|
brainiac agent remove <name>
|
|
2411
2713
|
|
|
2412
2714
|
Commands:
|
|
2413
2715
|
list List all agents in the registry
|
|
2414
2716
|
default [name] Show or set the default agent
|
|
2415
|
-
create <name> [options] Add a new agent
|
|
2717
|
+
create <name> [options] Add a new agent and run every plugin's setup
|
|
2718
|
+
setup <name> Re-run plugin setup (skips steps already done)
|
|
2416
2719
|
remove <name> Remove an agent from the registry
|
|
2417
2720
|
<name> show Show agent configuration (tokens redacted)
|
|
2418
2721
|
<name> set <field> <value> Update a config field (local, cli, role, persona, display_name)
|
|
@@ -2421,14 +2724,21 @@ when "agent"
|
|
|
2421
2724
|
<name> env --delete <KEY> Remove an env var from an agent
|
|
2422
2725
|
|
|
2423
2726
|
Create options (or interactive if no flags given):
|
|
2727
|
+
--avatar <path> Profile picture, used by plugins (Discord, Fizzy)
|
|
2728
|
+
--description <text> Persona description ("-" = stdin, "@file" = file; $EDITOR if omitted)
|
|
2729
|
+
--email <address> Email for the agent's accounts (default: agent_email_template)
|
|
2424
2730
|
--local Mark agent as local (dispatches on this machine)
|
|
2425
2731
|
--role <role> Assign a role (repeatable)
|
|
2426
2732
|
--cli <provider> Set CLI provider (default: kiro)
|
|
2427
|
-
--
|
|
2733
|
+
--skip <plugins> Don't set up these plugins for this agent (saved; also for setup)
|
|
2734
|
+
--only <plugins> Set up just these plugins (also for setup)
|
|
2735
|
+
--no-browser Print setup links instead of opening them
|
|
2428
2736
|
|
|
2429
2737
|
Examples:
|
|
2430
2738
|
brainiac agent list
|
|
2431
2739
|
brainiac agent default Sherlock
|
|
2740
|
+
brainiac agent create Quill --local --avatar quill.png
|
|
2741
|
+
brainiac agent setup quill
|
|
2432
2742
|
brainiac agent create SecurityBot --local --role code-reviewer
|
|
2433
2743
|
brainiac agent create Jane --cli grok
|
|
2434
2744
|
brainiac agent create MyAgent # interactive mode
|
|
@@ -3249,6 +3559,12 @@ when "plugin"
|
|
|
3249
3559
|
Cli.run(args)
|
|
3250
3560
|
end
|
|
3251
3561
|
|
|
3562
|
+
# Optional: provision a new agent on #{pascal_name} during
|
|
3563
|
+
# `brainiac agent create` / `brainiac agent setup`. Must be idempotent.
|
|
3564
|
+
# See "Agent Onboarding" in skills/#{plugin_name}/SKILL.md for the payload and rules.
|
|
3565
|
+
# def self.on_agent_created(agent_key, entry)
|
|
3566
|
+
# end
|
|
3567
|
+
|
|
3252
3568
|
# Subcommand names for bash completion.
|
|
3253
3569
|
def self.completions
|
|
3254
3570
|
%w[config setup status]
|
|
@@ -109,14 +109,14 @@ _brainiac() {
|
|
|
109
109
|
agent)
|
|
110
110
|
case $cword in
|
|
111
111
|
2)
|
|
112
|
-
COMPREPLY=($(compgen -W "list create remove $(_brainiac_agents)" -- "$cur"))
|
|
112
|
+
COMPREPLY=($(compgen -W "list create setup remove $(_brainiac_agents)" -- "$cur"))
|
|
113
113
|
;;
|
|
114
114
|
3)
|
|
115
115
|
local agent_name="${words[2]}"
|
|
116
116
|
case "$agent_name" in
|
|
117
|
-
list|remove|delete|rm) ;;
|
|
117
|
+
list|remove|delete|rm|setup) ;;
|
|
118
118
|
create|add)
|
|
119
|
-
COMPREPLY=($(compgen -W "--local --role --cli --
|
|
119
|
+
COMPREPLY=($(compgen -W "--avatar --description --description-file --email --local --role --cli --skip --only --no-browser" -- "$cur"))
|
|
120
120
|
;;
|
|
121
121
|
*)
|
|
122
122
|
COMPREPLY=($(compgen -W "show env" -- "$cur"))
|
data/lib/brainiac/helpers.rb
CHANGED
|
@@ -35,7 +35,7 @@ def load_cli_provider(provider_name)
|
|
|
35
35
|
%w[prompt_flag readonly_args list_models_command resume_flag resume_args resume_id_flag new_session_id_flag
|
|
36
36
|
session_list_command session_id_field session_directory_field session_updated_field
|
|
37
37
|
session_list_path session_dir output_last_message_flag cwd_flag config_override_flag
|
|
38
|
-
effort_config_key effort_map title_flag heaviness_probe settings_model_cmd].each do |key|
|
|
38
|
+
effort_config_key effort_map title_flag heaviness_probe settings_model_cmd stream_args stream_format].each do |key|
|
|
39
39
|
next unless raw[key]
|
|
40
40
|
next if raw[key].respond_to?(:empty?) && raw[key].empty?
|
|
41
41
|
|
|
@@ -942,6 +942,7 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
942
942
|
prompt_file: prompt_file, resume: should_resume,
|
|
943
943
|
output_file: output_file, chdir: chdir, title: work_item_id,
|
|
944
944
|
new_session_id: minted_session_id)
|
|
945
|
+
append_stream_args(cmd, resolved)
|
|
945
946
|
# Profile env (named bundle, e.g. an alternate kiro-cli account) layers between
|
|
946
947
|
# agent env and the explicit `env:` passed in. An explicitly-requested profile
|
|
947
948
|
# wins over agent env; the default profile is only a fallback. See profiles.rb.
|
|
@@ -962,10 +963,11 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
962
963
|
|
|
963
964
|
started_at = Time.now
|
|
964
965
|
stdin_redirect = (resolved["prompt_mode"] || "stdin") == "stdin" ? { in: prompt_file } : {}
|
|
965
|
-
pid =
|
|
966
|
+
pid, formatter = spawn_agent_process(spawn_env, cmd, resolved,
|
|
967
|
+
chdir: chdir, log_file: log_file, stdin_redirect: stdin_redirect)
|
|
966
968
|
|
|
967
969
|
spawn_completion_watcher(
|
|
968
|
-
pid: pid, agent_cli: resolved["agent_cli"], agent_config_name: agent_config_name,
|
|
970
|
+
pid: pid, formatter: formatter, agent_cli: resolved["agent_cli"], agent_config_name: agent_config_name,
|
|
969
971
|
agent_name: agent_name, log_file: log_file, log_name: log_name,
|
|
970
972
|
prompt_file: prompt_file, chdir: chdir, source: source,
|
|
971
973
|
source_context: source_context, project_config: project_config,
|
|
@@ -982,10 +984,42 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
982
984
|
[pid, log_file]
|
|
983
985
|
end
|
|
984
986
|
|
|
987
|
+
# stream_args (e.g. claude's --output-format stream-json) only apply to run_agent, where
|
|
988
|
+
# output goes to a log. ask_agent shares build_agent_cmd and needs plain text on stdout.
|
|
989
|
+
def append_stream_args(cmd, resolved)
|
|
990
|
+
cmd.concat(resolved["stream_args"].split) if resolved["stream_args"]
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
STREAM_FORMATTER_PATH = File.expand_path("stream_formatter.rb", __dir__)
|
|
994
|
+
# How long to wait for the formatter to drain after the agent exits. It normally sees EOF
|
|
995
|
+
# right away, but a background process the agent left running can hold the pipe open.
|
|
996
|
+
STREAM_FORMATTER_DRAIN_SECONDS = 10
|
|
997
|
+
|
|
998
|
+
# Start the agent with its output going to log_file. When the provider sets stream_format,
|
|
999
|
+
# the agent writes to a pipe instead and a separate formatter process turns the JSONL into
|
|
1000
|
+
# readable lines in the log. Both are plain child processes, so they outlive a server restart
|
|
1001
|
+
# the same way the agent alone does. Returns [agent_pid, formatter_waiter_or_nil].
|
|
1002
|
+
def spawn_agent_process(spawn_env, cmd, resolved, chdir:, log_file:, stdin_redirect:)
|
|
1003
|
+
return [spawn(spawn_env, *cmd, chdir: chdir, **stdin_redirect, out: [log_file, "w"], err: %i[child out]), nil] unless resolved["stream_format"]
|
|
1004
|
+
|
|
1005
|
+
reader, writer = IO.pipe
|
|
1006
|
+
formatter_pid = spawn(RbConfig.ruby, STREAM_FORMATTER_PATH, resolved["stream_format"],
|
|
1007
|
+
in: reader, out: [log_file, "w"], err: %i[child out])
|
|
1008
|
+
pid = spawn(spawn_env, *cmd, chdir: chdir, **stdin_redirect, out: writer, err: writer)
|
|
1009
|
+
[pid, Process.detach(formatter_pid)]
|
|
1010
|
+
ensure
|
|
1011
|
+
reader&.close
|
|
1012
|
+
writer&.close
|
|
1013
|
+
end
|
|
1014
|
+
|
|
985
1015
|
# Spawn a background thread that waits for the agent process to finish and runs completion handling.
|
|
986
1016
|
def spawn_completion_watcher(**ctx)
|
|
1017
|
+
formatter = ctx.delete(:formatter)
|
|
987
1018
|
thread = Thread.new do
|
|
988
1019
|
Process.wait(ctx[:pid])
|
|
1020
|
+
# Let the formatter finish writing the log before completion reads it. It runs in its
|
|
1021
|
+
# own waiter thread, so $CHILD_STATUS here still belongs to the agent.
|
|
1022
|
+
formatter&.join(STREAM_FORMATTER_DRAIN_SECONDS)
|
|
989
1023
|
handle_agent_completion(**ctx)
|
|
990
1024
|
end
|
|
991
1025
|
# Without this, an unrescued exception anywhere in handle_agent_completion silently
|
data/lib/brainiac/prompts.rb
CHANGED
|
@@ -58,6 +58,18 @@ PROMPT_CORE = <<~PROMPT
|
|
|
58
58
|
Standard unix commands (cd, ls, grep, cat, git, curl, etc.) don't need a brain search.
|
|
59
59
|
But for project-specific tools, do NOT guess at flags or syntax — wrong commands waste time and tokens. Look it up first.
|
|
60
60
|
|
|
61
|
+
**NEVER run a whole-filesystem search.** Do not run `find /`, `find ~`, `grep -r /`, `locate`, or any
|
|
62
|
+
command that walks the entire disk from root or home. On macOS these effectively never return — they
|
|
63
|
+
block on autofs automounts, iCloud Drive cloud-only files, sealed CoreSimulator volumes, and Spotlight
|
|
64
|
+
stores — and they pin a CPU for hours. To locate an installed gem or plugin, scope the search:
|
|
65
|
+
```
|
|
66
|
+
gem which <gem-name> # exact file for a loaded gem
|
|
67
|
+
ruby -e 'puts Gem.loaded_specs.keys.grep(/<pattern>/)' # list matching loaded gems
|
|
68
|
+
# Only if those fail, scope find to known roots with a timeout and head:
|
|
69
|
+
find "$(gem env gemdir)" ~/.asdf/installs ./ -maxdepth 6 -path '*<pattern>*' -name '*.rb' 2>/dev/null | head
|
|
70
|
+
```
|
|
71
|
+
Always point `find`/`grep -r` at a specific repo, gem root, or directory — never at `/` or `~`.
|
|
72
|
+
|
|
61
73
|
**When to save knowledge:** Be selective — only save significant architecture decisions,
|
|
62
74
|
non-obvious gotchas, major workflow changes, or things the user explicitly asks you to remember.
|
|
63
75
|
Routine card work and things already documented in the codebase don't need brain entries.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Turns a CLI's JSONL event stream into a readable agent log.
|
|
4
|
+
#
|
|
5
|
+
# run_agent spawns this as its own process between the agent and the log file when the
|
|
6
|
+
# provider sets `stream_format` (see run_agent / spawn_agent_with_formatter). It runs
|
|
7
|
+
# outside the server so a brainiac restart can't break the pipe and kill the agent.
|
|
8
|
+
#
|
|
9
|
+
# claude ... --output-format stream-json --verbose | ruby stream_formatter.rb claude > log
|
|
10
|
+
#
|
|
11
|
+
# Lines that aren't JSON (mise noise, stderr, crashes) pass through unchanged, so the
|
|
12
|
+
# crash snippet still shows whatever went wrong. Summary lines start with "---" so
|
|
13
|
+
# detect_no_output_reason treats them as boilerplate, like it does kiro's banners.
|
|
14
|
+
|
|
15
|
+
require "json"
|
|
16
|
+
|
|
17
|
+
module StreamFormatter
|
|
18
|
+
MAX_SUMMARY = 160
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Format a single line of stream output. Returns an array of log lines (maybe empty).
|
|
23
|
+
def format_line(format, line)
|
|
24
|
+
event = JSON.parse(line)
|
|
25
|
+
return [line.chomp] unless event.is_a?(Hash)
|
|
26
|
+
|
|
27
|
+
case format
|
|
28
|
+
when "claude" then format_claude(event)
|
|
29
|
+
else [line.chomp]
|
|
30
|
+
end
|
|
31
|
+
rescue JSON::ParserError
|
|
32
|
+
[line.chomp]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def format_claude(event)
|
|
36
|
+
case event["type"]
|
|
37
|
+
when "system" then claude_system(event)
|
|
38
|
+
when "assistant" then claude_assistant(event)
|
|
39
|
+
when "user" then claude_tool_errors(event)
|
|
40
|
+
when "result" then claude_result(event)
|
|
41
|
+
else []
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def claude_system(event)
|
|
46
|
+
return [] unless event["subtype"] == "init"
|
|
47
|
+
|
|
48
|
+
["--- claude #{event["claude_code_version"]} · #{event["model"]} · session #{event["session_id"]}"]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Assistant text prints as-is; each tool call becomes a "▸ Tool: summary" line.
|
|
52
|
+
# Events from a subagent carry parent_tool_use_id and are indented under it.
|
|
53
|
+
def claude_assistant(event)
|
|
54
|
+
indent = event["parent_tool_use_id"] ? " " : ""
|
|
55
|
+
Array(event.dig("message", "content")).flat_map do |block|
|
|
56
|
+
case block["type"]
|
|
57
|
+
when "text"
|
|
58
|
+
text = block["text"].to_s.strip
|
|
59
|
+
text.empty? ? [] : text.lines.map { |l| "#{indent}#{l.chomp}" } + [""]
|
|
60
|
+
when "tool_use"
|
|
61
|
+
summary = tool_summary(block["name"], block["input"] || {})
|
|
62
|
+
["#{indent}▸ #{block["name"]}#{": #{summary}" unless summary.empty?}"]
|
|
63
|
+
else []
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Successful tool results are noise in a progress log; failed ones are worth a line.
|
|
69
|
+
def claude_tool_errors(event)
|
|
70
|
+
indent = event["parent_tool_use_id"] ? " " : " "
|
|
71
|
+
Array(event.dig("message", "content")).filter_map do |block|
|
|
72
|
+
next unless block.is_a?(Hash) && block["type"] == "tool_result" && block["is_error"]
|
|
73
|
+
|
|
74
|
+
"#{indent}✗ #{truncate(first_line(result_text(block["content"])))}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def claude_result(event)
|
|
79
|
+
parts = [event["subtype"] == "success" ? "done" : event["subtype"]]
|
|
80
|
+
parts << "#{event["num_turns"]} turns" if event["num_turns"]
|
|
81
|
+
parts << format_duration(event["duration_ms"]) if event["duration_ms"]
|
|
82
|
+
parts << format("$%.2f", event["total_cost_usd"]) if event["total_cost_usd"]
|
|
83
|
+
lines = ["--- #{parts.join(" · ")}"]
|
|
84
|
+
# Errors (usage limits, API failures) only show up here, so print the message where
|
|
85
|
+
# detect_no_output_reason can match it.
|
|
86
|
+
lines.unshift("✗ #{event["result"]}") if event["is_error"] && event["result"]
|
|
87
|
+
lines
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def tool_summary(name, input)
|
|
91
|
+
value = case name
|
|
92
|
+
when "Bash" then input["command"]
|
|
93
|
+
when "Read", "Edit", "Write", "MultiEdit" then input["file_path"]
|
|
94
|
+
when "NotebookEdit" then input["notebook_path"]
|
|
95
|
+
when "Grep", "Glob" then [input["pattern"], input["path"]].compact.join(" in ")
|
|
96
|
+
when "Agent", "Task" then input["description"]
|
|
97
|
+
when "WebFetch" then input["url"]
|
|
98
|
+
when "WebSearch" then input["query"]
|
|
99
|
+
when "Skill" then input["skill"]
|
|
100
|
+
else input.values.find { |v| v.is_a?(String) }
|
|
101
|
+
end
|
|
102
|
+
truncate(first_line(value.to_s))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def result_text(content)
|
|
106
|
+
return content.to_s unless content.is_a?(Array)
|
|
107
|
+
|
|
108
|
+
content.filter_map { |c| c["text"] if c.is_a?(Hash) }.join("\n")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def first_line(text)
|
|
112
|
+
text.strip.lines.first.to_s.chomp
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def truncate(text)
|
|
116
|
+
text.length > MAX_SUMMARY ? "#{text[0, MAX_SUMMARY - 1]}…" : text
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def format_duration(millis)
|
|
120
|
+
secs = (millis / 1000.0).round
|
|
121
|
+
secs < 60 ? "#{secs}s" : "#{secs / 60}m#{format("%02d", secs % 60)}s"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def run(format, input: $stdin, output: $stdout)
|
|
125
|
+
output.sync = true
|
|
126
|
+
input.each_line do |line|
|
|
127
|
+
format_line(format, line).each { |l| output.puts(l) }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
StreamFormatter.run(ARGV.fetch(0, "claude")) if $PROGRAM_NAME == __FILE__
|
data/lib/brainiac/version.rb
CHANGED
|
@@ -110,6 +110,68 @@ Available hooks:
|
|
|
110
110
|
| `:detect_cli_provider` | Detect CLI provider | metadata hash |
|
|
111
111
|
| `:detect_effort` | Detect effort level | metadata hash |
|
|
112
112
|
|
|
113
|
+
## Agent Onboarding (`on_agent_created`)
|
|
114
|
+
|
|
115
|
+
`brainiac agent create` writes the brainiac files (agents.json, persona, brain),
|
|
116
|
+
then asks each plugin to provision the new agent on its service: a Discord bot,
|
|
117
|
+
a Fizzy user, a Basecamp person. `brainiac agent setup <name>` runs the same hook
|
|
118
|
+
again. If your plugin gives agents an identity on some service, implement it.
|
|
119
|
+
|
|
120
|
+
Define it in `cli.rb` (or a file `cli.rb` requires), since it runs in the CLI context:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
# cli.rb
|
|
124
|
+
require_relative "agent_setup"
|
|
125
|
+
|
|
126
|
+
module Brainiac
|
|
127
|
+
module Plugins
|
|
128
|
+
module MyService
|
|
129
|
+
# Called by `brainiac agent create` / `brainiac agent setup`.
|
|
130
|
+
def self.on_agent_created(agent_key, entry)
|
|
131
|
+
AgentSetup.run(agent_key, entry)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.on_agent_removed(agent_key, display_name) # optional: clean up config
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`entry` is the agents.json entry plus:
|
|
142
|
+
|
|
143
|
+
| Key | Value |
|
|
144
|
+
|-----|-------|
|
|
145
|
+
| `agent_key` | Registry key, e.g. `quill` |
|
|
146
|
+
| `display_name` | e.g. `Quill` |
|
|
147
|
+
| `avatar_path` | Profile image (png/jpg/gif/webp) or absent |
|
|
148
|
+
| `description` | Persona text (can be several paragraphs) or absent |
|
|
149
|
+
| `email` | Email for the agent's accounts (from `--email` / `agent_email_template`) or absent |
|
|
150
|
+
| `open_browser` | `true` when you may launch a browser for a manual step |
|
|
151
|
+
|
|
152
|
+
Rules:
|
|
153
|
+
|
|
154
|
+
1. **Idempotent.** `agent setup` re-runs every hook. Check your config first and
|
|
155
|
+
print `✓ <Name> already ... (skipping)` when the work is done.
|
|
156
|
+
2. **Automate everything the API allows.** Only stop for steps the service truly
|
|
157
|
+
can't do by API (e.g. Discord has no create-application endpoint). Then print
|
|
158
|
+
numbered steps and the URL, and read the result with `$stdin.noecho(&:gets)`
|
|
159
|
+
for secrets.
|
|
160
|
+
3. **Browser.** If `entry["open_browser"]` is true, open the URL as well as printing
|
|
161
|
+
it (`xdg-open` on Linux, `open` on macOS, spawned and detached). Don't open links
|
|
162
|
+
that must be used while signed in as someone else (e.g. a join link for the bot's
|
|
163
|
+
own account), because they'd land in the user's own session. Say "open in a
|
|
164
|
+
private window" instead.
|
|
165
|
+
4. **Optional inputs.** Skip quietly if `avatar_path`, `description` or `email` is missing.
|
|
166
|
+
Return without error when `$stdin` isn't a TTY and you need input.
|
|
167
|
+
5. **Never raise for expected failures.** Print `✗ ... re-run: brainiac agent setup <key>`.
|
|
168
|
+
Uncaught exceptions are reported as "hook failed" and the next plugin runs.
|
|
169
|
+
6. **Prefix output** with ` [MyService]` so it's clear whose step is running.
|
|
170
|
+
|
|
171
|
+
Users choose which plugins run with `--skip` / `--only` (or by answering "n" when
|
|
172
|
+
asked during create). Core filters on the plugin name before calling you, so
|
|
173
|
+
plugins don't need to handle this.
|
|
174
|
+
|
|
113
175
|
## Channel Prompts
|
|
114
176
|
|
|
115
177
|
If your plugin is a communication channel (like Discord, Fizzy), register a prompt:
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: brainiac
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Davis
|
|
@@ -146,6 +146,7 @@ files:
|
|
|
146
146
|
- lib/brainiac/session_history.rb
|
|
147
147
|
- lib/brainiac/sessions.rb
|
|
148
148
|
- lib/brainiac/skills.rb
|
|
149
|
+
- lib/brainiac/stream_formatter.rb
|
|
149
150
|
- lib/brainiac/users.rb
|
|
150
151
|
- lib/brainiac/version.rb
|
|
151
152
|
- monitor/daemon.rb
|