signalwire-sdk 2.0.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.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +259 -0
  4. data/bin/swaig-test +872 -0
  5. data/lib/signalwire/agent/agent_base.rb +2134 -0
  6. data/lib/signalwire/contexts/context_builder.rb +861 -0
  7. data/lib/signalwire/core/logging_config.rb +54 -0
  8. data/lib/signalwire/datamap/data_map.rb +315 -0
  9. data/lib/signalwire/logging.rb +92 -0
  10. data/lib/signalwire/pom/prompt_object_model.rb +269 -0
  11. data/lib/signalwire/pom/section.rb +202 -0
  12. data/lib/signalwire/prefabs/concierge.rb +92 -0
  13. data/lib/signalwire/prefabs/faq_bot.rb +67 -0
  14. data/lib/signalwire/prefabs/info_gatherer.rb +79 -0
  15. data/lib/signalwire/prefabs/receptionist.rb +74 -0
  16. data/lib/signalwire/prefabs/survey.rb +75 -0
  17. data/lib/signalwire/relay/action.rb +291 -0
  18. data/lib/signalwire/relay/call.rb +523 -0
  19. data/lib/signalwire/relay/client.rb +789 -0
  20. data/lib/signalwire/relay/constants.rb +124 -0
  21. data/lib/signalwire/relay/message.rb +137 -0
  22. data/lib/signalwire/relay/relay_event.rb +670 -0
  23. data/lib/signalwire/rest/http_client.rb +159 -0
  24. data/lib/signalwire/rest/namespaces/addresses.rb +19 -0
  25. data/lib/signalwire/rest/namespaces/calling.rb +179 -0
  26. data/lib/signalwire/rest/namespaces/chat.rb +18 -0
  27. data/lib/signalwire/rest/namespaces/compat.rb +229 -0
  28. data/lib/signalwire/rest/namespaces/datasphere.rb +39 -0
  29. data/lib/signalwire/rest/namespaces/fabric.rb +235 -0
  30. data/lib/signalwire/rest/namespaces/imported_numbers.rb +18 -0
  31. data/lib/signalwire/rest/namespaces/logs.rb +46 -0
  32. data/lib/signalwire/rest/namespaces/lookup.rb +18 -0
  33. data/lib/signalwire/rest/namespaces/mfa.rb +26 -0
  34. data/lib/signalwire/rest/namespaces/number_groups.rb +32 -0
  35. data/lib/signalwire/rest/namespaces/phone_numbers.rb +124 -0
  36. data/lib/signalwire/rest/namespaces/project.rb +33 -0
  37. data/lib/signalwire/rest/namespaces/pubsub.rb +18 -0
  38. data/lib/signalwire/rest/namespaces/queues.rb +28 -0
  39. data/lib/signalwire/rest/namespaces/recordings.rb +18 -0
  40. data/lib/signalwire/rest/namespaces/registry.rb +67 -0
  41. data/lib/signalwire/rest/namespaces/short_codes.rb +26 -0
  42. data/lib/signalwire/rest/namespaces/sip_profile.rb +22 -0
  43. data/lib/signalwire/rest/namespaces/verified_callers.rb +24 -0
  44. data/lib/signalwire/rest/namespaces/video.rb +129 -0
  45. data/lib/signalwire/rest/pagination.rb +89 -0
  46. data/lib/signalwire/rest/phone_call_handler.rb +56 -0
  47. data/lib/signalwire/rest/rest_client.rb +114 -0
  48. data/lib/signalwire/runtime.rb +98 -0
  49. data/lib/signalwire/security/session_manager.rb +124 -0
  50. data/lib/signalwire/security/webhook_middleware.rb +191 -0
  51. data/lib/signalwire/security/webhook_validator.rb +327 -0
  52. data/lib/signalwire/server/agent_server.rb +413 -0
  53. data/lib/signalwire/serverless/lambda_handler.rb +251 -0
  54. data/lib/signalwire/skills/builtin/api_ninjas_trivia.rb +99 -0
  55. data/lib/signalwire/skills/builtin/claude_skills.rb +92 -0
  56. data/lib/signalwire/skills/builtin/custom_skills.rb +54 -0
  57. data/lib/signalwire/skills/builtin/datasphere.rb +153 -0
  58. data/lib/signalwire/skills/builtin/datasphere_serverless.rb +107 -0
  59. data/lib/signalwire/skills/builtin/datetime.rb +97 -0
  60. data/lib/signalwire/skills/builtin/google_maps.rb +168 -0
  61. data/lib/signalwire/skills/builtin/info_gatherer.rb +189 -0
  62. data/lib/signalwire/skills/builtin/joke.rb +65 -0
  63. data/lib/signalwire/skills/builtin/math.rb +176 -0
  64. data/lib/signalwire/skills/builtin/mcp_gateway.rb +121 -0
  65. data/lib/signalwire/skills/builtin/native_vector_search.rb +116 -0
  66. data/lib/signalwire/skills/builtin/play_background_file.rb +86 -0
  67. data/lib/signalwire/skills/builtin/spider.rb +169 -0
  68. data/lib/signalwire/skills/builtin/swml_transfer.rb +118 -0
  69. data/lib/signalwire/skills/builtin/weather_api.rb +92 -0
  70. data/lib/signalwire/skills/builtin/web_search.rb +141 -0
  71. data/lib/signalwire/skills/builtin/wikipedia_search.rb +125 -0
  72. data/lib/signalwire/skills/skill_base.rb +82 -0
  73. data/lib/signalwire/skills/skill_manager.rb +97 -0
  74. data/lib/signalwire/skills/skill_registry.rb +258 -0
  75. data/lib/signalwire/swaig/function_result.rb +777 -0
  76. data/lib/signalwire/swml/document.rb +84 -0
  77. data/lib/signalwire/swml/schema.json +12250 -0
  78. data/lib/signalwire/swml/schema.rb +81 -0
  79. data/lib/signalwire/swml/service.rb +650 -0
  80. data/lib/signalwire/utils/schema_utils.rb +298 -0
  81. data/lib/signalwire/utils/serverless.rb +19 -0
  82. data/lib/signalwire/utils/url_validator.rb +138 -0
  83. data/lib/signalwire/version.rb +5 -0
  84. data/lib/signalwire.rb +114 -0
  85. metadata +225 -0
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ require_relative '../skill_base'
8
+ require_relative '../skill_registry'
9
+
10
+ module SignalWire
11
+ module Skills
12
+ module Builtin
13
+ class GoogleMapsSkill < SkillBase
14
+ def name; 'google_maps'; end
15
+ def description; 'Validate addresses and compute driving routes using Google Maps'; end
16
+
17
+ def setup
18
+ @api_key = get_param('api_key', env_var: 'GOOGLE_MAPS_API_KEY')
19
+ @lookup_tool = get_param('lookup_tool_name', default: 'lookup_address')
20
+ @route_tool = get_param('route_tool_name', default: 'compute_route')
21
+ return false unless @api_key && !@api_key.empty?
22
+ true
23
+ end
24
+
25
+ def register_tools
26
+ [
27
+ {
28
+ name: @lookup_tool,
29
+ description: 'Validate and geocode a street address or business name using Google Maps',
30
+ parameters: {
31
+ 'address' => { 'type' => 'string', 'description' => 'The address or business name to look up' },
32
+ 'bias_lat' => { 'type' => 'number', 'description' => 'Latitude to bias results toward (optional)' },
33
+ 'bias_lng' => { 'type' => 'number', 'description' => 'Longitude to bias results toward (optional)' }
34
+ },
35
+ handler: method(:handle_lookup)
36
+ },
37
+ {
38
+ name: @route_tool,
39
+ description: 'Compute a driving route between two points using Google Maps Routes API',
40
+ parameters: {
41
+ 'origin_lat' => { 'type' => 'number', 'description' => 'Origin latitude' },
42
+ 'origin_lng' => { 'type' => 'number', 'description' => 'Origin longitude' },
43
+ 'dest_lat' => { 'type' => 'number', 'description' => 'Destination latitude' },
44
+ 'dest_lng' => { 'type' => 'number', 'description' => 'Destination longitude' }
45
+ },
46
+ handler: method(:handle_route)
47
+ }
48
+ ]
49
+ end
50
+
51
+ def get_hints
52
+ %w[address location route directions miles distance]
53
+ end
54
+
55
+ def get_prompt_sections
56
+ [
57
+ {
58
+ 'title' => 'Google Maps',
59
+ 'body' => 'You can validate addresses and compute driving routes.',
60
+ 'bullets' => [
61
+ "Use #{@lookup_tool} to validate and geocode addresses or business names",
62
+ "Use #{@route_tool} to get driving distance and time between two points",
63
+ "Address lookup supports spoken numbers (e.g. 'seven one four' becomes '714')",
64
+ 'You can bias address results toward a known location to find the nearest match'
65
+ ]
66
+ }
67
+ ]
68
+ end
69
+
70
+ def get_parameter_schema
71
+ {
72
+ 'api_key' => { 'type' => 'string', 'required' => true, 'hidden' => true, 'env_var' => 'GOOGLE_MAPS_API_KEY' },
73
+ 'lookup_tool_name' => { 'type' => 'string', 'default' => 'lookup_address' },
74
+ 'route_tool_name' => { 'type' => 'string', 'default' => 'compute_route' }
75
+ }
76
+ end
77
+
78
+ private
79
+
80
+ def handle_lookup(args, _raw_data)
81
+ address = (args['address'] || '').strip
82
+ if address.empty?
83
+ return Swaig::FunctionResult.new('Please provide an address or business name to look up.')
84
+ end
85
+
86
+ bias_lat = args['bias_lat']
87
+ bias_lng = args['bias_lng']
88
+
89
+ # Use Geocoding API
90
+ params = { address: address, key: @api_key }
91
+ uri = URI('https://maps.googleapis.com/maps/api/geocode/json')
92
+ uri.query = URI.encode_www_form(params)
93
+
94
+ resp = Net::HTTP.get_response(uri)
95
+ unless resp.is_a?(Net::HTTPSuccess)
96
+ return Swaig::FunctionResult.new("I couldn't find that address. Could you provide a more specific address?")
97
+ end
98
+
99
+ data = JSON.parse(resp.body)
100
+ results = data['results'] || []
101
+ if results.empty?
102
+ return Swaig::FunctionResult.new("I couldn't find that address. Could you provide a more specific address?")
103
+ end
104
+
105
+ r = results.first
106
+ location = r.dig('geometry', 'location') || {}
107
+ formatted = r['formatted_address'] || address
108
+
109
+ Swaig::FunctionResult.new(
110
+ "Address: #{formatted}\nCoordinates: #{location['lat']}, #{location['lng']}"
111
+ )
112
+ rescue => e
113
+ Swaig::FunctionResult.new("Error looking up address: #{e.message}")
114
+ end
115
+
116
+ def handle_route(args, _raw_data)
117
+ origin_lat = args['origin_lat']
118
+ origin_lng = args['origin_lng']
119
+ dest_lat = args['dest_lat']
120
+ dest_lng = args['dest_lng']
121
+
122
+ if [origin_lat, origin_lng, dest_lat, dest_lng].any?(&:nil?)
123
+ return Swaig::FunctionResult.new('All four coordinates are required: origin_lat, origin_lng, dest_lat, dest_lng.')
124
+ end
125
+
126
+ uri = URI('https://routes.googleapis.com/directions/v2:computeRoutes')
127
+ http = Net::HTTP.new(uri.host, uri.port)
128
+ http.use_ssl = true
129
+
130
+ request = Net::HTTP::Post.new(uri.path)
131
+ request['Content-Type'] = 'application/json'
132
+ request['X-Goog-Api-Key'] = @api_key
133
+ request['X-Goog-FieldMask'] = 'routes.distanceMeters,routes.duration'
134
+ request.body = {
135
+ origin: { location: { latLng: { latitude: origin_lat, longitude: origin_lng } } },
136
+ destination: { location: { latLng: { latitude: dest_lat, longitude: dest_lng } } },
137
+ travelMode: 'DRIVE',
138
+ routingPreference: 'TRAFFIC_AWARE'
139
+ }.to_json
140
+
141
+ resp = http.request(request)
142
+ data = JSON.parse(resp.body)
143
+
144
+ routes = data['routes'] || []
145
+ if routes.empty?
146
+ return Swaig::FunctionResult.new("I couldn't compute a route between those locations.")
147
+ end
148
+
149
+ route = routes.first
150
+ distance_m = route['distanceMeters'] || 0
151
+ duration_s = (route['duration'] || '0s').to_s.delete('s').to_i
152
+ distance_mi = distance_m / 1609.344
153
+ duration_min = duration_s / 60.0
154
+
155
+ Swaig::FunctionResult.new(
156
+ "Distance: #{'%.1f' % distance_mi} miles\nEstimated travel time: #{duration_min.to_i} minutes"
157
+ )
158
+ rescue => e
159
+ Swaig::FunctionResult.new("Error computing route: #{e.message}")
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
165
+
166
+ SignalWire::Skills::SkillRegistry.register('google_maps') do |params|
167
+ SignalWire::Skills::Builtin::GoogleMapsSkill.new(params)
168
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../skill_base'
4
+ require_relative '../skill_registry'
5
+
6
+ module SignalWire
7
+ module Skills
8
+ module Builtin
9
+ class InfoGathererSkill < SkillBase
10
+ def name; 'info_gatherer'; end
11
+ def description; 'Gather answers to a configurable list of questions'; end
12
+ def supports_multiple_instances?; true; end
13
+
14
+ def setup
15
+ @questions = get_param('questions')
16
+ return false unless @questions.is_a?(Array) && !@questions.empty?
17
+
18
+ @questions.each_with_index do |q, i|
19
+ return false unless q.is_a?(Hash) && q['key_name'] && q['question_text']
20
+ end
21
+
22
+ prefix = get_param('prefix')
23
+ if prefix && !prefix.empty?
24
+ @start_tool = "#{prefix}_start_questions"
25
+ @submit_tool = "#{prefix}_submit_answer"
26
+ @namespace = "skill:#{prefix}"
27
+ else
28
+ @start_tool = 'start_questions'
29
+ @submit_tool = 'submit_answer'
30
+ @namespace = 'skill:info_gatherer'
31
+ end
32
+
33
+ @completion_message = get_param('completion_message',
34
+ default: 'Thank you! All questions have been answered.')
35
+ true
36
+ end
37
+
38
+ def instance_key
39
+ prefix = get_param('prefix')
40
+ prefix && !prefix.to_s.empty? ? "info_gatherer_#{prefix}" : 'info_gatherer'
41
+ end
42
+
43
+ def register_tools
44
+ [
45
+ {
46
+ name: @start_tool,
47
+ description: 'Start the question sequence with the first question',
48
+ parameters: {},
49
+ handler: method(:handle_start)
50
+ },
51
+ {
52
+ name: @submit_tool,
53
+ description: 'Submit an answer to the current question and move to the next one',
54
+ parameters: {
55
+ 'answer' => { 'type' => 'string', 'description' => "The user's answer to the current question" },
56
+ 'confirmed_by_user' => { 'type' => 'boolean', 'description' => 'Only set to true when the user has explicitly confirmed the answer.' }
57
+ },
58
+ handler: method(:handle_submit)
59
+ }
60
+ ]
61
+ end
62
+
63
+ def get_global_data
64
+ {
65
+ @namespace => {
66
+ 'questions' => @questions,
67
+ 'question_index' => 0,
68
+ 'answers' => []
69
+ }
70
+ }
71
+ end
72
+
73
+ def get_prompt_sections
74
+ [
75
+ {
76
+ 'title' => "Info Gatherer (#{instance_key})",
77
+ 'body' => "You need to gather answers to a series of questions from the user. " \
78
+ "Start by asking if they are ready, then call #{@start_tool} to get the first question. " \
79
+ "After each answer, call #{@submit_tool} to record it and get the next question."
80
+ }
81
+ ]
82
+ end
83
+
84
+ def get_parameter_schema
85
+ {
86
+ 'questions' => { 'type' => 'array', 'required' => true },
87
+ 'prefix' => { 'type' => 'string' },
88
+ 'completion_message' => { 'type' => 'string' }
89
+ }
90
+ end
91
+
92
+ private
93
+
94
+ def handle_start(args, raw_data)
95
+ state = extract_state(raw_data)
96
+ questions = state['questions'] || @questions
97
+ index = state['question_index'] || 0
98
+
99
+ if questions.empty? || index >= questions.size
100
+ return Swaig::FunctionResult.new("I don't have any questions to ask.")
101
+ end
102
+
103
+ current = questions[index]
104
+ instruction = generate_instruction(current, index, questions.size, true)
105
+ Swaig::FunctionResult.new(instruction)
106
+ end
107
+
108
+ def handle_submit(args, raw_data)
109
+ answer = args['answer'] || ''
110
+ confirmed = args['confirmed_by_user']
111
+
112
+ state = extract_state(raw_data)
113
+ questions = state['questions'] || @questions
114
+ index = state['question_index'] || 0
115
+ answers = state['answers'] || []
116
+
117
+ if index >= questions.size
118
+ return Swaig::FunctionResult.new('All questions have already been answered.')
119
+ end
120
+
121
+ current = questions[index]
122
+
123
+ if current['confirm'] && !confirmed
124
+ return Swaig::FunctionResult.new(
125
+ "Before submitting, read the answer \"#{answer}\" back to the user and ask them to confirm."
126
+ )
127
+ end
128
+
129
+ new_answers = answers + [{ 'key_name' => current['key_name'], 'answer' => answer }]
130
+ new_index = index + 1
131
+
132
+ if new_index < questions.size
133
+ next_q = questions[new_index]
134
+ instruction = generate_instruction(next_q, new_index, questions.size, false)
135
+ result = Swaig::FunctionResult.new(instruction)
136
+ else
137
+ result = Swaig::FunctionResult.new(@completion_message)
138
+ result.toggle_functions([
139
+ { 'function' => @start_tool, 'active' => false },
140
+ { 'function' => @submit_tool, 'active' => false }
141
+ ])
142
+ end
143
+
144
+ result.update_global_data({
145
+ @namespace => {
146
+ 'questions' => questions,
147
+ 'question_index' => new_index,
148
+ 'answers' => new_answers
149
+ }
150
+ })
151
+ result
152
+ end
153
+
154
+ def extract_state(raw_data)
155
+ return {} unless raw_data.is_a?(Hash)
156
+ gd = raw_data['global_data'] || {}
157
+ gd[@namespace] || {}
158
+ end
159
+
160
+ def generate_instruction(question, index, total, first)
161
+ text = question['question_text']
162
+ num = index + 1
163
+
164
+ if first
165
+ instr = "Ask each question one at a time, wait for the user's answer, " \
166
+ "then call #{@submit_tool} with their answer.\n\n" \
167
+ "[Question #{num} of #{total}]: \"#{text}\""
168
+ else
169
+ instr = "Previous answer saved. [Question #{num} of #{total}]: \"#{text}\""
170
+ end
171
+
172
+ if question['prompt_add'] && !question['prompt_add'].empty?
173
+ instr += "\nNote: #{question['prompt_add']}"
174
+ end
175
+
176
+ if question['confirm']
177
+ instr += "\nThis question requires confirmation. Read the answer back and ask the user to confirm."
178
+ end
179
+
180
+ instr
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
186
+
187
+ SignalWire::Skills::SkillRegistry.register('info_gatherer') do |params|
188
+ SignalWire::Skills::Builtin::InfoGathererSkill.new(params)
189
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../skill_base'
4
+ require_relative '../skill_registry'
5
+ require_relative '../../datamap/data_map'
6
+
7
+ module SignalWire
8
+ module Skills
9
+ module Builtin
10
+ class JokeSkill < SkillBase
11
+ def name; 'joke'; end
12
+ def description; 'Tell jokes using the API Ninjas joke API'; end
13
+
14
+ def setup
15
+ @api_key = get_param('api_key', env_var: 'API_NINJAS_KEY')
16
+ @tool_name = get_param('tool_name', default: 'get_joke')
17
+ return false unless @api_key && !@api_key.empty?
18
+ true
19
+ end
20
+
21
+ def register_tools
22
+ dm = DataMap.new(@tool_name)
23
+ .description('Get a random joke from API Ninjas')
24
+ .parameter('type', 'string', 'Type of joke to get', required: true, enum: %w[jokes dadjokes])
25
+ .webhook('GET', "https://api.api-ninjas.com/v1/${args.type}",
26
+ headers: { 'X-Api-Key' => @api_key })
27
+ .output(Swaig::FunctionResult.new("Here's a joke: ${array[0].joke}"))
28
+ .error_keys(%w[error])
29
+ .fallback_output(Swaig::FunctionResult.new('Sorry, there is a problem with the joke service right now. Please try again later.'))
30
+
31
+ [{ datamap: dm.to_swaig_function }]
32
+ end
33
+
34
+ def get_global_data
35
+ { 'joke_skill_enabled' => true }
36
+ end
37
+
38
+ def get_prompt_sections
39
+ [
40
+ {
41
+ 'title' => 'Joke Telling',
42
+ 'body' => 'You can tell jokes to entertain users.',
43
+ 'bullets' => [
44
+ "Use #{@tool_name || 'get_joke'} to tell jokes when users ask for humor",
45
+ 'You can tell regular jokes or dad jokes',
46
+ 'Be enthusiastic and fun when sharing jokes'
47
+ ]
48
+ }
49
+ ]
50
+ end
51
+
52
+ def get_parameter_schema
53
+ {
54
+ 'api_key' => { 'type' => 'string', 'required' => true, 'hidden' => true, 'env_var' => 'API_NINJAS_KEY' },
55
+ 'tool_name' => { 'type' => 'string', 'default' => 'get_joke' }
56
+ }
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ SignalWire::Skills::SkillRegistry.register('joke') do |params|
64
+ SignalWire::Skills::Builtin::JokeSkill.new(params)
65
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../skill_base'
4
+ require_relative '../skill_registry'
5
+
6
+ module SignalWire
7
+ module Skills
8
+ module Builtin
9
+ class MathSkill < SkillBase
10
+ def name; 'math'; end
11
+ def description; 'Perform basic mathematical calculations'; end
12
+
13
+ def register_tools
14
+ [
15
+ {
16
+ name: 'calculate',
17
+ description: 'Perform a mathematical calculation with basic operations (+, -, *, /, %, **)',
18
+ parameters: {
19
+ 'expression' => { 'type' => 'string', 'description' => "Mathematical expression to evaluate (e.g., '2 + 3 * 4', '(10 + 5) / 3')" }
20
+ },
21
+ handler: method(:handle_calculate)
22
+ }
23
+ ]
24
+ end
25
+
26
+ def get_prompt_sections
27
+ [
28
+ {
29
+ 'title' => 'Mathematical Calculations',
30
+ 'body' => 'You can perform mathematical calculations for users.',
31
+ 'bullets' => [
32
+ 'Use the calculate tool for any math expressions',
33
+ 'Supports basic operations: +, -, *, /, %, ** (power)',
34
+ 'Can handle parentheses for complex expressions'
35
+ ]
36
+ }
37
+ ]
38
+ end
39
+
40
+ private
41
+
42
+ # Safe expression evaluator. Only allows numbers and basic operators.
43
+ # Never calls eval on untrusted input.
44
+ def handle_calculate(args, _raw_data)
45
+ expression = (args['expression'] || '').strip
46
+ if expression.empty?
47
+ return Swaig::FunctionResult.new('Please provide a mathematical expression to calculate.')
48
+ end
49
+
50
+ result = safe_eval(expression)
51
+ Swaig::FunctionResult.new("#{expression} = #{result}")
52
+ rescue ZeroDivisionError
53
+ Swaig::FunctionResult.new('Error: Division by zero is not allowed.')
54
+ rescue => _e
55
+ Swaig::FunctionResult.new('Error: Invalid expression. Only numbers and basic math operators (+, -, *, /, %, **, parentheses) are allowed.')
56
+ end
57
+
58
+ # Tokenize, parse, and evaluate a mathematical expression safely.
59
+ # This uses a simple recursive-descent parser — no eval/exec.
60
+ def safe_eval(expr)
61
+ tokens = tokenize(expr)
62
+ pos = [0]
63
+ result = parse_expr(tokens, pos)
64
+ raise 'Unexpected tokens after expression' unless pos[0] >= tokens.length
65
+ result
66
+ end
67
+
68
+ def tokenize(expr)
69
+ tokens = []
70
+ i = 0
71
+ while i < expr.length
72
+ ch = expr[i]
73
+ if ch =~ /\s/
74
+ i += 1
75
+ elsif ch =~ /[\d.]/ || (ch == '-' && (tokens.empty? || %w[( + - * / % **].include?(tokens.last)))
76
+ num_str = +''
77
+ if ch == '-'
78
+ num_str << ch
79
+ i += 1
80
+ end
81
+ while i < expr.length && expr[i] =~ /[\d.]/
82
+ num_str << expr[i]
83
+ i += 1
84
+ end
85
+ tokens << num_str
86
+ elsif ch == '*' && i + 1 < expr.length && expr[i + 1] == '*'
87
+ tokens << '**'
88
+ i += 2
89
+ elsif '+-*/%()'.include?(ch)
90
+ tokens << ch
91
+ i += 1
92
+ else
93
+ raise "Invalid character: #{ch}"
94
+ end
95
+ end
96
+ tokens
97
+ end
98
+
99
+ def parse_expr(tokens, pos)
100
+ left = parse_term(tokens, pos)
101
+ while pos[0] < tokens.length && %w[+ -].include?(tokens[pos[0]])
102
+ op = tokens[pos[0]]
103
+ pos[0] += 1
104
+ right = parse_term(tokens, pos)
105
+ left = op == '+' ? left + right : left - right
106
+ end
107
+ left
108
+ end
109
+
110
+ def parse_term(tokens, pos)
111
+ left = parse_power(tokens, pos)
112
+ while pos[0] < tokens.length && %w[* / %].include?(tokens[pos[0]])
113
+ op = tokens[pos[0]]
114
+ pos[0] += 1
115
+ right = parse_power(tokens, pos)
116
+ case op
117
+ when '*' then left *= right
118
+ when '/'
119
+ raise ZeroDivisionError, 'division by zero' if right == 0
120
+ left = left.to_f / right
121
+ when '%'
122
+ raise ZeroDivisionError, 'division by zero' if right == 0
123
+ left %= right
124
+ end
125
+ end
126
+ left
127
+ end
128
+
129
+ def parse_power(tokens, pos)
130
+ base = parse_unary(tokens, pos)
131
+ if pos[0] < tokens.length && tokens[pos[0]] == '**'
132
+ pos[0] += 1
133
+ exp = parse_power(tokens, pos) # right-associative
134
+ raise 'Exponent too large (maximum is 1000)' if exp.is_a?(Numeric) && exp > 1000
135
+ base **= exp
136
+ end
137
+ base
138
+ end
139
+
140
+ def parse_unary(tokens, pos)
141
+ if pos[0] < tokens.length && tokens[pos[0]] == '-'
142
+ pos[0] += 1
143
+ -parse_atom(tokens, pos)
144
+ elsif pos[0] < tokens.length && tokens[pos[0]] == '+'
145
+ pos[0] += 1
146
+ parse_atom(tokens, pos)
147
+ else
148
+ parse_atom(tokens, pos)
149
+ end
150
+ end
151
+
152
+ def parse_atom(tokens, pos)
153
+ raise 'Unexpected end of expression' if pos[0] >= tokens.length
154
+
155
+ tok = tokens[pos[0]]
156
+ if tok == '('
157
+ pos[0] += 1
158
+ val = parse_expr(tokens, pos)
159
+ raise 'Missing closing parenthesis' unless pos[0] < tokens.length && tokens[pos[0]] == ')'
160
+ pos[0] += 1
161
+ val
162
+ elsif tok =~ /\A-?[\d.]+\z/
163
+ pos[0] += 1
164
+ tok.include?('.') ? tok.to_f : tok.to_i
165
+ else
166
+ raise "Unexpected token: #{tok}"
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+
174
+ SignalWire::Skills::SkillRegistry.register('math') do |params|
175
+ SignalWire::Skills::Builtin::MathSkill.new(params)
176
+ end