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
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
data/lib/brute/tools/adapter.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
description:
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/brute/tools/fs_patch.rb
CHANGED
|
@@ -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
|
-
|
|
23
|
+
unless File.exist?(path)
|
|
24
|
+
raise "File not found: #{path}"
|
|
25
|
+
end
|
|
24
26
|
|
|
25
27
|
original = File.read(path)
|
|
26
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
data/lib/brute/tools/fs_read.rb
CHANGED
|
@@ -47,101 +47,128 @@ module Brute
|
|
|
47
47
|
path = File.expand_path(file_path)
|
|
48
48
|
|
|
49
49
|
# Directory listing
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
63
|
+
unless File.file?(path)
|
|
64
|
+
raise "Not a file: #{path}"
|
|
65
|
+
end
|
|
76
66
|
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
content
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|