brute 0.1.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 (37) hide show
  1. checksums.yaml +7 -0
  2. data/lib/brute/agent_stream.rb +49 -0
  3. data/lib/brute/compactor.rb +105 -0
  4. data/lib/brute/doom_loop.rb +84 -0
  5. data/lib/brute/file_mutation_queue.rb +99 -0
  6. data/lib/brute/hooks.rb +84 -0
  7. data/lib/brute/middleware/base.rb +27 -0
  8. data/lib/brute/middleware/compaction_check.rb +56 -0
  9. data/lib/brute/middleware/doom_loop_detection.rb +33 -0
  10. data/lib/brute/middleware/llm_call.rb +28 -0
  11. data/lib/brute/middleware/reasoning_normalizer.rb +98 -0
  12. data/lib/brute/middleware/retry.rb +45 -0
  13. data/lib/brute/middleware/session_persistence.rb +29 -0
  14. data/lib/brute/middleware/token_tracking.rb +46 -0
  15. data/lib/brute/middleware/tool_error_tracking.rb +46 -0
  16. data/lib/brute/middleware/tracing.rb +34 -0
  17. data/lib/brute/orchestrator.rb +297 -0
  18. data/lib/brute/patches/anthropic_tool_role.rb +35 -0
  19. data/lib/brute/patches/buffer_nil_guard.rb +21 -0
  20. data/lib/brute/pipeline.rb +81 -0
  21. data/lib/brute/session.rb +86 -0
  22. data/lib/brute/snapshot_store.rb +49 -0
  23. data/lib/brute/system_prompt.rb +88 -0
  24. data/lib/brute/todo_store.rb +27 -0
  25. data/lib/brute/tools/delegate.rb +35 -0
  26. data/lib/brute/tools/fs_patch.rb +37 -0
  27. data/lib/brute/tools/fs_read.rb +37 -0
  28. data/lib/brute/tools/fs_remove.rb +31 -0
  29. data/lib/brute/tools/fs_search.rb +38 -0
  30. data/lib/brute/tools/fs_undo.rb +29 -0
  31. data/lib/brute/tools/fs_write.rb +26 -0
  32. data/lib/brute/tools/net_fetch.rb +37 -0
  33. data/lib/brute/tools/shell.rb +38 -0
  34. data/lib/brute/tools/todo_read.rb +15 -0
  35. data/lib/brute/tools/todo_write.rb +32 -0
  36. data/lib/brute.rb +121 -0
  37. metadata +101 -0
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "securerandom"
6
+
7
+ module Brute
8
+ # Manages session persistence. Each session is a conversation that can be
9
+ # saved to disk and resumed later.
10
+ #
11
+ # Sessions are stored as JSON files in a configurable directory
12
+ # (defaults to ~/.brute/sessions/).
13
+ class Session
14
+ attr_reader :id, :title, :path
15
+
16
+ def initialize(id: nil, dir: nil)
17
+ @id = id || SecureRandom.uuid
18
+ @dir = dir || File.join(Dir.home, ".brute", "sessions")
19
+ @path = File.join(@dir, "#{@id}.json")
20
+ @title = nil
21
+ @metadata = {}
22
+ FileUtils.mkdir_p(@dir)
23
+ end
24
+
25
+ # Save a context to this session file.
26
+ def save(context, title: nil, metadata: {})
27
+ @title = title if title
28
+ @metadata.merge!(metadata)
29
+
30
+ data = {
31
+ id: @id,
32
+ title: @title,
33
+ saved_at: Time.now.iso8601,
34
+ metadata: @metadata,
35
+ }
36
+
37
+ # Use llm.rb's built-in serialization
38
+ context.save(path: @path)
39
+
40
+ # Write metadata sidecar
41
+ meta_path = @path.sub(/\.json$/, ".meta.json")
42
+ File.write(meta_path, JSON.pretty_generate(data))
43
+ end
44
+
45
+ # Restore a context from this session file.
46
+ # Returns true if restored successfully, false if no session file found.
47
+ def restore(context)
48
+ return false unless File.exist?(@path)
49
+
50
+ context.restore(path: @path)
51
+
52
+ # Load metadata sidecar if present
53
+ meta_path = @path.sub(/\.json$/, ".meta.json")
54
+ if File.exist?(meta_path)
55
+ data = JSON.parse(File.read(meta_path), symbolize_names: true)
56
+ @title = data[:title]
57
+ @metadata = data[:metadata] || {}
58
+ end
59
+
60
+ true
61
+ end
62
+
63
+ # List all saved sessions, newest first.
64
+ def self.list(dir: nil)
65
+ dir ||= File.join(Dir.home, ".brute", "sessions")
66
+ return [] unless File.directory?(dir)
67
+
68
+ Dir.glob(File.join(dir, "*.meta.json")).map { |meta_path|
69
+ data = JSON.parse(File.read(meta_path), symbolize_names: true)
70
+ {
71
+ id: data[:id],
72
+ title: data[:title],
73
+ saved_at: data[:saved_at],
74
+ path: meta_path.sub(/\.meta\.json$/, ".json"),
75
+ }
76
+ }.sort_by { |s| s[:saved_at] || "" }.reverse
77
+ end
78
+
79
+ # Delete a session from disk.
80
+ def delete
81
+ File.delete(@path) if File.exist?(@path)
82
+ meta_path = @path.sub(/\.json$/, ".meta.json")
83
+ File.delete(meta_path) if File.exist?(meta_path)
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brute
4
+ # Copy-on-write snapshot storage for file undo support.
5
+ # Saves the previous content of a file before mutation so it can be restored.
6
+ # Each file maintains a stack of snapshots, supporting multiple undo levels.
7
+ module SnapshotStore
8
+ @store = {}
9
+ @mutex = Mutex.new
10
+
11
+ class << self
12
+ # Save the current state of a file before mutating it.
13
+ # If the file doesn't exist, records :did_not_exist so undo can delete it.
14
+ def save(path)
15
+ path = File.expand_path(path)
16
+ @mutex.synchronize do
17
+ @store[path] ||= []
18
+ if File.exist?(path)
19
+ @store[path].push(File.read(path))
20
+ else
21
+ @store[path].push(:did_not_exist)
22
+ end
23
+ end
24
+ end
25
+
26
+ # Pop the most recent snapshot for a file.
27
+ # Returns the content string, :did_not_exist, or nil if no history.
28
+ def pop(path)
29
+ path = File.expand_path(path)
30
+ @mutex.synchronize do
31
+ @store[path]&.pop
32
+ end
33
+ end
34
+
35
+ # Check how many undo levels are available for a file.
36
+ def depth(path)
37
+ path = File.expand_path(path)
38
+ @mutex.synchronize do
39
+ @store[path]&.size || 0
40
+ end
41
+ end
42
+
43
+ # Clear all snapshots (useful for testing or session reset).
44
+ def clear!
45
+ @mutex.synchronize { @store.clear }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brute
4
+ # Builds the system prompt dynamically based on available tools, environment,
5
+ # custom rules, and working directory context.
6
+ #
7
+ # Modeled after forgecode's SystemPrompt which composes a static agent
8
+ # personality block with dynamic environment/tool information.
9
+ class SystemPrompt
10
+ def initialize(cwd: Dir.pwd, tools: [], custom_rules: nil)
11
+ @cwd = cwd
12
+ @tools = tools
13
+ @custom_rules = custom_rules
14
+ end
15
+
16
+ def build
17
+ sections = []
18
+ sections << identity_section
19
+ sections << tools_section
20
+ sections << guidelines_section
21
+ sections << environment_section
22
+ sections << custom_rules_section if @custom_rules
23
+ sections.compact.join("\n\n")
24
+ end
25
+
26
+ private
27
+
28
+ def identity_section
29
+ <<~SECTION
30
+ # Identity
31
+
32
+ You are Brute, an expert software engineering agent. You help users with coding tasks
33
+ by reading, writing, and editing files, running shell commands, and searching codebases.
34
+ You are methodical, precise, and always verify your work.
35
+ SECTION
36
+ end
37
+
38
+ def tools_section
39
+ tool_list = LLM::Function.registry.filter_map { |fn|
40
+ "- **#{fn.name}**: #{fn.description.to_s.split(". ").first}."
41
+ }.join("\n")
42
+
43
+ <<~SECTION
44
+ # Available Tools
45
+
46
+ #{tool_list}
47
+ SECTION
48
+ end
49
+
50
+ def guidelines_section
51
+ <<~SECTION
52
+ # Guidelines
53
+
54
+ - **Always read before editing**: Use `read` to examine a file before using `patch` or `write` to modify it.
55
+ - **Verify your changes**: After editing, re-read the file or run tests to confirm correctness.
56
+ - **Use todo_write for multi-step tasks**: Break complex work into steps and track progress.
57
+ - **Use fs_search to find code**: Don't guess file locations — search first.
58
+ - **Use shell for git, tests, builds**: Run `git diff`, `git status`, test suites, etc.
59
+ - **Be precise with patch**: The `old_string` must match the file content exactly, including whitespace.
60
+ - **Prefer patch over write**: For existing files, use `patch` to change specific sections rather than rewriting the entire file.
61
+ - **Use undo to recover**: If a write or patch goes wrong, use `undo` to restore the previous version.
62
+ - **Delegate research**: Use `delegate` for complex analysis that needs focused investigation.
63
+ SECTION
64
+ end
65
+
66
+ def environment_section
67
+ files = Dir.entries(@cwd).reject { |f| f.start_with?(".") }.sort.first(50)
68
+
69
+ <<~SECTION
70
+ # Environment
71
+
72
+ - **Working directory**: #{@cwd}
73
+ - **OS**: #{RUBY_PLATFORM}
74
+ - **Ruby**: #{RUBY_VERSION}
75
+ - **Date**: #{Time.now.strftime("%Y-%m-%d")}
76
+ - **Files in cwd**: #{files.join(", ")}
77
+ SECTION
78
+ end
79
+
80
+ def custom_rules_section
81
+ <<~SECTION
82
+ # Project-Specific Rules
83
+
84
+ #{@custom_rules}
85
+ SECTION
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brute
4
+ # In-memory todo list storage. The agent uses this to track multi-step tasks.
5
+ # The list is replaced wholesale on each todo_write call.
6
+ module TodoStore
7
+ @items = []
8
+ @mutex = Mutex.new
9
+
10
+ class << self
11
+ # Replace the entire todo list.
12
+ def replace(items)
13
+ @mutex.synchronize { @items = items.dup }
14
+ end
15
+
16
+ # Return all current items.
17
+ def all
18
+ @mutex.synchronize { @items.dup }
19
+ end
20
+
21
+ # Clear all items.
22
+ def clear!
23
+ @mutex.synchronize { @items.clear }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brute
4
+ module Tools
5
+ class Delegate < LLM::Tool
6
+ name "delegate"
7
+ description "Delegate a research or analysis task to a specialist sub-agent. " \
8
+ "The sub-agent can read files and search but cannot write or execute commands. " \
9
+ "Use for code analysis, understanding patterns, or gathering information."
10
+
11
+ param :task, String, "A clear, detailed description of the research task", required: true
12
+
13
+ def call(task:)
14
+ provider = Brute.provider
15
+ sub = LLM::Context.new(provider, tools: [FSRead, FSSearch])
16
+
17
+ prompt = sub.prompt do
18
+ system "You are a research agent. Analyze code, explain patterns, and answer questions. " \
19
+ "You have read-only access to the filesystem. Be thorough and precise."
20
+ user task
21
+ end
22
+
23
+ # Run a manual tool loop (max 10 rounds)
24
+ res = sub.talk(prompt)
25
+ rounds = 0
26
+ while sub.functions.any? && rounds < 10
27
+ res = sub.talk(sub.functions.map(&:call))
28
+ rounds += 1
29
+ end
30
+
31
+ {result: res.content}
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brute
4
+ module Tools
5
+ class FSPatch < LLM::Tool
6
+ name "patch"
7
+ description "Replace a specific string in a file. The old_string must match exactly " \
8
+ "(including whitespace and indentation). Always read a file before patching it."
9
+
10
+ param :file_path, String, "Path to the file to patch", required: true
11
+ param :old_string, String, "The exact text to find and replace", required: true
12
+ param :new_string, String, "The replacement text", required: true
13
+ param :replace_all, Boolean, "Replace all occurrences (default: false)"
14
+
15
+ def call(file_path:, old_string:, new_string:, replace_all: false)
16
+ path = File.expand_path(file_path)
17
+ Brute::FileMutationQueue.serialize(path) do
18
+ raise "File not found: #{path}" unless File.exist?(path)
19
+
20
+ original = File.read(path)
21
+ raise "old_string not found in #{path}" unless original.include?(old_string)
22
+
23
+ Brute::SnapshotStore.save(path)
24
+
25
+ updated = if replace_all
26
+ original.gsub(old_string, new_string)
27
+ else
28
+ original.sub(old_string, new_string)
29
+ end
30
+
31
+ File.write(path, updated)
32
+ {success: true, file_path: path, replacements: replace_all ? original.scan(old_string).size : 1}
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brute
4
+ module Tools
5
+ class FSRead < LLM::Tool
6
+ name "read"
7
+ description "Read the contents of a file. Returns file content with line numbers. " \
8
+ "Use start_line/end_line for partial reads of large files."
9
+
10
+ param :file_path, String, "Absolute or relative path to the file to read", required: true
11
+ param :start_line, Integer, "Starting line number (1-indexed). Omit to read from beginning"
12
+ param :end_line, Integer, "Ending line number (inclusive). Omit to read to end"
13
+
14
+ def call(file_path:, start_line: nil, end_line: nil)
15
+ path = File.expand_path(file_path)
16
+ raise "File not found: #{path}" unless File.exist?(path)
17
+ raise "Not a file: #{path}" unless File.file?(path)
18
+
19
+ lines = File.readlines(path)
20
+ first = start_line ? [start_line - 1, 0].max : 0
21
+ last = end_line ? [end_line - 1, lines.size - 1].min : lines.size - 1
22
+
23
+ selected = lines[first..last] || []
24
+ numbered = selected.each_with_index.map do |line, i|
25
+ "#{first + i + 1}\t#{line}"
26
+ end
27
+
28
+ {
29
+ file_path: path,
30
+ total_lines: lines.size,
31
+ showing: "#{first + 1}-#{last + 1}",
32
+ content: numbered.join,
33
+ }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Brute
6
+ module Tools
7
+ class FSRemove < LLM::Tool
8
+ name "remove"
9
+ description "Remove a file or empty directory."
10
+
11
+ param :path, String, "Path to the file or directory to remove", required: true
12
+
13
+ def call(path:)
14
+ target = File.expand_path(path)
15
+ Brute::FileMutationQueue.serialize(target) do
16
+ raise "Path not found: #{target}" unless File.exist?(target)
17
+
18
+ Brute::SnapshotStore.save(target) if File.file?(target)
19
+
20
+ if File.directory?(target)
21
+ Dir.rmdir(target)
22
+ else
23
+ File.delete(target)
24
+ end
25
+
26
+ {success: true, path: target}
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Brute
6
+ module Tools
7
+ class FSSearch < LLM::Tool
8
+ name "fs_search"
9
+ description "Search file contents using ripgrep (regex), or find files by glob pattern. " \
10
+ "Returns matching lines with file paths and line numbers."
11
+
12
+ param :pattern, String, "Regex pattern to search for in file contents", required: true
13
+ param :path, String, "Directory to search in (defaults to current working directory)"
14
+ param :glob, String, "File glob filter, e.g. '*.rb', '*.{js,ts}'"
15
+ param :ignore_case, Boolean, "Case-insensitive search (default: false)"
16
+
17
+ MAX_OUTPUT = 40_000
18
+
19
+ def call(pattern:, path: nil, glob: nil, ignore_case: false)
20
+ dir = File.expand_path(path || Dir.pwd)
21
+ raise "Directory not found: #{dir}" unless File.directory?(dir)
22
+
23
+ cmd = ["rg", "--line-number", "--max-count=100", "--max-columns=200"]
24
+ cmd << "--ignore-case" if ignore_case
25
+ cmd += ["--glob", glob] if glob
26
+ cmd << pattern
27
+ cmd << dir
28
+
29
+ stdout, stderr, status = Open3.capture3(*cmd)
30
+
31
+ output = stdout.empty? ? stderr : stdout
32
+ output = output[0...MAX_OUTPUT] + "\n...(truncated)" if output.size > MAX_OUTPUT
33
+
34
+ {results: output, exit_code: status.exitstatus, truncated: output.size > MAX_OUTPUT}
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brute
4
+ module Tools
5
+ class FSUndo < LLM::Tool
6
+ name "undo"
7
+ description "Undo the last write or patch operation on a file, restoring it to " \
8
+ "its previous state."
9
+
10
+ param :path, String, "Path to the file to undo", required: true
11
+
12
+ def call(path:)
13
+ target = File.expand_path(path)
14
+ Brute::FileMutationQueue.serialize(target) do
15
+ snapshot = Brute::SnapshotStore.pop(target)
16
+ raise "No undo history available for: #{target}" unless snapshot
17
+
18
+ if snapshot == :did_not_exist
19
+ File.delete(target) if File.exist?(target)
20
+ {success: true, action: "deleted (file did not exist before)"}
21
+ else
22
+ File.write(target, snapshot)
23
+ {success: true, action: "restored", bytes: snapshot.bytesize}
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Brute
6
+ module Tools
7
+ class FSWrite < LLM::Tool
8
+ name "write"
9
+ description "Write content to a file. Creates parent directories if they don't exist. " \
10
+ "Use this for creating new files or completely replacing file contents."
11
+
12
+ param :file_path, String, "Path to the file to write", required: true
13
+ param :content, String, "The full content to write to the file", required: true
14
+
15
+ def call(file_path:, content:)
16
+ path = File.expand_path(file_path)
17
+ Brute::FileMutationQueue.serialize(path) do
18
+ Brute::SnapshotStore.save(path)
19
+ FileUtils.mkdir_p(File.dirname(path))
20
+ File.write(path, content)
21
+ {success: true, file_path: path, bytes: content.bytesize}
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Brute
7
+ module Tools
8
+ class NetFetch < LLM::Tool
9
+ name "fetch"
10
+ description "Fetch content from a URL. Returns the response body as text."
11
+
12
+ param :url, String, "The URL to fetch", required: true
13
+
14
+ MAX_BODY = 50_000
15
+ TIMEOUT = 30
16
+
17
+ def call(url:)
18
+ uri = URI.parse(url)
19
+ raise "Invalid URL scheme: #{uri.scheme}" unless %w[http https].include?(uri.scheme)
20
+
21
+ http = Net::HTTP.new(uri.host, uri.port)
22
+ http.use_ssl = uri.scheme == "https"
23
+ http.open_timeout = TIMEOUT
24
+ http.read_timeout = TIMEOUT
25
+
26
+ request = Net::HTTP::Get.new(uri)
27
+ request["User-Agent"] = "forge-rb/1.0"
28
+
29
+ response = http.request(request)
30
+ body = response.body.to_s
31
+ body = body[0...MAX_BODY] + "\n...(truncated)" if body.size > MAX_BODY
32
+
33
+ {status: response.code.to_i, body: body, content_type: response["content-type"]}
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Brute
6
+ module Tools
7
+ class Shell < LLM::Tool
8
+ name "shell"
9
+ description "Execute a shell command and return stdout, stderr, and exit code. " \
10
+ "Use for git operations, running tests, installing packages, etc."
11
+
12
+ param :command, String, "The shell command to execute", required: true
13
+ param :cwd, String, "Working directory for the command (defaults to project root)"
14
+
15
+ TIMEOUT = 300 # 5 minutes
16
+ MAX_OUTPUT = 50_000
17
+
18
+ def call(command:, cwd: nil)
19
+ dir = cwd ? File.expand_path(cwd) : Dir.pwd
20
+ raise "Directory not found: #{dir}" unless File.directory?(dir)
21
+
22
+ stdout, stderr, status = nil
23
+ Timeout.timeout(TIMEOUT) do
24
+ stdout, stderr, status = Open3.capture3("bash", "-c", command, chdir: dir)
25
+ end
26
+
27
+ out = stdout.to_s
28
+ err = stderr.to_s
29
+ out = out[0...MAX_OUTPUT] + "\n...(truncated)" if out.size > MAX_OUTPUT
30
+ err = err[0...MAX_OUTPUT] + "\n...(truncated)" if err.size > MAX_OUTPUT
31
+
32
+ {stdout: out, stderr: err, exit_code: status.exitstatus}
33
+ rescue Timeout::Error
34
+ {error: "Command timed out after #{TIMEOUT}s", command: command}
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brute
4
+ module Tools
5
+ class TodoRead < LLM::Tool
6
+ name "todo_read"
7
+ description "Read the current todo list to check task status and progress."
8
+ param :_placeholder, String, "Unused, pass any value"
9
+
10
+ def call(_placeholder: nil)
11
+ {todos: Brute::TodoStore.all}
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brute
4
+ module Tools
5
+ class TodoWrite < LLM::Tool
6
+ name "todo_write"
7
+ description "Create or update the todo list. Send the complete list each time — " \
8
+ "this replaces the existing list entirely."
9
+
10
+ params do |s|
11
+ s.object(
12
+ todos: s.array(
13
+ s.object(
14
+ id: s.string.required,
15
+ content: s.string.required,
16
+ status: s.string.enum("pending", "in_progress", "completed", "cancelled").required
17
+ )
18
+ ).required
19
+ )
20
+ end
21
+
22
+ def call(todos:)
23
+ items = todos.map do |t|
24
+ t = t.transform_keys(&:to_sym) if t.is_a?(Hash)
25
+ {id: t[:id], content: t[:content], status: t[:status]}
26
+ end
27
+ Brute::TodoStore.replace(items)
28
+ {success: true, count: items.size}
29
+ end
30
+ end
31
+ end
32
+ end