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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -4
  3. data/README.md +257 -262
  4. data/lib/shared_tools/browser_tool.rb +5 -0
  5. data/lib/shared_tools/calculator_tool.rb +4 -0
  6. data/lib/shared_tools/clipboard_tool.rb +4 -0
  7. data/lib/shared_tools/composite_analysis_tool.rb +4 -0
  8. data/lib/shared_tools/computer_tool.rb +5 -0
  9. data/lib/shared_tools/cron_tool.rb +4 -0
  10. data/lib/shared_tools/current_date_time_tool.rb +4 -0
  11. data/lib/shared_tools/data_science_kit.rb +4 -0
  12. data/lib/shared_tools/database.rb +4 -0
  13. data/lib/shared_tools/database_query_tool.rb +4 -0
  14. data/lib/shared_tools/database_tool.rb +5 -0
  15. data/lib/shared_tools/disk_tool.rb +5 -0
  16. data/lib/shared_tools/dns_tool.rb +4 -0
  17. data/lib/shared_tools/doc_tool.rb +5 -0
  18. data/lib/shared_tools/error_handling_tool.rb +4 -0
  19. data/lib/shared_tools/eval_tool.rb +5 -0
  20. data/lib/shared_tools/mcp/brave_search_client.rb +37 -0
  21. data/lib/shared_tools/mcp/chart_client.rb +32 -0
  22. data/lib/shared_tools/mcp/github_client.rb +38 -0
  23. data/lib/shared_tools/mcp/hugging_face_client.rb +43 -0
  24. data/lib/shared_tools/mcp/memory_client.rb +33 -0
  25. data/lib/shared_tools/mcp/notion_client.rb +40 -0
  26. data/lib/shared_tools/mcp/sequential_thinking_client.rb +33 -0
  27. data/lib/shared_tools/mcp/slack_client.rb +54 -0
  28. data/lib/shared_tools/mcp/streamable_http_patch.rb +42 -0
  29. data/lib/shared_tools/mcp/tavily_client.rb +41 -0
  30. data/lib/shared_tools/mcp.rb +45 -16
  31. data/lib/shared_tools/system_info_tool.rb +4 -0
  32. data/lib/shared_tools/tools/browser/base_tool.rb +8 -12
  33. data/lib/shared_tools/tools/browser/click_tool.rb +4 -2
  34. data/lib/shared_tools/tools/browser/ferrum_driver.rb +119 -0
  35. data/lib/shared_tools/tools/browser/inspect_tool.rb +4 -2
  36. data/lib/shared_tools/tools/browser/page_inspect_tool.rb +4 -2
  37. data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +19 -7
  38. data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +4 -2
  39. data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +4 -2
  40. data/lib/shared_tools/tools/browser/visit_tool.rb +4 -2
  41. data/lib/shared_tools/tools/browser.rb +31 -2
  42. data/lib/shared_tools/tools/browser_tool.rb +14 -2
  43. data/lib/shared_tools/tools/clipboard_tool.rb +119 -0
  44. data/lib/shared_tools/tools/composite_analysis_tool.rb +60 -4
  45. data/lib/shared_tools/tools/computer/mac_driver.rb +37 -4
  46. data/lib/shared_tools/tools/computer_tool.rb +8 -2
  47. data/lib/shared_tools/tools/cron_tool.rb +332 -0
  48. data/lib/shared_tools/tools/current_date_time_tool.rb +88 -0
  49. data/lib/shared_tools/tools/data_science_kit.rb +63 -13
  50. data/lib/shared_tools/tools/database_tool.rb +8 -3
  51. data/lib/shared_tools/tools/dns_tool.rb +422 -0
  52. data/lib/shared_tools/tools/doc/docx_reader_tool.rb +107 -0
  53. data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +171 -0
  54. data/lib/shared_tools/tools/doc/text_reader_tool.rb +57 -0
  55. data/lib/shared_tools/tools/doc.rb +3 -0
  56. data/lib/shared_tools/tools/doc_tool.rb +101 -6
  57. data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
  58. data/lib/shared_tools/tools/enabler.rb +42 -0
  59. data/lib/shared_tools/tools/error_handling_tool.rb +3 -1
  60. data/lib/shared_tools/tools/notification/base_driver.rb +51 -0
  61. data/lib/shared_tools/tools/notification/linux_driver.rb +115 -0
  62. data/lib/shared_tools/tools/notification/mac_driver.rb +66 -0
  63. data/lib/shared_tools/tools/notification/null_driver.rb +29 -0
  64. data/lib/shared_tools/tools/notification.rb +12 -0
  65. data/lib/shared_tools/tools/notification_tool.rb +99 -0
  66. data/lib/shared_tools/tools/system_info_tool.rb +204 -0
  67. data/lib/shared_tools/tools/workflow_manager_tool.rb +32 -0
  68. data/lib/shared_tools/utilities.rb +193 -0
  69. data/lib/shared_tools/version.rb +1 -1
  70. data/lib/shared_tools/weather_tool.rb +4 -0
  71. data/lib/shared_tools/workflow_manager_tool.rb +4 -0
  72. data/lib/shared_tools.rb +42 -11
  73. metadata +79 -9
  74. data/lib/shared_tools/mcp/github_mcp_server.rb +0 -58
  75. data/lib/shared_tools/mcp/imcp.rb +0 -28
  76. data/lib/shared_tools/mcp/tavily_mcp_server.rb +0 -44
  77. 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: true
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:, **parameters)
94
+ def execute(analysis_type:, data_source: nil, data: nil, **parameters)
86
95
  analysis_start = Time.now
87
96
 
88
97
  begin
89
- @logger.info("DataScienceKit#execute analysis_type=#{analysis_type} data_source=#{data_source}")
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
- data = load_data(data_source)
103
- validate_data_for_analysis(data, analysis_type, parameters)
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(data, parameters)
124
+ generate_statistical_summary(loaded_data, parameters)
109
125
  when "correlation_analysis"
110
- perform_correlation_analysis(data, parameters)
126
+ perform_correlation_analysis(loaded_data, parameters)
111
127
  when "time_series"
112
- analyze_time_series(data, parameters)
128
+ analyze_time_series(loaded_data, parameters)
113
129
  when "clustering"
114
- perform_clustering(data, parameters)
130
+ perform_clustering(loaded_data, parameters)
115
131
  when "prediction"
116
- generate_predictions(data, parameters)
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(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] required database driver (SqliteDriver, PostgresDriver, etc.)
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:, logger: nil)
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:)