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.
- checksums.yaml +7 -0
- data/lib/brute/agent_stream.rb +49 -0
- data/lib/brute/compactor.rb +105 -0
- data/lib/brute/doom_loop.rb +84 -0
- data/lib/brute/file_mutation_queue.rb +99 -0
- data/lib/brute/hooks.rb +84 -0
- data/lib/brute/middleware/base.rb +27 -0
- data/lib/brute/middleware/compaction_check.rb +56 -0
- data/lib/brute/middleware/doom_loop_detection.rb +33 -0
- data/lib/brute/middleware/llm_call.rb +28 -0
- data/lib/brute/middleware/reasoning_normalizer.rb +98 -0
- data/lib/brute/middleware/retry.rb +45 -0
- data/lib/brute/middleware/session_persistence.rb +29 -0
- data/lib/brute/middleware/token_tracking.rb +46 -0
- data/lib/brute/middleware/tool_error_tracking.rb +46 -0
- data/lib/brute/middleware/tracing.rb +34 -0
- data/lib/brute/orchestrator.rb +297 -0
- data/lib/brute/patches/anthropic_tool_role.rb +35 -0
- data/lib/brute/patches/buffer_nil_guard.rb +21 -0
- data/lib/brute/pipeline.rb +81 -0
- data/lib/brute/session.rb +86 -0
- data/lib/brute/snapshot_store.rb +49 -0
- data/lib/brute/system_prompt.rb +88 -0
- data/lib/brute/todo_store.rb +27 -0
- data/lib/brute/tools/delegate.rb +35 -0
- data/lib/brute/tools/fs_patch.rb +37 -0
- data/lib/brute/tools/fs_read.rb +37 -0
- data/lib/brute/tools/fs_remove.rb +31 -0
- data/lib/brute/tools/fs_search.rb +38 -0
- data/lib/brute/tools/fs_undo.rb +29 -0
- data/lib/brute/tools/fs_write.rb +26 -0
- data/lib/brute/tools/net_fetch.rb +37 -0
- data/lib/brute/tools/shell.rb +38 -0
- data/lib/brute/tools/todo_read.rb +15 -0
- data/lib/brute/tools/todo_write.rb +32 -0
- data/lib/brute.rb +121 -0
- 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
|