prescient 0.6.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +21 -268
  3. data/CHANGELOG.md +59 -0
  4. data/INTEGRATION_GUIDE.md +19 -1
  5. data/README.md +369 -21
  6. data/Steepfile +12 -12
  7. data/db/migrate/001_create_prescient_tables.rb +15 -16
  8. data/docker-compose.yml +22 -0
  9. data/examples/README.md +36 -1
  10. data/examples/custom_contexts.rb +4 -4
  11. data/examples/web_search.rb +34 -0
  12. data/exe/prescient +2 -2
  13. data/exe/prescient-mcp +7 -0
  14. data/lib/prescient/agent/audit_log.rb +37 -0
  15. data/lib/prescient/agent/cli_adapter.rb +29 -0
  16. data/lib/prescient/agent/configuration.rb +57 -0
  17. data/lib/prescient/agent/context.rb +56 -0
  18. data/lib/prescient/agent/error_serializer.rb +47 -0
  19. data/lib/prescient/agent/errors.rb +25 -0
  20. data/lib/prescient/agent/parser.rb +49 -0
  21. data/lib/prescient/agent/prompt_builder.rb +31 -0
  22. data/lib/prescient/agent/result.rb +36 -0
  23. data/lib/prescient/agent/runtime.rb +175 -0
  24. data/lib/prescient/agent/schema_validator.rb +215 -0
  25. data/lib/prescient/agent/tool_registry.rb +89 -0
  26. data/lib/prescient/agent.rb +22 -0
  27. data/lib/prescient/api.rb +346 -231
  28. data/lib/prescient/base.rb +370 -372
  29. data/lib/prescient/cli.rb +591 -402
  30. data/lib/prescient/client.rb +31 -4
  31. data/lib/prescient/configuration_loader.rb +511 -336
  32. data/lib/prescient/document_source.rb +114 -0
  33. data/lib/prescient/errors.rb +13 -3
  34. data/lib/prescient/mcp/authentication.rb +39 -0
  35. data/lib/prescient/mcp/configuration.rb +38 -0
  36. data/lib/prescient/mcp/rack.rb +243 -0
  37. data/lib/prescient/mcp/server.rb +202 -0
  38. data/lib/prescient/mcp/stdio.rb +42 -0
  39. data/lib/prescient/mcp.rb +8 -0
  40. data/lib/prescient/pgvector.rb +193 -189
  41. data/lib/prescient/provider/anthropic.rb +129 -125
  42. data/lib/prescient/provider/deepseek.rb +122 -118
  43. data/lib/prescient/provider/gemini.rb +153 -149
  44. data/lib/prescient/provider/huggingface.rb +191 -187
  45. data/lib/prescient/provider/mistral.rb +151 -147
  46. data/lib/prescient/provider/ollama.rb +168 -165
  47. data/lib/prescient/provider/openai.rb +174 -171
  48. data/lib/prescient/provider/xai.rb +122 -118
  49. data/lib/prescient/tool/search_api.rb +130 -0
  50. data/lib/prescient/tool/searxng.rb +128 -0
  51. data/lib/prescient/tool.rb +125 -0
  52. data/lib/prescient/version.rb +1 -1
  53. data/lib/prescient.rb +129 -55
  54. data/schema/prescient.configuration.schema.json +119 -0
  55. data/searxng/settings.yml +18 -0
  56. data/sig/prescient.rbs +228 -1
  57. metadata +33 -5
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
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
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
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
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prescient
4
+ # Namespace for external capability adapters.
5
+ module Tool
6
+ autoload :SearXNG, "prescient/tool/searxng"
7
+ autoload :SearchApi, "prescient/tool/search_api"
8
+
9
+ # Base contract for explicit external tool invocation.
10
+ class Base
11
+ # @return [Integer] Default request timeout in seconds
12
+ DEFAULT_TIMEOUT = 5
13
+ # @return [Integer] Default maximum number of returned results
14
+ DEFAULT_MAX_RESULTS = 5
15
+ # @return [Integer] Maximum accepted search query length
16
+ MAX_QUERY_LENGTH = 2_000
17
+ # @return [Integer] Maximum accepted tool response size in bytes
18
+ DEFAULT_MAX_RESPONSE_BYTES = 1_048_576
19
+
20
+ # @return [Hash<Symbol, untyped>] Tool configuration options
21
+ attr_reader :options
22
+
23
+ # @param options [Hash] Tool-specific configuration options
24
+ def initialize(**options)
25
+ @options = options
26
+ validate_configuration!
27
+ end
28
+
29
+ # Execute a search using the tool.
30
+ # @param query [String] Search query
31
+ # @param options [Hash] Per-request options
32
+ # @return [Hash] Normalized tool result
33
+ # @raise [NotImplementedError] If the adapter does not support searching
34
+ def search(query, **options)
35
+ raise NotImplementedError, "#{self.class} must implement #search"
36
+ end
37
+
38
+ protected
39
+
40
+ # Validate adapter configuration.
41
+ # @return [void]
42
+ def validate_configuration!
43
+ # Override in subclasses.
44
+ end
45
+
46
+ # @param query [String] Search query
47
+ # @return [String] Validated query
48
+ def validate_query(query)
49
+ unless query.is_a?(String) && !query.strip.empty?
50
+ raise Prescient::ToolConfigurationError, "search query must be a non-empty string"
51
+ end
52
+
53
+ cleaned_query = query.strip
54
+ if cleaned_query.length > MAX_QUERY_LENGTH
55
+ raise Prescient::ToolConfigurationError,
56
+ "search query cannot contain more than #{MAX_QUERY_LENGTH} characters"
57
+ end
58
+
59
+ cleaned_query
60
+ end
61
+
62
+ # @param value [Object] Requested result limit
63
+ # @return [Integer] Validated result limit
64
+ def result_limit(value)
65
+ limit = value.nil? ? @options.fetch(:max_results, DEFAULT_MAX_RESULTS) : value
66
+ unless limit.is_a?(Integer) && limit.positive?
67
+ raise Prescient::ToolConfigurationError, "max_results must be a positive integer"
68
+ end
69
+
70
+ [limit, 20].min
71
+ end
72
+
73
+ # @param value [Object] Requested timeout
74
+ # @return [Numeric] Validated timeout
75
+ def request_timeout(value)
76
+ timeout = value.nil? ? @options.fetch(:timeout, DEFAULT_TIMEOUT) : value
77
+ unless timeout.is_a?(Numeric) && timeout.positive?
78
+ raise Prescient::ToolConfigurationError, "timeout must be a positive number"
79
+ end
80
+
81
+ timeout
82
+ end
83
+
84
+ # @return [Integer] Maximum accepted response size
85
+ def max_response_bytes
86
+ value = @options.fetch(:max_response_bytes, DEFAULT_MAX_RESPONSE_BYTES)
87
+ unless value.is_a?(Integer) && value.positive?
88
+ raise Prescient::ToolConfigurationError,
89
+ "max_response_bytes must be a positive integer"
90
+ end
91
+
92
+ value
93
+ end
94
+ end
95
+
96
+ # Ordered implementations for one logical tool capability.
97
+ class Group < Base
98
+ # @return [Array<Base>] Ordered capability implementations
99
+ attr_reader :adapters
100
+
101
+ # @param adapters [Array<Base>] Ordered capability implementations
102
+ def initialize(adapters:)
103
+ super
104
+ @adapters = adapters
105
+ raise Prescient::ToolConfigurationError, "tool group requires an adapter" if @adapters.empty?
106
+ end
107
+
108
+ # Execute the first successful adapter, falling back only for transient
109
+ # connection and rate-limit failures.
110
+ # @param query [String] Search query
111
+ # @param options [Hash] Per-request options
112
+ # @return [Hash] Normalized tool result
113
+ def search(query, **options)
114
+ failures = []
115
+ @adapters.each do |adapter|
116
+ return adapter.search(query, **options)
117
+ rescue Prescient::ToolConnectionError, Prescient::RateLimitError => e
118
+ failures << e
119
+ end
120
+
121
+ raise failures.last
122
+ end
123
+ end
124
+ end
125
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Prescient
4
4
  # Current Prescient gem version.
5
- VERSION = '0.6.0'
5
+ VERSION = "0.8.0"
6
6
  end