brute 5.0.5 → 6.0.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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/compaction/middleware/sliding_window.rb +170 -0
  3. data/lib/brute/compaction/middleware/strategy.rb +137 -0
  4. data/lib/brute/compaction/middleware/tool_results.rb +146 -0
  5. data/lib/brute/compaction/summarize.rb +349 -0
  6. data/lib/brute/compaction/transcript.rb +166 -0
  7. data/lib/brute/compaction.rb +57 -0
  8. data/lib/brute/completion/lang_chain.rb +34 -31
  9. data/lib/brute/completion/llmrb.rb +31 -29
  10. data/lib/brute/completion/open_router.rb +43 -40
  11. data/lib/brute/completion/ruby_llm.rb +28 -27
  12. data/lib/brute/contrib/otel.rb +83 -51
  13. data/lib/brute/env.rb +54 -0
  14. data/lib/brute/eval/case.rb +254 -0
  15. data/lib/brute/eval/suite.rb +181 -0
  16. data/lib/brute/eval/transcript.rb +147 -0
  17. data/lib/brute/eval/world.rb +106 -0
  18. data/lib/brute/eval.rb +48 -0
  19. data/lib/brute/events/handler.rb +3 -1
  20. data/lib/brute/events/prefixed_terminal_output.rb +3 -1
  21. data/lib/brute/events/terminal_output_handler.rb +1 -1
  22. data/lib/brute/hooks.rb +89 -102
  23. data/lib/brute/message_transport/anthropic.rb +13 -3
  24. data/lib/brute/message_transport/llm.rb +8 -4
  25. data/lib/brute/message_transport/open_router.rb +30 -16
  26. data/lib/brute/message_transport/openai.rb +14 -7
  27. data/lib/brute/message_transport/ruby_llm.rb +21 -17
  28. data/lib/brute/message_transport/ruby_open_ai.rb +59 -57
  29. data/lib/brute/messages.rb +15 -8
  30. data/lib/brute/middleware/000_base.rb +6 -6
  31. data/lib/brute/middleware/002_session_log.rb +12 -4
  32. data/lib/brute/middleware/008_checkpoint.rb +27 -19
  33. data/lib/brute/middleware/010_max_iterations.rb +1 -1
  34. data/lib/brute/middleware/020_system_prompt.rb +1 -1
  35. data/lib/brute/middleware/040_default_compaction_pipeline.rb +356 -0
  36. data/lib/brute/middleware/{070_tool_pipeline.rb → 070_default_tool_pipeline.rb} +49 -58
  37. data/lib/brute/prompt_template.rb +19 -15
  38. data/lib/brute/prompts/base.rb +19 -10
  39. data/lib/brute/prompts/environment.rb +3 -1
  40. data/lib/brute/prompts/instructions.rb +9 -7
  41. data/lib/brute/prompts/skills.rb +5 -3
  42. data/lib/brute/rack/adapter.rb +40 -23
  43. data/lib/brute/skill.rb +136 -89
  44. data/lib/brute/system_prompt.rb +9 -9
  45. data/lib/brute/token_counter/approximate.rb +54 -0
  46. data/lib/brute/token_counter/tiktoken.rb +80 -0
  47. data/lib/brute/token_counter.rb +150 -0
  48. data/lib/brute/tool.rb +10 -6
  49. data/lib/brute/tools/adapter.rb +57 -47
  50. data/lib/brute/tools/fs/snapshot_store.rb +5 -1
  51. data/lib/brute/tools/fs_patch.rb +16 -8
  52. data/lib/brute/tools/fs_read.rb +107 -80
  53. data/lib/brute/tools/fs_remove.rb +6 -2
  54. data/lib/brute/tools/fs_search.rb +14 -4
  55. data/lib/brute/tools/fs_undo.rb +6 -2
  56. data/lib/brute/tools/fs_write.rb +5 -1
  57. data/lib/brute/tools/net_fetch.rb +6 -2
  58. data/lib/brute/tools/question.rb +42 -39
  59. data/lib/brute/tools/shell.rb +20 -5
  60. data/lib/brute/tools/skill_load.rb +46 -41
  61. data/lib/brute/tools/sub_agent.rb +2 -2
  62. data/lib/brute/tools/todo_write.rb +19 -15
  63. data/lib/brute/truncation.rb +68 -43
  64. data/lib/brute/turn/agent_pipeline.rb +23 -13
  65. data/lib/brute/turn/compaction_pipeline.rb +123 -0
  66. data/lib/brute/turn/pipeline.rb +87 -96
  67. data/lib/brute/turn/tool_pipeline.rb +4 -3
  68. data/lib/brute/usage_detection/llmrb.rb +18 -14
  69. data/lib/brute/usage_detection/open_router.rb +20 -17
  70. data/lib/brute/usage_detection/ruby_llm.rb +18 -14
  71. data/lib/brute/usage_detection/usage.rb +10 -1
  72. data/lib/brute/utils/diff.rb +18 -10
  73. data/lib/brute/version.rb +1 -1
  74. data/lib/brute.rb +25 -12
  75. data/lib/brute_cli/providers/shell.rb +32 -29
  76. data/lib/brute_cli/providers/shell_response.rb +20 -18
  77. metadata +48 -5
  78. data/lib/brute/middleware/040_compaction_check.rb +0 -157
  79. data/lib/brute/middleware/event_handler.rb +0 -27
@@ -17,9 +17,13 @@ module Brute
17
17
  def execute(path:)
18
18
  target = File.expand_path(path)
19
19
  Brute::Tools::FS::FileMutationQueue.serialize(target) do
20
- raise "Path not found: #{target}" unless File.exist?(target)
20
+ unless File.exist?(target)
21
+ raise "Path not found: #{target}"
22
+ end
21
23
 
22
- Brute::Tools::FS::SnapshotStore.save(target) if File.file?(target)
24
+ if File.file?(target)
25
+ Brute::Tools::FS::SnapshotStore.save(target)
26
+ end
23
27
 
24
28
  if File.directory?(target)
25
29
  Dir.rmdir(target)
@@ -36,17 +36,27 @@ module Brute
36
36
 
37
37
  def execute(pattern:, path: nil, glob: nil, ignore_case: false)
38
38
  dir = File.expand_path(path || Dir.pwd)
39
- raise "Directory not found: #{dir}" unless File.directory?(dir)
39
+ unless File.directory?(dir)
40
+ raise "Directory not found: #{dir}"
41
+ end
40
42
 
41
43
  cmd = ["rg", "--line-number", "--max-columns=2000", "--max-columns-preview", "--sortr=modified"]
42
- cmd << "--ignore-case" if ignore_case
43
- cmd += ["--glob", glob] if glob
44
+ if ignore_case
45
+ cmd << "--ignore-case"
46
+ end
47
+ if glob
48
+ cmd += ["--glob", glob]
49
+ end
44
50
  cmd << pattern
45
51
  cmd << dir
46
52
 
47
53
  stdout, stderr, status = Open3.capture3(*cmd)
48
54
 
49
- output = stdout.empty? ? stderr : stdout
55
+ if stdout.empty?
56
+ output = stderr
57
+ else
58
+ output = stdout
59
+ end
50
60
 
51
61
  # Global cap at MAX_TOTAL_MATCHES lines
52
62
  lines = output.lines
@@ -18,10 +18,14 @@ module Brute
18
18
  target = File.expand_path(path)
19
19
  Brute::Tools::FS::FileMutationQueue.serialize(target) do
20
20
  snapshot = Brute::Tools::FS::SnapshotStore.pop(target)
21
- raise "No undo history available for: #{target}" unless snapshot
21
+ unless snapshot
22
+ raise "No undo history available for: #{target}"
23
+ end
22
24
 
23
25
  if snapshot == :did_not_exist
24
- File.delete(target) if File.exist?(target)
26
+ if File.exist?(target)
27
+ File.delete(target)
28
+ end
25
29
  {success: true, action: "deleted (file did not exist before)"}
26
30
  else
27
31
  File.write(target, snapshot)
@@ -19,7 +19,11 @@ module Brute
19
19
  def execute(file_path:, content:)
20
20
  path = File.expand_path(file_path)
21
21
  Brute::Tools::FS::FileMutationQueue.serialize(path) do
22
- old_content = File.exist?(path) ? File.read(path) : ''
22
+ if File.exist?(path)
23
+ old_content = File.read(path)
24
+ else
25
+ old_content = ''
26
+ end
23
27
  Brute::Tools::FS::SnapshotStore.save(path)
24
28
  FileUtils.mkdir_p(File.dirname(path))
25
29
  File.write(path, content)
@@ -21,7 +21,9 @@ module Brute
21
21
 
22
22
  def execute(url:)
23
23
  uri = URI.parse(url)
24
- raise "Invalid URL scheme: #{uri.scheme}" unless %w[http https].include?(uri.scheme)
24
+ unless %w[http https].include?(uri.scheme)
25
+ raise "Invalid URL scheme: #{uri.scheme}"
26
+ end
25
27
 
26
28
  http = Net::HTTP.new(uri.host, uri.port)
27
29
  http.use_ssl = uri.scheme == "https"
@@ -33,7 +35,9 @@ module Brute
33
35
 
34
36
  response = http.request(request)
35
37
  body = response.body.to_s
36
- body = body[0...MAX_BODY] + "\n...(truncated)" if body.size > MAX_BODY
38
+ if body.size > MAX_BODY
39
+ body = body[0...MAX_BODY] + "\n...(truncated)"
40
+ end
37
41
 
38
42
  {status: response.code.to_i, body: body, content_type: response["content-type"]}
39
43
  end
@@ -14,63 +14,66 @@ module Brute
14
14
  "to allow selecting more than one. If you recommend a specific option, make it " \
15
15
  "the first option and add \"(Recommended)\" at the end of the label."
16
16
 
17
- params({
18
- type: 'object',
19
- properties: {
20
- questions: {
21
- type: 'array',
22
- items: {
23
- type: 'object',
24
- properties: {
25
- question: { type: 'string' },
26
- header: { type: 'string' },
27
- options: {
28
- type: 'array',
29
- items: {
30
- type: 'object',
31
- properties: {
32
- label: { type: 'string' },
33
- description: { type: 'string' },
17
+ params(
18
+ {
19
+ type: 'object',
20
+ properties: {
21
+ questions: {
22
+ type: 'array',
23
+ items: {
24
+ type: 'object',
25
+ properties: {
26
+ question: { type: 'string' },
27
+ header: { type: 'string' },
28
+ options: {
29
+ type: 'array',
30
+ items: {
31
+ type: 'object',
32
+ properties: {
33
+ label: { type: 'string' },
34
+ description: { type: 'string' },
35
+ },
36
+ required: %w[label description],
34
37
  },
35
- required: %w[label description],
36
38
  },
39
+ multiple: { type: 'boolean' },
37
40
  },
38
- multiple: { type: 'boolean' },
41
+ required: %w[question header options],
39
42
  },
40
- required: %w[question header options],
41
43
  },
42
44
  },
45
+ required: %w[questions],
43
46
  },
44
- required: %w[questions],
45
- })
47
+ )
46
48
 
47
49
  def name; "question"; end
48
50
 
49
51
  def execute(questions:)
50
52
  handler = Thread.current[:on_question]
51
- unless handler
52
- return { error: true, message: "Cannot ask questions in non-interactive mode" }
53
+ if handler
54
+ queue = Queue.new
55
+ handler.call(questions, queue)
56
+ answers = queue.pop
57
+ format_answers(questions, answers)
58
+ else
59
+ { error: true, message: "Cannot ask questions in non-interactive mode" }
53
60
  end
54
-
55
- queue = Queue.new
56
- handler.call(questions, queue)
57
- answers = queue.pop
58
-
59
- format_answers(questions, answers)
60
61
  end
61
62
 
62
63
  private
63
64
 
64
- def format_answers(questions, answers)
65
- pairs = questions.each_with_index.map do |q, i|
66
- q = q.transform_keys(&:to_s) if q.is_a?(Hash)
67
- header = q["header"]
68
- answer = answers[i] || []
69
- "\"#{header}\" = #{answer.join(', ')}"
70
- end
65
+ def format_answers(questions, answers)
66
+ pairs = questions.each_with_index.map do |q, i|
67
+ if q.is_a?(Hash)
68
+ q = q.transform_keys(&:to_s)
69
+ end
70
+ header = q["header"]
71
+ answer = answers[i] || []
72
+ "\"#{header}\" = #{answer.join(', ')}"
73
+ end
71
74
 
72
- { response: "User answered: #{pairs.join('; ')}" }
73
- end
75
+ { response: "User answered: #{pairs.join('; ')}" }
76
+ end
74
77
  end
75
78
  end
76
79
  end
@@ -35,13 +35,24 @@ module Brute
35
35
  DEFAULT_TIMEOUT = 300 # 5 minutes
36
36
 
37
37
  def execute(command:, cwd: nil, timeout: nil)
38
- dir = cwd ? File.expand_path(cwd) : Dir.pwd
39
- raise "Directory not found: #{dir}" unless File.directory?(dir)
38
+ if cwd
39
+ dir = File.expand_path(cwd)
40
+ else
41
+ dir = Dir.pwd
42
+ end
43
+ unless File.directory?(dir)
44
+ raise "Directory not found: #{dir}"
45
+ end
40
46
 
41
47
  timeout_secs = timeout || DEFAULT_TIMEOUT
42
48
  stdout, stderr, status = nil
43
49
  Timeout.timeout(timeout_secs) do
44
- stdout, stderr, status = Open3.capture3("bash", "-c", command, chdir: dir)
50
+ stdout, stderr, status = Open3.capture3(
51
+ "bash",
52
+ "-c",
53
+ command,
54
+ chdir: dir,
55
+ )
45
56
  end
46
57
 
47
58
  out = stdout.to_s
@@ -49,8 +60,12 @@ module Brute
49
60
 
50
61
  # Combine output, preferring tail-mode truncation so errors/summaries at end are preserved
51
62
  combined = out
52
- combined += "\nSTDERR:\n#{err}" unless err.empty?
53
- combined += "\n[exit code: #{status.exitstatus}]" if status.exitstatus != 0
63
+ unless err.empty?
64
+ combined += "\nSTDERR:\n#{err}"
65
+ end
66
+ if status.exitstatus != 0
67
+ combined += "\n[exit code: #{status.exitstatus}]"
68
+ end
54
69
 
55
70
  Brute::Truncation.truncate(combined, direction: :tail)
56
71
  rescue Timeout::Error
@@ -40,61 +40,66 @@ module Brute
40
40
 
41
41
  FILE_LIMIT = 10
42
42
 
43
- def name; "skill"; end
44
-
45
43
  def initialize(cwd: Dir.pwd)
46
44
  super()
47
45
  @cwd = cwd
48
46
  end
49
47
 
48
+ def name; "skill"; end
49
+
50
50
  def execute(name:)
51
51
  skill = Brute::Skill.get(name, cwd: @cwd)
52
- return unknown_skill(name) unless skill
53
-
54
- directory = File.dirname(skill.location)
55
- files = bundled_files(directory)
56
-
57
- render(skill, directory, files)
52
+ if skill
53
+ directory = File.dirname(skill.location)
54
+ files = bundled_files(directory)
55
+ render(skill, directory, files)
56
+ else
57
+ unknown_skill(name)
58
+ end
58
59
  end
59
60
 
60
61
  private
61
62
 
62
- def unknown_skill(name)
63
- available = Brute::Skill.all(cwd: @cwd).map(&:name)
64
- listing = available.empty? ? "(none)" : available.join(", ")
65
- "Error: unknown skill #{name.inspect}. Available skills: #{listing}"
66
- end
67
-
68
- def bundled_files(directory)
69
- Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH)
70
- .select { |p| File.file?(p) }
71
- .reject { |p| File.basename(p) == Brute::Skill::FILENAME }
72
- .map { |p| Pathname.new(p).relative_path_from(Pathname.new(directory)).to_s }
73
- .sort
74
- .first(FILE_LIMIT)
75
- end
63
+ def unknown_skill(name)
64
+ available = Brute::Skill.all(cwd: @cwd).map(&:name)
65
+ if available.empty?
66
+ listing = "(none)"
67
+ else
68
+ listing = available.join(", ")
69
+ end
70
+ "Error: unknown skill #{name.inspect}. Available skills: #{listing}"
71
+ end
76
72
 
77
- def render(skill, directory, files)
78
- lines = [
79
- "<skill name=\"#{skill.name}\">",
80
- "# Skill: #{skill.name}",
81
- "",
82
- skill.content,
83
- "",
84
- "Base directory for this skill: #{directory}",
85
- "Relative paths in this skill (e.g. scripts/, references/, assets/) are relative to " \
86
- "this base directory. Use your existing tools (read, shell) to access them.",
87
- ]
88
-
89
- unless files.empty?
90
- lines << ""
91
- lines << "Bundled files (sampled, up to #{FILE_LIMIT}):"
92
- files.each { |f| lines << " #{f}" }
73
+ def bundled_files(directory)
74
+ Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH)
75
+ .select { |p| File.file?(p) }
76
+ .reject { |p| File.basename(p) == Brute::Skill::FILENAME }
77
+ .map { |p| Pathname.new(p).relative_path_from(Pathname.new(directory)).to_s }
78
+ .sort
79
+ .first(FILE_LIMIT)
93
80
  end
94
81
 
95
- lines << "</skill>"
96
- lines.join("\n")
97
- end
82
+ def render(skill, directory, files)
83
+ lines = [
84
+ "<skill name=\"#{skill.name}\">",
85
+ "# Skill: #{skill.name}",
86
+ "",
87
+ skill.content,
88
+ "",
89
+ "Base directory for this skill: #{directory}",
90
+ "Relative paths in this skill (e.g. scripts/, references/, assets/) are relative to " \
91
+ "this base directory. Use your existing tools (read, shell) to access them.",
92
+ ]
93
+
94
+ unless files.empty?
95
+ lines << ""
96
+ lines << "Bundled files (sampled, up to #{FILE_LIMIT}):"
97
+ files.each { |f| lines << " #{f}" }
98
+ end
99
+
100
+ lines << "</skill>"
101
+ lines.join("\n")
102
+ end
98
103
  end
99
104
  end
100
105
  end
@@ -21,7 +21,7 @@ module Brute
21
21
  # use Brute::Middleware::SystemPrompt
22
22
  # use Brute::Middleware::Loop::ToolResult
23
23
  # use Brute::Middleware::MaxIterations, max_iterations: 10
24
- # use Brute::Middleware::ToolPipeline, tools: [Brute::Tools::FSRead, Brute::Tools::FSSearch]
24
+ # use Brute::Middleware::DefaultToolPipeline, tools: [Brute::Tools::FSRead, Brute::Tools::FSSearch]
25
25
  # run ->(env) do
26
26
  # # The LLM call, written with your library of choice. Convert
27
27
  # # env[:messages] to its format, call it, and append the response
@@ -32,7 +32,7 @@ module Brute
32
32
  #
33
33
  # # The SubAgent IS a tool — hand it to a parent agent's ToolPipeline:
34
34
  # main_agent = Brute.agent do
35
- # use Brute::Middleware::ToolPipeline, tools: [Brute::Tools::FSRead, researcher]
35
+ # use Brute::Middleware::DefaultToolPipeline, tools: [Brute::Tools::FSRead, researcher]
36
36
  # run ->(env) { ... }
37
37
  # end
38
38
  # main_agent.start("delegate some research")
@@ -10,30 +10,34 @@ module Brute
10
10
  description "Create or update the todo list. Send the complete list each time — " \
11
11
  "this replaces the existing list entirely."
12
12
 
13
- params({
14
- type: 'object',
15
- properties: {
16
- todos: {
17
- type: 'array',
18
- items: {
19
- type: 'object',
20
- properties: {
21
- id: { type: 'string' },
22
- content: { type: 'string' },
23
- status: { type: 'string', enum: %w[pending in_progress completed cancelled] },
13
+ params(
14
+ {
15
+ type: 'object',
16
+ properties: {
17
+ todos: {
18
+ type: 'array',
19
+ items: {
20
+ type: 'object',
21
+ properties: {
22
+ id: { type: 'string' },
23
+ content: { type: 'string' },
24
+ status: { type: 'string', enum: %w[pending in_progress completed cancelled] },
25
+ },
26
+ required: %w[id content status],
24
27
  },
25
- required: %w[id content status],
26
28
  },
27
29
  },
30
+ required: %w[todos],
28
31
  },
29
- required: %w[todos],
30
- })
32
+ )
31
33
 
32
34
  def name; "todo_write"; end
33
35
 
34
36
  def execute(todos:)
35
37
  items = todos.map do |t|
36
- t = t.transform_keys(&:to_sym) if t.is_a?(Hash)
38
+ if t.is_a?(Hash)
39
+ t = t.transform_keys(&:to_sym)
40
+ end
37
41
  {id: t[:id], content: t[:content], status: t[:status]}
38
42
  end
39
43
  Brute::Tools::TodoList::Store.replace(items)
@@ -40,7 +40,13 @@ module Brute
40
40
  MAX_LINE_LENGTH = 2000
41
41
  TRUNCATION_MARKER = "[Output truncated:"
42
42
 
43
- TRUNCATION_DIR = File.join(Dir.home, ".local", "share", "brute", "tool-output")
43
+ TRUNCATION_DIR = File.join(
44
+ Dir.home,
45
+ ".local",
46
+ "share",
47
+ "brute",
48
+ "tool-output",
49
+ )
44
50
 
45
51
  # Truncate text to fit within line and byte limits.
46
52
  #
@@ -55,38 +61,48 @@ module Brute
55
61
  # @returns [String] the (possibly truncated) text
56
62
  #
57
63
  def self.truncate(text, max_lines: MAX_LINES, max_bytes: MAX_BYTES, direction: :head, truncation_dir: nil)
58
- return text if text.nil? || text.empty?
59
-
60
- # Per-line truncation first — cap individual lines
61
- lines = text.lines.map { |line| truncate_line(line) }
62
- text = lines.join
63
-
64
- return text if lines.size <= max_lines && text.bytesize <= max_bytes
65
-
66
- # Determine how many lines we can keep within both caps
67
- kept = direction == :tail ? lines.last(max_lines) : lines.first(max_lines)
68
-
69
- # Enforce byte cap
70
- result_lines = []
71
- bytes = 0
72
- kept.each do |line|
73
- break if bytes + line.bytesize > max_bytes
74
- result_lines << line
75
- bytes += line.bytesize
76
- end
77
-
78
- result = result_lines.join
79
- total = lines.size
80
- shown = result_lines.size
81
-
82
- # Overflow to disk — save the full output so it can be inspected later
83
- saved_path = save_to_disk(text, truncation_dir)
84
-
85
- hint = "\n#{TRUNCATION_MARKER} showing #{shown} of #{total} lines]"
86
- if saved_path
87
- hint += "\nFull output saved to: #{saved_path}. Use Read with offset/limit to view specific sections."
64
+ if text.nil? || text.empty?
65
+ text
66
+ else
67
+ # Per-line truncation first cap individual lines
68
+ lines = text.lines.map { |line| truncate_line(line) }
69
+ text = lines.join
70
+
71
+ if lines.size <= max_lines && text.bytesize <= max_bytes
72
+ text
73
+ else
74
+ # Determine how many lines we can keep within both caps
75
+ if direction == :tail
76
+ kept = lines.last(max_lines)
77
+ else
78
+ kept = lines.first(max_lines)
79
+ end
80
+
81
+ # Enforce byte cap
82
+ result_lines = []
83
+ bytes = 0
84
+ kept.each do |line|
85
+ if bytes + line.bytesize > max_bytes
86
+ break
87
+ end
88
+ result_lines << line
89
+ bytes += line.bytesize
90
+ end
91
+
92
+ result = result_lines.join
93
+ total = lines.size
94
+ shown = result_lines.size
95
+
96
+ # Overflow to disk — save the full output so it can be inspected later
97
+ saved_path = save_to_disk(text, truncation_dir)
98
+
99
+ hint = "\n#{TRUNCATION_MARKER} showing #{shown} of #{total} lines]"
100
+ if saved_path
101
+ hint += "\nFull output saved to: #{saved_path}. Use Read with offset/limit to view specific sections."
102
+ end
103
+ result + hint
104
+ end
88
105
  end
89
- result + hint
90
106
  end
91
107
 
92
108
  # Check whether text already contains a truncation marker.
@@ -96,27 +112,36 @@ module Brute
96
112
 
97
113
  # Truncate a single line if it exceeds MAX_LINE_LENGTH.
98
114
  def self.truncate_line(line, max: MAX_LINE_LENGTH)
99
- return line if line.length <= max
100
- line[0, max] + "... (line truncated to #{max} chars)\n"
115
+ if line.length <= max
116
+ line
117
+ else
118
+ line[0, max] + "... (line truncated to #{max} chars)\n"
119
+ end
101
120
  end
102
121
 
103
122
  # Purge files older than retention_days from the given directory.
104
123
  def self.cleanup!(dir, retention_days: 7)
105
- return unless File.directory?(dir)
106
- cutoff = Time.now - (retention_days * 86400)
107
- Dir.glob(File.join(dir, "*")).each do |path|
108
- File.delete(path) if File.file?(path) && File.mtime(path) < cutoff
124
+ if File.directory?(dir)
125
+ cutoff = Time.now - (retention_days * 86400)
126
+ Dir.glob(File.join(dir, "*")).each do |path|
127
+ if File.file?(path) && File.mtime(path) < cutoff
128
+ File.delete(path)
129
+ end
130
+ end
131
+ else
132
+ nil
109
133
  end
110
134
  end
111
135
 
112
136
  # Save text to a file in truncation_dir. Returns the file path, or nil.
113
137
  def self.save_to_disk(text, truncation_dir)
114
138
  dir = truncation_dir || TRUNCATION_DIR
115
- return nil unless dir
116
- FileUtils.mkdir_p(dir)
117
- path = File.join(dir, "tool_#{SecureRandom.hex(8)}.txt")
118
- File.write(path, text)
119
- path
139
+ if dir
140
+ FileUtils.mkdir_p(dir)
141
+ path = File.join(dir, "tool_#{SecureRandom.hex(8)}.txt")
142
+ File.write(path, text)
143
+ path
144
+ end
120
145
  rescue => _e
121
146
  nil
122
147
  end
@@ -38,8 +38,10 @@ module Brute
38
38
  # the newest message, only when it is a user message, before the rest
39
39
  # of the stack.
40
40
  def map(matcher, &block)
41
- (@commands ||= []) << [check(matcher), block]
42
- self
41
+ tap do
42
+ @commands ||= []
43
+ @commands << [check(matcher), block]
44
+ end
43
45
  end
44
46
 
45
47
  # Every chain starts with the commands, registry or no registry: the
@@ -50,21 +52,25 @@ module Brute
50
52
  end
51
53
  alias_method :build, :to_app
52
54
 
53
- def start(input = nil, events: NullSink.new)
54
- env = {
55
+ def start(input = nil)
56
+ {
55
57
  messages: coerce_messages(input),
56
- events: events,
57
58
  metadata: {},
58
59
  current_iteration: 1,
59
60
  commands: @commands || [],
60
- }
61
- hooks.emit(TURN_START_EVENT, env)
62
- begin
63
- hooks.emit(TURN_DURATION_EVENT, env) { build.call(env) }
64
- ensure
65
- hooks.emit(TURN_END_EVENT, env)
61
+ }.tap do |turn|
62
+ Brute::Hooks::Trace.new(turn, hooks: hooks).emit_trace do |env|
63
+ env.emit(TURN_START_EVENT)
64
+ begin
65
+ env.emit(TURN_DURATION_EVENT) { build.call(env) }
66
+ rescue => error
67
+ env.emit(TURN_FAILURE_EVENT, error)
68
+ raise
69
+ ensure
70
+ env.emit(TURN_END_EVENT)
71
+ end
72
+ end
66
73
  end
67
- env
68
74
  end
69
75
 
70
76
  private
@@ -86,7 +92,11 @@ module Brute
86
92
  matcher
87
93
  else
88
94
  name = matcher.to_s
89
- name = name.start_with?("/") ? name : "/#{name}"
95
+ if name.start_with?("/")
96
+ name = name
97
+ else
98
+ name = "/#{name}"
99
+ end
90
100
  /^#{::Regexp.escape(name)}.*/
91
101
  end
92
102
  end