shared_tools 0.3.0 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -4
- data/README.md +257 -262
- data/lib/shared_tools/browser_tool.rb +5 -0
- data/lib/shared_tools/calculator_tool.rb +4 -0
- data/lib/shared_tools/clipboard_tool.rb +4 -0
- data/lib/shared_tools/composite_analysis_tool.rb +4 -0
- data/lib/shared_tools/computer_tool.rb +5 -0
- data/lib/shared_tools/cron_tool.rb +4 -0
- data/lib/shared_tools/current_date_time_tool.rb +4 -0
- data/lib/shared_tools/data_science_kit.rb +4 -0
- data/lib/shared_tools/database.rb +4 -0
- data/lib/shared_tools/database_query_tool.rb +4 -0
- data/lib/shared_tools/database_tool.rb +5 -0
- data/lib/shared_tools/disk_tool.rb +5 -0
- data/lib/shared_tools/dns_tool.rb +4 -0
- data/lib/shared_tools/doc_tool.rb +5 -0
- data/lib/shared_tools/error_handling_tool.rb +4 -0
- data/lib/shared_tools/eval_tool.rb +5 -0
- data/lib/shared_tools/mcp/brave_search_client.rb +37 -0
- data/lib/shared_tools/mcp/chart_client.rb +32 -0
- data/lib/shared_tools/mcp/github_client.rb +38 -0
- data/lib/shared_tools/mcp/hugging_face_client.rb +43 -0
- data/lib/shared_tools/mcp/memory_client.rb +33 -0
- data/lib/shared_tools/mcp/notion_client.rb +40 -0
- data/lib/shared_tools/mcp/sequential_thinking_client.rb +33 -0
- data/lib/shared_tools/mcp/slack_client.rb +54 -0
- data/lib/shared_tools/mcp/streamable_http_patch.rb +42 -0
- data/lib/shared_tools/mcp/tavily_client.rb +41 -0
- data/lib/shared_tools/mcp.rb +45 -16
- data/lib/shared_tools/system_info_tool.rb +4 -0
- data/lib/shared_tools/tools/browser/base_tool.rb +8 -12
- data/lib/shared_tools/tools/browser/click_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/ferrum_driver.rb +119 -0
- data/lib/shared_tools/tools/browser/inspect_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/page_inspect_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +19 -7
- data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/visit_tool.rb +4 -2
- data/lib/shared_tools/tools/browser.rb +31 -2
- data/lib/shared_tools/tools/browser_tool.rb +14 -2
- data/lib/shared_tools/tools/clipboard_tool.rb +119 -0
- data/lib/shared_tools/tools/composite_analysis_tool.rb +60 -4
- data/lib/shared_tools/tools/computer/mac_driver.rb +37 -4
- data/lib/shared_tools/tools/computer_tool.rb +8 -2
- data/lib/shared_tools/tools/cron_tool.rb +332 -0
- data/lib/shared_tools/tools/current_date_time_tool.rb +88 -0
- data/lib/shared_tools/tools/data_science_kit.rb +63 -13
- data/lib/shared_tools/tools/database_tool.rb +8 -3
- data/lib/shared_tools/tools/dns_tool.rb +422 -0
- data/lib/shared_tools/tools/doc/docx_reader_tool.rb +107 -0
- data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +171 -0
- data/lib/shared_tools/tools/doc/text_reader_tool.rb +57 -0
- data/lib/shared_tools/tools/doc.rb +3 -0
- data/lib/shared_tools/tools/doc_tool.rb +101 -6
- data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
- data/lib/shared_tools/tools/enabler.rb +42 -0
- data/lib/shared_tools/tools/error_handling_tool.rb +3 -1
- data/lib/shared_tools/tools/notification/base_driver.rb +51 -0
- data/lib/shared_tools/tools/notification/linux_driver.rb +115 -0
- data/lib/shared_tools/tools/notification/mac_driver.rb +66 -0
- data/lib/shared_tools/tools/notification/null_driver.rb +29 -0
- data/lib/shared_tools/tools/notification.rb +12 -0
- data/lib/shared_tools/tools/notification_tool.rb +99 -0
- data/lib/shared_tools/tools/system_info_tool.rb +204 -0
- data/lib/shared_tools/tools/workflow_manager_tool.rb +32 -0
- data/lib/shared_tools/utilities.rb +193 -0
- data/lib/shared_tools/version.rb +1 -1
- data/lib/shared_tools/weather_tool.rb +4 -0
- data/lib/shared_tools/workflow_manager_tool.rb +4 -0
- data/lib/shared_tools.rb +42 -11
- metadata +79 -9
- data/lib/shared_tools/mcp/github_mcp_server.rb +0 -58
- data/lib/shared_tools/mcp/imcp.rb +0 -28
- data/lib/shared_tools/mcp/tavily_mcp_server.rb +0 -44
- data/lib/shared_tools/tools/devops_toolkit.rb +0 -420
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require_relative '../../shared_tools'
|
|
5
|
+
|
|
6
|
+
module SharedTools
|
|
7
|
+
module Tools
|
|
8
|
+
# Parse, validate, explain, and generate cron expressions.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# tool = SharedTools::Tools::CronTool.new
|
|
12
|
+
# tool.execute(action: 'parse', expression: '0 9 * * 1-5')
|
|
13
|
+
# tool.execute(action: 'validate', expression: '*/15 * * * *')
|
|
14
|
+
# tool.execute(action: 'next_times', expression: '0 * * * *', count: 5)
|
|
15
|
+
# tool.execute(action: 'generate', description: 'every day at 9am')
|
|
16
|
+
class CronTool < ::RubyLLM::Tool
|
|
17
|
+
def self.name = 'cron_tool'
|
|
18
|
+
|
|
19
|
+
description <<~DESC
|
|
20
|
+
Parse, validate, explain, and generate cron expressions (standard 5-field format).
|
|
21
|
+
|
|
22
|
+
Actions:
|
|
23
|
+
- 'parse' — Parse and explain a cron expression
|
|
24
|
+
- 'validate' — Check whether a cron expression is valid
|
|
25
|
+
- 'next_times' — List the next N execution times (default 5)
|
|
26
|
+
- 'generate' — Generate a cron expression from a natural language description
|
|
27
|
+
|
|
28
|
+
Cron format: minute hour day month weekday
|
|
29
|
+
- Each field accepts: number, range (1-5), list (1,3,5), step (*/15), or wildcard (*)
|
|
30
|
+
- Weekday: 0-7 (0 and 7 both mean Sunday)
|
|
31
|
+
|
|
32
|
+
Generate examples: 'every day at 9am', 'every monday at noon', 'every 15 minutes',
|
|
33
|
+
'every weekday', 'first day of every month at midnight'
|
|
34
|
+
DESC
|
|
35
|
+
|
|
36
|
+
params do
|
|
37
|
+
string :action, description: "Action: 'parse', 'validate', 'next_times', 'generate'"
|
|
38
|
+
string :expression, required: false, description: "5-field cron expression. Required for parse, validate, next_times."
|
|
39
|
+
integer :count, required: false, description: "Number of next execution times. Default: 5."
|
|
40
|
+
string :description, required: false, description: "Natural language schedule description. Required for generate."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param logger [Logger] optional logger
|
|
44
|
+
def initialize(logger: nil)
|
|
45
|
+
@logger = logger || RubyLLM.logger
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param action [String] action to perform
|
|
49
|
+
# @param expression [String, nil] cron expression
|
|
50
|
+
# @param count [Integer, nil] number of times for next_times
|
|
51
|
+
# @param description [String, nil] natural language description for generate
|
|
52
|
+
# @return [Hash] result
|
|
53
|
+
def execute(action:, expression: nil, count: nil, description: nil)
|
|
54
|
+
@logger.info("CronTool#execute action=#{action}")
|
|
55
|
+
|
|
56
|
+
case action.to_s.downcase
|
|
57
|
+
when 'parse' then parse_expression(expression)
|
|
58
|
+
when 'validate' then validate_expression(expression)
|
|
59
|
+
when 'next_times' then next_times(expression, (count || 5).to_i)
|
|
60
|
+
when 'generate' then generate_expression(description)
|
|
61
|
+
else
|
|
62
|
+
{ success: false, error: "Unknown action '#{action}'. Use: parse, validate, next_times, generate" }
|
|
63
|
+
end
|
|
64
|
+
rescue => e
|
|
65
|
+
@logger.error("CronTool error: #{e.message}")
|
|
66
|
+
{ success: false, error: e.message }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
FIELD_NAMES = %w[minute hour day month weekday].freeze
|
|
72
|
+
FIELD_RANGES = {
|
|
73
|
+
'minute' => 0..59,
|
|
74
|
+
'hour' => 0..23,
|
|
75
|
+
'day' => 1..31,
|
|
76
|
+
'month' => 1..12,
|
|
77
|
+
'weekday' => 0..7
|
|
78
|
+
}.freeze
|
|
79
|
+
DAY_NAMES = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze
|
|
80
|
+
MONTH_NAMES = %w[January February March April May June July August
|
|
81
|
+
September October November December].freeze
|
|
82
|
+
|
|
83
|
+
# -------------------------------------------------------------------------
|
|
84
|
+
# Action implementations
|
|
85
|
+
# -------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def parse_expression(expr)
|
|
88
|
+
require_expr!(expr)
|
|
89
|
+
parts = split_expr(expr)
|
|
90
|
+
fields = {}
|
|
91
|
+
FIELD_NAMES.zip(parts).each do |name, raw|
|
|
92
|
+
fields[name] = { raw: raw, values: expand_field(raw, FIELD_RANGES[name]) }
|
|
93
|
+
end
|
|
94
|
+
{ success: true, valid: true, expression: expr, fields: fields, explanation: explain(parts) }
|
|
95
|
+
rescue ArgumentError => e
|
|
96
|
+
{ success: false, valid: false, expression: expr, error: e.message }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_expression(expr)
|
|
100
|
+
require_expr!(expr)
|
|
101
|
+
parts = split_expr(expr)
|
|
102
|
+
FIELD_NAMES.zip(parts).each { |name, raw| expand_field(raw, FIELD_RANGES[name]) }
|
|
103
|
+
{ success: true, valid: true, expression: expr, explanation: explain(parts) }
|
|
104
|
+
rescue ArgumentError => e
|
|
105
|
+
{ success: true, valid: false, expression: expr, error: e.message }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def next_times(expr, count)
|
|
109
|
+
require_expr!(expr)
|
|
110
|
+
parts = split_expr(expr)
|
|
111
|
+
|
|
112
|
+
sets = FIELD_NAMES.map.with_index { |name, i| expand_field(parts[i], FIELD_RANGES[name]) }
|
|
113
|
+
mins_set, hrs_set, days_set, months_set, wdays_set = sets
|
|
114
|
+
|
|
115
|
+
# Normalise Sunday: weekday 7 == 0
|
|
116
|
+
wdays_set = wdays_set.map { |d| d == 7 ? 0 : d }.uniq.sort
|
|
117
|
+
|
|
118
|
+
times = []
|
|
119
|
+
t = Time.now
|
|
120
|
+
# Advance to the next minute boundary
|
|
121
|
+
t = Time.new(t.year, t.month, t.day, t.hour, t.min + 1, 0)
|
|
122
|
+
limit = 527_040 # 1 year of minutes — safety cap
|
|
123
|
+
|
|
124
|
+
while times.size < count && limit > 0
|
|
125
|
+
limit -= 1
|
|
126
|
+
if months_set.include?(t.month) &&
|
|
127
|
+
days_set.include?(t.day) &&
|
|
128
|
+
wdays_set.include?(t.wday) &&
|
|
129
|
+
hrs_set.include?(t.hour) &&
|
|
130
|
+
mins_set.include?(t.min)
|
|
131
|
+
times << t.strftime('%Y-%m-%d %H:%M (%A)')
|
|
132
|
+
end
|
|
133
|
+
t += 60
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
{ success: true, expression: expr, explanation: explain(parts), next_times: times }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def generate_expression(desc)
|
|
140
|
+
raise ArgumentError, "description is required for the generate action" if desc.nil? || desc.strip.empty?
|
|
141
|
+
|
|
142
|
+
d = desc.downcase
|
|
143
|
+
expr = match_pattern(d)
|
|
144
|
+
|
|
145
|
+
if expr
|
|
146
|
+
parts = expr.split
|
|
147
|
+
{ success: true, description: desc, expression: expr, explanation: explain(parts) }
|
|
148
|
+
else
|
|
149
|
+
{
|
|
150
|
+
success: false,
|
|
151
|
+
description: desc,
|
|
152
|
+
error: "Could not generate an expression from that description. " \
|
|
153
|
+
"Try: 'every day at 9am', 'every monday at noon', 'every 15 minutes', " \
|
|
154
|
+
"'every weekday', 'first day of every month at midnight'."
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# -------------------------------------------------------------------------
|
|
160
|
+
# Pattern matching for generate
|
|
161
|
+
# -------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def match_pattern(d)
|
|
164
|
+
return '* * * * *' if d.include?('every minute')
|
|
165
|
+
|
|
166
|
+
if (m = d.match(/every\s+(\d+)\s+minutes?/))
|
|
167
|
+
return "*/#{m[1]} * * * *"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
if d.match?(/every\s+hour\b/) && !d.match?(/\d+\s+hours?/)
|
|
171
|
+
return '0 * * * *'
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
if (m = d.match(/every\s+(\d+)\s+hours?/))
|
|
175
|
+
return "0 */#{m[1]} * * *"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
return '0 9 * * 1-5' if d.include?('weekday')
|
|
179
|
+
return '0 0 * * 0,6' if d.include?('weekend')
|
|
180
|
+
|
|
181
|
+
day_pattern = DAY_NAMES.map(&:downcase).join('|')
|
|
182
|
+
|
|
183
|
+
if (m = d.match(/every\s+(#{day_pattern})\s+at\s+(\d+)(?::(\d+))?\s*(am|pm)?/))
|
|
184
|
+
day_num = DAY_NAMES.map(&:downcase).index(m[1])
|
|
185
|
+
return "#{hm_to_cron(m[2], m[3], m[4])} * * #{day_num}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
if (m = d.match(/every\s+(#{day_pattern})\s+at\s+noon/))
|
|
189
|
+
day_num = DAY_NAMES.map(&:downcase).index(m[1])
|
|
190
|
+
return "0 12 * * #{day_num}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
if (m = d.match(/every\s+(#{day_pattern})/))
|
|
194
|
+
day_num = DAY_NAMES.map(&:downcase).index(m[1])
|
|
195
|
+
return "0 0 * * #{day_num}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
return '0 12 * * *' if d.include?('noon')
|
|
199
|
+
return '0 0 * * *' if d.include?('midnight')
|
|
200
|
+
|
|
201
|
+
if (m = d.match(/every\s+day\s+at\s+(\d+)(?::(\d+))?\s*(am|pm)?/))
|
|
202
|
+
return "#{hm_to_cron(m[1], m[2], m[3])} * * *"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
if (m = d.match(/first\s+day\s+(?:of\s+(?:every\s+)?month\s+)?at\s+(\d+)(?::(\d+))?\s*(am|pm)?/))
|
|
206
|
+
return "#{hm_to_cron(m[1], m[2], m[3])} 1 * *"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
return '0 0 1 * *' if d.match?(/first\s+day/)
|
|
210
|
+
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Convert hour/minute/ampm strings to "min hour" cron tokens.
|
|
215
|
+
def hm_to_cron(h, m, ap)
|
|
216
|
+
hour = h.to_i
|
|
217
|
+
min = (m || '0').to_i
|
|
218
|
+
hour += 12 if ap == 'pm' && hour != 12
|
|
219
|
+
hour = 0 if ap == 'am' && hour == 12
|
|
220
|
+
"#{min} #{hour}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# -------------------------------------------------------------------------
|
|
224
|
+
# Field expansion
|
|
225
|
+
# -------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
# Expand one cron field to a sorted array of integers.
|
|
228
|
+
def expand_field(value, range)
|
|
229
|
+
return range.to_a if value == '*'
|
|
230
|
+
|
|
231
|
+
result = []
|
|
232
|
+
value.split(',').each do |part|
|
|
233
|
+
if part.include?('/')
|
|
234
|
+
base_str, step_str = part.split('/')
|
|
235
|
+
step = step_str.to_i
|
|
236
|
+
raise ArgumentError, "Step must be >= 1, got #{step}" if step < 1
|
|
237
|
+
base_set = base_str == '*' ? range.to_a : expand_range_part(base_str, range)
|
|
238
|
+
result.concat(base_set.each_with_index.filter_map { |v, i| v if (i % step).zero? })
|
|
239
|
+
elsif part.include?('-')
|
|
240
|
+
a, b = part.split('-').map(&:to_i)
|
|
241
|
+
unless range_with_sunday(range).cover?(a) && range_with_sunday(range).cover?(b)
|
|
242
|
+
raise ArgumentError, "Range #{part} is out of bounds for #{range}"
|
|
243
|
+
end
|
|
244
|
+
result.concat((a..b).to_a)
|
|
245
|
+
else
|
|
246
|
+
v = part.to_i
|
|
247
|
+
unless range_with_sunday(range).cover?(v)
|
|
248
|
+
raise ArgumentError, "Value #{v} is out of range #{range}"
|
|
249
|
+
end
|
|
250
|
+
result << v
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
result.uniq.sort
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def expand_range_part(str, range)
|
|
258
|
+
if str.include?('-')
|
|
259
|
+
a, b = str.split('-').map(&:to_i)
|
|
260
|
+
(a..b).to_a
|
|
261
|
+
else
|
|
262
|
+
v = str.to_i
|
|
263
|
+
(v..range.last).to_a
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Allow weekday 7 (Sunday alias)
|
|
268
|
+
def range_with_sunday(range)
|
|
269
|
+
range == FIELD_RANGES['weekday'] ? (0..7) : range
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# -------------------------------------------------------------------------
|
|
273
|
+
# Human-readable explanation
|
|
274
|
+
# -------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
def explain(parts)
|
|
277
|
+
min, hour, day, month, weekday = parts
|
|
278
|
+
segments = []
|
|
279
|
+
|
|
280
|
+
segments << if min == '*' then 'every minute'
|
|
281
|
+
elsif min.start_with?('*/') then "every #{min[2..]} minutes"
|
|
282
|
+
else "at minute #{min}"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
segments << if hour == '*' then 'of every hour'
|
|
286
|
+
elsif hour.start_with?('*/') then "every #{hour[2..]} hours"
|
|
287
|
+
else "at #{fmt_hour(hour)}"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
segments << "on day #{day} of the month" unless day == '*'
|
|
291
|
+
segments << "in #{fmt_month(month)}" unless month == '*'
|
|
292
|
+
segments << "on #{fmt_weekday(weekday)}" unless weekday == '*'
|
|
293
|
+
|
|
294
|
+
segments.join(', ')
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def fmt_hour(h)
|
|
298
|
+
return h if h.match?(/[,\-\/]/)
|
|
299
|
+
n = h.to_i
|
|
300
|
+
disp = n == 0 ? 12 : (n > 12 ? n - 12 : n)
|
|
301
|
+
ampm = n < 12 ? 'AM' : 'PM'
|
|
302
|
+
"#{disp}:00 #{ampm}"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def fmt_month(m)
|
|
306
|
+
return m if m.match?(/[,\-\/]/)
|
|
307
|
+
MONTH_NAMES[m.to_i - 1] || m
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def fmt_weekday(w)
|
|
311
|
+
return 'weekdays (Mon–Fri)' if w == '1-5'
|
|
312
|
+
return w if w.match?(/[,\/]/)
|
|
313
|
+
return w if w.include?('-')
|
|
314
|
+
DAY_NAMES[w.to_i % 7] || w
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# -------------------------------------------------------------------------
|
|
318
|
+
# Helpers
|
|
319
|
+
# -------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
def require_expr!(expr)
|
|
322
|
+
raise ArgumentError, "expression is required for this action" if expr.nil? || expr.strip.empty?
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def split_expr(expr)
|
|
326
|
+
parts = expr.strip.split(/\s+/)
|
|
327
|
+
raise ArgumentError, "Cron expression must have 5 fields, got #{parts.size}" unless parts.size == 5
|
|
328
|
+
parts
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require_relative '../../shared_tools'
|
|
5
|
+
|
|
6
|
+
module SharedTools
|
|
7
|
+
module Tools
|
|
8
|
+
# Returns the current date, time, and timezone from the local system.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# tool = SharedTools::Tools::CurrentDateTimeTool.new
|
|
12
|
+
# tool.execute # full output
|
|
13
|
+
# tool.execute(format: 'date') # date fields only
|
|
14
|
+
class CurrentDateTimeTool < ::RubyLLM::Tool
|
|
15
|
+
def self.name = 'current_date_time_tool'
|
|
16
|
+
|
|
17
|
+
description <<~DESC
|
|
18
|
+
Returns the current date, time, timezone, and calendar metadata from the system clock.
|
|
19
|
+
|
|
20
|
+
Supported formats:
|
|
21
|
+
- 'full' (default) — all fields: date, time, timezone, ISO 8601, unix timestamp, DST flag
|
|
22
|
+
- 'date' — year, month, day, day_of_week, week_of_year, quarter, ordinal_day
|
|
23
|
+
- 'time' — hour, minute, second, timezone, utc_offset
|
|
24
|
+
- 'iso8601' — iso8601, iso8601_utc, unix_timestamp
|
|
25
|
+
DESC
|
|
26
|
+
|
|
27
|
+
params do
|
|
28
|
+
string :format, required: false, description: <<~DESC.strip
|
|
29
|
+
Output format. Options: 'full' (default), 'date', 'time', 'iso8601'.
|
|
30
|
+
DESC
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param logger [Logger] optional logger
|
|
34
|
+
def initialize(logger: nil)
|
|
35
|
+
@logger = logger || RubyLLM.logger
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param format [String] output format
|
|
39
|
+
# @return [Hash] date/time information
|
|
40
|
+
def execute(format: 'full')
|
|
41
|
+
@logger.info("CurrentDateTimeTool#execute format=#{format}")
|
|
42
|
+
|
|
43
|
+
now = Time.now
|
|
44
|
+
utc = now.utc
|
|
45
|
+
|
|
46
|
+
date_info = {
|
|
47
|
+
year: now.year,
|
|
48
|
+
month: now.month,
|
|
49
|
+
day: now.day,
|
|
50
|
+
day_of_week: now.strftime('%A'),
|
|
51
|
+
day_of_week_num: now.wday,
|
|
52
|
+
week_of_year: now.strftime('%U').to_i,
|
|
53
|
+
quarter: ((now.month - 1) / 3) + 1,
|
|
54
|
+
ordinal_day: now.yday
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
time_info = {
|
|
58
|
+
hour: now.hour,
|
|
59
|
+
minute: now.min,
|
|
60
|
+
second: now.sec,
|
|
61
|
+
timezone: now.zone,
|
|
62
|
+
utc_offset: now.strftime('%z'),
|
|
63
|
+
utc_offset_hours: (now.utc_offset / 3600.0).round(2)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
iso_info = {
|
|
67
|
+
iso8601: now.iso8601,
|
|
68
|
+
iso8601_utc: utc.iso8601,
|
|
69
|
+
unix_timestamp: now.to_i
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
case format.to_s.downcase
|
|
73
|
+
when 'date'
|
|
74
|
+
{ success: true }.merge(date_info)
|
|
75
|
+
when 'time'
|
|
76
|
+
{ success: true }.merge(time_info)
|
|
77
|
+
when 'iso8601'
|
|
78
|
+
{ success: true }.merge(iso_info)
|
|
79
|
+
else
|
|
80
|
+
{ success: true, dst: now.dst? }
|
|
81
|
+
.merge(date_info)
|
|
82
|
+
.merge(time_info)
|
|
83
|
+
.merge(iso_info)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -28,13 +28,22 @@ module SharedTools
|
|
|
28
28
|
Each analysis type requires specific data formats and optional parameters.
|
|
29
29
|
DESC
|
|
30
30
|
|
|
31
|
-
string :data_source, description: <<~DESC.strip, required:
|
|
31
|
+
string :data_source, description: <<~DESC.strip, required: false
|
|
32
32
|
Data source specification for analysis. Can be:
|
|
33
33
|
- File path: Relative or absolute path to CSV, JSON, Excel, or Parquet files
|
|
34
34
|
- Database query: SQL SELECT statement for database-sourced data
|
|
35
35
|
- API endpoint: HTTP URL for REST API data sources
|
|
36
36
|
The tool automatically detects the format and applies appropriate parsing.
|
|
37
37
|
Examples: './sales_data.csv', 'SELECT * FROM transactions', 'https://api.company.com/data'
|
|
38
|
+
Either data_source or data must be provided.
|
|
39
|
+
DESC
|
|
40
|
+
|
|
41
|
+
string :data, description: <<~DESC.strip, required: false
|
|
42
|
+
Inline data to analyse, provided directly as a JSON string. Accepted formats:
|
|
43
|
+
- Array of hashes: '[{"month":"Jan","value":42},{"month":"Feb","value":45}]'
|
|
44
|
+
- Pipe-delimited table string: "Col A | Col B\n1 | 2\n3 | 4"
|
|
45
|
+
- Comma-separated numbers (single series): "42,45,51,48,55" — parsed as [{value: n}]
|
|
46
|
+
Either data or data_source must be provided.
|
|
38
47
|
DESC
|
|
39
48
|
|
|
40
49
|
object :parameters, description: <<~DESC.strip, required: false do
|
|
@@ -82,11 +91,18 @@ module SharedTools
|
|
|
82
91
|
@logger = logger || RubyLLM.logger
|
|
83
92
|
end
|
|
84
93
|
|
|
85
|
-
def execute(analysis_type:, data_source
|
|
94
|
+
def execute(analysis_type:, data_source: nil, data: nil, **parameters)
|
|
86
95
|
analysis_start = Time.now
|
|
87
96
|
|
|
88
97
|
begin
|
|
89
|
-
|
|
98
|
+
if data_source.nil? && data.nil?
|
|
99
|
+
return {
|
|
100
|
+
success: false,
|
|
101
|
+
error: "Either data_source or data must be provided."
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
@logger.info("DataScienceKit#execute analysis_type=#{analysis_type}")
|
|
90
106
|
|
|
91
107
|
# Validate analysis type
|
|
92
108
|
unless VALID_ANALYSIS_TYPES.include?(analysis_type)
|
|
@@ -99,21 +115,21 @@ module SharedTools
|
|
|
99
115
|
end
|
|
100
116
|
|
|
101
117
|
# Load and validate data
|
|
102
|
-
|
|
103
|
-
validate_data_for_analysis(
|
|
118
|
+
loaded_data = data ? parse_inline_data(data) : load_data(data_source)
|
|
119
|
+
validate_data_for_analysis(loaded_data, analysis_type, parameters)
|
|
104
120
|
|
|
105
121
|
# Perform analysis
|
|
106
122
|
result = case analysis_type
|
|
107
123
|
when "statistical_summary"
|
|
108
|
-
generate_statistical_summary(
|
|
124
|
+
generate_statistical_summary(loaded_data, parameters)
|
|
109
125
|
when "correlation_analysis"
|
|
110
|
-
perform_correlation_analysis(
|
|
126
|
+
perform_correlation_analysis(loaded_data, parameters)
|
|
111
127
|
when "time_series"
|
|
112
|
-
analyze_time_series(
|
|
128
|
+
analyze_time_series(loaded_data, parameters)
|
|
113
129
|
when "clustering"
|
|
114
|
-
perform_clustering(
|
|
130
|
+
perform_clustering(loaded_data, parameters)
|
|
115
131
|
when "prediction"
|
|
116
|
-
generate_predictions(
|
|
132
|
+
generate_predictions(loaded_data, parameters)
|
|
117
133
|
end
|
|
118
134
|
|
|
119
135
|
analysis_duration = (Time.now - analysis_start).round(3)
|
|
@@ -123,7 +139,7 @@ module SharedTools
|
|
|
123
139
|
success: true,
|
|
124
140
|
analysis_type: analysis_type,
|
|
125
141
|
result: result,
|
|
126
|
-
data_summary: summarize_data(
|
|
142
|
+
data_summary: summarize_data(loaded_data),
|
|
127
143
|
analyzed_at: Time.now.iso8601,
|
|
128
144
|
duration_seconds: analysis_duration
|
|
129
145
|
}
|
|
@@ -133,8 +149,7 @@ module SharedTools
|
|
|
133
149
|
success: false,
|
|
134
150
|
error: e.message,
|
|
135
151
|
error_type: e.class.name,
|
|
136
|
-
analysis_type: analysis_type
|
|
137
|
-
data_source: data_source
|
|
152
|
+
analysis_type: analysis_type
|
|
138
153
|
}
|
|
139
154
|
end
|
|
140
155
|
end
|
|
@@ -189,6 +204,41 @@ module SharedTools
|
|
|
189
204
|
end
|
|
190
205
|
end
|
|
191
206
|
|
|
207
|
+
def parse_inline_data(raw)
|
|
208
|
+
raw = raw.strip
|
|
209
|
+
# JSON array or object
|
|
210
|
+
if raw.start_with?('{', '[')
|
|
211
|
+
begin
|
|
212
|
+
return JSON.parse(raw)
|
|
213
|
+
rescue JSON::ParserError
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
lines = raw.lines.map(&:strip).reject(&:empty?)
|
|
217
|
+
# Pipe-delimited table
|
|
218
|
+
if lines.first&.include?('|')
|
|
219
|
+
headers = lines.first.split('|').map(&:strip).reject(&:empty?)
|
|
220
|
+
data_lines = lines.drop(1).reject { |l| l.match?(/^\|?[-:\s|]+$/) }
|
|
221
|
+
return data_lines.map do |line|
|
|
222
|
+
values = line.split('|').map(&:strip).reject(&:empty?)
|
|
223
|
+
headers.zip(values).to_h
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
# CSV header row
|
|
227
|
+
if lines.size > 1 && lines.first.include?(',') && lines.first.match?(/[a-zA-Z]/)
|
|
228
|
+
headers = lines.first.split(',').map(&:strip)
|
|
229
|
+
return lines.drop(1).map do |line|
|
|
230
|
+
values = line.split(',').map(&:strip)
|
|
231
|
+
headers.zip(values).to_h
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
# Comma-separated numbers — single series
|
|
235
|
+
if lines.size == 1 && lines.first.match?(/^[\d.,\s]+$/)
|
|
236
|
+
return lines.first.split(',').map { |v| { "value" => v.strip.to_f } }
|
|
237
|
+
end
|
|
238
|
+
# Plain lines
|
|
239
|
+
lines.map { |l| { "value" => l } }
|
|
240
|
+
end
|
|
241
|
+
|
|
192
242
|
# Generate sample data for testing
|
|
193
243
|
def generate_sample_data(size = 30)
|
|
194
244
|
(1..size).map do |i|
|
|
@@ -62,14 +62,17 @@ module SharedTools
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
|
|
65
|
-
# @param driver [SharedTools::Tools::Database::BaseDriver]
|
|
65
|
+
# @param driver [SharedTools::Tools::Database::BaseDriver] database driver (SqliteDriver, PostgresDriver, etc.)
|
|
66
|
+
# Required for execute, but optional for instantiation to support RubyLLM tool discovery
|
|
66
67
|
# @param logger [Logger] optional logger
|
|
67
|
-
def initialize(driver
|
|
68
|
-
raise ArgumentError, "driver is required for DatabaseTool" if driver.nil?
|
|
68
|
+
def initialize(driver: nil, logger: nil)
|
|
69
69
|
@driver = driver
|
|
70
70
|
@logger = logger || RubyLLM.logger
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# Set driver after instantiation (useful when tool is discovered by RubyLLM)
|
|
74
|
+
attr_writer :driver
|
|
75
|
+
|
|
73
76
|
# @example
|
|
74
77
|
# tool = SharedTools::Tools::Database::BaseTool.new
|
|
75
78
|
# tool.execute(statements: ["SELECT * FROM people"])
|
|
@@ -78,6 +81,8 @@ module SharedTools
|
|
|
78
81
|
#
|
|
79
82
|
# @return [Array<Hash>]
|
|
80
83
|
def execute(statements:)
|
|
84
|
+
raise ArgumentError, "driver is required for DatabaseTool#execute. Set via initialize(driver:) or tool.driver=" if @driver.nil?
|
|
85
|
+
|
|
81
86
|
[].tap do |executions|
|
|
82
87
|
statements.map do |statement|
|
|
83
88
|
execution = perform(statement:).merge(statement:)
|