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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/compaction/middleware/sliding_window.rb +1 -1
  3. data/lib/brute/compaction/middleware/strategy.rb +0 -2
  4. data/lib/brute/compaction/summarize.rb +30 -21
  5. data/lib/brute/completion/lang_chain.rb +34 -31
  6. data/lib/brute/completion/llmrb.rb +31 -29
  7. data/lib/brute/completion/open_router.rb +43 -40
  8. data/lib/brute/completion/ruby_llm.rb +28 -27
  9. data/lib/brute/contrib/otel.rb +81 -49
  10. data/lib/brute/eval/case.rb +7 -2
  11. data/lib/brute/eval/suite.rb +10 -5
  12. data/lib/brute/eval/transcript.rb +4 -4
  13. data/lib/brute/eval/world.rb +2 -2
  14. data/lib/brute/eval.rb +1 -1
  15. data/lib/brute/events/handler.rb +3 -1
  16. data/lib/brute/events/prefixed_terminal_output.rb +3 -1
  17. data/lib/brute/events/terminal_output_handler.rb +1 -1
  18. data/lib/brute/hooks.rb +91 -112
  19. data/lib/brute/message_transport/anthropic.rb +13 -3
  20. data/lib/brute/message_transport/llm.rb +8 -4
  21. data/lib/brute/message_transport/open_router.rb +30 -16
  22. data/lib/brute/message_transport/openai.rb +14 -7
  23. data/lib/brute/message_transport/ruby_llm.rb +21 -17
  24. data/lib/brute/message_transport/ruby_open_ai.rb +59 -57
  25. data/lib/brute/messages.rb +15 -8
  26. data/lib/brute/middleware/000_base.rb +5 -5
  27. data/lib/brute/middleware/002_session_log.rb +12 -4
  28. data/lib/brute/middleware/008_checkpoint.rb +26 -18
  29. data/lib/brute/middleware/010_max_iterations.rb +1 -1
  30. data/lib/brute/middleware/020_system_prompt.rb +1 -1
  31. data/lib/brute/middleware/040_default_compaction_pipeline.rb +49 -45
  32. data/lib/brute/middleware/070_default_tool_pipeline.rb +44 -53
  33. data/lib/brute/prompt_template.rb +19 -15
  34. data/lib/brute/prompts/base.rb +19 -10
  35. data/lib/brute/prompts/environment.rb +3 -1
  36. data/lib/brute/prompts/instructions.rb +9 -7
  37. data/lib/brute/prompts/skills.rb +5 -3
  38. data/lib/brute/rack/adapter.rb +40 -23
  39. data/lib/brute/skill.rb +136 -89
  40. data/lib/brute/system_prompt.rb +9 -9
  41. data/lib/brute/token_counter.rb +3 -1
  42. data/lib/brute/tool.rb +10 -6
  43. data/lib/brute/tools/adapter.rb +53 -43
  44. data/lib/brute/tools/fs/snapshot_store.rb +5 -1
  45. data/lib/brute/tools/fs_patch.rb +16 -8
  46. data/lib/brute/tools/fs_read.rb +107 -80
  47. data/lib/brute/tools/fs_remove.rb +6 -2
  48. data/lib/brute/tools/fs_search.rb +14 -4
  49. data/lib/brute/tools/fs_undo.rb +6 -2
  50. data/lib/brute/tools/fs_write.rb +5 -1
  51. data/lib/brute/tools/net_fetch.rb +6 -2
  52. data/lib/brute/tools/question.rb +42 -39
  53. data/lib/brute/tools/shell.rb +20 -5
  54. data/lib/brute/tools/skill_load.rb +44 -39
  55. data/lib/brute/tools/todo_write.rb +19 -15
  56. data/lib/brute/truncation.rb +68 -43
  57. data/lib/brute/turn/agent_pipeline.rb +23 -13
  58. data/lib/brute/turn/compaction_pipeline.rb +4 -3
  59. data/lib/brute/turn/pipeline.rb +93 -96
  60. data/lib/brute/turn/tool_pipeline.rb +4 -3
  61. data/lib/brute/usage_detection/llmrb.rb +18 -14
  62. data/lib/brute/usage_detection/open_router.rb +20 -17
  63. data/lib/brute/usage_detection/ruby_llm.rb +18 -14
  64. data/lib/brute/usage_detection/usage.rb +10 -1
  65. data/lib/brute/utils/diff.rb +18 -10
  66. data/lib/brute/version.rb +1 -1
  67. data/lib/brute.rb +10 -11
  68. data/lib/brute_cli/providers/shell.rb +32 -29
  69. data/lib/brute_cli/providers/shell_response.rb +20 -18
  70. metadata +30 -5
  71. data/lib/brute/middleware/040_compaction_check.rb +0 -73
  72. data/lib/brute/middleware/070_tool_pipeline.rb +0 -61
  73. data/lib/brute/middleware/event_handler.rb +0 -27
@@ -47,101 +47,128 @@ module Brute
47
47
  path = File.expand_path(file_path)
48
48
 
49
49
  # Directory listing
50
- return list_directory(path) if File.directory?(path)
51
-
52
- # File-not-found suggestions
53
- unless File.exist?(path)
54
- suggestions = find_similar(path)
55
- msg = "File not found: #{path}"
56
- msg += ". Did you mean: #{suggestions.join(', ')}?" if suggestions.any?
57
- raise msg
58
- end
59
-
60
- raise "Not a file: #{path}" unless File.file?(path)
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
- # Apply default line cap when no explicit range given
74
- default_last = end_line ? [end_line - 1, total - 1].min : [first + DEFAULT_LINE_CAP - 1, total - 1].min
75
- last = default_last
63
+ unless File.file?(path)
64
+ raise "Not a file: #{path}"
65
+ end
76
66
 
77
- selected = lines[first..last] || []
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
- # Per-line truncation + byte cap
80
- numbered = []
81
- bytes = 0
82
- selected.each_with_index do |line, i|
83
- truncated_line = Brute::Truncation.truncate_line(line, max: MAX_LINE_LENGTH)
84
- numbered_line = "#{first + i + 1}\t#{truncated_line}"
85
- break if bytes + numbered_line.bytesize > MAX_BYTES
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
- actual_last = first + numbered.size - 1
91
- content = numbered.join
92
- truncated = (actual_last < total - 1) && end_line.nil?
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
- if truncated
95
- content + "\n(Showing lines #{first + 1}-#{actual_last + 1} of #{total}. Use start_line=#{actual_last + 2} to continue.)"
96
- else
97
- content
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
- def list_directory(path)
104
- entries = Dir.entries(path).reject { |e| e.start_with?(".") }.sort
105
- total = entries.size
106
- capped = entries.first(DEFAULT_LINE_CAP)
107
- result = capped.map do |entry|
108
- full = File.join(path, entry)
109
- type = File.directory?(full) ? "dir" : "file"
110
- "#{entry} (#{type})"
111
- end.join("\n")
112
-
113
- if total > DEFAULT_LINE_CAP
114
- result += "\n(Showing #{DEFAULT_LINE_CAP} of #{total} entries)"
115
- end
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
- def find_similar(path)
120
- dir = File.dirname(path)
121
- target = File.basename(path)
122
- return [] unless File.directory?(dir)
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
- entries = Dir.entries(dir).reject { |e| e.start_with?(".") }
125
- entries.select { |e| levenshtein(e.downcase, target.downcase) <= 3 }
126
- .sort_by { |e| levenshtein(e.downcase, target.downcase) }
127
- .first(3)
128
- end
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
- def levenshtein(a, b)
131
- m, n = a.length, b.length
132
- d = Array.new(m + 1) { |i| i }
133
- (1..n).each do |j|
134
- prev = d[0]
135
- d[0] = j
136
- (1..m).each do |i|
137
- cost = a[i - 1] == b[j - 1] ? 0 : 1
138
- temp = d[i]
139
- d[i] = [d[i] + 1, d[i - 1] + 1, prev + cost].min
140
- prev = temp
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
- raise "Path not found: #{target}" unless File.exist?(target)
20
+ unless File.exist?(target)
21
+ raise "Path not found: #{target}"
22
+ end
21
23
 
22
- Brute::Tools::FS::SnapshotStore.save(target) if File.file?(target)
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
- raise "Directory not found: #{dir}" unless File.directory?(dir)
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
- cmd << "--ignore-case" if ignore_case
43
- cmd += ["--glob", glob] if glob
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
- output = stdout.empty? ? stderr : stdout
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
@@ -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
- raise "No undo history available for: #{target}" unless snapshot
21
+ unless snapshot
22
+ raise "No undo history available for: #{target}"
23
+ end
22
24
 
23
25
  if snapshot == :did_not_exist
24
- File.delete(target) if File.exist?(target)
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)
@@ -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
- old_content = File.exist?(path) ? File.read(path) : ''
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
- raise "Invalid URL scheme: #{uri.scheme}" unless %w[http https].include?(uri.scheme)
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
- body = body[0...MAX_BODY] + "\n...(truncated)" if body.size > MAX_BODY
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
@@ -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
- type: 'object',
19
- properties: {
20
- questions: {
21
- type: 'array',
22
- items: {
23
- type: 'object',
24
- properties: {
25
- question: { type: 'string' },
26
- header: { type: 'string' },
27
- options: {
28
- type: 'array',
29
- items: {
30
- type: 'object',
31
- properties: {
32
- label: { type: 'string' },
33
- description: { type: 'string' },
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
- multiple: { type: 'boolean' },
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
- required: %w[questions],
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
- unless handler
52
- return { error: true, message: "Cannot ask questions in non-interactive mode" }
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
- def format_answers(questions, answers)
65
- pairs = questions.each_with_index.map do |q, i|
66
- q = q.transform_keys(&:to_s) if q.is_a?(Hash)
67
- header = q["header"]
68
- answer = answers[i] || []
69
- "\"#{header}\" = #{answer.join(', ')}"
70
- end
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
- { response: "User answered: #{pairs.join('; ')}" }
73
- end
75
+ { response: "User answered: #{pairs.join('; ')}" }
76
+ end
74
77
  end
75
78
  end
76
79
  end
@@ -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
- dir = cwd ? File.expand_path(cwd) : Dir.pwd
39
- raise "Directory not found: #{dir}" unless File.directory?(dir)
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("bash", "-c", command, chdir: dir)
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
- combined += "\nSTDERR:\n#{err}" unless err.empty?
53
- combined += "\n[exit code: #{status.exitstatus}]" if status.exitstatus != 0
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
- return unknown_skill(name) unless skill
53
-
54
- directory = File.dirname(skill.location)
55
- files = bundled_files(directory)
56
-
57
- render(skill, directory, files)
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
- def unknown_skill(name)
63
- available = Brute::Skill.all(cwd: @cwd).map(&:name)
64
- listing = available.empty? ? "(none)" : available.join(", ")
65
- "Error: unknown skill #{name.inspect}. Available skills: #{listing}"
66
- end
67
-
68
- def bundled_files(directory)
69
- Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH)
70
- .select { |p| File.file?(p) }
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
- def render(skill, directory, files)
78
- lines = [
79
- "<skill name=\"#{skill.name}\">",
80
- "# Skill: #{skill.name}",
81
- "",
82
- skill.content,
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
- lines << "</skill>"
96
- lines.join("\n")
97
- end
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
- type: 'object',
15
- properties: {
16
- todos: {
17
- type: 'array',
18
- items: {
19
- type: 'object',
20
- properties: {
21
- id: { type: 'string' },
22
- content: { type: 'string' },
23
- status: { type: 'string', enum: %w[pending in_progress completed cancelled] },
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
- required: %w[todos],
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
- t = t.transform_keys(&:to_sym) if t.is_a?(Hash)
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)