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
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module TokenCounter
8
+ # Tokens from OpenAI's byte-pair encoder, through the tiktoken_ruby gem.
9
+ #
10
+ # gem "tiktoken_ruby"
11
+ # use Brute::Middleware::DefaultCompactionPipeline,
12
+ # window: 200_000,
13
+ # token_counter: Brute::TokenCounter::Tiktoken.new
14
+ #
15
+ # Brute depends on no LLM library, and this is no exception: the gem is
16
+ # required the first time the counter is asked for a number, so an agent
17
+ # that never installs it never pays for it and never hears about it.
18
+ #
19
+ # It is exact about the text and still approximate about the request --
20
+ # `per_message` stands in for the chat-template framing, which is the
21
+ # provider's and not in any encoder.
22
+ class Tiktoken
23
+ ENCODING = "o200k_base"
24
+
25
+ def initialize(encoding: ENCODING, per_message: 4, encoder: nil)
26
+ @encoding = encoding
27
+ @per_message = per_message
28
+ @encoder = encoder
29
+ end
30
+
31
+ # Load the encoder, downloading its vocabulary if it is not cached yet.
32
+ def warm_up
33
+ @encoder ||= load_encoder
34
+ end
35
+
36
+ def count(messages, tools: nil)
37
+ warm_up
38
+ text = Rendering.conversation(messages) + Rendering.tools(tools)
39
+
40
+ @encoder.encode(text).length + (Array(messages).length * @per_message)
41
+ end
42
+
43
+ private
44
+
45
+ def load_encoder
46
+ begin
47
+ require "tiktoken_ruby"
48
+ rescue LoadError
49
+ raise LoadError, "#{self.class} needs the tiktoken_ruby gem: add `gem \"tiktoken_ruby\"` to your Gemfile"
50
+ end
51
+
52
+ ::Tiktoken.get_encoding(@encoding)
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ __END__
59
+
60
+ describe "brute/token_counter/tiktoken" do
61
+ it "encodes the same rendering the approximate counter measures, and says what to install when it cannot" do
62
+ encoded = []
63
+ encoder = Object.new
64
+ encoder.define_singleton_method(:encode) { |text| encoded << text; text.split(/\b/) }
65
+
66
+ log = Brute.log
67
+ log.user("what changed?")
68
+
69
+ counter = Brute::TokenCounter::Tiktoken.new(encoder: encoder, per_message: 4)
70
+ counter.count(log).should == encoder.encode("user: what changed?").length + 4
71
+ encoded.last.should == "user: what changed?"
72
+
73
+ # The vocabulary is loaded once, on the first count rather than at boot.
74
+ counter.warm_up.should.equal? encoder
75
+
76
+ missing = Brute::TokenCounter::Tiktoken.new(encoding: "nothing-real")
77
+ missing.define_singleton_method(:require) { |_name| raise LoadError }
78
+ lambda { missing.count(log) }.should.raise(LoadError).message.should.include "tiktoken_ruby"
79
+ end
80
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "json"
6
+
7
+ module Brute
8
+ # How big a conversation is, in tokens.
9
+ #
10
+ # A counter is anything answering `count(messages, tools: nil)`. The tools
11
+ # are part of the question because their schemas ride in every request
12
+ # alongside the messages -- an agent carrying a dozen of them is spending
13
+ # context on JSON Schema before anyone has said a word.
14
+ #
15
+ # counter = Brute::TokenCounter::Approximate.new
16
+ # counter.count(env[:messages], tools: env[:tools])
17
+ #
18
+ # What a turn is *currently* costing is a different question, and
19
+ # `estimate` answers it: the provider counted the conversation exactly when
20
+ # it answered, so that number is trusted and only what has been appended
21
+ # since is counted locally.
22
+ #
23
+ # Brute::TokenCounter.estimate(env)
24
+ #
25
+ module TokenCounter
26
+ def self.default = Approximate.new
27
+
28
+ # What the whole turn costs right now.
29
+ #
30
+ # :counter defaults to the one the turn already decided on
31
+ # (env[:token_counter]), :tools to the ones the pipeline advertises
32
+ # (env[:tools]).
33
+ #
34
+ # The reported total already covers the system prompt, the tool schemas
35
+ # and the provider's own chat-template overhead, so the warm path adds
36
+ # only the messages that landed after the reply it describes -- and never
37
+ # the schemas, which are already inside it. Anything else double counts.
38
+ def self.estimate(env, counter: nil, tools: nil)
39
+ counter ||= env[:token_counter] || default
40
+ if tools.nil?
41
+ tools = env[:tools]
42
+ end
43
+ messages = env[:messages] || []
44
+ reported = env.dig(:metadata, :last_llm_usage)&.total.to_i
45
+ answered = messages.rindex { |message| message.role == :assistant }
46
+
47
+ # Nothing sent yet, or a conversation the reported number no longer
48
+ # describes -- a compaction that left no reply behind, say. Count it all.
49
+ if reported.zero? || answered.nil?
50
+ counter.count(messages, tools: tools)
51
+ else
52
+ reported + counter.count(messages[(answered + 1)..])
53
+ end
54
+ end
55
+
56
+ # The text a counter measures.
57
+ #
58
+ # One rendering, so two counters cannot disagree about what the
59
+ # conversation even is -- and it is the same text a summariser reads,
60
+ # which is why it says who spoke and what was called rather than being
61
+ # the shortest thing that could be measured.
62
+ module Rendering
63
+ def self.conversation(messages) = Array(messages).map { |message| message(message) }.join("\n")
64
+
65
+ def self.message(message)
66
+ parts = ["#{message.role}:", message.content.to_s]
67
+
68
+ message.tool_calls&.each do |call|
69
+ parts << "#{call.name}(#{JSON.generate(call.arguments)}) -> #{call.id}"
70
+ end
71
+
72
+ unless message.tool_call_id.nil?
73
+ parts << "(answering #{message.tool_call_id})"
74
+ end
75
+
76
+ parts.reject(&:empty?).join(" ")
77
+ end
78
+
79
+ # The schemas as the provider is given them, which is what they cost.
80
+ def self.tools(tools)
81
+ wrapped = Brute::Tools::Adapter.wrap_all(tools || [])
82
+
83
+ if wrapped.empty?
84
+ ""
85
+ else
86
+ JSON.generate(wrapped.values.map(&:to_h))
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ __END__
94
+
95
+ describe "brute/token_counter" do
96
+ def usage(total) = Brute::UsageDetection::Usage.new(total: total)
97
+
98
+ it "measures a conversation and its schemas, and trusts the provider for what it already counted" do
99
+ log = Brute.log
100
+ log.system("you are a helpful agent")
101
+ log.user("what changed?")
102
+
103
+ counter = Brute::TokenCounter.default
104
+ counter.should.be.kind_of Brute::TokenCounter::Approximate
105
+
106
+ text = Brute::TokenCounter::Rendering.conversation(log)
107
+ text.should == "system: you are a helpful agent\nuser: what changed?"
108
+
109
+ # A tool call and the result answering it are rendered too -- both cost
110
+ # tokens, and neither is in the message's content.
111
+ log << Brute::Message.new(
112
+ role: :assistant,
113
+ content: "",
114
+ tool_calls: [{ id: "tc1", name: "search", arguments: { "query" => "fed" } }]
115
+ )
116
+ log.tool("found nothing", tool_call_id: "tc1")
117
+ Brute::TokenCounter::Rendering.conversation(log).should.include 'search({"query":"fed"}) -> tc1'
118
+ Brute::TokenCounter::Rendering.conversation(log).should.include "(answering tc1)"
119
+
120
+ # Schemas ride in every request, so they are part of the question.
121
+ tool = Brute::Turn::ToolPipeline.new(name: "search", description: "search the web") do
122
+ run ->(env) { env[:result] = "" }
123
+ end
124
+ Brute::TokenCounter::Rendering.tools(nil).should == ""
125
+ Brute::TokenCounter::Rendering.tools([tool]).should.include "search the web"
126
+ counter.count(log, tools: [tool]).should > counter.count(log)
127
+
128
+ # Nothing reported yet: everything is counted, schemas included.
129
+ cold = { messages: log, tools: [tool], metadata: {} }
130
+ Brute::TokenCounter.estimate(cold).should == counter.count(log, tools: [tool])
131
+
132
+ # Reported: the provider's number, plus only what landed after the reply
133
+ # it describes. The schemas are already inside it.
134
+ log.assistant("nothing changed")
135
+ log.user("are you sure?")
136
+ warm = { messages: log, tools: [tool], metadata: { last_llm_usage: usage(9_000) } }
137
+ Brute::TokenCounter.estimate(warm).should == 9_000 + counter.count([log.last])
138
+
139
+ # A conversation the reported number no longer describes -- a compaction
140
+ # that left no reply behind -- is counted from scratch rather than added
141
+ # to a total for a conversation that is gone.
142
+ stale = { messages: Brute.log.tap { |l| l.user("only me") }, metadata: { last_llm_usage: usage(9_000) } }
143
+ Brute::TokenCounter.estimate(stale).should == counter.count(stale[:messages])
144
+
145
+ # The counter the turn already decided on is the one that gets used.
146
+ given = { messages: log, metadata: {}, token_counter: Object.new }
147
+ given[:token_counter].define_singleton_method(:count) { |_messages, tools: nil| 7 }
148
+ Brute::TokenCounter.estimate(given).should == 7
149
+ end
150
+ end
data/lib/brute/tool.rb CHANGED
@@ -30,9 +30,11 @@ module Brute
30
30
  class Tool
31
31
  class << self
32
32
  def description(text = nil)
33
- return @description unless text
34
-
35
- @description = text
33
+ if text
34
+ @description = text
35
+ else
36
+ @description
37
+ end
36
38
  end
37
39
 
38
40
  # Declare one parameter: param :key, type:, desc:, required:
@@ -46,9 +48,11 @@ module Brute
46
48
 
47
49
  # Raw JSON-schema override for complex argument shapes.
48
50
  def params(schema = nil)
49
- return @params_schema unless schema
50
-
51
- @params_schema = schema.deep_symbolize_keys
51
+ if schema
52
+ @params_schema = schema.deep_symbolize_keys
53
+ else
54
+ @params_schema
55
+ end
52
56
  end
53
57
  end
54
58
 
@@ -39,29 +39,32 @@ module Brute
39
39
 
40
40
  # Wrap a single tool of any supported shape. Idempotent.
41
41
  def self.wrap(tool)
42
- return tool if tool.is_a?(Adapter)
43
-
44
- tool = tool.new if tool.is_a?(Class)
45
-
46
- case tool
47
- when Hash then from_hash(tool)
48
- when ::Brute::Tool then from_brute_tool(tool)
49
- when Brute::Tools::SubAgent then new(
50
- name: tool.name,
51
- description: tool.description,
52
- params: tool.params,
53
- handler: ->(**args) { tool.execute(args) },
54
- original: tool,
55
- )
56
- when Brute::Turn::ToolPipeline then new(
57
- name: tool.name,
58
- description: tool.description,
59
- params: tool.params,
60
- handler: ->(**args) { tool.call(**args) },
61
- original: tool,
62
- )
42
+ if tool.is_a?(Adapter)
43
+ tool
63
44
  else
64
- from_duck_type(tool)
45
+ if tool.is_a?(Class)
46
+ tool = tool.new
47
+ end
48
+ case tool
49
+ when Hash then from_hash(tool)
50
+ when ::Brute::Tool then from_brute_tool(tool)
51
+ when Brute::Tools::SubAgent then new(
52
+ name: tool.name,
53
+ description: tool.description,
54
+ params: tool.params,
55
+ handler: ->(**args) { tool.execute(args) },
56
+ original: tool,
57
+ )
58
+ when Brute::Turn::ToolPipeline then new(
59
+ name: tool.name,
60
+ description: tool.description,
61
+ params: tool.params,
62
+ handler: ->(**args) { tool.call(**args) },
63
+ original: tool,
64
+ )
65
+ else
66
+ from_duck_type(tool)
67
+ end
65
68
  end
66
69
  end
67
70
 
@@ -78,7 +81,9 @@ module Brute
78
81
  def self.from_hash(definition)
79
82
  definition = definition.transform_keys(&:to_sym)
80
83
  handler = definition.fetch(:execute) { definition[:handler] }
81
- raise ArgumentError, "inline tool needs an :execute proc" unless handler.respond_to?(:call)
84
+ unless handler.respond_to?(:call)
85
+ raise ArgumentError, "inline tool needs an :execute proc"
86
+ end
82
87
 
83
88
  new(
84
89
  name: definition.fetch(:name).to_s,
@@ -108,7 +113,11 @@ module Brute
108
113
  raise ArgumentError, "don't know how to adapt #{tool.inspect} into a tool"
109
114
  end
110
115
 
111
- entry = tool.respond_to?(:execute) ? tool.method(:execute) : tool.method(:call)
116
+ if tool.respond_to?(:execute)
117
+ entry = tool.method(:execute)
118
+ else
119
+ entry = tool.method(:call)
120
+ end
112
121
  new(
113
122
  name: tool.name.to_s,
114
123
  description: tool.respond_to?(:description) ? tool.description : "",
@@ -118,6 +127,10 @@ module Brute
118
127
  )
119
128
  end
120
129
 
130
+ # The tool object this adapter wraps (Brute::Tool, Brute::Turn::ToolPipeline,
131
+ # SubAgent, Hash definition, ...).
132
+ attr_reader :original
133
+
121
134
  def initialize(name:, description:, params:, handler:, schema: nil, original: nil)
122
135
  @name = name
123
136
  @description = description
@@ -127,10 +140,6 @@ module Brute
127
140
  @original = original
128
141
  end
129
142
 
130
- # The tool object this adapter wraps (Brute::Tool, Brute::Turn::ToolPipeline,
131
- # SubAgent, Hash definition, ...).
132
- attr_reader :original
133
-
134
143
  # Execute the tool. Accepts string- or symbol-keyed argument hashes,
135
144
  # as delivered by LLM providers.
136
145
  def call(arguments = {})
@@ -141,27 +150,28 @@ module Brute
141
150
  # Library-neutral tool definition (JSON-Schema-ish). The inline `run`
142
151
  # proc reshapes this into whatever its LLM library expects.
143
152
  def to_h
144
- return { name: @name, description: @description, parameters: @schema.deep_symbolize_keys } if @schema
145
-
146
- properties = @params.transform_values do |opts|
153
+ if @schema
154
+ { name: @name, description: @description, parameters: @schema.deep_symbolize_keys }
155
+ else
156
+ properties = @params.transform_values do |opts|
157
+ {
158
+ type: opts[:type] || "string",
159
+ description: opts[:desc] || opts[:description],
160
+ items: opts[:items],
161
+ enum: opts[:enum],
162
+ }.compact
163
+ end
164
+ required = @params.select { |_k, opts| opts[:required] }.keys
147
165
  {
148
- type: opts[:type] || "string",
149
- description: opts[:desc] || opts[:description],
150
- items: opts[:items],
151
- enum: opts[:enum],
152
- }.compact
166
+ name: @name,
167
+ description: @description,
168
+ parameters: {
169
+ type: "object",
170
+ properties: properties,
171
+ required: required.map(&:to_s),
172
+ },
173
+ }
153
174
  end
154
- required = @params.select { |_k, opts| opts[:required] }.keys
155
-
156
- {
157
- name: @name,
158
- description: @description,
159
- parameters: {
160
- type: "object",
161
- properties: properties,
162
- required: required.map(&:to_s),
163
- },
164
- }
165
175
  end
166
176
  end
167
177
  end
@@ -19,7 +19,11 @@ module Brute
19
19
  # If the file doesn't exist yet, records +:did_not_exist+.
20
20
  def save(path)
21
21
  key = File.expand_path(path)
22
- content = File.exist?(key) ? File.read(key) : :did_not_exist
22
+ if File.exist?(key)
23
+ content = File.read(key)
24
+ else
25
+ content = :did_not_exist
26
+ end
23
27
  @mutex.synchronize { @snapshots[key].push(content) }
24
28
  end
25
29
 
@@ -20,22 +20,30 @@ module Brute
20
20
  def execute(file_path:, old_string:, new_string:, replace_all: false)
21
21
  path = File.expand_path(file_path)
22
22
  Brute::Tools::FS::FileMutationQueue.serialize(path) do
23
- raise "File not found: #{path}" unless File.exist?(path)
23
+ unless File.exist?(path)
24
+ raise "File not found: #{path}"
25
+ end
24
26
 
25
27
  original = File.read(path)
26
- raise "old_string not found in #{path}" unless original.include?(old_string)
28
+ unless original.include?(old_string)
29
+ raise "old_string not found in #{path}"
30
+ end
27
31
 
28
32
  Brute::Tools::FS::SnapshotStore.save(path)
29
33
 
30
- updated = if replace_all
31
- original.gsub(old_string, new_string)
32
- else
33
- original.sub(old_string, new_string)
34
- end
34
+ if replace_all
35
+ updated = original.gsub(old_string, new_string)
36
+ else
37
+ updated = original.sub(old_string, new_string)
38
+ end
35
39
 
36
40
  File.write(path, updated)
37
41
  diff = Brute::Diff.unified(original, updated)
38
- count = replace_all ? original.scan(old_string).size : 1
42
+ if replace_all
43
+ count = original.scan(old_string).size
44
+ else
45
+ count = 1
46
+ end
39
47
  { success: true, file_path: path, replacements: count, diff: diff }
40
48
  end
41
49
  end
@@ -47,101 +47,128 @@ module Brute
47
47
  path = File.expand_path(file_path)
48
48
 
49
49
  # Directory listing
50
- return list_directory(path) if File.directory?(path)
51
-
52
- # File-not-found suggestions
53
- unless File.exist?(path)
54
- suggestions = find_similar(path)
55
- msg = "File not found: #{path}"
56
- msg += ". Did you mean: #{suggestions.join(', ')}?" if suggestions.any?
57
- raise msg
58
- end
59
-
60
- raise "Not a file: #{path}" unless File.file?(path)
61
-
62
- # Binary file detection
63
- ext = File.extname(path).downcase
64
- raise "Cannot read binary file: #{path}" if BINARY_EXTENSIONS.include?(ext)
65
-
66
- sample = File.binread(path, 4096) || ""
67
- raise "Cannot read binary file: #{path}" if sample.include?("\x00")
68
-
69
- lines = File.readlines(path)
70
- total = lines.size
71
- first = start_line ? [start_line - 1, 0].max : 0
50
+ if File.directory?(path)
51
+ list_directory(path)
52
+ else
53
+ # File-not-found suggestions
54
+ unless File.exist?(path)
55
+ suggestions = find_similar(path)
56
+ msg = "File not found: #{path}"
57
+ if suggestions.any?
58
+ msg += ". Did you mean: #{suggestions.join(', ')}?"
59
+ end
60
+ raise msg
61
+ end
72
62
 
73
- # Apply default line cap when no explicit range given
74
- default_last = end_line ? [end_line - 1, total - 1].min : [first + DEFAULT_LINE_CAP - 1, total - 1].min
75
- last = default_last
63
+ unless File.file?(path)
64
+ raise "Not a file: #{path}"
65
+ end
76
66
 
77
- selected = lines[first..last] || []
67
+ # Binary file detection
68
+ ext = File.extname(path).downcase
69
+ if BINARY_EXTENSIONS.include?(ext)
70
+ raise "Cannot read binary file: #{path}"
71
+ end
72
+ sample = File.binread(path, 4096) || ""
73
+ if sample.include?("\x00")
74
+ raise "Cannot read binary file: #{path}"
75
+ end
78
76
 
79
- # Per-line truncation + byte cap
80
- numbered = []
81
- bytes = 0
82
- selected.each_with_index do |line, i|
83
- truncated_line = Brute::Truncation.truncate_line(line, max: MAX_LINE_LENGTH)
84
- numbered_line = "#{first + i + 1}\t#{truncated_line}"
85
- break if bytes + numbered_line.bytesize > MAX_BYTES
86
- numbered << numbered_line
87
- bytes += numbered_line.bytesize
88
- end
77
+ lines = File.readlines(path)
78
+ total = lines.size
79
+ if start_line
80
+ first = [start_line - 1, 0].max
81
+ else
82
+ first = 0
83
+ end
89
84
 
90
- actual_last = first + numbered.size - 1
91
- content = numbered.join
92
- truncated = (actual_last < total - 1) && end_line.nil?
85
+ # Apply default line cap when no explicit range given
86
+ if end_line
87
+ default_last = [end_line - 1, total - 1].min
88
+ else
89
+ default_last = [first + DEFAULT_LINE_CAP - 1, total - 1].min
90
+ end
91
+ last = default_last
92
+ selected = lines[first..last] || []
93
+
94
+ # Per-line truncation + byte cap
95
+ numbered = []
96
+ bytes = 0
97
+ selected.each_with_index do |line, i|
98
+ truncated_line = Brute::Truncation.truncate_line(line, max: MAX_LINE_LENGTH)
99
+ numbered_line = "#{first + i + 1}\t#{truncated_line}"
100
+ if bytes + numbered_line.bytesize > MAX_BYTES
101
+ break
102
+ end
103
+ numbered << numbered_line
104
+ bytes += numbered_line.bytesize
105
+ end
93
106
 
94
- if truncated
95
- content + "\n(Showing lines #{first + 1}-#{actual_last + 1} of #{total}. Use start_line=#{actual_last + 2} to continue.)"
96
- else
97
- content
107
+ actual_last = first + numbered.size - 1
108
+ content = numbered.join
109
+ truncated = (actual_last < total - 1) && end_line.nil?
110
+ if truncated
111
+ content + "\n(Showing lines #{first + 1}-#{actual_last + 1} of #{total}. Use start_line=#{actual_last + 2} to continue.)"
112
+ else
113
+ content
114
+ end
98
115
  end
99
116
  end
100
117
 
101
118
  private
102
119
 
103
- def list_directory(path)
104
- entries = Dir.entries(path).reject { |e| e.start_with?(".") }.sort
105
- total = entries.size
106
- capped = entries.first(DEFAULT_LINE_CAP)
107
- result = capped.map do |entry|
108
- full = File.join(path, entry)
109
- type = File.directory?(full) ? "dir" : "file"
110
- "#{entry} (#{type})"
111
- end.join("\n")
112
-
113
- if total > DEFAULT_LINE_CAP
114
- result += "\n(Showing #{DEFAULT_LINE_CAP} of #{total} entries)"
115
- end
116
- result
117
- end
120
+ def list_directory(path)
121
+ entries = Dir.entries(path).reject { |e| e.start_with?(".") }.sort
122
+ total = entries.size
123
+ capped = entries.first(DEFAULT_LINE_CAP)
124
+ result = capped.map do |entry|
125
+ full = File.join(path, entry)
126
+ if File.directory?(full)
127
+ type = "dir"
128
+ else
129
+ type = "file"
130
+ end
131
+ "#{entry} (#{type})"
132
+ end.join("\n")
118
133
 
119
- def find_similar(path)
120
- dir = File.dirname(path)
121
- target = File.basename(path)
122
- return [] unless File.directory?(dir)
134
+ if total > DEFAULT_LINE_CAP
135
+ result += "\n(Showing #{DEFAULT_LINE_CAP} of #{total} entries)"
136
+ end
137
+ result
138
+ end
123
139
 
124
- entries = Dir.entries(dir).reject { |e| e.start_with?(".") }
125
- entries.select { |e| levenshtein(e.downcase, target.downcase) <= 3 }
126
- .sort_by { |e| levenshtein(e.downcase, target.downcase) }
127
- .first(3)
128
- end
140
+ def find_similar(path)
141
+ dir = File.dirname(path)
142
+ target = File.basename(path)
143
+ if File.directory?(dir)
144
+ entries = Dir.entries(dir).reject { |e| e.start_with?(".") }
145
+ entries.select { |e| levenshtein(e.downcase, target.downcase) <= 3 }
146
+ .sort_by { |e| levenshtein(e.downcase, target.downcase) }
147
+ .first(3)
148
+ else
149
+ []
150
+ end
151
+ end
129
152
 
130
- def levenshtein(a, b)
131
- m, n = a.length, b.length
132
- d = Array.new(m + 1) { |i| i }
133
- (1..n).each do |j|
134
- prev = d[0]
135
- d[0] = j
136
- (1..m).each do |i|
137
- cost = a[i - 1] == b[j - 1] ? 0 : 1
138
- temp = d[i]
139
- d[i] = [d[i] + 1, d[i - 1] + 1, prev + cost].min
140
- prev = temp
153
+ def levenshtein(a, b)
154
+ m, n = a.length, b.length
155
+ d = Array.new(m + 1) { |i| i }
156
+ (1..n).each do |j|
157
+ prev = d[0]
158
+ d[0] = j
159
+ (1..m).each do |i|
160
+ if a[i - 1] == b[j - 1]
161
+ cost = 0
162
+ else
163
+ cost = 1
164
+ end
165
+ temp = d[i]
166
+ d[i] = [d[i] + 1, d[i - 1] + 1, prev + cost].min
167
+ prev = temp
168
+ end
141
169
  end
170
+ d[m]
142
171
  end
143
- d[m]
144
- end
145
172
  end
146
173
  end
147
174
  end