roast-ai 0.1.5 → 0.1.7
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/.github/workflows/ci.yaml +1 -1
- data/CHANGELOG.md +31 -0
- data/CLAUDE.md +118 -8
- data/Gemfile +1 -2
- data/Gemfile.lock +8 -21
- data/README.md +169 -2
- data/Rakefile +5 -1
- data/docs/INSTRUMENTATION.md +42 -1
- data/examples/interpolation/analyze_file/prompt.md +1 -13
- data/examples/interpolation/generate_report_for_js/prompt.md +2 -27
- data/examples/interpolation/generate_report_for_rb/prompt.md +1 -1
- data/examples/interpolation/workflow.yml +1 -3
- data/examples/openrouter_example/README.md +48 -0
- data/examples/openrouter_example/analyze_input/prompt.md +16 -0
- data/examples/openrouter_example/generate_response/prompt.md +9 -0
- data/examples/openrouter_example/workflow.yml +12 -0
- data/lib/roast/initializers.rb +39 -0
- data/lib/roast/tools/search_file.rb +22 -15
- data/lib/roast/tools/update_files.rb +413 -0
- data/lib/roast/tools.rb +1 -0
- data/lib/roast/version.rb +1 -1
- data/lib/roast/workflow/configuration.rb +28 -1
- data/lib/roast/workflow/configuration_parser.rb +10 -21
- data/lib/roast.rb +6 -0
- data/roast.gemspec +1 -0
- metadata +21 -1
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
class Initializers
|
5
|
+
class << self
|
6
|
+
def config_root(starting_path = Dir.pwd, ending_path = File.dirname(Dir.home))
|
7
|
+
paths = []
|
8
|
+
candidate = starting_path
|
9
|
+
while candidate != ending_path
|
10
|
+
paths << File.join(candidate, ".roast")
|
11
|
+
candidate = File.dirname(candidate)
|
12
|
+
end
|
13
|
+
|
14
|
+
first_existing = paths.find { |path| Dir.exist?(path) }
|
15
|
+
first_existing || paths.first
|
16
|
+
end
|
17
|
+
|
18
|
+
def initializers_path
|
19
|
+
File.join(Roast::Initializers.config_root, "initializers")
|
20
|
+
end
|
21
|
+
|
22
|
+
def load_all
|
23
|
+
project_initializers = Roast::Initializers.initializers_path
|
24
|
+
return unless Dir.exist?(project_initializers)
|
25
|
+
|
26
|
+
$stderr.puts "Loading project initializers from #{project_initializers}"
|
27
|
+
pattern = File.join(project_initializers, "**/*.rb")
|
28
|
+
Dir.glob(pattern, sort: true).each do |file|
|
29
|
+
$stderr.puts "Loading initializer: #{file}"
|
30
|
+
require file
|
31
|
+
end
|
32
|
+
rescue => e
|
33
|
+
puts "ERROR: Error loading initializers: #{e.message}"
|
34
|
+
Roast::Helpers::Logger.error("Error loading initializers: #{e.message}")
|
35
|
+
# Don't fail the workflow if initializers can't be loaded
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -13,10 +13,11 @@ module Roast
|
|
13
13
|
base.class_eval do
|
14
14
|
function(
|
15
15
|
:search_for_file,
|
16
|
-
|
17
|
-
|
16
|
+
"Search for a file in the project using a glob pattern.",
|
17
|
+
glob_pattern: { type: "string", description: "A glob pattern to search for. Example: 'test/**/*_test.rb'" },
|
18
|
+
path: { type: "string", description: "path to search from" },
|
18
19
|
) do |params|
|
19
|
-
Roast::Tools::SearchFile.call(params[:
|
20
|
+
Roast::Tools::SearchFile.call(params[:glob_pattern], params[:path]).tap do |result|
|
20
21
|
Roast::Helpers::Logger.debug(result) if ENV["DEBUG"]
|
21
22
|
end
|
22
23
|
end
|
@@ -24,27 +25,33 @@ module Roast
|
|
24
25
|
end
|
25
26
|
end
|
26
27
|
|
27
|
-
def call(
|
28
|
-
Roast::Helpers::Logger.info("🔍 Searching for
|
29
|
-
search_for(
|
30
|
-
return "No results found for #{
|
31
|
-
return
|
28
|
+
def call(glob_pattern, path = ".")
|
29
|
+
Roast::Helpers::Logger.info("🔍 Searching for: '#{glob_pattern}' in '#{path}'\n")
|
30
|
+
search_for(glob_pattern, path).then do |results|
|
31
|
+
return "No results found for #{glob_pattern} in #{path}" if results.empty?
|
32
|
+
return read_contents(results.first) if results.size == 1
|
32
33
|
|
33
|
-
results.
|
34
|
+
results.join("\n") # purposely give the AI list of actual paths so that it can read without searching first
|
34
35
|
end
|
35
36
|
rescue StandardError => e
|
36
|
-
"Error searching for
|
37
|
+
"Error searching for '#{glob_pattern}' in '#{path}': #{e.message}".tap do |error_message|
|
37
38
|
Roast::Helpers::Logger.error(error_message + "\n")
|
38
39
|
Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
|
39
40
|
end
|
40
41
|
end
|
41
42
|
|
42
|
-
def
|
43
|
-
|
44
|
-
|
43
|
+
def read_contents(path)
|
44
|
+
contents = File.read(path)
|
45
|
+
token_count = contents.size / 4
|
46
|
+
if token_count > 25_000
|
47
|
+
path
|
48
|
+
else
|
49
|
+
contents
|
50
|
+
end
|
51
|
+
end
|
45
52
|
|
46
|
-
|
47
|
-
|
53
|
+
def search_for(pattern, path)
|
54
|
+
Dir.glob(pattern, base: path)
|
48
55
|
end
|
49
56
|
end
|
50
57
|
end
|
@@ -0,0 +1,413 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fileutils"
|
4
|
+
require "roast/helpers/logger"
|
5
|
+
require "diff/lcs"
|
6
|
+
|
7
|
+
module Roast
|
8
|
+
module Tools
|
9
|
+
module UpdateFiles
|
10
|
+
extend self
|
11
|
+
|
12
|
+
class << self
|
13
|
+
# Add this method to be included in other classes
|
14
|
+
def included(base)
|
15
|
+
base.class_eval do
|
16
|
+
function(
|
17
|
+
:update_files,
|
18
|
+
"Apply a unified diff/patch to files in the workspace. Changes are applied atomically if possible.",
|
19
|
+
diff: {
|
20
|
+
type: "string",
|
21
|
+
description: "The unified diff/patch content to apply",
|
22
|
+
},
|
23
|
+
base_path: {
|
24
|
+
type: "string",
|
25
|
+
description: "Base path for relative file paths in the diff (default: current working directory)",
|
26
|
+
required: false,
|
27
|
+
},
|
28
|
+
restrict_path: {
|
29
|
+
type: "string",
|
30
|
+
description: "Optional path restriction to limit where files can be modified",
|
31
|
+
required: false,
|
32
|
+
},
|
33
|
+
create_files: {
|
34
|
+
type: "boolean",
|
35
|
+
description: "Whether to create new files if they don't exist (default: true)",
|
36
|
+
required: false,
|
37
|
+
},
|
38
|
+
) do |params|
|
39
|
+
base_path = params[:base_path] || Dir.pwd
|
40
|
+
create_files = params.fetch(:create_files, true)
|
41
|
+
restrict_path = params[:restrict_path]
|
42
|
+
|
43
|
+
Roast::Tools::UpdateFiles.call(
|
44
|
+
params[:diff],
|
45
|
+
base_path,
|
46
|
+
restrict_path,
|
47
|
+
create_files,
|
48
|
+
)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Apply a unified diff to files
|
55
|
+
# @param diff [String] unified diff content
|
56
|
+
# @param base_path [String] base path for relative paths in the diff
|
57
|
+
# @param restrict_path [String, nil] optional path restriction
|
58
|
+
# @param create_files [Boolean] whether to create new files if they don't exist
|
59
|
+
# @return [String] result message
|
60
|
+
def call(diff, base_path = Dir.pwd, restrict_path = nil, create_files = true)
|
61
|
+
Roast::Helpers::Logger.info("🔄 Applying patch to files\n")
|
62
|
+
|
63
|
+
# Parse the unified diff to identify files and changes
|
64
|
+
file_changes = parse_unified_diff(diff)
|
65
|
+
|
66
|
+
if file_changes.empty?
|
67
|
+
return "Error: No valid file changes found in the provided diff"
|
68
|
+
end
|
69
|
+
|
70
|
+
# Validate changes
|
71
|
+
validation_result = validate_changes(file_changes, base_path, restrict_path, create_files)
|
72
|
+
return validation_result if validation_result.is_a?(String) && validation_result.start_with?("Error:")
|
73
|
+
|
74
|
+
# Apply changes atomically
|
75
|
+
apply_changes(file_changes, base_path, create_files)
|
76
|
+
rescue StandardError => e
|
77
|
+
"Error applying patch: #{e.message}".tap do |error_message|
|
78
|
+
Roast::Helpers::Logger.error(error_message + "\n")
|
79
|
+
Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
# Parse a unified diff to extract file changes
|
86
|
+
# @param diff [String] unified diff content
|
87
|
+
# @return [Array<Hash>] array of file change objects
|
88
|
+
def parse_unified_diff(diff)
|
89
|
+
lines = diff.split("\n")
|
90
|
+
file_changes = []
|
91
|
+
current_files = { src: nil, dst: nil }
|
92
|
+
current_hunks = []
|
93
|
+
|
94
|
+
i = 0
|
95
|
+
while i < lines.length
|
96
|
+
line = lines[i]
|
97
|
+
|
98
|
+
# New file header (--- line followed by +++ line)
|
99
|
+
if line.start_with?("--- ") && i + 1 < lines.length && lines[i + 1].start_with?("+++ ")
|
100
|
+
# Save previous file if exists
|
101
|
+
if current_files[:src] && current_files[:dst] && !current_hunks.empty?
|
102
|
+
file_changes << {
|
103
|
+
src_path: current_files[:src],
|
104
|
+
dst_path: current_files[:dst],
|
105
|
+
hunks: current_hunks.dup,
|
106
|
+
}
|
107
|
+
end
|
108
|
+
|
109
|
+
# Extract new file paths
|
110
|
+
current_files = {
|
111
|
+
src: extract_file_path(line),
|
112
|
+
dst: extract_file_path(lines[i + 1]),
|
113
|
+
}
|
114
|
+
current_hunks = []
|
115
|
+
i += 2
|
116
|
+
next
|
117
|
+
end
|
118
|
+
|
119
|
+
# Hunk header
|
120
|
+
if line.match(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/)
|
121
|
+
current_hunk = { header: line, changes: [] }
|
122
|
+
|
123
|
+
# Parse the header
|
124
|
+
header_match = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
|
125
|
+
if header_match
|
126
|
+
current_hunk[:src_start] = header_match[1].to_i
|
127
|
+
current_hunk[:src_count] = header_match[2] ? header_match[2].to_i : 1 # rubocop:disable Metrics/BlockNesting
|
128
|
+
current_hunk[:dst_start] = header_match[3].to_i
|
129
|
+
current_hunk[:dst_count] = header_match[4] ? header_match[4].to_i : 1 # rubocop:disable Metrics/BlockNesting
|
130
|
+
end
|
131
|
+
|
132
|
+
current_hunks << current_hunk
|
133
|
+
i += 1
|
134
|
+
next
|
135
|
+
end
|
136
|
+
|
137
|
+
# Capture content lines for the current hunk
|
138
|
+
if !current_hunks.empty? && line.start_with?("+", "-", " ")
|
139
|
+
current_hunks.last[:changes] << line
|
140
|
+
end
|
141
|
+
|
142
|
+
i += 1
|
143
|
+
end
|
144
|
+
|
145
|
+
# Add the last file
|
146
|
+
if current_files[:src] && current_files[:dst] && !current_hunks.empty?
|
147
|
+
file_changes << {
|
148
|
+
src_path: current_files[:src],
|
149
|
+
dst_path: current_files[:dst],
|
150
|
+
hunks: current_hunks.dup,
|
151
|
+
}
|
152
|
+
end
|
153
|
+
|
154
|
+
file_changes
|
155
|
+
end
|
156
|
+
|
157
|
+
# Extract file path from a diff header line
|
158
|
+
# @param line [String] diff header line (--- or +++ line)
|
159
|
+
# @return [String] file path
|
160
|
+
def extract_file_path(line)
|
161
|
+
# Handle special cases
|
162
|
+
return "/dev/null" if line.include?("/dev/null")
|
163
|
+
|
164
|
+
# Remove prefixes
|
165
|
+
path = line.sub(%r{^(\+\+\+|\-\-\-) (a|b)/}, "")
|
166
|
+
# Handle files without 'a/' or 'b/' prefix
|
167
|
+
path = line.sub(/^(\+\+\+|\-\-\-) /, "") if path == line
|
168
|
+
# Remove timestamps if present
|
169
|
+
path = path.sub(/\t.*$/, "")
|
170
|
+
path
|
171
|
+
end
|
172
|
+
|
173
|
+
# Validate changes before applying them
|
174
|
+
# @param file_changes [Array<Hash>] array of file change objects
|
175
|
+
# @param base_path [String] base path for relative paths
|
176
|
+
# @param restrict_path [String, nil] optional path restriction
|
177
|
+
# @param create_files [Boolean] whether to create new files if they don't exist
|
178
|
+
# @return [Boolean, String] true if valid, error message if invalid
|
179
|
+
def validate_changes(file_changes, base_path, restrict_path, create_files)
|
180
|
+
# Validate each file in the changes
|
181
|
+
file_changes.each do |file_change|
|
182
|
+
# For destination files (they will be written to)
|
183
|
+
if file_change[:dst_path] && file_change[:dst_path] != "/dev/null"
|
184
|
+
absolute_path = File.expand_path(file_change[:dst_path], base_path)
|
185
|
+
|
186
|
+
# Check path restriction
|
187
|
+
if restrict_path && !absolute_path.start_with?(restrict_path)
|
188
|
+
return "Error: Path #{file_change[:dst_path]} must start with '#{restrict_path}' to use the update_files tool"
|
189
|
+
end
|
190
|
+
|
191
|
+
# Check if file exists
|
192
|
+
if !File.exist?(absolute_path) && !create_files
|
193
|
+
return "Error: File #{file_change[:dst_path]} does not exist and create_files is false"
|
194
|
+
end
|
195
|
+
|
196
|
+
# Check if file is readable and writable if it exists
|
197
|
+
if File.exist?(absolute_path)
|
198
|
+
unless File.readable?(absolute_path)
|
199
|
+
return "Error: File #{file_change[:dst_path]} is not readable"
|
200
|
+
end
|
201
|
+
|
202
|
+
unless File.writable?(absolute_path)
|
203
|
+
return "Error: File #{file_change[:dst_path]} is not writable"
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
# For source files (they will be read from)
|
209
|
+
next unless file_change[:src_path] && file_change[:src_path] != "/dev/null" && file_change[:src_path] != file_change[:dst_path]
|
210
|
+
|
211
|
+
absolute_path = File.expand_path(file_change[:src_path], base_path)
|
212
|
+
|
213
|
+
# Source file must exist unless it's a new file
|
214
|
+
if !File.exist?(absolute_path) && file_change[:src_path] != "/dev/null"
|
215
|
+
# Special case for new files (src: /dev/null)
|
216
|
+
if file_change[:src_path] != "/dev/null"
|
217
|
+
return "Error: Source file #{file_change[:src_path]} does not exist"
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
# Check if file is readable if it exists
|
222
|
+
next unless File.exist?(absolute_path)
|
223
|
+
unless File.readable?(absolute_path)
|
224
|
+
return "Error: Source file #{file_change[:src_path]} is not readable"
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
true
|
229
|
+
end
|
230
|
+
|
231
|
+
# Apply changes to files
|
232
|
+
# @param file_changes [Array<Hash>] array of file change objects
|
233
|
+
# @param base_path [String] base path for relative paths
|
234
|
+
# @param create_files [Boolean] whether to create new files if they don't exist
|
235
|
+
# @return [String] result message
|
236
|
+
def apply_changes(file_changes, base_path, create_files)
|
237
|
+
# Create a temporary backup of all files to be modified
|
238
|
+
backup_files = {}
|
239
|
+
modified_files = []
|
240
|
+
|
241
|
+
# Step 1: Create backups
|
242
|
+
file_changes.each do |file_change|
|
243
|
+
next unless file_change[:dst_path] && file_change[:dst_path] != "/dev/null"
|
244
|
+
|
245
|
+
absolute_path = File.expand_path(file_change[:dst_path], base_path)
|
246
|
+
|
247
|
+
if File.exist?(absolute_path)
|
248
|
+
backup_files[absolute_path] = File.read(absolute_path)
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
# Step 2: Try to apply all changes
|
253
|
+
begin
|
254
|
+
file_changes.each do |file_change|
|
255
|
+
next unless file_change[:dst_path]
|
256
|
+
|
257
|
+
# Special case for file deletion
|
258
|
+
if file_change[:dst_path] == "/dev/null" && file_change[:src_path] != "/dev/null"
|
259
|
+
absolute_src_path = File.expand_path(file_change[:src_path], base_path)
|
260
|
+
if File.exist?(absolute_src_path)
|
261
|
+
File.delete(absolute_src_path)
|
262
|
+
modified_files << file_change[:src_path]
|
263
|
+
end
|
264
|
+
next
|
265
|
+
end
|
266
|
+
|
267
|
+
# Skip if both src and dst are /dev/null (shouldn't happen but just in case)
|
268
|
+
next if file_change[:dst_path] == "/dev/null" && file_change[:src_path] == "/dev/null"
|
269
|
+
|
270
|
+
absolute_dst_path = File.expand_path(file_change[:dst_path], base_path)
|
271
|
+
|
272
|
+
# Special case for new files
|
273
|
+
if file_change[:src_path] == "/dev/null"
|
274
|
+
# For new files, ensure directory exists
|
275
|
+
dir = File.dirname(absolute_dst_path)
|
276
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
277
|
+
|
278
|
+
# Create the file with the added content
|
279
|
+
content = []
|
280
|
+
file_change[:hunks].each do |hunk|
|
281
|
+
hunk[:changes].each do |line|
|
282
|
+
content << line[1..-1] if line.start_with?("+")
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
# Write the content
|
287
|
+
File.write(absolute_dst_path, content.join("\n") + (content.empty? ? "" : "\n"))
|
288
|
+
modified_files << file_change[:dst_path]
|
289
|
+
next
|
290
|
+
end
|
291
|
+
|
292
|
+
# Normal case: Modify existing file
|
293
|
+
content = ""
|
294
|
+
if File.exist?(absolute_dst_path)
|
295
|
+
content = File.read(absolute_dst_path)
|
296
|
+
else
|
297
|
+
# For new files that aren't from /dev/null, ensure directory exists
|
298
|
+
dir = File.dirname(absolute_dst_path)
|
299
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
300
|
+
end
|
301
|
+
content_lines = content.split("\n")
|
302
|
+
|
303
|
+
# Apply each hunk to the file
|
304
|
+
file_change[:hunks].each do |hunk|
|
305
|
+
# Apply the changes to the content
|
306
|
+
new_content_lines = apply_hunk(content_lines, hunk)
|
307
|
+
|
308
|
+
# Check if the hunk was applied successfully
|
309
|
+
if new_content_lines
|
310
|
+
content_lines = new_content_lines
|
311
|
+
else
|
312
|
+
raise "Hunk could not be applied cleanly: #{hunk[:header]}"
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
# Write the updated content
|
317
|
+
File.write(absolute_dst_path, content_lines.join("\n") + (content_lines.empty? ? "" : "\n"))
|
318
|
+
modified_files << file_change[:dst_path]
|
319
|
+
end
|
320
|
+
|
321
|
+
"Successfully applied patch to #{modified_files.size} file(s): #{modified_files.join(", ")}"
|
322
|
+
rescue StandardError => e
|
323
|
+
# Restore backups if any change fails
|
324
|
+
backup_files.each do |path, content|
|
325
|
+
File.write(path, content) if File.exist?(path)
|
326
|
+
end
|
327
|
+
|
328
|
+
"Error applying patch: #{e.message}"
|
329
|
+
end
|
330
|
+
end
|
331
|
+
|
332
|
+
# Apply a single hunk to file content
|
333
|
+
# @param content_lines [Array<String>] lines of file content
|
334
|
+
# @param hunk [Hash] hunk information
|
335
|
+
# @return [Array<String>, nil] updated content lines or nil if cannot apply
|
336
|
+
def apply_hunk(content_lines, hunk)
|
337
|
+
# For completely new files with no content
|
338
|
+
if content_lines.empty? && hunk[:src_start] == 1 && hunk[:src_count] == 0
|
339
|
+
# Just extract the added lines
|
340
|
+
return hunk[:changes].select { |line| line.start_with?("+") }.map { |line| line[1..-1] }
|
341
|
+
end
|
342
|
+
|
343
|
+
# For complete file replacement
|
344
|
+
if !content_lines.empty? &&
|
345
|
+
hunk[:src_start] == 1 &&
|
346
|
+
hunk[:changes].count { |line| line.start_with?("-") } >= content_lines.size
|
347
|
+
# Get only the added lines for the new content
|
348
|
+
return hunk[:changes].select { |line| line.start_with?("+") }.map { |line| line[1..-1] }
|
349
|
+
end
|
350
|
+
|
351
|
+
# Standard case with context matching
|
352
|
+
result = content_lines.dup
|
353
|
+
src_line = hunk[:src_start] - 1 # 0-based index
|
354
|
+
dst_line = hunk[:dst_start] - 1 # 0-based index
|
355
|
+
|
356
|
+
# Process each change line
|
357
|
+
hunk[:changes].each do |line|
|
358
|
+
if line.start_with?(" ") # Context line
|
359
|
+
# Verify context matches
|
360
|
+
if src_line >= result.size || result[src_line] != line[1..-1]
|
361
|
+
# Try to find the context nearby (fuzzy matching)
|
362
|
+
found = false
|
363
|
+
(-3..3).each do |offset|
|
364
|
+
check_pos = src_line + offset
|
365
|
+
next if check_pos < 0 || check_pos >= result.size
|
366
|
+
|
367
|
+
next unless result[check_pos] == line[1..-1]
|
368
|
+
|
369
|
+
src_line = check_pos
|
370
|
+
dst_line = check_pos
|
371
|
+
found = true
|
372
|
+
break
|
373
|
+
end
|
374
|
+
|
375
|
+
return nil unless found # Context doesn't match, cannot apply hunk
|
376
|
+
end
|
377
|
+
|
378
|
+
src_line += 1
|
379
|
+
dst_line += 1
|
380
|
+
elsif line.start_with?("-") # Removal
|
381
|
+
# Verify line exists and matches
|
382
|
+
if src_line >= result.size || result[src_line] != line[1..-1]
|
383
|
+
# Try to find the line nearby (fuzzy matching)
|
384
|
+
found = false
|
385
|
+
(-3..3).each do |offset|
|
386
|
+
check_pos = src_line + offset
|
387
|
+
next if check_pos < 0 || check_pos >= result.size
|
388
|
+
|
389
|
+
next unless result[check_pos] == line[1..-1]
|
390
|
+
|
391
|
+
src_line = check_pos
|
392
|
+
dst_line = check_pos
|
393
|
+
found = true
|
394
|
+
break
|
395
|
+
end
|
396
|
+
|
397
|
+
return nil unless found # Line to remove doesn't match, cannot apply hunk
|
398
|
+
end
|
399
|
+
|
400
|
+
# Remove the line
|
401
|
+
result.delete_at(src_line)
|
402
|
+
elsif line.start_with?("+") # Addition
|
403
|
+
# Insert the new line
|
404
|
+
result.insert(dst_line, line[1..-1])
|
405
|
+
dst_line += 1
|
406
|
+
end
|
407
|
+
end
|
408
|
+
|
409
|
+
result
|
410
|
+
end
|
411
|
+
end
|
412
|
+
end
|
413
|
+
end
|
data/lib/roast/tools.rb
CHANGED
data/lib/roast/version.rb
CHANGED
@@ -8,7 +8,7 @@ module Roast
|
|
8
8
|
# Encapsulates workflow configuration data and provides structured access
|
9
9
|
# to the configuration settings
|
10
10
|
class Configuration
|
11
|
-
attr_reader :config_hash, :workflow_path, :name, :steps, :tools, :function_configs, :api_token, :model, :resource
|
11
|
+
attr_reader :config_hash, :workflow_path, :name, :steps, :tools, :function_configs, :api_token, :api_provider, :model, :resource
|
12
12
|
attr_accessor :target
|
13
13
|
|
14
14
|
def initialize(workflow_path, options = {})
|
@@ -45,6 +45,9 @@ module Roast
|
|
45
45
|
@api_token = process_shell_command(@config_hash["api_token"])
|
46
46
|
end
|
47
47
|
|
48
|
+
# Determine API provider (defaults to OpenAI if not specified)
|
49
|
+
@api_provider = determine_api_provider
|
50
|
+
|
48
51
|
# Extract default model if provided
|
49
52
|
@model = @config_hash["model"]
|
50
53
|
end
|
@@ -138,8 +141,32 @@ module Roast
|
|
138
141
|
@function_configs[function_name.to_s] || {}
|
139
142
|
end
|
140
143
|
|
144
|
+
def openrouter?
|
145
|
+
@api_provider == :openrouter
|
146
|
+
end
|
147
|
+
|
148
|
+
def openai?
|
149
|
+
@api_provider == :openai
|
150
|
+
end
|
151
|
+
|
141
152
|
private
|
142
153
|
|
154
|
+
def determine_api_provider
|
155
|
+
return :openai unless @config_hash["api_provider"]
|
156
|
+
|
157
|
+
provider = @config_hash["api_provider"].to_s.downcase
|
158
|
+
|
159
|
+
case provider
|
160
|
+
when "openai"
|
161
|
+
:openai
|
162
|
+
when "openrouter"
|
163
|
+
:openrouter
|
164
|
+
else
|
165
|
+
Roast::Helpers::Logger.warn("Unknown API provider '#{provider}', defaulting to OpenAI")
|
166
|
+
:openai
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
143
170
|
def process_shell_command(command)
|
144
171
|
# If it's a bash command with the $(command) syntax
|
145
172
|
if command =~ /^\$\((.*)\)$/
|
@@ -6,6 +6,7 @@ require_relative "../helpers/function_caching_interceptor"
|
|
6
6
|
require "active_support"
|
7
7
|
require "active_support/isolated_execution_state"
|
8
8
|
require "active_support/notifications"
|
9
|
+
require "raix"
|
9
10
|
|
10
11
|
module Roast
|
11
12
|
module Workflow
|
@@ -98,35 +99,23 @@ module Roast
|
|
98
99
|
end
|
99
100
|
|
100
101
|
def load_roast_initializers
|
101
|
-
|
102
|
-
project_initializers = File.join(Dir.pwd, ".roast", "initializers")
|
103
|
-
|
104
|
-
if Dir.exist?(project_initializers)
|
105
|
-
$stderr.puts "Loading project initializers from #{project_initializers}"
|
106
|
-
Dir.glob(File.join(project_initializers, "**/*.rb")).sort.each do |file|
|
107
|
-
$stderr.puts "Loading initializer: #{file}"
|
108
|
-
require file
|
109
|
-
end
|
110
|
-
end
|
111
|
-
rescue => e
|
112
|
-
Roast::Helpers::Logger.error("Error loading initializers: #{e.message}")
|
113
|
-
# Don't fail the workflow if initializers can't be loaded
|
102
|
+
Roast::Initializers.load_all
|
114
103
|
end
|
115
104
|
|
116
105
|
def configure_api_client
|
117
106
|
return unless configuration.api_token
|
118
107
|
|
119
108
|
begin
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
109
|
+
case configuration.api_provider
|
110
|
+
when :openrouter
|
111
|
+
$stderr.puts "Configuring OpenRouter client with token from workflow"
|
112
|
+
require "open_router"
|
124
113
|
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
Raix.configuration.openai_client = OpenAI::Client.new(access_token: configuration.api_token)
|
114
|
+
Raix.configure do |config|
|
115
|
+
config.openrouter_client = OpenRouter::Client.new(api_key: configuration.api_token)
|
116
|
+
end
|
129
117
|
else
|
118
|
+
$stderr.puts "Configuring OpenAI client with token from workflow"
|
130
119
|
require "openai"
|
131
120
|
|
132
121
|
Raix.configure do |config|
|
data/lib/roast.rb
CHANGED
@@ -7,6 +7,7 @@ require "roast/tools"
|
|
7
7
|
require "roast/helpers"
|
8
8
|
require "roast/resources"
|
9
9
|
require "roast/workflow"
|
10
|
+
require "roast/initializers"
|
10
11
|
|
11
12
|
module Roast
|
12
13
|
ROOT = File.expand_path("../..", __FILE__)
|
@@ -28,6 +29,11 @@ module Roast
|
|
28
29
|
Roast::Workflow::ConfigurationParser.new(expanded_workflow_path, files, options.transform_keys(&:to_sym)).begin!
|
29
30
|
end
|
30
31
|
|
32
|
+
desc "version", "Display the current version of Roast"
|
33
|
+
def version
|
34
|
+
puts "Roast version #{Roast::VERSION}"
|
35
|
+
end
|
36
|
+
|
31
37
|
class << self
|
32
38
|
def exit_on_failure?
|
33
39
|
true
|
data/roast.gemspec
CHANGED
@@ -37,6 +37,7 @@ Gem::Specification.new do |spec|
|
|
37
37
|
spec.require_paths = ["lib"]
|
38
38
|
|
39
39
|
spec.add_dependency("activesupport", "~> 8.0")
|
40
|
+
spec.add_dependency("diff-lcs", "~> 1.5")
|
40
41
|
spec.add_dependency("faraday-retry")
|
41
42
|
spec.add_dependency("json-schema")
|
42
43
|
spec.add_dependency("raix", "~> 0.8.4")
|