swarm_sdk 2.0.0.pre.2
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/swarm_sdk/agent/builder.rb +333 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +271 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
- data/lib/swarm_sdk/agent/chat/logging_helpers.rb +99 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +114 -0
- data/lib/swarm_sdk/agent/chat.rb +779 -0
- data/lib/swarm_sdk/agent/context.rb +108 -0
- data/lib/swarm_sdk/agent/definition.rb +335 -0
- data/lib/swarm_sdk/configuration.rb +251 -0
- data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
- data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
- data/lib/swarm_sdk/context_compactor.rb +340 -0
- data/lib/swarm_sdk/hooks/adapter.rb +359 -0
- data/lib/swarm_sdk/hooks/context.rb +163 -0
- data/lib/swarm_sdk/hooks/definition.rb +80 -0
- data/lib/swarm_sdk/hooks/error.rb +29 -0
- data/lib/swarm_sdk/hooks/executor.rb +146 -0
- data/lib/swarm_sdk/hooks/registry.rb +143 -0
- data/lib/swarm_sdk/hooks/result.rb +150 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
- data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
- data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
- data/lib/swarm_sdk/log_collector.rb +83 -0
- data/lib/swarm_sdk/log_stream.rb +69 -0
- data/lib/swarm_sdk/markdown_parser.rb +46 -0
- data/lib/swarm_sdk/permissions/config.rb +239 -0
- data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
- data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
- data/lib/swarm_sdk/permissions/validator.rb +173 -0
- data/lib/swarm_sdk/permissions_builder.rb +122 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +237 -0
- data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
- data/lib/swarm_sdk/result.rb +97 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +224 -0
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +62 -0
- data/lib/swarm_sdk/swarm/builder.rb +240 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +267 -0
- data/lib/swarm_sdk/swarm.rb +837 -0
- data/lib/swarm_sdk/tools/bash.rb +274 -0
- data/lib/swarm_sdk/tools/delegate.rb +152 -0
- data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
- data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
- data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
- data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
- data/lib/swarm_sdk/tools/edit.rb +150 -0
- data/lib/swarm_sdk/tools/glob.rb +158 -0
- data/lib/swarm_sdk/tools/grep.rb +231 -0
- data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
- data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
- data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
- data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
- data/lib/swarm_sdk/tools/read.rb +251 -0
- data/lib/swarm_sdk/tools/registry.rb +73 -0
- data/lib/swarm_sdk/tools/scratchpad_list.rb +88 -0
- data/lib/swarm_sdk/tools/scratchpad_read.rb +59 -0
- data/lib/swarm_sdk/tools/scratchpad_write.rb +88 -0
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
- data/lib/swarm_sdk/tools/stores/scratchpad.rb +153 -0
- data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
- data/lib/swarm_sdk/tools/todo_write.rb +216 -0
- data/lib/swarm_sdk/tools/write.rb +117 -0
- data/lib/swarm_sdk/utils.rb +50 -0
- data/lib/swarm_sdk/version.rb +5 -0
- data/lib/swarm_sdk.rb +69 -0
- metadata +169 -0
@@ -0,0 +1,194 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
module DocumentConverters
|
6
|
+
# Converts XLSX/XLS spreadsheets to text with image extraction
|
7
|
+
class XlsxConverter < BaseConverter
|
8
|
+
class << self
|
9
|
+
def gem_name
|
10
|
+
"roo"
|
11
|
+
end
|
12
|
+
|
13
|
+
def format_name
|
14
|
+
"XLSX/XLS"
|
15
|
+
end
|
16
|
+
|
17
|
+
def extensions
|
18
|
+
[".xlsx", ".xls"]
|
19
|
+
end
|
20
|
+
|
21
|
+
# XLS files require an additional gem
|
22
|
+
def xls_gem_available?
|
23
|
+
gem_available?("roo-xls")
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Convert a spreadsheet to text/content
|
28
|
+
# @param file_path [String] Path to the spreadsheet file
|
29
|
+
# @return [String, RubyLLM::Content] Converted content or error message
|
30
|
+
def convert(file_path)
|
31
|
+
unless self.class.available?
|
32
|
+
return unsupported_format_reminder(self.class.format_name, self.class.gem_name)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Check for legacy XLS files
|
36
|
+
extension = File.extname(file_path).downcase
|
37
|
+
if extension == ".xls" && !self.class.xls_gem_available?
|
38
|
+
return error("Legacy .xls files require the 'roo-xls' gem. Install with: gem install roo-xls")
|
39
|
+
end
|
40
|
+
|
41
|
+
spreadsheet = nil
|
42
|
+
|
43
|
+
begin
|
44
|
+
require "roo"
|
45
|
+
require "csv"
|
46
|
+
require "tmpdir"
|
47
|
+
|
48
|
+
spreadsheet = Roo::Spreadsheet.open(file_path)
|
49
|
+
|
50
|
+
# Extract images from all sheets
|
51
|
+
image_paths = extract_images(spreadsheet, file_path)
|
52
|
+
|
53
|
+
output = []
|
54
|
+
output << "Spreadsheet: #{File.basename(file_path)}"
|
55
|
+
output << "Sheets: #{spreadsheet.sheets.size}"
|
56
|
+
output << "=" * 60
|
57
|
+
output << ""
|
58
|
+
|
59
|
+
spreadsheet.sheets.each_with_index do |sheet_name, sheet_idx|
|
60
|
+
sheet = spreadsheet.sheet(sheet_name)
|
61
|
+
|
62
|
+
output << "Sheet #{sheet_idx + 1}: #{sheet_name}"
|
63
|
+
output << "-" * 60
|
64
|
+
|
65
|
+
# Add sheet dimensions
|
66
|
+
first_row = sheet.first_row
|
67
|
+
last_row = sheet.last_row
|
68
|
+
first_col = sheet.first_column
|
69
|
+
last_col = sheet.last_column
|
70
|
+
|
71
|
+
if first_row && last_row && first_col && last_col
|
72
|
+
row_count = last_row - first_row + 1
|
73
|
+
col_count = last_col - first_col + 1
|
74
|
+
output << "Dimensions: #{row_count} rows × #{col_count} columns"
|
75
|
+
output << ""
|
76
|
+
else
|
77
|
+
output << "(Empty sheet)"
|
78
|
+
output << ""
|
79
|
+
next
|
80
|
+
end
|
81
|
+
|
82
|
+
# Extract data rows
|
83
|
+
sheet.each_row_streaming(pad_cells: true) do |row|
|
84
|
+
row_values = row.map do |cell|
|
85
|
+
format_cell_value(cell)
|
86
|
+
end
|
87
|
+
|
88
|
+
# Skip completely empty rows
|
89
|
+
next if row_values.all? { |v| v.nil? || v.empty? }
|
90
|
+
|
91
|
+
# Format as CSV with proper escaping
|
92
|
+
output << CSV.generate_line(row_values).chomp
|
93
|
+
end
|
94
|
+
|
95
|
+
output << ""
|
96
|
+
end
|
97
|
+
|
98
|
+
text_content = output.join("\n")
|
99
|
+
|
100
|
+
# If there are images, return Content with attachments
|
101
|
+
if image_paths.any?
|
102
|
+
content = RubyLLM::Content.new(text_content)
|
103
|
+
image_paths.each do |image_path|
|
104
|
+
content.add_attachment(image_path)
|
105
|
+
end
|
106
|
+
content
|
107
|
+
else
|
108
|
+
# No images, return just text
|
109
|
+
text_content
|
110
|
+
end
|
111
|
+
rescue ArgumentError => e
|
112
|
+
error("Failed to open spreadsheet: #{e.message}")
|
113
|
+
rescue RangeError => e
|
114
|
+
error("Sheet access error: #{e.message}")
|
115
|
+
rescue Zip::Error => e
|
116
|
+
error("Corrupted or invalid XLSX file: #{e.message}")
|
117
|
+
rescue IOError => e
|
118
|
+
error("File reading error: #{e.message}")
|
119
|
+
rescue StandardError => e
|
120
|
+
error("Failed to parse spreadsheet: #{e.message}")
|
121
|
+
ensure
|
122
|
+
# Always clean up resources
|
123
|
+
spreadsheet.close if spreadsheet&.respond_to?(:close)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
private
|
128
|
+
|
129
|
+
# Format cell value based on type
|
130
|
+
# @param cell [Roo::Cell] The cell to format
|
131
|
+
# @return [String] Formatted cell value
|
132
|
+
def format_cell_value(cell)
|
133
|
+
return "" if cell.nil? || cell.empty?
|
134
|
+
|
135
|
+
case cell.type
|
136
|
+
when :string
|
137
|
+
cell.value.to_s
|
138
|
+
when :float, :number
|
139
|
+
cell.value.to_s
|
140
|
+
when :date
|
141
|
+
cell.value.strftime("%Y-%m-%d")
|
142
|
+
when :datetime
|
143
|
+
cell.value.strftime("%Y-%m-%d %H:%M:%S")
|
144
|
+
when :time
|
145
|
+
hours = (cell.value / 3600).to_i
|
146
|
+
minutes = ((cell.value % 3600) / 60).to_i
|
147
|
+
seconds = (cell.value % 60).to_i
|
148
|
+
format("%02d:%02d:%02d", hours, minutes, seconds)
|
149
|
+
when :boolean
|
150
|
+
cell.value ? "TRUE" : "FALSE"
|
151
|
+
when :formula
|
152
|
+
# Returns calculated value, not the formula itself
|
153
|
+
cell.value.to_s
|
154
|
+
when :link
|
155
|
+
cell.value.to_s
|
156
|
+
when :percentage
|
157
|
+
(cell.value * 100).to_s + "%"
|
158
|
+
else
|
159
|
+
cell.value.to_s
|
160
|
+
end
|
161
|
+
rescue StandardError
|
162
|
+
"[ERROR]"
|
163
|
+
end
|
164
|
+
|
165
|
+
# Extract images from spreadsheet
|
166
|
+
# @param spreadsheet [Roo::Spreadsheet] The spreadsheet
|
167
|
+
# @param _xlsx_path [String] Path to the XLSX file (unused but kept for consistency)
|
168
|
+
# @return [Array<String>] Array of temporary file paths containing extracted images
|
169
|
+
def extract_images(spreadsheet, _xlsx_path)
|
170
|
+
image_paths = []
|
171
|
+
|
172
|
+
spreadsheet.sheets.each do |sheet_name|
|
173
|
+
# Check if the spreadsheet supports image extraction
|
174
|
+
next unless spreadsheet.respond_to?(:images)
|
175
|
+
|
176
|
+
images = spreadsheet.images(sheet_name)
|
177
|
+
next unless images && !images.empty?
|
178
|
+
|
179
|
+
images.each do |img_path|
|
180
|
+
if img_path && File.exist?(img_path)
|
181
|
+
image_paths << img_path
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
image_paths
|
187
|
+
rescue StandardError
|
188
|
+
# If image extraction fails, don't fail the entire spreadsheet read
|
189
|
+
[]
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
@@ -0,0 +1,150 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
# Edit tool for performing exact string replacements in files
|
6
|
+
#
|
7
|
+
# Uses exact string matching to find and replace content.
|
8
|
+
# Requires unique matches and proper Read tool usage beforehand.
|
9
|
+
# Enforces read-before-edit rule.
|
10
|
+
class Edit < RubyLLM::Tool
|
11
|
+
include PathResolver
|
12
|
+
|
13
|
+
description <<~DESC
|
14
|
+
Performs exact string replacements in files.
|
15
|
+
You must use your Read tool at least once in the conversation before editing.
|
16
|
+
This tool will error if you attempt an edit without reading the file.
|
17
|
+
When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix.
|
18
|
+
The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match.
|
19
|
+
Never include any part of the line number prefix in the old_string or new_string.
|
20
|
+
ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
21
|
+
Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
22
|
+
The edit will FAIL if old_string is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use replace_all to change every instance of old_string.
|
23
|
+
Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
24
|
+
|
25
|
+
IMPORTANT - Path Handling:
|
26
|
+
- Relative paths (e.g., "tmp/file.txt", "src/main.rb") are resolved relative to your agent's working directory
|
27
|
+
- Absolute paths (e.g., "/tmp/file.txt", "/etc/passwd") are treated as system absolute paths
|
28
|
+
- When the user says "tmp/file.txt" they mean the tmp directory in your working directory, NOT /tmp
|
29
|
+
- Only use absolute paths (starting with /) when explicitly referring to system-level paths
|
30
|
+
DESC
|
31
|
+
|
32
|
+
param :file_path,
|
33
|
+
type: "string",
|
34
|
+
desc: "Path to the file. Use relative paths (e.g., 'tmp/file.txt') for files in your working directory, or absolute paths (e.g., '/etc/passwd') for system files.",
|
35
|
+
required: true
|
36
|
+
|
37
|
+
param :old_string,
|
38
|
+
type: "string",
|
39
|
+
desc: "The exact text to replace (must match exactly including whitespace)",
|
40
|
+
required: true
|
41
|
+
|
42
|
+
param :new_string,
|
43
|
+
type: "string",
|
44
|
+
desc: "The text to replace it with (must be different from old_string)",
|
45
|
+
required: true
|
46
|
+
|
47
|
+
param :replace_all,
|
48
|
+
type: "boolean",
|
49
|
+
desc: "Replace all occurrences of old_string (default false)",
|
50
|
+
required: false
|
51
|
+
|
52
|
+
# Initialize the Edit tool for a specific agent
|
53
|
+
#
|
54
|
+
# @param agent_name [Symbol, String] The agent identifier
|
55
|
+
# @param directory [String] Agent's working directory
|
56
|
+
def initialize(agent_name:, directory:)
|
57
|
+
super()
|
58
|
+
@agent_name = agent_name.to_sym
|
59
|
+
@directory = File.expand_path(directory)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Override name to return simple "Edit" instead of full class path
|
63
|
+
def name
|
64
|
+
"Edit"
|
65
|
+
end
|
66
|
+
|
67
|
+
def execute(file_path:, old_string:, new_string:, replace_all: false)
|
68
|
+
# Validate inputs
|
69
|
+
return validation_error("file_path is required") if file_path.nil? || file_path.to_s.strip.empty?
|
70
|
+
return validation_error("old_string is required") if old_string.nil? || old_string.empty?
|
71
|
+
return validation_error("new_string is required") if new_string.nil?
|
72
|
+
|
73
|
+
# old_string and new_string must be different
|
74
|
+
if old_string == new_string
|
75
|
+
return validation_error("old_string and new_string must be different. They are currently identical.")
|
76
|
+
end
|
77
|
+
|
78
|
+
# CRITICAL: Resolve path against agent directory
|
79
|
+
resolved_path = resolve_path(file_path)
|
80
|
+
|
81
|
+
# File must exist (use resolved path)
|
82
|
+
unless File.exist?(resolved_path)
|
83
|
+
return validation_error("File does not exist: #{file_path}")
|
84
|
+
end
|
85
|
+
|
86
|
+
# Enforce read-before-edit (use resolved path)
|
87
|
+
unless Stores::ReadTracker.file_read?(@agent_name, resolved_path)
|
88
|
+
return validation_error(
|
89
|
+
"Cannot edit file without reading it first. " \
|
90
|
+
"You must use the Read tool on '#{file_path}' before editing it. " \
|
91
|
+
"This ensures you have the current file contents to match against.",
|
92
|
+
)
|
93
|
+
end
|
94
|
+
|
95
|
+
# Read current content (use resolved path)
|
96
|
+
content = File.read(resolved_path, encoding: "UTF-8")
|
97
|
+
|
98
|
+
# Check if old_string exists in file
|
99
|
+
unless content.include?(old_string)
|
100
|
+
return validation_error(<<~ERROR.chomp)
|
101
|
+
old_string not found in file. Make sure it matches exactly, including all whitespace and indentation.
|
102
|
+
Do not include line number prefixes from Read tool output.
|
103
|
+
ERROR
|
104
|
+
end
|
105
|
+
|
106
|
+
# Count occurrences
|
107
|
+
occurrences = content.scan(old_string).count
|
108
|
+
|
109
|
+
# If not replace_all and multiple occurrences, error
|
110
|
+
if !replace_all && occurrences > 1
|
111
|
+
return validation_error(<<~ERROR.chomp)
|
112
|
+
Found #{occurrences} occurrences of old_string.
|
113
|
+
Either provide more surrounding context to make the match unique, or use replace_all: true to replace all occurrences.
|
114
|
+
ERROR
|
115
|
+
end
|
116
|
+
|
117
|
+
# Perform replacement
|
118
|
+
new_content = if replace_all
|
119
|
+
content.gsub(old_string, new_string)
|
120
|
+
else
|
121
|
+
content.sub(old_string, new_string)
|
122
|
+
end
|
123
|
+
|
124
|
+
# Write back to file (use resolved path)
|
125
|
+
File.write(resolved_path, new_content, encoding: "UTF-8")
|
126
|
+
|
127
|
+
# Build success message
|
128
|
+
replaced_count = replace_all ? occurrences : 1
|
129
|
+
"Successfully replaced #{replaced_count} occurrence(s) in #{file_path}"
|
130
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
131
|
+
error("File contains invalid UTF-8. Cannot edit binary or improperly encoded files.")
|
132
|
+
rescue Errno::EACCES
|
133
|
+
error("Permission denied: Cannot read or write file '#{file_path}'")
|
134
|
+
rescue StandardError => e
|
135
|
+
error("Unexpected error editing file: #{e.class.name} - #{e.message}")
|
136
|
+
end
|
137
|
+
|
138
|
+
private
|
139
|
+
|
140
|
+
# Helper methods
|
141
|
+
def validation_error(message)
|
142
|
+
"<tool_use_error>InputValidationError: #{message}</tool_use_error>"
|
143
|
+
end
|
144
|
+
|
145
|
+
def error(message)
|
146
|
+
"Error: #{message}"
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
@@ -0,0 +1,158 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
# Glob tool for fast file and directory pattern matching
|
6
|
+
#
|
7
|
+
# Finds files and directories matching glob patterns, sorted by modification time.
|
8
|
+
# Works efficiently with any codebase size.
|
9
|
+
class Glob < RubyLLM::Tool
|
10
|
+
include PathResolver
|
11
|
+
|
12
|
+
def initialize(directory:)
|
13
|
+
super()
|
14
|
+
@directory = File.expand_path(directory)
|
15
|
+
end
|
16
|
+
|
17
|
+
define_method(:name) { "Glob" }
|
18
|
+
|
19
|
+
description <<~DESC
|
20
|
+
- Fast file pattern matching tool that works with any directory size
|
21
|
+
|
22
|
+
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
|
23
|
+
|
24
|
+
- Returns matching file paths sorted by modification time
|
25
|
+
|
26
|
+
- Use this tool when you need to find files by name patterns
|
27
|
+
|
28
|
+
- When you are doing an open ended search that may require multiple rounds of
|
29
|
+
globbing and grepping, use the Agent tool instead
|
30
|
+
|
31
|
+
- You have the capability to call multiple tools in a single response. It is
|
32
|
+
always better to speculatively perform multiple searches as a batch that are
|
33
|
+
potentially useful.
|
34
|
+
DESC
|
35
|
+
|
36
|
+
param :pattern,
|
37
|
+
type: "string",
|
38
|
+
desc: "The glob pattern to match files against",
|
39
|
+
required: true
|
40
|
+
|
41
|
+
param :path,
|
42
|
+
type: "string",
|
43
|
+
desc: "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - simply omit it for the default behavior. Must be a valid directory path if provided.",
|
44
|
+
required: false
|
45
|
+
|
46
|
+
MAX_RESULTS = 1000 # Limit results to prevent overwhelming output
|
47
|
+
|
48
|
+
def execute(pattern:, path: nil)
|
49
|
+
# Validate inputs
|
50
|
+
return validation_error("pattern is required") if pattern.nil? || pattern.to_s.strip.empty?
|
51
|
+
|
52
|
+
# Validate path if provided
|
53
|
+
if path && !path.to_s.strip.empty?
|
54
|
+
# Check for literal "undefined" or "null" strings
|
55
|
+
if path.to_s.strip.downcase == "undefined" || path.to_s.strip.downcase == "null"
|
56
|
+
return validation_error("Invalid path value. Omit the path parameter entirely to use the current working directory.")
|
57
|
+
end
|
58
|
+
|
59
|
+
unless File.exist?(path)
|
60
|
+
return validation_error("Path does not exist: #{path}")
|
61
|
+
end
|
62
|
+
|
63
|
+
unless File.directory?(path)
|
64
|
+
return validation_error("Path is not a directory: #{path}")
|
65
|
+
end
|
66
|
+
|
67
|
+
# CRITICAL: Resolve relative paths against agent directory
|
68
|
+
search_path = resolve_path(path)
|
69
|
+
else
|
70
|
+
# CRITICAL: Use agent's directory as default (NOT Dir.pwd)
|
71
|
+
search_path = @directory
|
72
|
+
end
|
73
|
+
|
74
|
+
# Execute glob from specified path
|
75
|
+
begin
|
76
|
+
# Build full pattern by joining search path with pattern
|
77
|
+
# If pattern is already absolute, File.join will use it as-is
|
78
|
+
full_pattern = if pattern.start_with?("/")
|
79
|
+
# Pattern is absolute, use it directly
|
80
|
+
pattern
|
81
|
+
else
|
82
|
+
# Pattern is relative, join with search path
|
83
|
+
File.join(search_path, pattern)
|
84
|
+
end
|
85
|
+
|
86
|
+
matches = Dir.glob(full_pattern, File::FNM_DOTMATCH)
|
87
|
+
|
88
|
+
# Remove . and .. entries (handle both with and without trailing slashes)
|
89
|
+
matches.reject! do |f|
|
90
|
+
basename = File.basename(f.chomp("/"))
|
91
|
+
basename == "." || basename == ".."
|
92
|
+
end
|
93
|
+
|
94
|
+
# Handle no matches
|
95
|
+
if matches.empty?
|
96
|
+
return "No matches found for pattern: #{pattern}"
|
97
|
+
end
|
98
|
+
|
99
|
+
# Sort by modification time (most recent first)
|
100
|
+
matches.sort_by! { |f| -File.mtime(f).to_i }
|
101
|
+
|
102
|
+
# Limit results
|
103
|
+
if matches.count > MAX_RESULTS
|
104
|
+
matches = matches.take(MAX_RESULTS)
|
105
|
+
truncated = true
|
106
|
+
else
|
107
|
+
truncated = false
|
108
|
+
end
|
109
|
+
|
110
|
+
# Format output
|
111
|
+
output = matches.join("\n")
|
112
|
+
|
113
|
+
# Add system reminder if truncated
|
114
|
+
if truncated
|
115
|
+
output += <<~REMINDER
|
116
|
+
|
117
|
+
<system-reminder>
|
118
|
+
Results limited to first #{MAX_RESULTS} matches (sorted by most recently modified).
|
119
|
+
Consider using a more specific pattern to narrow your search.
|
120
|
+
</system-reminder>
|
121
|
+
REMINDER
|
122
|
+
end
|
123
|
+
|
124
|
+
# Add usage reminder
|
125
|
+
output += "\n\n" + build_usage_reminder(matches.count, pattern)
|
126
|
+
|
127
|
+
output
|
128
|
+
rescue Errno::EACCES => e
|
129
|
+
error("Permission denied: #{e.message}")
|
130
|
+
rescue StandardError => e
|
131
|
+
error("Failed to execute glob: #{e.class.name} - #{e.message}")
|
132
|
+
end
|
133
|
+
rescue StandardError => e
|
134
|
+
error("Unexpected error during glob: #{e.class.name} - #{e.message}")
|
135
|
+
end
|
136
|
+
|
137
|
+
private
|
138
|
+
|
139
|
+
def validation_error(message)
|
140
|
+
"<tool_use_error>InputValidationError: #{message}</tool_use_error>"
|
141
|
+
end
|
142
|
+
|
143
|
+
def error(message)
|
144
|
+
"Error: #{message}"
|
145
|
+
end
|
146
|
+
|
147
|
+
def build_usage_reminder(count, pattern)
|
148
|
+
<<~REMINDER
|
149
|
+
<system-reminder>
|
150
|
+
Found #{count} match#{"es" if count != 1} for '#{pattern}' (files and directories).
|
151
|
+
These paths are sorted by modification time (most recent first).
|
152
|
+
You can now use the Read tool to examine specific files, or use Grep to search within these files.
|
153
|
+
</system-reminder>
|
154
|
+
REMINDER
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|