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 +4 -4
- data/README.md +9 -0
- data/lib/brainiac/helpers.rb +37 -3
- data/lib/brainiac/stream_formatter.rb +132 -0
- data/lib/brainiac/version.rb +1 -1
- data/receiver.rb +1 -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: 6acdbbb2c88f69d1ca544dca2cb71afb7d4abd3963e4ffb93c1ea90666d4f966
|
|
4
|
+
data.tar.gz: 738962e302868862b6be89f4cb65f300b0140dc15bfaf1ff258f286492c9c45e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
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
|
|
@@ -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
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.
|
|
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
|