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.
- checksums.yaml +4 -4
- data/lib/brute/compaction/middleware/sliding_window.rb +170 -0
- data/lib/brute/compaction/middleware/strategy.rb +137 -0
- data/lib/brute/compaction/middleware/tool_results.rb +146 -0
- data/lib/brute/compaction/summarize.rb +349 -0
- data/lib/brute/compaction/transcript.rb +166 -0
- data/lib/brute/compaction.rb +57 -0
- data/lib/brute/completion/lang_chain.rb +34 -31
- data/lib/brute/completion/llmrb.rb +31 -29
- data/lib/brute/completion/open_router.rb +43 -40
- data/lib/brute/completion/ruby_llm.rb +28 -27
- data/lib/brute/contrib/otel.rb +83 -51
- data/lib/brute/env.rb +54 -0
- data/lib/brute/eval/case.rb +254 -0
- data/lib/brute/eval/suite.rb +181 -0
- data/lib/brute/eval/transcript.rb +147 -0
- data/lib/brute/eval/world.rb +106 -0
- data/lib/brute/eval.rb +48 -0
- data/lib/brute/events/handler.rb +3 -1
- data/lib/brute/events/prefixed_terminal_output.rb +3 -1
- data/lib/brute/events/terminal_output_handler.rb +1 -1
- data/lib/brute/hooks.rb +89 -102
- data/lib/brute/message_transport/anthropic.rb +13 -3
- data/lib/brute/message_transport/llm.rb +8 -4
- data/lib/brute/message_transport/open_router.rb +30 -16
- data/lib/brute/message_transport/openai.rb +14 -7
- data/lib/brute/message_transport/ruby_llm.rb +21 -17
- data/lib/brute/message_transport/ruby_open_ai.rb +59 -57
- data/lib/brute/messages.rb +15 -8
- data/lib/brute/middleware/000_base.rb +6 -6
- data/lib/brute/middleware/002_session_log.rb +12 -4
- data/lib/brute/middleware/008_checkpoint.rb +27 -19
- data/lib/brute/middleware/010_max_iterations.rb +1 -1
- data/lib/brute/middleware/020_system_prompt.rb +1 -1
- data/lib/brute/middleware/040_default_compaction_pipeline.rb +356 -0
- data/lib/brute/middleware/{070_tool_pipeline.rb → 070_default_tool_pipeline.rb} +49 -58
- data/lib/brute/prompt_template.rb +19 -15
- data/lib/brute/prompts/base.rb +19 -10
- data/lib/brute/prompts/environment.rb +3 -1
- data/lib/brute/prompts/instructions.rb +9 -7
- data/lib/brute/prompts/skills.rb +5 -3
- data/lib/brute/rack/adapter.rb +40 -23
- data/lib/brute/skill.rb +136 -89
- data/lib/brute/system_prompt.rb +9 -9
- data/lib/brute/token_counter/approximate.rb +54 -0
- data/lib/brute/token_counter/tiktoken.rb +80 -0
- data/lib/brute/token_counter.rb +150 -0
- data/lib/brute/tool.rb +10 -6
- data/lib/brute/tools/adapter.rb +57 -47
- data/lib/brute/tools/fs/snapshot_store.rb +5 -1
- data/lib/brute/tools/fs_patch.rb +16 -8
- data/lib/brute/tools/fs_read.rb +107 -80
- data/lib/brute/tools/fs_remove.rb +6 -2
- data/lib/brute/tools/fs_search.rb +14 -4
- data/lib/brute/tools/fs_undo.rb +6 -2
- data/lib/brute/tools/fs_write.rb +5 -1
- data/lib/brute/tools/net_fetch.rb +6 -2
- data/lib/brute/tools/question.rb +42 -39
- data/lib/brute/tools/shell.rb +20 -5
- data/lib/brute/tools/skill_load.rb +46 -41
- data/lib/brute/tools/sub_agent.rb +2 -2
- data/lib/brute/tools/todo_write.rb +19 -15
- data/lib/brute/truncation.rb +68 -43
- data/lib/brute/turn/agent_pipeline.rb +23 -13
- data/lib/brute/turn/compaction_pipeline.rb +123 -0
- data/lib/brute/turn/pipeline.rb +87 -96
- data/lib/brute/turn/tool_pipeline.rb +4 -3
- data/lib/brute/usage_detection/llmrb.rb +18 -14
- data/lib/brute/usage_detection/open_router.rb +20 -17
- data/lib/brute/usage_detection/ruby_llm.rb +18 -14
- data/lib/brute/usage_detection/usage.rb +10 -1
- data/lib/brute/utils/diff.rb +18 -10
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +25 -12
- data/lib/brute_cli/providers/shell.rb +32 -29
- data/lib/brute_cli/providers/shell_response.rb +20 -18
- metadata +48 -5
- data/lib/brute/middleware/040_compaction_check.rb +0 -157
- 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
|
-
|
|
20
|
+
unless File.exist?(target)
|
|
21
|
+
raise "Path not found: #{target}"
|
|
22
|
+
end
|
|
21
23
|
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
data/lib/brute/tools/fs_undo.rb
CHANGED
|
@@ -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
|
-
|
|
21
|
+
unless snapshot
|
|
22
|
+
raise "No undo history available for: #{target}"
|
|
23
|
+
end
|
|
22
24
|
|
|
23
25
|
if snapshot == :did_not_exist
|
|
24
|
-
|
|
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)
|
data/lib/brute/tools/fs_write.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/brute/tools/question.rb
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
75
|
+
{ response: "User answered: #{pairs.join('; ')}" }
|
|
76
|
+
end
|
|
74
77
|
end
|
|
75
78
|
end
|
|
76
79
|
end
|
data/lib/brute/tools/shell.rb
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
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(
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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::
|
|
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::
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/brute/truncation.rb
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
54
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|