brute 5.1.0 → 6.0.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.
- checksums.yaml +4 -4
- data/lib/brute/compaction/middleware/sliding_window.rb +1 -1
- data/lib/brute/compaction/middleware/strategy.rb +0 -2
- data/lib/brute/compaction/summarize.rb +30 -21
- 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 +81 -49
- data/lib/brute/eval/case.rb +7 -2
- data/lib/brute/eval/suite.rb +10 -5
- data/lib/brute/eval/transcript.rb +4 -4
- data/lib/brute/eval/world.rb +2 -2
- data/lib/brute/eval.rb +1 -1
- 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 +91 -112
- 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 +5 -5
- data/lib/brute/middleware/002_session_log.rb +12 -4
- data/lib/brute/middleware/008_checkpoint.rb +26 -18
- 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 +49 -45
- data/lib/brute/middleware/070_default_tool_pipeline.rb +44 -53
- 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.rb +3 -1
- data/lib/brute/tool.rb +10 -6
- data/lib/brute/tools/adapter.rb +53 -43
- 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 +44 -39
- 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 +4 -3
- data/lib/brute/turn/pipeline.rb +93 -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 +10 -11
- data/lib/brute_cli/providers/shell.rb +32 -29
- data/lib/brute_cli/providers/shell_response.rb +20 -18
- metadata +30 -5
- data/lib/brute/middleware/040_compaction_check.rb +0 -73
- data/lib/brute/middleware/070_tool_pipeline.rb +0 -61
- data/lib/brute/middleware/event_handler.rb +0 -27
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
|
|
@@ -17,9 +17,13 @@ module Brute
|
|
|
17
17
|
def execute(path:)
|
|
18
18
|
target = File.expand_path(path)
|
|
19
19
|
Brute::Tools::FS::FileMutationQueue.serialize(target) do
|
|
20
|
-
|
|
20
|
+
unless File.exist?(target)
|
|
21
|
+
raise "Path not found: #{target}"
|
|
22
|
+
end
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
if File.file?(target)
|
|
25
|
+
Brute::Tools::FS::SnapshotStore.save(target)
|
|
26
|
+
end
|
|
23
27
|
|
|
24
28
|
if File.directory?(target)
|
|
25
29
|
Dir.rmdir(target)
|
|
@@ -36,17 +36,27 @@ module Brute
|
|
|
36
36
|
|
|
37
37
|
def execute(pattern:, path: nil, glob: nil, ignore_case: false)
|
|
38
38
|
dir = File.expand_path(path || Dir.pwd)
|
|
39
|
-
|
|
39
|
+
unless File.directory?(dir)
|
|
40
|
+
raise "Directory not found: #{dir}"
|
|
41
|
+
end
|
|
40
42
|
|
|
41
43
|
cmd = ["rg", "--line-number", "--max-columns=2000", "--max-columns-preview", "--sortr=modified"]
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
if ignore_case
|
|
45
|
+
cmd << "--ignore-case"
|
|
46
|
+
end
|
|
47
|
+
if glob
|
|
48
|
+
cmd += ["--glob", glob]
|
|
49
|
+
end
|
|
44
50
|
cmd << pattern
|
|
45
51
|
cmd << dir
|
|
46
52
|
|
|
47
53
|
stdout, stderr, status = Open3.capture3(*cmd)
|
|
48
54
|
|
|
49
|
-
|
|
55
|
+
if stdout.empty?
|
|
56
|
+
output = stderr
|
|
57
|
+
else
|
|
58
|
+
output = stdout
|
|
59
|
+
end
|
|
50
60
|
|
|
51
61
|
# Global cap at MAX_TOTAL_MATCHES lines
|
|
52
62
|
lines = output.lines
|
data/lib/brute/tools/fs_undo.rb
CHANGED
|
@@ -18,10 +18,14 @@ module Brute
|
|
|
18
18
|
target = File.expand_path(path)
|
|
19
19
|
Brute::Tools::FS::FileMutationQueue.serialize(target) do
|
|
20
20
|
snapshot = Brute::Tools::FS::SnapshotStore.pop(target)
|
|
21
|
-
|
|
21
|
+
unless snapshot
|
|
22
|
+
raise "No undo history available for: #{target}"
|
|
23
|
+
end
|
|
22
24
|
|
|
23
25
|
if snapshot == :did_not_exist
|
|
24
|
-
|
|
26
|
+
if File.exist?(target)
|
|
27
|
+
File.delete(target)
|
|
28
|
+
end
|
|
25
29
|
{success: true, action: "deleted (file did not exist before)"}
|
|
26
30
|
else
|
|
27
31
|
File.write(target, snapshot)
|
data/lib/brute/tools/fs_write.rb
CHANGED
|
@@ -19,7 +19,11 @@ module Brute
|
|
|
19
19
|
def execute(file_path:, content:)
|
|
20
20
|
path = File.expand_path(file_path)
|
|
21
21
|
Brute::Tools::FS::FileMutationQueue.serialize(path) do
|
|
22
|
-
|
|
22
|
+
if File.exist?(path)
|
|
23
|
+
old_content = File.read(path)
|
|
24
|
+
else
|
|
25
|
+
old_content = ''
|
|
26
|
+
end
|
|
23
27
|
Brute::Tools::FS::SnapshotStore.save(path)
|
|
24
28
|
FileUtils.mkdir_p(File.dirname(path))
|
|
25
29
|
File.write(path, content)
|
|
@@ -21,7 +21,9 @@ module Brute
|
|
|
21
21
|
|
|
22
22
|
def execute(url:)
|
|
23
23
|
uri = URI.parse(url)
|
|
24
|
-
|
|
24
|
+
unless %w[http https].include?(uri.scheme)
|
|
25
|
+
raise "Invalid URL scheme: #{uri.scheme}"
|
|
26
|
+
end
|
|
25
27
|
|
|
26
28
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
27
29
|
http.use_ssl = uri.scheme == "https"
|
|
@@ -33,7 +35,9 @@ module Brute
|
|
|
33
35
|
|
|
34
36
|
response = http.request(request)
|
|
35
37
|
body = response.body.to_s
|
|
36
|
-
|
|
38
|
+
if body.size > MAX_BODY
|
|
39
|
+
body = body[0...MAX_BODY] + "\n...(truncated)"
|
|
40
|
+
end
|
|
37
41
|
|
|
38
42
|
{status: response.code.to_i, body: body, content_type: response["content-type"]}
|
|
39
43
|
end
|
data/lib/brute/tools/question.rb
CHANGED
|
@@ -14,63 +14,66 @@ module Brute
|
|
|
14
14
|
"to allow selecting more than one. If you recommend a specific option, make it " \
|
|
15
15
|
"the first option and add \"(Recommended)\" at the end of the label."
|
|
16
16
|
|
|
17
|
-
params(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
17
|
+
params(
|
|
18
|
+
{
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
questions: {
|
|
22
|
+
type: 'array',
|
|
23
|
+
items: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
question: { type: 'string' },
|
|
27
|
+
header: { type: 'string' },
|
|
28
|
+
options: {
|
|
29
|
+
type: 'array',
|
|
30
|
+
items: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
label: { type: 'string' },
|
|
34
|
+
description: { type: 'string' },
|
|
35
|
+
},
|
|
36
|
+
required: %w[label description],
|
|
34
37
|
},
|
|
35
|
-
required: %w[label description],
|
|
36
38
|
},
|
|
39
|
+
multiple: { type: 'boolean' },
|
|
37
40
|
},
|
|
38
|
-
|
|
41
|
+
required: %w[question header options],
|
|
39
42
|
},
|
|
40
|
-
required: %w[question header options],
|
|
41
43
|
},
|
|
42
44
|
},
|
|
45
|
+
required: %w[questions],
|
|
43
46
|
},
|
|
44
|
-
|
|
45
|
-
})
|
|
47
|
+
)
|
|
46
48
|
|
|
47
49
|
def name; "question"; end
|
|
48
50
|
|
|
49
51
|
def execute(questions:)
|
|
50
52
|
handler = Thread.current[:on_question]
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
if handler
|
|
54
|
+
queue = Queue.new
|
|
55
|
+
handler.call(questions, queue)
|
|
56
|
+
answers = queue.pop
|
|
57
|
+
format_answers(questions, answers)
|
|
58
|
+
else
|
|
59
|
+
{ error: true, message: "Cannot ask questions in non-interactive mode" }
|
|
53
60
|
end
|
|
54
|
-
|
|
55
|
-
queue = Queue.new
|
|
56
|
-
handler.call(questions, queue)
|
|
57
|
-
answers = queue.pop
|
|
58
|
-
|
|
59
|
-
format_answers(questions, answers)
|
|
60
61
|
end
|
|
61
62
|
|
|
62
63
|
private
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
65
|
+
def format_answers(questions, answers)
|
|
66
|
+
pairs = questions.each_with_index.map do |q, i|
|
|
67
|
+
if q.is_a?(Hash)
|
|
68
|
+
q = q.transform_keys(&:to_s)
|
|
69
|
+
end
|
|
70
|
+
header = q["header"]
|
|
71
|
+
answer = answers[i] || []
|
|
72
|
+
"\"#{header}\" = #{answer.join(', ')}"
|
|
73
|
+
end
|
|
71
74
|
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
{ response: "User answered: #{pairs.join('; ')}" }
|
|
76
|
+
end
|
|
74
77
|
end
|
|
75
78
|
end
|
|
76
79
|
end
|
data/lib/brute/tools/shell.rb
CHANGED
|
@@ -35,13 +35,24 @@ module Brute
|
|
|
35
35
|
DEFAULT_TIMEOUT = 300 # 5 minutes
|
|
36
36
|
|
|
37
37
|
def execute(command:, cwd: nil, timeout: nil)
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
if cwd
|
|
39
|
+
dir = File.expand_path(cwd)
|
|
40
|
+
else
|
|
41
|
+
dir = Dir.pwd
|
|
42
|
+
end
|
|
43
|
+
unless File.directory?(dir)
|
|
44
|
+
raise "Directory not found: #{dir}"
|
|
45
|
+
end
|
|
40
46
|
|
|
41
47
|
timeout_secs = timeout || DEFAULT_TIMEOUT
|
|
42
48
|
stdout, stderr, status = nil
|
|
43
49
|
Timeout.timeout(timeout_secs) do
|
|
44
|
-
stdout, stderr, status = Open3.capture3(
|
|
50
|
+
stdout, stderr, status = Open3.capture3(
|
|
51
|
+
"bash",
|
|
52
|
+
"-c",
|
|
53
|
+
command,
|
|
54
|
+
chdir: dir,
|
|
55
|
+
)
|
|
45
56
|
end
|
|
46
57
|
|
|
47
58
|
out = stdout.to_s
|
|
@@ -49,8 +60,12 @@ module Brute
|
|
|
49
60
|
|
|
50
61
|
# Combine output, preferring tail-mode truncation so errors/summaries at end are preserved
|
|
51
62
|
combined = out
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
unless err.empty?
|
|
64
|
+
combined += "\nSTDERR:\n#{err}"
|
|
65
|
+
end
|
|
66
|
+
if status.exitstatus != 0
|
|
67
|
+
combined += "\n[exit code: #{status.exitstatus}]"
|
|
68
|
+
end
|
|
54
69
|
|
|
55
70
|
Brute::Truncation.truncate(combined, direction: :tail)
|
|
56
71
|
rescue Timeout::Error
|
|
@@ -49,52 +49,57 @@ module Brute
|
|
|
49
49
|
|
|
50
50
|
def execute(name:)
|
|
51
51
|
skill = Brute::Skill.get(name, cwd: @cwd)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
if skill
|
|
53
|
+
directory = File.dirname(skill.location)
|
|
54
|
+
files = bundled_files(directory)
|
|
55
|
+
render(skill, directory, files)
|
|
56
|
+
else
|
|
57
|
+
unknown_skill(name)
|
|
58
|
+
end
|
|
58
59
|
end
|
|
59
60
|
|
|
60
61
|
private
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
.reject { |p| File.basename(p) == Brute::Skill::FILENAME }
|
|
72
|
-
.map { |p| Pathname.new(p).relative_path_from(Pathname.new(directory)).to_s }
|
|
73
|
-
.sort
|
|
74
|
-
.first(FILE_LIMIT)
|
|
75
|
-
end
|
|
63
|
+
def unknown_skill(name)
|
|
64
|
+
available = Brute::Skill.all(cwd: @cwd).map(&:name)
|
|
65
|
+
if available.empty?
|
|
66
|
+
listing = "(none)"
|
|
67
|
+
else
|
|
68
|
+
listing = available.join(", ")
|
|
69
|
+
end
|
|
70
|
+
"Error: unknown skill #{name.inspect}. Available skills: #{listing}"
|
|
71
|
+
end
|
|
76
72
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"Base directory for this skill: #{directory}",
|
|
85
|
-
"Relative paths in this skill (e.g. scripts/, references/, assets/) are relative to " \
|
|
86
|
-
"this base directory. Use your existing tools (read, shell) to access them.",
|
|
87
|
-
]
|
|
88
|
-
|
|
89
|
-
unless files.empty?
|
|
90
|
-
lines << ""
|
|
91
|
-
lines << "Bundled files (sampled, up to #{FILE_LIMIT}):"
|
|
92
|
-
files.each { |f| lines << " #{f}" }
|
|
73
|
+
def bundled_files(directory)
|
|
74
|
+
Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH)
|
|
75
|
+
.select { |p| File.file?(p) }
|
|
76
|
+
.reject { |p| File.basename(p) == Brute::Skill::FILENAME }
|
|
77
|
+
.map { |p| Pathname.new(p).relative_path_from(Pathname.new(directory)).to_s }
|
|
78
|
+
.sort
|
|
79
|
+
.first(FILE_LIMIT)
|
|
93
80
|
end
|
|
94
81
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
82
|
+
def render(skill, directory, files)
|
|
83
|
+
lines = [
|
|
84
|
+
"<skill name=\"#{skill.name}\">",
|
|
85
|
+
"# Skill: #{skill.name}",
|
|
86
|
+
"",
|
|
87
|
+
skill.content,
|
|
88
|
+
"",
|
|
89
|
+
"Base directory for this skill: #{directory}",
|
|
90
|
+
"Relative paths in this skill (e.g. scripts/, references/, assets/) are relative to " \
|
|
91
|
+
"this base directory. Use your existing tools (read, shell) to access them.",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
unless files.empty?
|
|
95
|
+
lines << ""
|
|
96
|
+
lines << "Bundled files (sampled, up to #{FILE_LIMIT}):"
|
|
97
|
+
files.each { |f| lines << " #{f}" }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
lines << "</skill>"
|
|
101
|
+
lines.join("\n")
|
|
102
|
+
end
|
|
98
103
|
end
|
|
99
104
|
end
|
|
100
105
|
end
|
|
@@ -10,30 +10,34 @@ module Brute
|
|
|
10
10
|
description "Create or update the todo list. Send the complete list each time — " \
|
|
11
11
|
"this replaces the existing list entirely."
|
|
12
12
|
|
|
13
|
-
params(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
13
|
+
params(
|
|
14
|
+
{
|
|
15
|
+
type: 'object',
|
|
16
|
+
properties: {
|
|
17
|
+
todos: {
|
|
18
|
+
type: 'array',
|
|
19
|
+
items: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
id: { type: 'string' },
|
|
23
|
+
content: { type: 'string' },
|
|
24
|
+
status: { type: 'string', enum: %w[pending in_progress completed cancelled] },
|
|
25
|
+
},
|
|
26
|
+
required: %w[id content status],
|
|
24
27
|
},
|
|
25
|
-
required: %w[id content status],
|
|
26
28
|
},
|
|
27
29
|
},
|
|
30
|
+
required: %w[todos],
|
|
28
31
|
},
|
|
29
|
-
|
|
30
|
-
})
|
|
32
|
+
)
|
|
31
33
|
|
|
32
34
|
def name; "todo_write"; end
|
|
33
35
|
|
|
34
36
|
def execute(todos:)
|
|
35
37
|
items = todos.map do |t|
|
|
36
|
-
|
|
38
|
+
if t.is_a?(Hash)
|
|
39
|
+
t = t.transform_keys(&:to_sym)
|
|
40
|
+
end
|
|
37
41
|
{id: t[:id], content: t[:content], status: t[:status]}
|
|
38
42
|
end
|
|
39
43
|
Brute::Tools::TodoList::Store.replace(items)
|