brute 1.0.1 → 2.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/agent.rb +72 -6
- data/lib/brute/events/handler.rb +69 -0
- data/lib/brute/events/prefixed_terminal_output.rb +72 -0
- data/lib/brute/events/terminal_output_handler.rb +68 -0
- data/lib/brute/middleware/001_otel_span.rb +77 -0
- data/lib/brute/middleware/003_tool_result_loop.rb +103 -0
- data/lib/brute/middleware/004_summarize.rb +139 -0
- data/lib/brute/middleware/005_tracing.rb +86 -0
- data/lib/brute/middleware/010_max_iterations.rb +73 -0
- data/lib/brute/middleware/015_otel_token_usage.rb +42 -0
- data/lib/brute/middleware/020_system_prompt.rb +128 -0
- data/lib/brute/middleware/040_compaction_check.rb +155 -0
- data/lib/brute/middleware/060_questions.rb +41 -0
- data/lib/brute/middleware/070_tool_call.rb +247 -0
- data/lib/brute/middleware/073_otel_tool_call.rb +49 -0
- data/lib/brute/middleware/075_otel_tool_results.rb +46 -0
- data/lib/brute/middleware/100_llm_call.rb +62 -0
- data/lib/brute/middleware/event_handler.rb +25 -0
- data/lib/brute/middleware/user_queue.rb +35 -0
- data/lib/brute/pipeline.rb +44 -107
- data/lib/brute/prompts/skills.rb +2 -2
- data/lib/brute/prompts.rb +23 -23
- data/lib/brute/providers/shell.rb +6 -19
- data/lib/brute/providers/shell_response.rb +22 -30
- data/lib/brute/session.rb +52 -0
- data/lib/brute/store/snapshot_store.rb +21 -37
- data/lib/brute/sub_agent.rb +106 -0
- data/lib/brute/system_prompt.rb +1 -83
- data/lib/brute/tool.rb +107 -0
- data/lib/brute/tools/delegate.rb +61 -70
- data/lib/brute/tools/fs_patch.rb +9 -7
- data/lib/brute/tools/fs_read.rb +233 -20
- data/lib/brute/tools/fs_remove.rb +8 -9
- data/lib/brute/tools/fs_search.rb +98 -16
- data/lib/brute/tools/fs_undo.rb +8 -8
- data/lib/brute/tools/fs_write.rb +7 -5
- data/lib/brute/tools/net_fetch.rb +8 -8
- data/lib/brute/tools/question.rb +36 -24
- data/lib/brute/tools/shell.rb +74 -16
- data/lib/brute/tools/todo_read.rb +8 -8
- data/lib/brute/tools/todo_write.rb +25 -18
- data/lib/brute/tools.rb +8 -12
- data/lib/brute/truncation.rb +219 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +82 -45
- metadata +59 -46
- data/lib/brute/loop/agent_stream.rb +0 -118
- data/lib/brute/loop/agent_turn.rb +0 -520
- data/lib/brute/loop/compactor.rb +0 -107
- data/lib/brute/loop/doom_loop.rb +0 -86
- data/lib/brute/loop/step.rb +0 -332
- data/lib/brute/loop/tool_call_step.rb +0 -90
- data/lib/brute/middleware/base.rb +0 -27
- data/lib/brute/middleware/compaction_check.rb +0 -106
- data/lib/brute/middleware/doom_loop_detection.rb +0 -136
- data/lib/brute/middleware/llm_call.rb +0 -128
- data/lib/brute/middleware/message_tracking.rb +0 -339
- data/lib/brute/middleware/otel/span.rb +0 -105
- data/lib/brute/middleware/otel/token_usage.rb +0 -68
- data/lib/brute/middleware/otel/tool_calls.rb +0 -68
- data/lib/brute/middleware/otel/tool_results.rb +0 -65
- data/lib/brute/middleware/otel.rb +0 -34
- data/lib/brute/middleware/reasoning_normalizer.rb +0 -192
- data/lib/brute/middleware/retry.rb +0 -157
- data/lib/brute/middleware/session_persistence.rb +0 -72
- data/lib/brute/middleware/token_tracking.rb +0 -124
- data/lib/brute/middleware/tool_error_tracking.rb +0 -179
- data/lib/brute/middleware/tool_use_guard.rb +0 -133
- data/lib/brute/middleware/tracing.rb +0 -124
- data/lib/brute/middleware.rb +0 -18
- data/lib/brute/orchestrator/turn.rb +0 -105
- data/lib/brute/patches/anthropic_tool_role.rb +0 -35
- data/lib/brute/patches/buffer_nil_guard.rb +0 -26
- data/lib/brute/providers/models_dev.rb +0 -111
- data/lib/brute/providers/ollama.rb +0 -135
- data/lib/brute/providers/opencode_go.rb +0 -43
- data/lib/brute/providers/opencode_zen.rb +0 -87
- data/lib/brute/providers.rb +0 -62
- data/lib/brute/queue/base_queue.rb +0 -222
- data/lib/brute/queue/parallel_queue.rb +0 -66
- data/lib/brute/queue/sequential_queue.rb +0 -63
- data/lib/brute/store/message_store.rb +0 -362
- data/lib/brute/store/session.rb +0 -106
- /data/lib/brute/{diff.rb → utils/diff.rb} +0 -0
data/lib/brute/tools/fs_write.rb
CHANGED
|
@@ -2,19 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
require "bundler/setup"
|
|
4
4
|
require "brute"
|
|
5
|
+
require "brute/tools"
|
|
5
6
|
require 'fileutils'
|
|
6
7
|
|
|
7
8
|
module Brute
|
|
8
9
|
module Tools
|
|
9
|
-
class FSWrite <
|
|
10
|
-
name 'write'
|
|
10
|
+
class FSWrite < RubyLLM::Tool
|
|
11
11
|
description "Write content to a file. Creates parent directories if they don't exist. " \
|
|
12
12
|
'Use this for creating new files or completely replacing file contents.'
|
|
13
13
|
|
|
14
|
-
param :file_path,
|
|
15
|
-
param :content,
|
|
14
|
+
param :file_path, type: 'string', desc: 'Path to the file to write', required: true
|
|
15
|
+
param :content, type: 'string', desc: 'The full content to write to the file', required: true
|
|
16
16
|
|
|
17
|
-
def
|
|
17
|
+
def name; "write"; end
|
|
18
|
+
|
|
19
|
+
def execute(file_path:, content:)
|
|
18
20
|
path = File.expand_path(file_path)
|
|
19
21
|
Brute::Queue::FileMutationQueue.serialize(path) do
|
|
20
22
|
old_content = File.exist?(path) ? File.read(path) : ''
|
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
end
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require "brute/tools"
|
|
7
6
|
|
|
8
7
|
require "net/http"
|
|
9
8
|
require "uri"
|
|
10
9
|
|
|
11
10
|
module Brute
|
|
12
11
|
module Tools
|
|
13
|
-
class NetFetch <
|
|
14
|
-
name "fetch"
|
|
12
|
+
class NetFetch < RubyLLM::Tool
|
|
15
13
|
description "Fetch content from a URL. Returns the response body as text."
|
|
16
14
|
|
|
17
|
-
param :url,
|
|
15
|
+
param :url, type: 'string', desc: "The URL to fetch", required: true
|
|
16
|
+
|
|
17
|
+
def name; "fetch"; end
|
|
18
18
|
|
|
19
19
|
MAX_BODY = 50_000
|
|
20
20
|
TIMEOUT = 30
|
|
21
21
|
|
|
22
|
-
def
|
|
22
|
+
def execute(url:)
|
|
23
23
|
uri = URI.parse(url)
|
|
24
24
|
raise "Invalid URL scheme: #{uri.scheme}" unless %w[http https].include?(uri.scheme)
|
|
25
25
|
|
data/lib/brute/tools/question.rb
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
end
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require "brute/tools"
|
|
7
6
|
|
|
8
7
|
module Brute
|
|
9
8
|
module Tools
|
|
10
|
-
class Question <
|
|
11
|
-
name "question"
|
|
9
|
+
class Question < RubyLLM::Tool
|
|
12
10
|
description "Ask the user questions during execution. Use this to gather preferences, " \
|
|
13
11
|
"clarify ambiguous instructions, get decisions on implementation choices, or " \
|
|
14
12
|
"offer choices about direction. Users can always select \"Other\" to provide " \
|
|
@@ -16,25 +14,39 @@ module Brute
|
|
|
16
14
|
"to allow selecting more than one. If you recommend a specific option, make it " \
|
|
17
15
|
"the first option and add \"(Recommended)\" at the end of the label."
|
|
18
16
|
|
|
19
|
-
params
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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' },
|
|
34
|
+
},
|
|
35
|
+
required: %w[label description],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
multiple: { type: 'boolean' },
|
|
39
|
+
},
|
|
40
|
+
required: %w[question header options],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
required: %w[questions],
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
def name; "question"; end
|
|
36
48
|
|
|
37
|
-
def
|
|
49
|
+
def execute(questions:)
|
|
38
50
|
handler = Thread.current[:on_question]
|
|
39
51
|
unless handler
|
|
40
52
|
return { error: true, message: "Cannot ask questions in non-interactive mode" }
|
data/lib/brute/tools/shell.rb
CHANGED
|
@@ -1,43 +1,101 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require "brute/tools"
|
|
6
|
+
require "brute/truncation"
|
|
7
7
|
|
|
8
8
|
require "open3"
|
|
9
9
|
|
|
10
10
|
module Brute
|
|
11
11
|
module Tools
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
# Existing features (ref: opencode bash tool):
|
|
13
|
+
#
|
|
14
|
+
# 1. Tail-mode truncation — when output exceeds limits, keep the LAST
|
|
15
|
+
# N lines / bytes instead of the first. Command output typically has
|
|
16
|
+
# the important info (errors, summaries) at the end.
|
|
17
|
+
# 2. Save full output to disk — when truncating, write the complete
|
|
18
|
+
# output to a temp file and include the path in the truncated result
|
|
19
|
+
# so the LLM can use Read with offset/limit to inspect it.
|
|
20
|
+
# 3. Align limits with universal truncation (2000 lines / 50 KB).
|
|
21
|
+
# 4. Configurable per-call timeout — accept a timeout parameter from
|
|
22
|
+
# the LLM (defaults to 5 minutes).
|
|
23
|
+
# 5. Return a plain string instead of a Hash.
|
|
24
|
+
#
|
|
25
|
+
class Shell < RubyLLM::Tool
|
|
14
26
|
description "Execute a shell command and return stdout, stderr, and exit code. " \
|
|
15
27
|
"Use for git operations, running tests, installing packages, etc."
|
|
16
28
|
|
|
17
|
-
param :command,
|
|
18
|
-
param :cwd,
|
|
29
|
+
param :command, type: 'string', desc: "The shell command to execute", required: true
|
|
30
|
+
param :cwd, type: 'string', desc: "Working directory for the command (defaults to project root)", required: false
|
|
31
|
+
param :timeout, type: 'integer', desc: "Timeout in seconds (defaults to 300)", required: false
|
|
32
|
+
|
|
33
|
+
def name; "shell"; end
|
|
19
34
|
|
|
20
|
-
|
|
21
|
-
MAX_OUTPUT = 50_000
|
|
35
|
+
DEFAULT_TIMEOUT = 300 # 5 minutes
|
|
22
36
|
|
|
23
|
-
def
|
|
37
|
+
def execute(command:, cwd: nil, timeout: nil)
|
|
24
38
|
dir = cwd ? File.expand_path(cwd) : Dir.pwd
|
|
25
39
|
raise "Directory not found: #{dir}" unless File.directory?(dir)
|
|
26
40
|
|
|
41
|
+
timeout_secs = timeout || DEFAULT_TIMEOUT
|
|
27
42
|
stdout, stderr, status = nil
|
|
28
|
-
Timeout.timeout(
|
|
43
|
+
Timeout.timeout(timeout_secs) do
|
|
29
44
|
stdout, stderr, status = Open3.capture3("bash", "-c", command, chdir: dir)
|
|
30
45
|
end
|
|
31
46
|
|
|
32
47
|
out = stdout.to_s
|
|
33
48
|
err = stderr.to_s
|
|
34
|
-
out = out[0...MAX_OUTPUT] + "\n...(truncated)" if out.size > MAX_OUTPUT
|
|
35
|
-
err = err[0...MAX_OUTPUT] + "\n...(truncated)" if err.size > MAX_OUTPUT
|
|
36
49
|
|
|
37
|
-
|
|
50
|
+
# Combine output, preferring tail-mode truncation so errors/summaries at end are preserved
|
|
51
|
+
combined = out
|
|
52
|
+
combined += "\nSTDERR:\n#{err}" unless err.empty?
|
|
53
|
+
combined += "\n[exit code: #{status.exitstatus}]" if status.exitstatus != 0
|
|
54
|
+
|
|
55
|
+
Brute::Truncation.truncate(combined, direction: :tail)
|
|
38
56
|
rescue Timeout::Error
|
|
39
|
-
|
|
57
|
+
"Command timed out after #{timeout_secs}s: #{command}"
|
|
40
58
|
end
|
|
41
59
|
end
|
|
42
60
|
end
|
|
43
61
|
end
|
|
62
|
+
|
|
63
|
+
test do
|
|
64
|
+
it "runs a command without error" do
|
|
65
|
+
result = Brute::Tools::Shell.new.call(command: "echo hello")
|
|
66
|
+
result.strip.should =~ /hello/
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "returns exit code" do
|
|
70
|
+
result = Brute::Tools::Shell.new.call(command: "false")
|
|
71
|
+
result.should =~ /exit code: 1/
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "returns a String, not a Hash" do
|
|
75
|
+
Brute::Tools::Shell.new.call(command: "echo hello").should.be.kind_of(String)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "preserves the end of output when truncating (tail mode)" do
|
|
79
|
+
result = Brute::Tools::Shell.new.call(command: "seq 1 100000")
|
|
80
|
+
result.should =~ /100000/
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# --- Save full output to disk ---
|
|
84
|
+
|
|
85
|
+
it "saves full output to disk when truncated" do
|
|
86
|
+
result = Brute::Tools::Shell.new.call(command: "seq 1 100000")
|
|
87
|
+
result.should =~ /Full output saved to:/
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# --- Configurable timeout ---
|
|
91
|
+
|
|
92
|
+
it "accepts a timeout parameter" do
|
|
93
|
+
result = Brute::Tools::Shell.new.call(command: "sleep 0.1 && echo done", timeout: 10)
|
|
94
|
+
result.should =~ /done/
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "times out with a short timeout" do
|
|
98
|
+
result = Brute::Tools::Shell.new.call(command: "sleep 10", timeout: 1)
|
|
99
|
+
result.should =~ /timed out/i
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
end
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require "brute/tools"
|
|
7
6
|
|
|
8
7
|
module Brute
|
|
9
8
|
module Tools
|
|
10
|
-
class TodoRead <
|
|
11
|
-
name "todo_read"
|
|
9
|
+
class TodoRead < RubyLLM::Tool
|
|
12
10
|
description "Read the current todo list to check task status and progress."
|
|
13
|
-
param :_placeholder,
|
|
11
|
+
param :_placeholder, type: 'string', desc: "Unused, pass any value", required: false
|
|
12
|
+
|
|
13
|
+
def name; "todo_read"; end
|
|
14
14
|
|
|
15
|
-
def
|
|
15
|
+
def execute(_placeholder: nil)
|
|
16
16
|
{todos: Brute::Store::TodoStore.all}
|
|
17
17
|
end
|
|
18
18
|
end
|
|
@@ -1,30 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
end
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require "brute/tools"
|
|
7
6
|
|
|
8
7
|
module Brute
|
|
9
8
|
module Tools
|
|
10
|
-
class TodoWrite <
|
|
11
|
-
name "todo_write"
|
|
9
|
+
class TodoWrite < RubyLLM::Tool
|
|
12
10
|
description "Create or update the todo list. Send the complete list each time — " \
|
|
13
11
|
"this replaces the existing list entirely."
|
|
14
12
|
|
|
15
|
-
params
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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] },
|
|
24
|
+
},
|
|
25
|
+
required: %w[id content status],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
required: %w[todos],
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
def name; "todo_write"; end
|
|
26
33
|
|
|
27
|
-
def
|
|
34
|
+
def execute(todos:)
|
|
28
35
|
items = todos.map do |t|
|
|
29
36
|
t = t.transform_keys(&:to_sym) if t.is_a?(Hash)
|
|
30
37
|
{id: t[:id], content: t[:content], status: t[:status]}
|
data/lib/brute/tools.rb
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
require_relative 'tools/todo_write'
|
|
10
|
-
require_relative 'tools/todo_read'
|
|
11
|
-
require_relative 'tools/delegate'
|
|
12
|
-
require_relative 'tools/question'
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
Dir.glob("#{__dir__}/tools/**/*.rb").sort.each do |path|
|
|
7
|
+
require path
|
|
8
|
+
end
|
|
13
9
|
|
|
14
10
|
module Brute
|
|
15
11
|
module Tools
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module Brute
|
|
9
|
+
# Universal tool output truncation.
|
|
10
|
+
#
|
|
11
|
+
# Every tool result passes through Truncation.truncate() before entering
|
|
12
|
+
# the LLM context. This is the primary guard against context window
|
|
13
|
+
# explosion — even if a tool has no internal limits, this module caps
|
|
14
|
+
# the output to a safe size.
|
|
15
|
+
#
|
|
16
|
+
# Existing features (ref: opencode truncate.ts):
|
|
17
|
+
#
|
|
18
|
+
# 1. Line + byte dual cap — truncate when output exceeds MAX_LINES
|
|
19
|
+
# (2000) or MAX_BYTES (50 KB), whichever is hit first.
|
|
20
|
+
# 2. Head mode (default) — keep the first N lines / bytes. Used for
|
|
21
|
+
# most tool output where the beginning is most relevant.
|
|
22
|
+
# 3. Tail mode — keep the last N lines / bytes. Used for shell output
|
|
23
|
+
# where errors and summaries appear at the end.
|
|
24
|
+
# 4. Overflow to disk — when truncating, write the full text to a file
|
|
25
|
+
# under TRUNCATION_DIR (e.g. ~/.local/share/brute/tool-output/).
|
|
26
|
+
# Return a preview + hint pointing to the saved file.
|
|
27
|
+
# 5. Hint message — when truncated, append a contextual hint:
|
|
28
|
+
# "Full output saved to: <path>. Use Read with offset/limit to
|
|
29
|
+
# view specific sections."
|
|
30
|
+
# 6. Configurable limits — allow overriding MAX_LINES / MAX_BYTES
|
|
31
|
+
# via per-call options.
|
|
32
|
+
# 7. Retention cleanup — purge saved output files older than a
|
|
33
|
+
# configurable retention period from a truncation directory.
|
|
34
|
+
# 8. Per-line truncation — truncate individual lines longer than
|
|
35
|
+
# MAX_LINE_LENGTH (2000 chars) with a suffix.
|
|
36
|
+
#
|
|
37
|
+
module Truncation
|
|
38
|
+
MAX_LINES = 2000
|
|
39
|
+
MAX_BYTES = 50 * 1024 # 50 KB
|
|
40
|
+
MAX_LINE_LENGTH = 2000
|
|
41
|
+
TRUNCATION_MARKER = "[Output truncated:"
|
|
42
|
+
|
|
43
|
+
TRUNCATION_DIR = File.join(Dir.home, ".local", "share", "brute", "tool-output")
|
|
44
|
+
|
|
45
|
+
# Truncate text to fit within line and byte limits.
|
|
46
|
+
#
|
|
47
|
+
# Returns the text unchanged if it fits. Otherwise returns a
|
|
48
|
+
# truncated preview with a hint message.
|
|
49
|
+
#
|
|
50
|
+
# @param text [String] the tool output to truncate
|
|
51
|
+
# @param max_lines [Integer] maximum number of lines to keep
|
|
52
|
+
# @param max_bytes [Integer] maximum byte size to keep
|
|
53
|
+
# @param direction [:head, :tail] which end to keep
|
|
54
|
+
# @param truncation_dir [String, nil] directory to save full output when truncating
|
|
55
|
+
# @return [String] the (possibly truncated) text
|
|
56
|
+
#
|
|
57
|
+
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."
|
|
88
|
+
end
|
|
89
|
+
result + hint
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Check whether text already contains a truncation marker.
|
|
93
|
+
def self.already_truncated?(text)
|
|
94
|
+
text.include?(TRUNCATION_MARKER)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Truncate a single line if it exceeds MAX_LINE_LENGTH.
|
|
98
|
+
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"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Purge files older than retention_days from the given directory.
|
|
104
|
+
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
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Save text to a file in truncation_dir. Returns the file path, or nil.
|
|
113
|
+
def self.save_to_disk(text, truncation_dir)
|
|
114
|
+
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
|
|
120
|
+
rescue => _e
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
private_class_method :save_to_disk
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
test do
|
|
128
|
+
require "tmpdir"
|
|
129
|
+
require "fileutils"
|
|
130
|
+
|
|
131
|
+
it "returns short text unchanged" do
|
|
132
|
+
Brute::Truncation.truncate("hello world").should == "hello world"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it "truncates text exceeding 2000 lines" do
|
|
136
|
+
big = "line\n" * 3000
|
|
137
|
+
result = Brute::Truncation.truncate(big)
|
|
138
|
+
result.lines.size.should.be < 2100
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "includes a hint when truncated" do
|
|
142
|
+
big = "line\n" * 3000
|
|
143
|
+
Brute::Truncation.truncate(big).should =~ /truncated/i
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# --- Per-line truncation ---
|
|
147
|
+
|
|
148
|
+
it "truncates individual lines longer than MAX_LINE_LENGTH" do
|
|
149
|
+
long_line = "x" * 3000 + "\n"
|
|
150
|
+
result = Brute::Truncation.truncate(long_line)
|
|
151
|
+
result.lines.first.size.should.be < 2100
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "adds a suffix to truncated lines" do
|
|
155
|
+
long_line = "x" * 3000 + "\n"
|
|
156
|
+
result = Brute::Truncation.truncate(long_line)
|
|
157
|
+
result.should =~ /truncated/i
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# --- Overflow to disk ---
|
|
161
|
+
|
|
162
|
+
it "saves full output to disk when truncating" do
|
|
163
|
+
Dir.mktmpdir do |dir|
|
|
164
|
+
big = "line\n" * 3000
|
|
165
|
+
result = Brute::Truncation.truncate(big, truncation_dir: dir)
|
|
166
|
+
files = Dir.glob(File.join(dir, "*"))
|
|
167
|
+
files.size.should == 1
|
|
168
|
+
File.read(files.first).should == big
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "includes saved file path in hint" do
|
|
173
|
+
Dir.mktmpdir do |dir|
|
|
174
|
+
big = "line\n" * 3000
|
|
175
|
+
result = Brute::Truncation.truncate(big, truncation_dir: dir)
|
|
176
|
+
result.should =~ /Full output saved to:/
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
it "does not save to disk when not truncated" do
|
|
181
|
+
Dir.mktmpdir do |dir|
|
|
182
|
+
result = Brute::Truncation.truncate("short\n", truncation_dir: dir)
|
|
183
|
+
Dir.glob(File.join(dir, "*")).size.should == 0
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# --- Configurable limits ---
|
|
188
|
+
|
|
189
|
+
it "accepts custom max_lines" do
|
|
190
|
+
text = "line\n" * 50
|
|
191
|
+
result = Brute::Truncation.truncate(text, max_lines: 10)
|
|
192
|
+
result.lines.count { |l| l.strip == "line" }.should.be <= 10
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it "accepts custom max_bytes" do
|
|
196
|
+
text = "line\n" * 50
|
|
197
|
+
result = Brute::Truncation.truncate(text, max_bytes: 20)
|
|
198
|
+
result.should =~ /truncated/i
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# --- Retention cleanup ---
|
|
202
|
+
|
|
203
|
+
it "purges files older than retention period" do
|
|
204
|
+
Dir.mktmpdir do |dir|
|
|
205
|
+
old_file = File.join(dir, "old_output.txt")
|
|
206
|
+
File.write(old_file, "old content")
|
|
207
|
+
# Backdate the file to 8 days ago
|
|
208
|
+
old_time = Time.now - (8 * 86400)
|
|
209
|
+
File.utime(old_time, old_time, old_file)
|
|
210
|
+
|
|
211
|
+
new_file = File.join(dir, "new_output.txt")
|
|
212
|
+
File.write(new_file, "new content")
|
|
213
|
+
|
|
214
|
+
Brute::Truncation.cleanup!(dir, retention_days: 7)
|
|
215
|
+
File.exist?(old_file).should == false
|
|
216
|
+
File.exist?(new_file).should == true
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
data/lib/brute/version.rb
CHANGED