clacky 0.5.0
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/.clackyrules +80 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +74 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +272 -0
- data/Rakefile +12 -0
- data/lib/clacky/agent.rb +964 -0
- data/lib/clacky/agent_config.rb +47 -0
- data/lib/clacky/cli.rb +666 -0
- data/lib/clacky/client.rb +159 -0
- data/lib/clacky/config.rb +43 -0
- data/lib/clacky/conversation.rb +41 -0
- data/lib/clacky/hook_manager.rb +61 -0
- data/lib/clacky/progress_indicator.rb +53 -0
- data/lib/clacky/session_manager.rb +124 -0
- data/lib/clacky/thinking_verbs.rb +26 -0
- data/lib/clacky/tool_registry.rb +44 -0
- data/lib/clacky/tools/base.rb +64 -0
- data/lib/clacky/tools/edit.rb +100 -0
- data/lib/clacky/tools/file_reader.rb +79 -0
- data/lib/clacky/tools/glob.rb +93 -0
- data/lib/clacky/tools/grep.rb +169 -0
- data/lib/clacky/tools/run_project.rb +287 -0
- data/lib/clacky/tools/safe_shell.rb +397 -0
- data/lib/clacky/tools/shell.rb +305 -0
- data/lib/clacky/tools/todo_manager.rb +228 -0
- data/lib/clacky/tools/trash_manager.rb +367 -0
- data/lib/clacky/tools/web_fetch.rb +161 -0
- data/lib/clacky/tools/web_search.rb +138 -0
- data/lib/clacky/tools/write.rb +65 -0
- data/lib/clacky/utils/arguments_parser.rb +139 -0
- data/lib/clacky/utils/limit_stack.rb +80 -0
- data/lib/clacky/utils/path_helper.rb +15 -0
- data/lib/clacky/version.rb +5 -0
- data/lib/clacky.rb +38 -0
- data/sig/clacky.rbs +4 -0
- metadata +152 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module Clacky
|
|
8
|
+
module Tools
|
|
9
|
+
class TrashManager < Base
|
|
10
|
+
self.tool_name = "trash_manager"
|
|
11
|
+
self.tool_description = "Manage deleted files in the AI trash - list, restore, or permanently delete files"
|
|
12
|
+
self.tool_category = "system"
|
|
13
|
+
self.tool_parameters = {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
action: {
|
|
17
|
+
type: "string",
|
|
18
|
+
enum: ["list", "restore", "status", "empty", "help"],
|
|
19
|
+
description: "Action to perform: 'list' (show deleted files), 'restore' (restore a file), 'status' (show trash summary), 'empty' (permanently delete old files), 'help' (show usage)"
|
|
20
|
+
},
|
|
21
|
+
file_path: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "Original file path to restore (required for 'restore' action)"
|
|
24
|
+
},
|
|
25
|
+
days_old: {
|
|
26
|
+
type: "integer",
|
|
27
|
+
description: "For 'empty' action: permanently delete files older than this many days (default: 7)"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
required: ["action"]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def execute(action:, file_path: nil, days_old: 7)
|
|
34
|
+
project_root = Dir.pwd
|
|
35
|
+
trash_dir = File.join(project_root, '.ai_trash')
|
|
36
|
+
|
|
37
|
+
unless Dir.exist?(trash_dir)
|
|
38
|
+
return {
|
|
39
|
+
action: action,
|
|
40
|
+
success: false,
|
|
41
|
+
message: "No trash directory found. No files have been safely deleted yet."
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
case action.downcase
|
|
46
|
+
when 'list'
|
|
47
|
+
list_deleted_files(trash_dir)
|
|
48
|
+
when 'restore'
|
|
49
|
+
return { action: action, success: false, message: "file_path is required for restore action" } unless file_path
|
|
50
|
+
restore_file(trash_dir, file_path, project_root)
|
|
51
|
+
when 'status'
|
|
52
|
+
show_trash_status(trash_dir)
|
|
53
|
+
when 'empty'
|
|
54
|
+
empty_trash(trash_dir, days_old)
|
|
55
|
+
when 'help'
|
|
56
|
+
show_help
|
|
57
|
+
else
|
|
58
|
+
{ action: action, success: false, message: "Unknown action: #{action}" }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def list_deleted_files(trash_dir)
|
|
63
|
+
deleted_files = get_deleted_files(trash_dir)
|
|
64
|
+
|
|
65
|
+
if deleted_files.empty?
|
|
66
|
+
return {
|
|
67
|
+
action: 'list',
|
|
68
|
+
success: true,
|
|
69
|
+
count: 0,
|
|
70
|
+
message: "🗑️ Trash is empty"
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
file_list = deleted_files.map.with_index(1) do |file, index|
|
|
75
|
+
size_info = file[:file_size] ? " (#{format_bytes(file[:file_size])})" : ""
|
|
76
|
+
"#{index}. #{file[:original_path]}#{size_info}\n Deleted: #{format_time(file[:deleted_at])}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
action: 'list',
|
|
81
|
+
success: true,
|
|
82
|
+
count: deleted_files.size,
|
|
83
|
+
files: deleted_files,
|
|
84
|
+
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"
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def restore_file(trash_dir, file_path, project_root)
|
|
89
|
+
deleted_files = get_deleted_files(trash_dir)
|
|
90
|
+
expanded_path = File.expand_path(file_path, project_root)
|
|
91
|
+
|
|
92
|
+
target_file = deleted_files.find { |f| f[:original_path] == expanded_path }
|
|
93
|
+
|
|
94
|
+
unless target_file
|
|
95
|
+
similar_files = deleted_files.select { |f| File.basename(f[:original_path]) == File.basename(file_path) }
|
|
96
|
+
|
|
97
|
+
if similar_files.any?
|
|
98
|
+
suggestions = similar_files.map { |f| f[:original_path] }.join("\n - ")
|
|
99
|
+
return {
|
|
100
|
+
action: 'restore',
|
|
101
|
+
success: false,
|
|
102
|
+
message: "File not found in trash: #{file_path}\n\nDid you mean one of these?\n - #{suggestions}"
|
|
103
|
+
}
|
|
104
|
+
else
|
|
105
|
+
return {
|
|
106
|
+
action: 'restore',
|
|
107
|
+
success: false,
|
|
108
|
+
message: "File not found in trash: #{file_path}\n\nUse trash_manager with action='list' to see available files."
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
if File.exist?(expanded_path)
|
|
114
|
+
return {
|
|
115
|
+
action: 'restore',
|
|
116
|
+
success: false,
|
|
117
|
+
message: "Cannot restore: file already exists at #{file_path}"
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
begin
|
|
122
|
+
# Ensure target directory exists
|
|
123
|
+
FileUtils.mkdir_p(File.dirname(expanded_path))
|
|
124
|
+
|
|
125
|
+
# Restore file
|
|
126
|
+
FileUtils.mv(target_file[:trash_file], expanded_path)
|
|
127
|
+
File.delete("#{target_file[:trash_file]}.metadata.json")
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
action: 'restore',
|
|
131
|
+
success: true,
|
|
132
|
+
restored_file: expanded_path,
|
|
133
|
+
message: "✅ Successfully restored: #{file_path}"
|
|
134
|
+
}
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
{
|
|
137
|
+
action: 'restore',
|
|
138
|
+
success: false,
|
|
139
|
+
message: "❌ Failed to restore file: #{e.message}"
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def show_trash_status(trash_dir)
|
|
145
|
+
deleted_files = get_deleted_files(trash_dir)
|
|
146
|
+
total_size = deleted_files.sum { |f| f[:file_size] || 0 }
|
|
147
|
+
|
|
148
|
+
if deleted_files.empty?
|
|
149
|
+
return {
|
|
150
|
+
action: 'status',
|
|
151
|
+
success: true,
|
|
152
|
+
count: 0,
|
|
153
|
+
total_size: 0,
|
|
154
|
+
message: "🗑️ Trash is empty"
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Group by file type
|
|
159
|
+
by_type = deleted_files.group_by { |f| f[:file_type] || 'no extension' }
|
|
160
|
+
type_summary = by_type.map do |ext, files|
|
|
161
|
+
size = files.sum { |f| f[:file_size] || 0 }
|
|
162
|
+
" #{ext}: #{files.size} files (#{format_bytes(size)})"
|
|
163
|
+
end.join("\n")
|
|
164
|
+
|
|
165
|
+
recent_files = deleted_files.first(3).map do |file|
|
|
166
|
+
" - #{File.basename(file[:original_path])} (#{format_time(file[:deleted_at])})"
|
|
167
|
+
end.join("\n")
|
|
168
|
+
|
|
169
|
+
message = []
|
|
170
|
+
message << "🗑️ Trash Status:"
|
|
171
|
+
message << " Files: #{deleted_files.count}"
|
|
172
|
+
message << " Total size: #{format_bytes(total_size)}"
|
|
173
|
+
message << " Location: #{trash_dir}"
|
|
174
|
+
message << ""
|
|
175
|
+
message << "📊 By file type:"
|
|
176
|
+
message << type_summary
|
|
177
|
+
message << ""
|
|
178
|
+
message << "📅 Recently deleted:"
|
|
179
|
+
message << recent_files
|
|
180
|
+
|
|
181
|
+
{
|
|
182
|
+
action: 'status',
|
|
183
|
+
success: true,
|
|
184
|
+
count: deleted_files.size,
|
|
185
|
+
total_size: total_size,
|
|
186
|
+
by_type: by_type.transform_values(&:size),
|
|
187
|
+
message: message.join("\n")
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def empty_trash(trash_dir, days_old)
|
|
192
|
+
deleted_files = get_deleted_files(trash_dir)
|
|
193
|
+
cutoff_time = Time.now - (days_old * 24 * 60 * 60)
|
|
194
|
+
|
|
195
|
+
old_files = deleted_files.select do |file|
|
|
196
|
+
Time.parse(file[:deleted_at]) < cutoff_time
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
if old_files.empty?
|
|
200
|
+
return {
|
|
201
|
+
action: 'empty',
|
|
202
|
+
success: true,
|
|
203
|
+
deleted_count: 0,
|
|
204
|
+
message: "🗑️ No files older than #{days_old} days found in trash"
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
deleted_count = 0
|
|
209
|
+
freed_size = 0
|
|
210
|
+
|
|
211
|
+
old_files.each do |file|
|
|
212
|
+
begin
|
|
213
|
+
File.delete(file[:trash_file]) if File.exist?(file[:trash_file])
|
|
214
|
+
File.delete("#{file[:trash_file]}.metadata.json") if File.exist?("#{file[:trash_file]}.metadata.json")
|
|
215
|
+
deleted_count += 1
|
|
216
|
+
freed_size += file[:file_size] || 0
|
|
217
|
+
rescue StandardError => e
|
|
218
|
+
# Continue processing other files, but log the error
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
{
|
|
223
|
+
action: 'empty',
|
|
224
|
+
success: true,
|
|
225
|
+
deleted_count: deleted_count,
|
|
226
|
+
freed_size: freed_size,
|
|
227
|
+
days_old: days_old,
|
|
228
|
+
message: "🗑️ Permanently deleted #{deleted_count} files older than #{days_old} days\n💾 Freed up #{format_bytes(freed_size)} of disk space"
|
|
229
|
+
}
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def show_help
|
|
233
|
+
help_text = <<~HELP
|
|
234
|
+
🗑️ Trash Manager Help
|
|
235
|
+
|
|
236
|
+
The SafeShell tool automatically moves deleted files to a trash directory
|
|
237
|
+
instead of permanently deleting them. This tool helps you manage those files.
|
|
238
|
+
|
|
239
|
+
Available actions:
|
|
240
|
+
|
|
241
|
+
📋 list - Show all deleted files
|
|
242
|
+
Example: trash_manager(action="list")
|
|
243
|
+
|
|
244
|
+
♻️ restore - Restore a deleted file to its original location
|
|
245
|
+
Example: trash_manager(action="restore", file_path="path/to/file.txt")
|
|
246
|
+
|
|
247
|
+
📊 status - Show trash summary with statistics
|
|
248
|
+
Example: trash_manager(action="status")
|
|
249
|
+
|
|
250
|
+
🗑️ empty - Permanently delete files older than N days (default: 7)
|
|
251
|
+
Example: trash_manager(action="empty", days_old=7)
|
|
252
|
+
|
|
253
|
+
❓ help - Show this help message
|
|
254
|
+
|
|
255
|
+
💡 Tips:
|
|
256
|
+
- Use 'list' to see what files are in trash
|
|
257
|
+
- Use 'restore' to get back accidentally deleted files
|
|
258
|
+
- Use 'empty' periodically to free up disk space
|
|
259
|
+
- All deletions by SafeShell are logged in .ai_safety.log
|
|
260
|
+
HELP
|
|
261
|
+
|
|
262
|
+
{
|
|
263
|
+
action: 'help',
|
|
264
|
+
success: true,
|
|
265
|
+
message: help_text
|
|
266
|
+
}
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def get_deleted_files(trash_dir)
|
|
270
|
+
deleted_files = []
|
|
271
|
+
|
|
272
|
+
Dir.glob(File.join(trash_dir, "*.metadata.json")).each do |metadata_file|
|
|
273
|
+
begin
|
|
274
|
+
metadata = JSON.parse(File.read(metadata_file))
|
|
275
|
+
trash_file = metadata_file.sub('.metadata.json', '')
|
|
276
|
+
|
|
277
|
+
# Only include existing trash files
|
|
278
|
+
if File.exist?(trash_file)
|
|
279
|
+
deleted_files << {
|
|
280
|
+
original_path: metadata['original_path'],
|
|
281
|
+
deleted_at: metadata['deleted_at'],
|
|
282
|
+
trash_file: trash_file,
|
|
283
|
+
file_size: metadata['file_size'],
|
|
284
|
+
file_type: metadata['file_type'],
|
|
285
|
+
file_mode: metadata['file_mode']
|
|
286
|
+
}
|
|
287
|
+
end
|
|
288
|
+
rescue StandardError
|
|
289
|
+
# Skip corrupted metadata files
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
deleted_files.sort_by { |f| f[:deleted_at] }.reverse
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def format_bytes(bytes)
|
|
297
|
+
return "0 B" if bytes.zero?
|
|
298
|
+
|
|
299
|
+
units = %w[B KB MB GB]
|
|
300
|
+
unit_index = 0
|
|
301
|
+
size = bytes.to_f
|
|
302
|
+
|
|
303
|
+
while size >= 1024 && unit_index < units.length - 1
|
|
304
|
+
size /= 1024.0
|
|
305
|
+
unit_index += 1
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
if unit_index == 0
|
|
309
|
+
"#{size.to_i} #{units[unit_index]}"
|
|
310
|
+
else
|
|
311
|
+
"#{size.round(2)} #{units[unit_index]}"
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def format_time(time_str)
|
|
316
|
+
time = Time.parse(time_str)
|
|
317
|
+
if time.to_date == Date.today
|
|
318
|
+
time.strftime("%H:%M")
|
|
319
|
+
elsif time.to_date == Date.today - 1
|
|
320
|
+
"yesterday #{time.strftime('%H:%M')}"
|
|
321
|
+
elsif time.year == Date.today.year
|
|
322
|
+
time.strftime("%m/%d %H:%M")
|
|
323
|
+
else
|
|
324
|
+
time.strftime("%Y/%m/%d")
|
|
325
|
+
end
|
|
326
|
+
rescue
|
|
327
|
+
time_str
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def format_call(args)
|
|
331
|
+
action = args[:action] || args['action'] || 'unknown'
|
|
332
|
+
"TrashManager(#{action})"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def format_result(result)
|
|
336
|
+
action = result[:action] || 'unknown'
|
|
337
|
+
success = result[:success]
|
|
338
|
+
|
|
339
|
+
case action
|
|
340
|
+
when 'list'
|
|
341
|
+
count = result[:count] || 0
|
|
342
|
+
"📋 Listed #{count} deleted files"
|
|
343
|
+
when 'restore'
|
|
344
|
+
if success
|
|
345
|
+
"♻️ File restored successfully"
|
|
346
|
+
else
|
|
347
|
+
"❌ Restore failed"
|
|
348
|
+
end
|
|
349
|
+
when 'status'
|
|
350
|
+
count = result[:count] || 0
|
|
351
|
+
"📊 Trash: #{count} files"
|
|
352
|
+
when 'empty'
|
|
353
|
+
if success
|
|
354
|
+
deleted_count = result[:deleted_count] || 0
|
|
355
|
+
"🗑️ Emptied #{deleted_count} files"
|
|
356
|
+
else
|
|
357
|
+
"❌ Empty failed"
|
|
358
|
+
end
|
|
359
|
+
when 'help'
|
|
360
|
+
"❓ Help displayed"
|
|
361
|
+
else
|
|
362
|
+
success ? "✓ #{action} completed" : "✗ #{action} failed"
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
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
|