prescient 0.7.0 → 0.8.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +21 -268
  3. data/CHANGELOG.md +37 -0
  4. data/INTEGRATION_GUIDE.md +7 -1
  5. data/README.md +210 -1
  6. data/Steepfile +12 -12
  7. data/db/migrate/001_create_prescient_tables.rb +15 -16
  8. data/examples/README.md +2 -1
  9. data/examples/custom_contexts.rb +4 -4
  10. data/exe/prescient +2 -2
  11. data/exe/prescient-mcp +7 -0
  12. data/lib/prescient/agent/audit_log.rb +37 -0
  13. data/lib/prescient/agent/cli_adapter.rb +29 -0
  14. data/lib/prescient/agent/configuration.rb +57 -0
  15. data/lib/prescient/agent/context.rb +56 -0
  16. data/lib/prescient/agent/error_serializer.rb +47 -0
  17. data/lib/prescient/agent/errors.rb +25 -0
  18. data/lib/prescient/agent/parser.rb +49 -0
  19. data/lib/prescient/agent/prompt_builder.rb +31 -0
  20. data/lib/prescient/agent/result.rb +36 -0
  21. data/lib/prescient/agent/runtime.rb +175 -0
  22. data/lib/prescient/agent/schema_validator.rb +215 -0
  23. data/lib/prescient/agent/tool_registry.rb +89 -0
  24. data/lib/prescient/agent.rb +22 -0
  25. data/lib/prescient/api.rb +337 -274
  26. data/lib/prescient/base.rb +370 -372
  27. data/lib/prescient/cli.rb +586 -526
  28. data/lib/prescient/client.rb +7 -6
  29. data/lib/prescient/configuration_loader.rb +492 -488
  30. data/lib/prescient/document_source.rb +114 -0
  31. data/lib/prescient/errors.rb +1 -3
  32. data/lib/prescient/mcp/authentication.rb +39 -0
  33. data/lib/prescient/mcp/configuration.rb +38 -0
  34. data/lib/prescient/mcp/rack.rb +243 -0
  35. data/lib/prescient/mcp/server.rb +202 -0
  36. data/lib/prescient/mcp/stdio.rb +42 -0
  37. data/lib/prescient/mcp.rb +8 -0
  38. data/lib/prescient/pgvector.rb +193 -189
  39. data/lib/prescient/provider/anthropic.rb +129 -125
  40. data/lib/prescient/provider/deepseek.rb +122 -118
  41. data/lib/prescient/provider/gemini.rb +153 -149
  42. data/lib/prescient/provider/huggingface.rb +191 -187
  43. data/lib/prescient/provider/mistral.rb +151 -147
  44. data/lib/prescient/provider/ollama.rb +168 -165
  45. data/lib/prescient/provider/openai.rb +174 -169
  46. data/lib/prescient/provider/xai.rb +122 -118
  47. data/lib/prescient/tool/search_api.rb +125 -121
  48. data/lib/prescient/tool/searxng.rb +123 -119
  49. data/lib/prescient/tool.rb +100 -98
  50. data/lib/prescient/version.rb +1 -1
  51. data/lib/prescient.rb +68 -62
  52. data/sig/prescient.rbs +176 -1
  53. metadata +23 -1
@@ -1,126 +1,130 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'httparty'
4
- require 'net/http'
5
- require 'timeout'
6
-
7
- # SearchApi web-search tool adapter.
8
- class Prescient::Tool::SearchApi < Prescient::Tool::Base
9
- # SearchApi's stable search endpoint.
10
- API_URL = 'https://www.searchapi.io/api/v1/search'
11
- # Network failures that should be reported as connection errors.
12
- NETWORK_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Timeout::Error, SocketError,
13
- Errno::ECONNREFUSED].freeze
14
-
15
- # @param options [Hash] SearchApi configuration options
16
- # @option options [String] :api_key SearchApi API key
17
- # @option options [String] :engine SearchApi engine, defaulting to Google
18
- # @option options [String] :location Optional search location
19
- # @option options [String] :hl Optional interface language
20
- # @option options [String] :gl Optional country code
21
- def initialize(**options)
22
- super
23
- @api_key = required_api_key(options[:api_key])
24
- end
25
-
26
- # Search SearchApi and return normalized result metadata.
27
- # @param query [String] Search query
28
- # @param limit [Integer, nil] Maximum number of results
29
- # @param timeout [Numeric, nil] Request timeout in seconds
30
- # @return [Hash] Normalized search result envelope
31
- def search(query, limit: nil, timeout: nil)
32
- normalized_query = validate_query(query)
33
- requested_limit = result_limit(limit)
34
- requested_timeout = request_timeout(timeout)
35
- response = HTTParty.get(
36
- API_URL,
37
- headers: request_headers,
38
- query: request_parameters(normalized_query, requested_limit),
39
- timeout: requested_timeout,
40
- )
41
- validate_response!(response)
42
-
43
- {
44
- tool: 'web_search',
45
- query: normalized_query,
46
- source: 'searchapi',
47
- results: normalize_results(response.parsed_response, requested_limit),
48
- }
49
- rescue Prescient::Error
50
- raise
51
- rescue *NETWORK_ERRORS => e
52
- raise Prescient::ToolConnectionError, "SearchApi request failed: #{e.class}"
53
- rescue StandardError => e
54
- raise Prescient::ToolError, "SearchApi request failed: #{e.message}"
55
- end
56
-
57
- protected
58
-
59
- # Validate SearchApi-specific configuration.
60
- # @return [void]
61
- def validate_configuration!
62
- required_api_key(@options[:api_key])
63
- max_response_bytes
64
- result_limit(nil)
65
- request_timeout(nil)
66
- end
67
-
68
- private
69
-
70
- def required_api_key(value)
71
- return value if value.is_a?(String) && !value.empty?
72
-
73
- raise Prescient::ToolConfigurationError, 'SearchApi requires an api_key'
74
- end
75
-
76
- def request_headers
77
- { 'Authorization' => "Bearer #{@api_key}" }
78
- end
79
-
80
- def request_parameters(query, limit)
81
- parameters = {
82
- engine: @options.fetch(:engine, 'google'),
83
- q: query,
84
- num: limit,
85
- }
86
- [:location, :hl, :gl].each do |key|
87
- parameters[key] = @options[key] if @options[key]
88
- end
89
- parameters
90
- end
91
-
92
- def validate_response!(response)
93
- if response.respond_to?(:body) && response.body.to_s.bytesize > max_response_bytes
94
- raise Prescient::ToolInvalidResponseError, 'SearchApi response exceeds configured size limit'
3
+ require "httparty"
4
+ require "net/http"
5
+ require "timeout"
6
+
7
+ module Prescient
8
+ module Tool
9
+ # SearchApi web-search tool adapter.
10
+ class SearchApi < Prescient::Tool::Base
11
+ # SearchApi's stable search endpoint.
12
+ API_URL = "https://www.searchapi.io/api/v1/search"
13
+ # Network failures that should be reported as connection errors.
14
+ NETWORK_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Timeout::Error, SocketError,
15
+ Errno::ECONNREFUSED].freeze
16
+
17
+ # @param options [Hash] SearchApi configuration options
18
+ # @option options [String] :api_key SearchApi API key
19
+ # @option options [String] :engine SearchApi engine, defaulting to Google
20
+ # @option options [String] :location Optional search location
21
+ # @option options [String] :hl Optional interface language
22
+ # @option options [String] :gl Optional country code
23
+ def initialize(**options)
24
+ super
25
+ @api_key = required_api_key(options[:api_key])
26
+ end
27
+
28
+ # Search SearchApi and return normalized result metadata.
29
+ # @param query [String] Search query
30
+ # @param limit [Integer, nil] Maximum number of results
31
+ # @param timeout [Numeric, nil] Request timeout in seconds
32
+ # @return [Hash] Normalized search result envelope
33
+ def search(query, limit: nil, timeout: nil)
34
+ normalized_query = validate_query(query)
35
+ requested_limit = result_limit(limit)
36
+ requested_timeout = request_timeout(timeout)
37
+ response = HTTParty.get(
38
+ API_URL,
39
+ headers: request_headers,
40
+ query: request_parameters(normalized_query, requested_limit),
41
+ timeout: requested_timeout
42
+ )
43
+ validate_response!(response)
44
+
45
+ {
46
+ tool: "web_search",
47
+ query: normalized_query,
48
+ source: "searchapi",
49
+ results: normalize_results(response.parsed_response, requested_limit)
50
+ }
51
+ rescue Prescient::Error
52
+ raise
53
+ rescue *NETWORK_ERRORS => e
54
+ raise Prescient::ToolConnectionError, "SearchApi request failed: #{e.class}"
55
+ rescue StandardError => e
56
+ raise Prescient::ToolError, "SearchApi request failed: #{e.message}"
57
+ end
58
+
59
+ protected
60
+
61
+ # Validate SearchApi-specific configuration.
62
+ # @return [void]
63
+ def validate_configuration!
64
+ required_api_key(@options[:api_key])
65
+ max_response_bytes
66
+ result_limit(nil)
67
+ request_timeout(nil)
68
+ end
69
+
70
+ private
71
+
72
+ def required_api_key(value)
73
+ return value if value.is_a?(String) && !value.empty?
74
+
75
+ raise Prescient::ToolConfigurationError, "SearchApi requires an api_key"
76
+ end
77
+
78
+ def request_headers
79
+ { "Authorization" => "Bearer #{@api_key}" }
80
+ end
81
+
82
+ def request_parameters(query, limit)
83
+ parameters = {
84
+ engine: @options.fetch(:engine, "google"),
85
+ q: query,
86
+ num: limit
87
+ }
88
+ %i[location hl gl].each do |key|
89
+ parameters[key] = @options[key] if @options[key]
90
+ end
91
+ parameters
92
+ end
93
+
94
+ def validate_response!(response)
95
+ if response.respond_to?(:body) && response.body.to_s.bytesize > max_response_bytes
96
+ raise Prescient::ToolInvalidResponseError, "SearchApi response exceeds configured size limit"
97
+ end
98
+
99
+ return if response.success?
100
+
101
+ error_class = case response.code.to_i
102
+ when 401, 403 then Prescient::AuthenticationError
103
+ when 429 then Prescient::RateLimitError
104
+ when 500..599 then Prescient::ToolConnectionError
105
+ else Prescient::ToolError
106
+ end
107
+ raise error_class, "SearchApi returned HTTP #{response.code}"
108
+ end
109
+
110
+ def normalize_results(payload, limit)
111
+ raw_results = payload.is_a?(Hash) ? payload["organic_results"] : nil
112
+ unless raw_results.is_a?(Array)
113
+ raise Prescient::ToolInvalidResponseError, "SearchApi response did not contain organic_results"
114
+ end
115
+
116
+ raw_results.filter_map do |result|
117
+ next unless result.is_a?(Hash)
118
+ next unless result["link"].is_a?(String) && !result["link"].empty?
119
+
120
+ {
121
+ title: result["title"].to_s,
122
+ url: result["link"],
123
+ snippet: result["snippet"].to_s,
124
+ source: "searchapi"
125
+ }
126
+ end.first(limit)
127
+ end
95
128
  end
96
-
97
- return if response.success?
98
-
99
- error_class = case response.code.to_i
100
- when 401, 403 then Prescient::AuthenticationError
101
- when 429 then Prescient::RateLimitError
102
- when 500..599 then Prescient::ToolConnectionError
103
- else Prescient::ToolError
104
- end
105
- raise error_class, "SearchApi returned HTTP #{response.code}"
106
- end
107
-
108
- def normalize_results(payload, limit)
109
- raw_results = payload.is_a?(Hash) ? payload['organic_results'] : nil
110
- unless raw_results.is_a?(Array)
111
- raise Prescient::ToolInvalidResponseError, 'SearchApi response did not contain organic_results'
112
- end
113
-
114
- raw_results.filter_map { |result|
115
- next unless result.is_a?(Hash)
116
- next unless result['link'].is_a?(String) && !result['link'].empty?
117
-
118
- {
119
- title: result['title'].to_s,
120
- url: result['link'],
121
- snippet: result['snippet'].to_s,
122
- source: 'searchapi',
123
- }
124
- }.first(limit)
125
129
  end
126
130
  end
@@ -1,124 +1,128 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'httparty'
4
- require 'net/http'
5
- require 'timeout'
6
- require 'uri'
7
-
8
- # SearXNG web-search tool adapter.
9
- class Prescient::Tool::SearXNG < Prescient::Tool::Base
10
- # Network failures that should be reported as connection errors.
11
- NETWORK_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Timeout::Error, SocketError, Errno::ECONNREFUSED].freeze
12
-
13
- # @param options [Hash] SearXNG configuration options
14
- # @option options [String] :url SearXNG base URL
15
- # @option options [String] :language Optional search language
16
- # @option options [String, Array<String>] :categories Optional categories
17
- def initialize(**options)
18
- super
19
- @base_url = normalized_base_url(options.fetch(:url))
20
- end
21
-
22
- # Search SearXNG and return normalized result metadata.
23
- # @param query [String] Search query
24
- # @param limit [Integer, nil] Maximum number of results
25
- # @param timeout [Numeric, nil] Request timeout in seconds
26
- # @return [Hash] Normalized search result envelope
27
- def search(query, limit: nil, timeout: nil)
28
- normalized_query = validate_query(query)
29
- requested_limit = result_limit(limit)
30
- requested_timeout = request_timeout(timeout)
31
- response = HTTParty.get(
32
- search_url,
33
- query: request_parameters(normalized_query),
34
- timeout: requested_timeout,
35
- )
36
- validate_response!(response)
37
-
38
- {
39
- tool: 'web_search',
40
- query: normalized_query,
41
- source: 'searxng',
42
- results: normalize_results(response.parsed_response, requested_limit),
43
- }
44
- rescue Prescient::Error
45
- raise
46
- rescue *NETWORK_ERRORS => e
47
- raise Prescient::ToolConnectionError, "SearXNG request failed: #{e.class}"
48
- rescue StandardError => e
49
- raise Prescient::ToolError, "SearXNG request failed: #{e.message}"
50
- end
51
-
52
- protected
53
-
54
- # Validate SearXNG-specific configuration.
55
- # @return [void]
56
- def validate_configuration!
57
- normalized_base_url(@options.fetch(:url))
58
- max_response_bytes
59
- result_limit(nil)
60
- request_timeout(nil)
61
- rescue KeyError
62
- raise Prescient::ToolConfigurationError, 'SearXNG requires a url'
63
- end
64
-
65
- private
66
-
67
- def normalized_base_url(value)
68
- uri = URI.parse(value.to_s)
69
- valid_scheme = ['http', 'https'].include?(uri.scheme)
70
- if !valid_scheme || uri.host.nil? || uri.userinfo
71
- raise Prescient::ToolConfigurationError, 'SearXNG url must be an HTTP(S) URL without credentials'
3
+ require "httparty"
4
+ require "net/http"
5
+ require "timeout"
6
+ require "uri"
7
+
8
+ module Prescient
9
+ module Tool
10
+ # SearXNG web-search tool adapter.
11
+ class SearXNG < Prescient::Tool::Base
12
+ # Network failures that should be reported as connection errors.
13
+ NETWORK_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Timeout::Error, SocketError, Errno::ECONNREFUSED].freeze
14
+
15
+ # @param options [Hash] SearXNG configuration options
16
+ # @option options [String] :url SearXNG base URL
17
+ # @option options [String] :language Optional search language
18
+ # @option options [String, Array<String>] :categories Optional categories
19
+ def initialize(**options)
20
+ super
21
+ @base_url = normalized_base_url(options.fetch(:url))
22
+ end
23
+
24
+ # Search SearXNG and return normalized result metadata.
25
+ # @param query [String] Search query
26
+ # @param limit [Integer, nil] Maximum number of results
27
+ # @param timeout [Numeric, nil] Request timeout in seconds
28
+ # @return [Hash] Normalized search result envelope
29
+ def search(query, limit: nil, timeout: nil)
30
+ normalized_query = validate_query(query)
31
+ requested_limit = result_limit(limit)
32
+ requested_timeout = request_timeout(timeout)
33
+ response = HTTParty.get(
34
+ search_url,
35
+ query: request_parameters(normalized_query),
36
+ timeout: requested_timeout
37
+ )
38
+ validate_response!(response)
39
+
40
+ {
41
+ tool: "web_search",
42
+ query: normalized_query,
43
+ source: "searxng",
44
+ results: normalize_results(response.parsed_response, requested_limit)
45
+ }
46
+ rescue Prescient::Error
47
+ raise
48
+ rescue *NETWORK_ERRORS => e
49
+ raise Prescient::ToolConnectionError, "SearXNG request failed: #{e.class}"
50
+ rescue StandardError => e
51
+ raise Prescient::ToolError, "SearXNG request failed: #{e.message}"
52
+ end
53
+
54
+ protected
55
+
56
+ # Validate SearXNG-specific configuration.
57
+ # @return [void]
58
+ def validate_configuration!
59
+ normalized_base_url(@options.fetch(:url))
60
+ max_response_bytes
61
+ result_limit(nil)
62
+ request_timeout(nil)
63
+ rescue KeyError
64
+ raise Prescient::ToolConfigurationError, "SearXNG requires a url"
65
+ end
66
+
67
+ private
68
+
69
+ def normalized_base_url(value)
70
+ uri = URI.parse(value.to_s)
71
+ valid_scheme = %w[http https].include?(uri.scheme)
72
+ if !valid_scheme || uri.host.nil? || uri.userinfo
73
+ raise Prescient::ToolConfigurationError, "SearXNG url must be an HTTP(S) URL without credentials"
74
+ end
75
+
76
+ uri.to_s.delete_suffix("/")
77
+ rescue URI::InvalidURIError
78
+ raise Prescient::ToolConfigurationError, "SearXNG url must be a valid HTTP(S) URL"
79
+ end
80
+
81
+ def search_url
82
+ "#{@base_url}/search"
83
+ end
84
+
85
+ def request_parameters(query)
86
+ parameters = { q: query, format: "json" }
87
+ parameters[:language] = @options[:language] if @options[:language]
88
+ parameters[:categories] = Array(@options[:categories]).join(",") if @options[:categories]
89
+ parameters
90
+ end
91
+
92
+ def validate_response!(response)
93
+ if response.respond_to?(:body) && response.body.to_s.bytesize > max_response_bytes
94
+ raise Prescient::ToolInvalidResponseError, "SearXNG response exceeds configured size limit"
95
+ end
96
+
97
+ return if response.success?
98
+
99
+ error_class = case response.code.to_i
100
+ when 401, 403 then Prescient::AuthenticationError
101
+ when 429 then Prescient::RateLimitError
102
+ when 500..599 then Prescient::ToolConnectionError
103
+ else Prescient::ToolError
104
+ end
105
+ raise error_class, "SearXNG returned HTTP #{response.code}"
106
+ end
107
+
108
+ def normalize_results(payload, limit)
109
+ raw_results = payload.is_a?(Hash) ? payload["results"] : nil
110
+ unless raw_results.is_a?(Array)
111
+ raise Prescient::ToolInvalidResponseError, "SearXNG response did not contain results"
112
+ end
113
+
114
+ raw_results.filter_map do |result|
115
+ next unless result.is_a?(Hash)
116
+ next unless result["url"].is_a?(String) && !result["url"].empty?
117
+
118
+ {
119
+ title: result["title"].to_s,
120
+ url: result["url"],
121
+ snippet: result["content"].to_s,
122
+ source: "searxng"
123
+ }
124
+ end.first(limit)
125
+ end
72
126
  end
73
-
74
- uri.to_s.delete_suffix('/')
75
- rescue URI::InvalidURIError
76
- raise Prescient::ToolConfigurationError, 'SearXNG url must be a valid HTTP(S) URL'
77
- end
78
-
79
- def search_url
80
- "#{@base_url}/search"
81
- end
82
-
83
- def request_parameters(query)
84
- parameters = { q: query, format: 'json' }
85
- parameters[:language] = @options[:language] if @options[:language]
86
- parameters[:categories] = Array(@options[:categories]).join(',') if @options[:categories]
87
- parameters
88
- end
89
-
90
- def validate_response!(response)
91
- if response.respond_to?(:body) && response.body.to_s.bytesize > max_response_bytes
92
- raise Prescient::ToolInvalidResponseError, 'SearXNG response exceeds configured size limit'
93
- end
94
-
95
- return if response.success?
96
-
97
- error_class = case response.code.to_i
98
- when 401, 403 then Prescient::AuthenticationError
99
- when 429 then Prescient::RateLimitError
100
- when 500..599 then Prescient::ToolConnectionError
101
- else Prescient::ToolError
102
- end
103
- raise error_class, "SearXNG returned HTTP #{response.code}"
104
- end
105
-
106
- def normalize_results(payload, limit)
107
- raw_results = payload.is_a?(Hash) ? payload['results'] : nil
108
- unless raw_results.is_a?(Array)
109
- raise Prescient::ToolInvalidResponseError, 'SearXNG response did not contain results'
110
- end
111
-
112
- raw_results.filter_map { |result|
113
- next unless result.is_a?(Hash)
114
- next unless result['url'].is_a?(String) && !result['url'].empty?
115
-
116
- {
117
- title: result['title'].to_s,
118
- url: result['url'],
119
- snippet: result['content'].to_s,
120
- source: 'searxng',
121
- }
122
- }.first(limit)
123
127
  end
124
128
  end