openclacky 0.5.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 (45) hide show
  1. checksums.yaml +7 -0
  2. data/.clackyrules +80 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +8 -0
  5. data/CHANGELOG.md +74 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +272 -0
  9. data/Rakefile +38 -0
  10. data/bin/clacky +7 -0
  11. data/bin/openclacky +6 -0
  12. data/clacky-legacy/clacky.gemspec +24 -0
  13. data/clacky-legacy/clarky.gemspec +24 -0
  14. data/lib/clacky/agent.rb +1015 -0
  15. data/lib/clacky/agent_config.rb +47 -0
  16. data/lib/clacky/cli.rb +713 -0
  17. data/lib/clacky/client.rb +159 -0
  18. data/lib/clacky/config.rb +43 -0
  19. data/lib/clacky/conversation.rb +41 -0
  20. data/lib/clacky/hook_manager.rb +61 -0
  21. data/lib/clacky/progress_indicator.rb +53 -0
  22. data/lib/clacky/session_manager.rb +124 -0
  23. data/lib/clacky/thinking_verbs.rb +26 -0
  24. data/lib/clacky/tool_registry.rb +44 -0
  25. data/lib/clacky/tools/base.rb +64 -0
  26. data/lib/clacky/tools/edit.rb +100 -0
  27. data/lib/clacky/tools/file_reader.rb +79 -0
  28. data/lib/clacky/tools/glob.rb +93 -0
  29. data/lib/clacky/tools/grep.rb +169 -0
  30. data/lib/clacky/tools/run_project.rb +287 -0
  31. data/lib/clacky/tools/safe_shell.rb +396 -0
  32. data/lib/clacky/tools/shell.rb +305 -0
  33. data/lib/clacky/tools/todo_manager.rb +228 -0
  34. data/lib/clacky/tools/trash_manager.rb +371 -0
  35. data/lib/clacky/tools/web_fetch.rb +161 -0
  36. data/lib/clacky/tools/web_search.rb +138 -0
  37. data/lib/clacky/tools/write.rb +65 -0
  38. data/lib/clacky/trash_directory.rb +112 -0
  39. data/lib/clacky/utils/arguments_parser.rb +139 -0
  40. data/lib/clacky/utils/limit_stack.rb +80 -0
  41. data/lib/clacky/utils/path_helper.rb +15 -0
  42. data/lib/clacky/version.rb +5 -0
  43. data/lib/clacky.rb +38 -0
  44. data/sig/clacky.rbs +4 -0
  45. metadata +160 -0
@@ -0,0 +1,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "base"
6
+ require_relative "../trash_directory"
7
+
8
+ module Clacky
9
+ module Tools
10
+ class TrashManager < Base
11
+ self.tool_name = "trash_manager"
12
+ self.tool_description = "Manage deleted files in the AI trash - list, restore, or permanently delete files"
13
+ self.tool_category = "system"
14
+ self.tool_parameters = {
15
+ type: "object",
16
+ properties: {
17
+ action: {
18
+ type: "string",
19
+ enum: ["list", "restore", "status", "empty", "help"],
20
+ description: "Action to perform: 'list' (show deleted files), 'restore' (restore a file), 'status' (show trash summary), 'empty' (permanently delete old files), 'help' (show usage)"
21
+ },
22
+ file_path: {
23
+ type: "string",
24
+ description: "Original file path to restore (required for 'restore' action)"
25
+ },
26
+ days_old: {
27
+ type: "integer",
28
+ description: "For 'empty' action: permanently delete files older than this many days (default: 7)"
29
+ }
30
+ },
31
+ required: ["action"]
32
+ }
33
+
34
+ def execute(action:, file_path: nil, days_old: 7)
35
+ project_root = Dir.pwd
36
+
37
+ # Use global trash directory organized by project
38
+ trash_directory = Clacky::TrashDirectory.new(project_root)
39
+ trash_dir = trash_directory.trash_dir
40
+
41
+ unless Dir.exist?(trash_dir)
42
+ return {
43
+ action: action,
44
+ success: false,
45
+ message: "No trash directory found. No files have been safely deleted yet."
46
+ }
47
+ end
48
+
49
+ case action.downcase
50
+ when 'list'
51
+ list_deleted_files(trash_dir, project_root)
52
+ when 'restore'
53
+ return { action: action, success: false, message: "file_path is required for restore action" } unless file_path
54
+ restore_file(trash_dir, file_path, project_root)
55
+ when 'status'
56
+ show_trash_status(trash_dir, project_root)
57
+ when 'empty'
58
+ empty_trash(trash_dir, days_old, project_root)
59
+ when 'help'
60
+ show_help
61
+ else
62
+ { action: action, success: false, message: "Unknown action: #{action}" }
63
+ end
64
+ end
65
+
66
+ def list_deleted_files(trash_dir, project_root)
67
+ deleted_files = get_deleted_files(trash_dir, project_root)
68
+
69
+ if deleted_files.empty?
70
+ return {
71
+ action: 'list',
72
+ success: true,
73
+ count: 0,
74
+ message: "🗑️ Trash is empty"
75
+ }
76
+ end
77
+
78
+ file_list = deleted_files.map.with_index(1) do |file, index|
79
+ size_info = file[:file_size] ? " (#{format_bytes(file[:file_size])})" : ""
80
+ "#{index}. #{file[:original_path]}#{size_info}\n Deleted: #{format_time(file[:deleted_at])}"
81
+ end
82
+
83
+ {
84
+ action: 'list',
85
+ success: true,
86
+ count: deleted_files.size,
87
+ files: deleted_files,
88
+ message: "🗑️ Deleted Files:\n\n#{file_list.join("\n\n")}\n\n💡 Use trash_manager with action='restore' and file_path='<path>' to restore a file"
89
+ }
90
+ end
91
+
92
+ def restore_file(trash_dir, file_path, project_root)
93
+ deleted_files = get_deleted_files(trash_dir, project_root)
94
+ expanded_path = File.expand_path(file_path, project_root)
95
+
96
+ target_file = deleted_files.find { |f| f[:original_path] == expanded_path }
97
+
98
+ unless target_file
99
+ similar_files = deleted_files.select { |f| File.basename(f[:original_path]) == File.basename(file_path) }
100
+
101
+ if similar_files.any?
102
+ suggestions = similar_files.map { |f| f[:original_path] }.join("\n - ")
103
+ return {
104
+ action: 'restore',
105
+ success: false,
106
+ message: "File not found in trash: #{file_path}\n\nDid you mean one of these?\n - #{suggestions}"
107
+ }
108
+ else
109
+ return {
110
+ action: 'restore',
111
+ success: false,
112
+ message: "File not found in trash: #{file_path}\n\nUse trash_manager with action='list' to see available files."
113
+ }
114
+ end
115
+ end
116
+
117
+ if File.exist?(expanded_path)
118
+ return {
119
+ action: 'restore',
120
+ success: false,
121
+ message: "Cannot restore: file already exists at #{file_path}"
122
+ }
123
+ end
124
+
125
+ begin
126
+ # Ensure target directory exists
127
+ FileUtils.mkdir_p(File.dirname(expanded_path))
128
+
129
+ # Restore file
130
+ FileUtils.mv(target_file[:trash_file], expanded_path)
131
+ File.delete("#{target_file[:trash_file]}.metadata.json")
132
+
133
+ {
134
+ action: 'restore',
135
+ success: true,
136
+ restored_file: expanded_path,
137
+ message: "✅ Successfully restored: #{file_path}"
138
+ }
139
+ rescue StandardError => e
140
+ {
141
+ action: 'restore',
142
+ success: false,
143
+ message: "❌ Failed to restore file: #{e.message}"
144
+ }
145
+ end
146
+ end
147
+
148
+ def show_trash_status(trash_dir, project_root)
149
+ deleted_files = get_deleted_files(trash_dir, project_root)
150
+ total_size = deleted_files.sum { |f| f[:file_size] || 0 }
151
+
152
+ if deleted_files.empty?
153
+ return {
154
+ action: 'status',
155
+ success: true,
156
+ count: 0,
157
+ total_size: 0,
158
+ message: "🗑️ Trash is empty"
159
+ }
160
+ end
161
+
162
+ # Group by file type
163
+ by_type = deleted_files.group_by { |f| f[:file_type] || 'no extension' }
164
+ type_summary = by_type.map do |ext, files|
165
+ size = files.sum { |f| f[:file_size] || 0 }
166
+ " #{ext}: #{files.size} files (#{format_bytes(size)})"
167
+ end.join("\n")
168
+
169
+ recent_files = deleted_files.first(3).map do |file|
170
+ " - #{File.basename(file[:original_path])} (#{format_time(file[:deleted_at])})"
171
+ end.join("\n")
172
+
173
+ message = []
174
+ message << "🗑️ Trash Status:"
175
+ message << " Files: #{deleted_files.count}"
176
+ message << " Total size: #{format_bytes(total_size)}"
177
+ message << " Location: #{trash_dir}"
178
+ message << ""
179
+ message << "📊 By file type:"
180
+ message << type_summary
181
+ message << ""
182
+ message << "📅 Recently deleted:"
183
+ message << recent_files
184
+
185
+ {
186
+ action: 'status',
187
+ success: true,
188
+ count: deleted_files.size,
189
+ total_size: total_size,
190
+ by_type: by_type.transform_values(&:size),
191
+ message: message.join("\n")
192
+ }
193
+ end
194
+
195
+ def empty_trash(trash_dir, days_old, project_root)
196
+ deleted_files = get_deleted_files(trash_dir, project_root)
197
+ cutoff_time = Time.now - (days_old * 24 * 60 * 60)
198
+
199
+ old_files = deleted_files.select do |file|
200
+ Time.parse(file[:deleted_at]) < cutoff_time
201
+ end
202
+
203
+ if old_files.empty?
204
+ return {
205
+ action: 'empty',
206
+ success: true,
207
+ deleted_count: 0,
208
+ message: "🗑️ No files older than #{days_old} days found in trash"
209
+ }
210
+ end
211
+
212
+ deleted_count = 0
213
+ freed_size = 0
214
+
215
+ old_files.each do |file|
216
+ begin
217
+ File.delete(file[:trash_file]) if File.exist?(file[:trash_file])
218
+ File.delete("#{file[:trash_file]}.metadata.json") if File.exist?("#{file[:trash_file]}.metadata.json")
219
+ deleted_count += 1
220
+ freed_size += file[:file_size] || 0
221
+ rescue StandardError => e
222
+ # Continue processing other files, but log the error
223
+ end
224
+ end
225
+
226
+ {
227
+ action: 'empty',
228
+ success: true,
229
+ deleted_count: deleted_count,
230
+ freed_size: freed_size,
231
+ days_old: days_old,
232
+ message: "🗑️ Permanently deleted #{deleted_count} files older than #{days_old} days\n💾 Freed up #{format_bytes(freed_size)} of disk space"
233
+ }
234
+ end
235
+
236
+ def show_help
237
+ help_text = <<~HELP
238
+ 🗑️ Trash Manager Help
239
+
240
+ The SafeShell tool automatically moves deleted files to a trash directory
241
+ instead of permanently deleting them. This tool helps you manage those files.
242
+
243
+ Available actions:
244
+
245
+ 📋 list - Show all deleted files
246
+ Example: trash_manager(action="list")
247
+
248
+ ♻️ restore - Restore a deleted file to its original location
249
+ Example: trash_manager(action="restore", file_path="path/to/file.txt")
250
+
251
+ 📊 status - Show trash summary with statistics
252
+ Example: trash_manager(action="status")
253
+
254
+ 🗑️ empty - Permanently delete files older than N days (default: 7)
255
+ Example: trash_manager(action="empty", days_old=7)
256
+
257
+ ❓ help - Show this help message
258
+
259
+ 💡 Tips:
260
+ - Use 'list' to see what files are in trash
261
+ - Use 'restore' to get back accidentally deleted files
262
+ - Use 'empty' periodically to free up disk space
263
+ - All deletions by SafeShell are logged in .ai_safety.log
264
+ HELP
265
+
266
+ {
267
+ action: 'help',
268
+ success: true,
269
+ message: help_text
270
+ }
271
+ end
272
+
273
+ def get_deleted_files(trash_dir, project_root)
274
+ deleted_files = []
275
+
276
+ Dir.glob(File.join(trash_dir, "*.metadata.json")).each do |metadata_file|
277
+ begin
278
+ metadata = JSON.parse(File.read(metadata_file))
279
+ trash_file = metadata_file.sub('.metadata.json', '')
280
+
281
+ # Only include existing trash files
282
+ if File.exist?(trash_file)
283
+ deleted_files << {
284
+ original_path: metadata['original_path'],
285
+ deleted_at: metadata['deleted_at'],
286
+ trash_file: trash_file,
287
+ file_size: metadata['file_size'],
288
+ file_type: metadata['file_type'],
289
+ file_mode: metadata['file_mode']
290
+ }
291
+ end
292
+ rescue StandardError
293
+ # Skip corrupted metadata files
294
+ end
295
+ end
296
+
297
+ deleted_files.sort_by { |f| f[:deleted_at] }.reverse
298
+ end
299
+
300
+ def format_bytes(bytes)
301
+ return "0 B" if bytes.zero?
302
+
303
+ units = %w[B KB MB GB]
304
+ unit_index = 0
305
+ size = bytes.to_f
306
+
307
+ while size >= 1024 && unit_index < units.length - 1
308
+ size /= 1024.0
309
+ unit_index += 1
310
+ end
311
+
312
+ if unit_index == 0
313
+ "#{size.to_i} #{units[unit_index]}"
314
+ else
315
+ "#{size.round(2)} #{units[unit_index]}"
316
+ end
317
+ end
318
+
319
+ def format_time(time_str)
320
+ time = Time.parse(time_str)
321
+ if time.to_date == Date.today
322
+ time.strftime("%H:%M")
323
+ elsif time.to_date == Date.today - 1
324
+ "yesterday #{time.strftime('%H:%M')}"
325
+ elsif time.year == Date.today.year
326
+ time.strftime("%m/%d %H:%M")
327
+ else
328
+ time.strftime("%Y/%m/%d")
329
+ end
330
+ rescue
331
+ time_str
332
+ end
333
+
334
+ def format_call(args)
335
+ action = args[:action] || args['action'] || 'unknown'
336
+ "TrashManager(#{action})"
337
+ end
338
+
339
+ def format_result(result)
340
+ action = result[:action] || 'unknown'
341
+ success = result[:success]
342
+
343
+ case action
344
+ when 'list'
345
+ count = result[:count] || 0
346
+ "📋 Listed #{count} deleted files"
347
+ when 'restore'
348
+ if success
349
+ "♻️ File restored successfully"
350
+ else
351
+ "❌ Restore failed"
352
+ end
353
+ when 'status'
354
+ count = result[:count] || 0
355
+ "📊 Trash: #{count} files"
356
+ when 'empty'
357
+ if success
358
+ deleted_count = result[:deleted_count] || 0
359
+ "🗑️ Emptied #{deleted_count} files"
360
+ else
361
+ "❌ Empty failed"
362
+ end
363
+ when 'help'
364
+ "❓ Help displayed"
365
+ else
366
+ success ? "✓ #{action} completed" : "✗ #{action} failed"
367
+ end
368
+ end
369
+ end
370
+ end
371
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Clacky
7
+ module Tools
8
+ class WebFetch < Base
9
+ self.tool_name = "web_fetch"
10
+ self.tool_description = "Fetch and parse content from a web page. Returns the page content, title, and metadata."
11
+ self.tool_category = "web"
12
+ self.tool_parameters = {
13
+ type: "object",
14
+ properties: {
15
+ url: {
16
+ type: "string",
17
+ description: "The URL to fetch (must be a valid HTTP/HTTPS URL)"
18
+ },
19
+ max_length: {
20
+ type: "integer",
21
+ description: "Maximum content length to return in characters (default: 50000)",
22
+ default: 50000
23
+ }
24
+ },
25
+ required: %w[url]
26
+ }
27
+
28
+ def execute(url:, max_length: 50000)
29
+ # Validate URL
30
+ begin
31
+ uri = URI.parse(url)
32
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
33
+ return { error: "URL must be HTTP or HTTPS" }
34
+ end
35
+ rescue URI::InvalidURIError => e
36
+ return { error: "Invalid URL: #{e.message}" }
37
+ end
38
+
39
+ begin
40
+ # Fetch the web page
41
+ response = fetch_url(uri)
42
+
43
+ # Extract content
44
+ content = response.body
45
+ content_type = response["content-type"] || ""
46
+
47
+ # Parse HTML if it's an HTML page
48
+ if content_type.include?("text/html")
49
+ result = parse_html(content, max_length)
50
+ result[:url] = url
51
+ result[:content_type] = content_type
52
+ result[:status_code] = response.code.to_i
53
+ result[:error] = nil
54
+ result
55
+ else
56
+ # For non-HTML content, return raw text
57
+ truncated_content = content[0, max_length]
58
+ {
59
+ url: url,
60
+ content_type: content_type,
61
+ status_code: response.code.to_i,
62
+ content: truncated_content,
63
+ truncated: content.length > max_length,
64
+ error: nil
65
+ }
66
+ end
67
+ rescue StandardError => e
68
+ { error: "Failed to fetch URL: #{e.message}" }
69
+ end
70
+ end
71
+
72
+ def fetch_url(uri)
73
+ # Follow redirects (max 5)
74
+ redirects = 0
75
+ max_redirects = 5
76
+
77
+ loop do
78
+ request = Net::HTTP::Get.new(uri)
79
+ request["User-Agent"] = "Mozilla/5.0 (compatible; Clacky/1.0)"
80
+
81
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", read_timeout: 15) do |http|
82
+ http.request(request)
83
+ end
84
+
85
+ case response
86
+ when Net::HTTPSuccess
87
+ return response
88
+ when Net::HTTPRedirection
89
+ redirects += 1
90
+ raise "Too many redirects" if redirects > max_redirects
91
+
92
+ location = response["location"]
93
+ uri = URI.parse(location)
94
+ else
95
+ raise "HTTP error: #{response.code} #{response.message}"
96
+ end
97
+ end
98
+ end
99
+
100
+ def parse_html(html, max_length)
101
+ # Extract title
102
+ title = ""
103
+ if html =~ %r{<title[^>]*>(.*?)</title>}mi
104
+ title = $1.strip.gsub(/\s+/, " ")
105
+ end
106
+
107
+ # Extract meta description
108
+ description = ""
109
+ if html =~ /<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/mi
110
+ description = $1.strip
111
+ elsif html =~ /<meta[^>]*content=["']([^"']*)["'][^>]*name=["']description["']/mi
112
+ description = $1.strip
113
+ end
114
+
115
+ # Remove script and style tags
116
+ text = html.gsub(%r{<script[^>]*>.*?</script>}mi, "")
117
+ .gsub(%r{<style[^>]*>.*?</style>}mi, "")
118
+
119
+ # Remove HTML tags
120
+ text = text.gsub(/<[^>]+>/, " ")
121
+
122
+ # Clean up whitespace
123
+ text = text.gsub(/\s+/, " ").strip
124
+
125
+ # Truncate if needed
126
+ truncated = text.length > max_length
127
+ text = text[0, max_length] if truncated
128
+
129
+ {
130
+ title: title,
131
+ description: description,
132
+ content: text,
133
+ truncated: truncated
134
+ }
135
+ end
136
+
137
+ def format_call(args)
138
+ url = args[:url] || args['url'] || ''
139
+ # Extract domain from URL for display
140
+ begin
141
+ uri = URI.parse(url)
142
+ domain = uri.host || url
143
+ "web_fetch(#{domain})"
144
+ rescue
145
+ display_url = url.length > 40 ? "#{url[0..37]}..." : url
146
+ "web_fetch(\"#{display_url}\")"
147
+ end
148
+ end
149
+
150
+ def format_result(result)
151
+ if result[:error]
152
+ "✗ #{result[:error]}"
153
+ else
154
+ title = result[:title] || 'Untitled'
155
+ display_title = title.length > 40 ? "#{title[0..37]}..." : title
156
+ "✓ Fetched: #{display_title}"
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require "cgi"
7
+
8
+ module Clacky
9
+ module Tools
10
+ class WebSearch < Base
11
+ self.tool_name = "web_search"
12
+ self.tool_description = "Search the web for current information. Returns search results with titles, URLs, and snippets."
13
+ self.tool_category = "web"
14
+ self.tool_parameters = {
15
+ type: "object",
16
+ properties: {
17
+ query: {
18
+ type: "string",
19
+ description: "The search query"
20
+ },
21
+ max_results: {
22
+ type: "integer",
23
+ description: "Maximum number of results to return (default: 10)",
24
+ default: 10
25
+ }
26
+ },
27
+ required: %w[query]
28
+ }
29
+
30
+ def execute(query:, max_results: 10)
31
+ # Validate query
32
+ if query.nil? || query.strip.empty?
33
+ return { error: "Query cannot be empty" }
34
+ end
35
+
36
+ begin
37
+ # Use DuckDuckGo HTML search (no API key needed)
38
+ results = search_duckduckgo(query, max_results)
39
+
40
+ {
41
+ query: query,
42
+ results: results,
43
+ count: results.length,
44
+ error: nil
45
+ }
46
+ rescue StandardError => e
47
+ { error: "Failed to perform web search: #{e.message}" }
48
+ end
49
+ end
50
+
51
+ def search_duckduckgo(query, max_results)
52
+ # DuckDuckGo HTML search endpoint
53
+ encoded_query = CGI.escape(query)
54
+ url = URI("https://html.duckduckgo.com/html/?q=#{encoded_query}")
55
+
56
+ # Make request with user agent
57
+ request = Net::HTTP::Get.new(url)
58
+ request["User-Agent"] = "Mozilla/5.0 (compatible; Clacky/1.0)"
59
+
60
+ response = Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: 10) do |http|
61
+ http.request(request)
62
+ end
63
+
64
+ unless response.is_a?(Net::HTTPSuccess)
65
+ return []
66
+ end
67
+
68
+ # Parse HTML results (simple extraction)
69
+ parse_duckduckgo_html(response.body, max_results)
70
+ rescue StandardError => e
71
+ # Fallback: return basic search URL
72
+ [
73
+ {
74
+ title: "Search results for: #{query}",
75
+ url: "https://duckduckgo.com/?q=#{CGI.escape(query)}",
76
+ snippet: "Click to view search results in browser. Error: #{e.message}"
77
+ }
78
+ ]
79
+ end
80
+
81
+ def parse_duckduckgo_html(html, max_results)
82
+ results = []
83
+
84
+ # Simple regex-based parsing (not perfect but works for basic cases)
85
+ # Look for result blocks in DuckDuckGo HTML
86
+ html.scan(%r{<div class="result__body">.*?</div>}m).each do |block|
87
+ break if results.length >= max_results
88
+
89
+ # Extract title and URL
90
+ if block =~ %r{<a.*?href="//duckduckgo\.com/l/\?uddg=([^"&]+).*?".*?>(.*?)</a>}m
91
+ url = CGI.unescape($1)
92
+ title = $2.gsub(/<[^>]+>/, "").strip
93
+
94
+ # Extract snippet
95
+ snippet = ""
96
+ if block =~ %r{<a class="result__snippet".*?>(.*?)</a>}m
97
+ snippet = $1.gsub(/<[^>]+>/, "").strip
98
+ end
99
+
100
+ results << {
101
+ title: title,
102
+ url: url,
103
+ snippet: snippet
104
+ }
105
+ end
106
+ end
107
+
108
+ # If parsing failed, provide a fallback
109
+ if results.empty?
110
+ results << {
111
+ title: "Web search results",
112
+ url: "https://duckduckgo.com/?q=#{CGI.escape(query)}",
113
+ snippet: "Could not parse search results. Visit the URL to see results."
114
+ }
115
+ end
116
+
117
+ results
118
+ rescue StandardError
119
+ []
120
+ end
121
+
122
+ def format_call(args)
123
+ query = args[:query] || args['query'] || ''
124
+ display_query = query.length > 40 ? "#{query[0..37]}..." : query
125
+ "web_search(\"#{display_query}\")"
126
+ end
127
+
128
+ def format_result(result)
129
+ if result[:error]
130
+ "✗ #{result[:error]}"
131
+ else
132
+ count = result[:count] || 0
133
+ "✓ Found #{count} results"
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end