brute 0.3.0 → 0.4.1

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent_stream.rb +126 -2
  3. data/lib/brute/diff.rb +34 -0
  4. data/lib/brute/message_store.rb +194 -0
  5. data/lib/brute/middleware/compaction_check.rb +133 -0
  6. data/lib/brute/middleware/doom_loop_detection.rb +100 -0
  7. data/lib/brute/middleware/llm_call.rb +89 -0
  8. data/lib/brute/middleware/message_tracking.rb +177 -0
  9. data/lib/brute/middleware/otel/span.rb +111 -0
  10. data/lib/brute/middleware/otel/token_usage.rb +93 -0
  11. data/lib/brute/middleware/otel/tool_calls.rb +113 -0
  12. data/lib/brute/middleware/otel/tool_results.rb +92 -0
  13. data/lib/brute/middleware/otel.rb +5 -0
  14. data/lib/brute/middleware/reasoning_normalizer.rb +119 -0
  15. data/lib/brute/middleware/retry.rb +93 -0
  16. data/lib/brute/middleware/session_persistence.rb +42 -0
  17. data/lib/brute/middleware/token_tracking.rb +77 -0
  18. data/lib/brute/middleware/tool_error_tracking.rb +101 -0
  19. data/lib/brute/middleware/tool_use_guard.rb +70 -1
  20. data/lib/brute/middleware/tracing.rb +71 -0
  21. data/lib/brute/orchestrator.rb +169 -3
  22. data/lib/brute/patches/buffer_nil_guard.rb +5 -0
  23. data/lib/brute/pipeline.rb +135 -0
  24. data/lib/brute/prompts/build_switch.rb +33 -0
  25. data/lib/brute/prompts/environment.rb +47 -0
  26. data/lib/brute/prompts/identity.rb +36 -0
  27. data/lib/brute/prompts/instructions.rb +24 -0
  28. data/lib/brute/prompts/max_steps.rb +32 -0
  29. data/lib/brute/prompts/plan_reminder.rb +33 -0
  30. data/lib/brute/prompts/skills.rb +35 -0
  31. data/lib/brute/providers/opencode_go.rb +5 -0
  32. data/lib/brute/providers/opencode_zen.rb +7 -2
  33. data/lib/brute/providers/shell_response.rb +5 -0
  34. data/lib/brute/system_prompt.rb +214 -0
  35. data/lib/brute/tools/delegate.rb +129 -0
  36. data/lib/brute/tools/fs_patch.rb +53 -0
  37. data/lib/brute/tools/fs_read.rb +5 -0
  38. data/lib/brute/tools/fs_remove.rb +5 -0
  39. data/lib/brute/tools/fs_search.rb +5 -0
  40. data/lib/brute/tools/fs_undo.rb +5 -0
  41. data/lib/brute/tools/fs_write.rb +50 -0
  42. data/lib/brute/tools/net_fetch.rb +5 -0
  43. data/lib/brute/tools/question.rb +5 -0
  44. data/lib/brute/tools/shell.rb +5 -0
  45. data/lib/brute/tools/todo_read.rb +5 -0
  46. data/lib/brute/tools/todo_write.rb +5 -0
  47. data/lib/brute/version.rb +1 -1
  48. data/lib/brute.rb +8 -8
  49. metadata +2 -2
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module LLM
4
9
  ##
5
10
  # OpenAI-compatible provider for the OpenCode Go API gateway.
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  # Ensure the OpenAI provider is loaded (llm.rb lazy-loads providers).
4
9
  unless defined?(LLM::OpenAI)
5
10
  require "llm/providers/openai"
@@ -41,10 +46,10 @@ module LLM
41
46
  end
42
47
 
43
48
  ##
44
- # Returns the default model (Claude Sonnet 4, the most common Zen model).
49
+ # Returns the default model.
45
50
  # @return [String]
46
51
  def default_model
47
- "claude-sonnet-4-20250514"
52
+ "zen-bickpickle"
48
53
  end
49
54
 
50
55
  ##
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  require "securerandom"
4
9
 
5
10
  module Brute
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  # Deferred system prompt builder.
5
10
  #
@@ -164,3 +169,212 @@ module Brute
164
169
  end
165
170
  end
166
171
  end
172
+
173
+ if __FILE__ == $0
174
+ require_relative "../../spec/spec_helper"
175
+
176
+ RSpec.describe Brute::SystemPrompt do
177
+ describe ".build" do
178
+ it "stores a block and executes it on prepare" do
179
+ sp = described_class.build do |prompt, ctx|
180
+ prompt << "hello #{ctx[:name]}"
181
+ end
182
+
183
+ result = sp.prepare(name: "world")
184
+ expect(result.to_s).to eq("hello world")
185
+ end
186
+
187
+ it "returns a Result with sections" do
188
+ sp = described_class.build do |prompt, _ctx|
189
+ prompt << "section one"
190
+ prompt << "section two"
191
+ end
192
+
193
+ result = sp.prepare({})
194
+ expect(result.sections).to eq(["section one", "section two"])
195
+ end
196
+
197
+ it "strips nil and empty sections" do
198
+ sp = described_class.build do |prompt, _ctx|
199
+ prompt << "kept"
200
+ prompt << nil
201
+ prompt << ""
202
+ prompt << "also kept"
203
+ end
204
+
205
+ result = sp.prepare({})
206
+ expect(result.sections).to eq(["kept", "also kept"])
207
+ end
208
+ end
209
+
210
+ describe Brute::SystemPrompt::Result do
211
+ subject(:result) { described_class.new(["a", "b", "c"]) }
212
+
213
+ it "joins sections with double newlines via to_s" do
214
+ expect(result.to_s).to eq("a\n\nb\n\nc")
215
+ end
216
+
217
+ it "iterates over sections via each" do
218
+ collected = []
219
+ result.each { |s| collected << s }
220
+ expect(collected).to eq(["a", "b", "c"])
221
+ end
222
+
223
+ it "reports empty? correctly" do
224
+ expect(described_class.new([]).empty?).to be true
225
+ expect(result.empty?).to be false
226
+ end
227
+ end
228
+
229
+ describe ".default" do
230
+ # Minimal context for all tests — enough to avoid nil errors
231
+ let(:base_ctx) do
232
+ {
233
+ provider_name: "anthropic",
234
+ model_name: "test-model",
235
+ cwd: Dir.pwd,
236
+ custom_rules: nil,
237
+ agent: nil,
238
+ agent_switched: nil,
239
+ max_steps_reached: nil,
240
+ }
241
+ end
242
+
243
+ let(:builder) { described_class.default }
244
+
245
+ # ── Provider stack selection ──
246
+
247
+ described_class::STACKS.each_key do |provider|
248
+ context "with provider '#{provider}'" do
249
+ it "produces non-empty sections" do
250
+ ctx = base_ctx.merge(provider_name: provider)
251
+ result = builder.prepare(ctx)
252
+ expect(result.sections).not_to be_empty
253
+ result.sections.each do |section|
254
+ expect(section).to be_a(String)
255
+ end
256
+ end
257
+ end
258
+ end
259
+
260
+ it "falls back to 'default' stack for unknown providers" do
261
+ default_result = builder.prepare(base_ctx.merge(provider_name: "default"))
262
+ unknown_result = builder.prepare(base_ctx.merge(provider_name: "unknown_provider"))
263
+
264
+ # Both should produce the same number of sections (same stack)
265
+ expect(unknown_result.sections.size).to eq(default_result.sections.size)
266
+ end
267
+
268
+ # ── Plan mode conditional ──
269
+
270
+ context "when agent is 'plan'" do
271
+ it "includes PlanReminder" do
272
+ result = builder.prepare(base_ctx.merge(agent: "plan"))
273
+ expect(result.to_s).to include("Plan Mode")
274
+ expect(result.to_s).to include("<system-reminder>")
275
+ expect(result.to_s).to include("READ-ONLY")
276
+ end
277
+ end
278
+
279
+ context "when agent is 'build'" do
280
+ it "does NOT include PlanReminder" do
281
+ result = builder.prepare(base_ctx.merge(agent: "build"))
282
+ expect(result.to_s).not_to include("Plan Mode - System Reminder")
283
+ expect(result.to_s).not_to include("READ-ONLY")
284
+ end
285
+ end
286
+
287
+ context "when agent is nil" do
288
+ it "does NOT include PlanReminder" do
289
+ result = builder.prepare(base_ctx.merge(agent: nil))
290
+ expect(result.to_s).not_to include("Plan Mode - System Reminder")
291
+ end
292
+ end
293
+
294
+ # ── Build switch conditional ──
295
+
296
+ context "when agent_switched is 'build'" do
297
+ it "includes BuildSwitch" do
298
+ result = builder.prepare(base_ctx.merge(agent_switched: "build"))
299
+ expect(result.to_s).to include("operational mode has changed from plan to build")
300
+ end
301
+ end
302
+
303
+ context "when agent_switched is nil" do
304
+ it "does NOT include BuildSwitch" do
305
+ result = builder.prepare(base_ctx.merge(agent_switched: nil))
306
+ expect(result.to_s).not_to include("operational mode has changed")
307
+ end
308
+ end
309
+
310
+ # ── Max steps conditional ──
311
+
312
+ context "when max_steps_reached is truthy" do
313
+ it "includes MaxSteps" do
314
+ result = builder.prepare(base_ctx.merge(max_steps_reached: true))
315
+ expect(result.to_s).to include("MAXIMUM STEPS REACHED")
316
+ expect(result.to_s).to include("Tools are disabled")
317
+ end
318
+ end
319
+
320
+ context "when max_steps_reached is falsy" do
321
+ it "does NOT include MaxSteps" do
322
+ result = builder.prepare(base_ctx.merge(max_steps_reached: nil))
323
+ expect(result.to_s).not_to include("MAXIMUM STEPS REACHED")
324
+ end
325
+ end
326
+
327
+ # ── Combined states (mid-session switch scenarios) ──
328
+
329
+ context "plan mode includes PlanReminder but excludes BuildSwitch" do
330
+ it "contains only plan-specific content" do
331
+ result = builder.prepare(base_ctx.merge(agent: "plan"))
332
+ expect(result.to_s).to include("READ-ONLY")
333
+ expect(result.to_s).not_to include("operational mode has changed")
334
+ end
335
+ end
336
+
337
+ context "build mode with agent_switched includes BuildSwitch but excludes PlanReminder" do
338
+ it "contains only build-switch content" do
339
+ result = builder.prepare(base_ctx.merge(agent: "build", agent_switched: "build"))
340
+ expect(result.to_s).to include("operational mode has changed from plan to build")
341
+ expect(result.to_s).not_to include("Plan Mode - System Reminder")
342
+ end
343
+ end
344
+
345
+ context "plan mode with max_steps_reached includes both" do
346
+ it "contains both PlanReminder and MaxSteps" do
347
+ result = builder.prepare(base_ctx.merge(agent: "plan", max_steps_reached: true))
348
+ expect(result.to_s).to include("READ-ONLY")
349
+ expect(result.to_s).to include("MAXIMUM STEPS REACHED")
350
+ end
351
+ end
352
+
353
+ # ── Provider stack composition ──
354
+
355
+ it "anthropic stack includes expected modules" do
356
+ result = builder.prepare(base_ctx.merge(provider_name: "anthropic"))
357
+ text = result.to_s
358
+ # Anthropic has Identity, Objectivity, Conventions, GitSafety etc.
359
+ expect(text).to include("Professional objectivity")
360
+ expect(text).to include("Following conventions")
361
+ expect(text).to include("Git safety")
362
+ end
363
+
364
+ it "openai stack includes editing-focused modules" do
365
+ result = builder.prepare(base_ctx.merge(provider_name: "openai"))
366
+ text = result.to_s
367
+ # OpenAI has EditingApproach, Autonomy, EditingConstraints
368
+ expect(text).to include("Following conventions")
369
+ expect(text).to include("Git safety")
370
+ end
371
+
372
+ it "google stack includes security module" do
373
+ result = builder.prepare(base_ctx.merge(provider_name: "google"))
374
+ text = result.to_s
375
+ expect(text).to include("Following conventions")
376
+ expect(text).to include("Git safety")
377
+ end
378
+ end
379
+ end
380
+ end
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  module Tools
5
10
  class Delegate < LLM::Tool
@@ -57,3 +62,127 @@ module Brute
57
62
  end
58
63
  end
59
64
  end
65
+
66
+ if __FILE__ == $0
67
+ require_relative "../../../spec/spec_helper"
68
+
69
+ RSpec.describe Brute::Tools::Delegate do
70
+ let(:provider) { MockProvider.new }
71
+
72
+ # Simple stand-in for LLM::Message in context history.
73
+ FakeMessage = Struct.new(:role, :content) do
74
+ def assistant?
75
+ role == :assistant
76
+ end
77
+ end
78
+
79
+ before do
80
+ allow(Brute).to receive(:provider).and_return(provider)
81
+ end
82
+
83
+ def fake_context(messages)
84
+ msgs_obj = Object.new
85
+ msgs_obj.define_singleton_method(:to_a) { messages }
86
+ ctx = Object.new
87
+ ctx.define_singleton_method(:messages) { msgs_obj }
88
+ ctx
89
+ end
90
+
91
+ describe "#call" do
92
+ it "returns the sub-agent's text content" do
93
+ result = described_class.new.call(task: "What files exist?")
94
+ expect(result).to be_a(Hash)
95
+ expect(result[:result]).to eq("mock response")
96
+ end
97
+ end
98
+
99
+ describe "#extract_content" do
100
+ let(:delegate) { described_class.new }
101
+
102
+ it "returns content when response has text" do
103
+ res = MockResponse.new(content: "analysis complete")
104
+ context = fake_context([])
105
+ result = delegate.send(:extract_content, res, context)
106
+ expect(result).to eq("analysis complete")
107
+ end
108
+
109
+ context "when res.content raises NoMethodError (tool-only response)" do
110
+ let(:bad_res) do
111
+ obj = Object.new
112
+ obj.define_singleton_method(:content) do
113
+ raise NoMethodError, "undefined method 'content' for nil"
114
+ end
115
+ obj
116
+ end
117
+
118
+ it "falls back to the last assistant text in context messages" do
119
+ context = fake_context([
120
+ FakeMessage.new(:user, "input"),
121
+ FakeMessage.new(:assistant, "found the answer"),
122
+ ])
123
+
124
+ result = delegate.send(:extract_content, bad_res, context)
125
+ expect(result).to eq("found the answer")
126
+ end
127
+
128
+ it "returns fallback text when no assistant messages exist" do
129
+ context = fake_context([])
130
+ result = delegate.send(:extract_content, bad_res, context)
131
+ expect(result).to eq("(sub-agent completed but produced no text response)")
132
+ end
133
+
134
+ it "skips assistant messages with empty content" do
135
+ context = fake_context([
136
+ FakeMessage.new(:assistant, "real answer"),
137
+ FakeMessage.new(:assistant, ""),
138
+ ])
139
+
140
+ result = delegate.send(:extract_content, bad_res, context)
141
+ expect(result).to eq("real answer")
142
+ end
143
+
144
+ it "skips assistant messages with non-string content" do
145
+ context = fake_context([
146
+ FakeMessage.new(:assistant, "text answer"),
147
+ FakeMessage.new(:assistant, [{"type" => "tool_use"}]),
148
+ ])
149
+
150
+ result = delegate.send(:extract_content, bad_res, context)
151
+ expect(result).to eq("text answer")
152
+ end
153
+ end
154
+
155
+ context "when res.content returns nil (empty response)" do
156
+ let(:nil_res) { Struct.new(:content).new(nil) }
157
+
158
+ it "falls back to the last assistant text in context messages" do
159
+ context = fake_context([
160
+ FakeMessage.new(:assistant, "previous answer"),
161
+ ])
162
+
163
+ result = delegate.send(:extract_content, nil_res, context)
164
+ expect(result).to eq("previous answer")
165
+ end
166
+
167
+ it "returns fallback text when no assistant messages exist" do
168
+ context = fake_context([])
169
+ result = delegate.send(:extract_content, nil_res, context)
170
+ expect(result).to eq("(sub-agent completed but produced no text response)")
171
+ end
172
+ end
173
+
174
+ context "when res.content returns empty string" do
175
+ let(:empty_res) { Struct.new(:content).new("") }
176
+
177
+ it "falls back to the last assistant text in context messages" do
178
+ context = fake_context([
179
+ FakeMessage.new(:assistant, "previous answer"),
180
+ ])
181
+
182
+ result = delegate.send(:extract_content, empty_res, context)
183
+ expect(result).to eq("previous answer")
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  module Tools
5
10
  class FSPatch < LLM::Tool
@@ -37,3 +42,51 @@ module Brute
37
42
  end
38
43
  end
39
44
  end
45
+
46
+ if __FILE__ == $0
47
+ require_relative "../../../spec/spec_helper"
48
+
49
+ RSpec.describe Brute::Tools::FSPatch do
50
+ around(:each) { |ex| Dir.mktmpdir { |d| @dir = d; ex.run } }
51
+
52
+ let(:tool) { described_class.new }
53
+
54
+ it "replaces old_string with new_string" do
55
+ path = File.join(@dir, "test.rb")
56
+ File.write(path, "hello world\n")
57
+ result = tool.call(file_path: path, old_string: "world", new_string: "ruby")
58
+ expect(result[:success]).to be true
59
+ expect(File.read(path)).to eq("hello ruby\n")
60
+ end
61
+
62
+ it "returns a unified diff" do
63
+ path = File.join(@dir, "test.rb")
64
+ File.write(path, "line1\nold line\nline3\n")
65
+ result = tool.call(file_path: path, old_string: "old line", new_string: "new line")
66
+ expect(result[:diff]).to include("-old line")
67
+ expect(result[:diff]).to include("+new line")
68
+ end
69
+
70
+ it "raises when file not found" do
71
+ expect {
72
+ tool.call(file_path: File.join(@dir, "nope.rb"), old_string: "a", new_string: "b")
73
+ }.to raise_error(/File not found/)
74
+ end
75
+
76
+ it "raises when old_string not found" do
77
+ path = File.join(@dir, "test.rb")
78
+ File.write(path, "hello\n")
79
+ expect {
80
+ tool.call(file_path: path, old_string: "missing", new_string: "new")
81
+ }.to raise_error(/old_string not found/)
82
+ end
83
+
84
+ it "supports replace_all" do
85
+ path = File.join(@dir, "test.rb")
86
+ File.write(path, "aaa bbb aaa\n")
87
+ result = tool.call(file_path: path, old_string: "aaa", new_string: "ccc", replace_all: true)
88
+ expect(result[:replacements]).to eq(2)
89
+ expect(File.read(path)).to eq("ccc bbb ccc\n")
90
+ end
91
+ end
92
+ end
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  module Tools
5
10
  class FSRead < LLM::Tool
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  require "fileutils"
4
9
 
5
10
  module Brute
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  require "open3"
4
9
 
5
10
  module Brute
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  module Tools
5
10
  class FSUndo < LLM::Tool
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  require 'fileutils'
4
9
 
5
10
  module Brute
@@ -26,3 +31,48 @@ module Brute
26
31
  end
27
32
  end
28
33
  end
34
+
35
+ if __FILE__ == $0
36
+ require_relative "../../../spec/spec_helper"
37
+
38
+ RSpec.describe Brute::Tools::FSWrite do
39
+ around(:each) { |ex| Dir.mktmpdir { |d| @dir = d; ex.run } }
40
+
41
+ let(:tool) { described_class.new }
42
+
43
+ it "writes content to a new file" do
44
+ path = File.join(@dir, "new.rb")
45
+ result = tool.call(file_path: path, content: "hello\n")
46
+ expect(result[:success]).to be true
47
+ expect(File.read(path)).to eq("hello\n")
48
+ end
49
+
50
+ it "returns a diff for new files" do
51
+ path = File.join(@dir, "new.rb")
52
+ result = tool.call(file_path: path, content: "line1\nline2\n")
53
+ expect(result[:diff]).to include("+line1")
54
+ expect(result[:diff]).to include("+line2")
55
+ end
56
+
57
+ it "returns a diff for overwritten files" do
58
+ path = File.join(@dir, "existing.rb")
59
+ File.write(path, "old content\n")
60
+ result = tool.call(file_path: path, content: "new content\n")
61
+ expect(result[:diff]).to include("-old content")
62
+ expect(result[:diff]).to include("+new content")
63
+ end
64
+
65
+ it "creates parent directories" do
66
+ path = File.join(@dir, "deep", "nested", "file.rb")
67
+ result = tool.call(file_path: path, content: "nested\n")
68
+ expect(result[:success]).to be true
69
+ expect(File.exist?(path)).to be true
70
+ end
71
+
72
+ it "returns byte count" do
73
+ path = File.join(@dir, "test.rb")
74
+ result = tool.call(file_path: path, content: "hello")
75
+ expect(result[:bytes]).to eq(5)
76
+ end
77
+ end
78
+ end
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  require "net/http"
4
9
  require "uri"
5
10
 
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  module Tools
5
10
  class Question < LLM::Tool
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  require "open3"
4
9
 
5
10
  module Brute
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  module Tools
5
10
  class TodoRead < LLM::Tool
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  module Tools
5
10
  class TodoWrite < LLM::Tool
data/lib/brute/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brute
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.1"
5
5
  end
data/lib/brute.rb CHANGED
@@ -194,16 +194,19 @@ module Brute
194
194
  #
195
195
  # Checks in order:
196
196
  # 1. LLM_API_KEY + LLM_PROVIDER (explicit)
197
- # 2. ANTHROPIC_API_KEY (implicit: provider = anthropic)
198
- # 3. OPENAI_API_KEY (implicit: provider = openai)
199
- # 4. GOOGLE_API_KEY (implicit: provider = google)
200
- # 5. OPENCODE_API_KEY (implicit: provider = opencode_zen)
197
+ # 2. OPENCODE_API_KEY (implicit: provider = opencode_zen)
198
+ # 3. ANTHROPIC_API_KEY (implicit: provider = anthropic)
199
+ # 4. OPENAI_API_KEY (implicit: provider = openai)
200
+ # 5. GOOGLE_API_KEY (implicit: provider = google)
201
201
  #
202
202
  # Returns nil if no key is found. Error is deferred to Orchestrator#run.
203
203
  def self.resolve_provider
204
204
  if ENV['LLM_API_KEY']
205
205
  key = ENV['LLM_API_KEY']
206
- name = ENV.fetch('LLM_PROVIDER', 'anthropic').downcase
206
+ name = ENV.fetch('LLM_PROVIDER', 'opencode_zen').downcase
207
+ elsif ENV['OPENCODE_API_KEY']
208
+ key = ENV['OPENCODE_API_KEY']
209
+ name = 'opencode_zen'
207
210
  elsif ENV['ANTHROPIC_API_KEY']
208
211
  key = ENV['ANTHROPIC_API_KEY']
209
212
  name = 'anthropic'
@@ -213,9 +216,6 @@ module Brute
213
216
  elsif ENV['GOOGLE_API_KEY']
214
217
  key = ENV['GOOGLE_API_KEY']
215
218
  name = 'google'
216
- elsif ENV['OPENCODE_API_KEY']
217
- key = ENV['OPENCODE_API_KEY']
218
- name = 'opencode_zen'
219
219
  else
220
220
  return nil
221
221
  end