brainiac 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d90a30fad5756179777b90413b87bbd41550783c75c2e9a38f726e1a2e1ef5c
4
- data.tar.gz: 32607dc0510acec6449bdb16e8a4acbec358caa94a0d8592d7a6d06e4f73e9a2
3
+ metadata.gz: 6acdbbb2c88f69d1ca544dca2cb71afb7d4abd3963e4ffb93c1ea90666d4f966
4
+ data.tar.gz: 738962e302868862b6be89f4cb65f300b0140dc15bfaf1ff258f286492c9c45e
5
5
  SHA512:
6
- metadata.gz: 1a16401ff3896e546d5411785418228ef5ef2a360dc10fd3633a798723969d90a7d5668d8f8e908856a08bdac35e939519f94bcb79ad6f2f259d0f55dac9c525
7
- data.tar.gz: 29947e9e2dacd7007a630a06be63e7e8dbc913f9e677b28ccf92a5dcf4c3659c189291c7d07971b42e06c6c0d08880d266854aa759721d701308a415dfdbb79f
6
+ metadata.gz: c31b44489df80beab3cb6ed1e5bc7308e1e1926d78793bc73bb9c339ab06a98375ee1b8be1b70e031cdd3c1433c2e9e088976afe96343a08449600868eb206a1
7
+ data.tar.gz: dabb28067671721b8890aea7a9b5a67d4a3a4bfc434836fcdd41e3cc13c5f7ee05efc6b93acc43dbf09c426b01749d5f68bcb39bfae451a8605ad9e8a4778703
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
@@ -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 = spawn(spawn_env, *cmd, chdir: chdir, **stdin_redirect, out: [log_file, "w"], err: %i[child out])
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
@@ -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__
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
data/receiver.rb CHANGED
@@ -22,6 +22,7 @@ require_relative "lib/brainiac/sessions"
22
22
  require_relative "lib/brainiac/session_history"
23
23
  require_relative "lib/brainiac/prompts"
24
24
  require_relative "lib/brainiac/helpers"
25
+ require_relative "lib/brainiac/ask"
25
26
  require_relative "lib/brainiac/notifications"
26
27
  require_relative "lib/brainiac/cron"
27
28
  require_relative "lib/brainiac/restart"
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.2.0
4
+ version: 0.3.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