prescient 0.8.0 → 0.8.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e272a00a9f7baaee44c4ad7b9827da6c1e5b930a98228e8d58718474d3090214
4
- data.tar.gz: a7ac75b948c326d0829db56e0ab297561d178fa4643a6a4bbd5b4dc11cff150b
3
+ metadata.gz: 3dc5e5bb3c441f18ba241696a3b37e2bd127b1cdbf312f5ec80adb6717f84b69
4
+ data.tar.gz: 361bdbe883a03971f796491ff19a30c514e6a105702f3f19cc251ab6eb1feaeb
5
5
  SHA512:
6
- metadata.gz: 1e4ff77f5dd524e7bc3217b4f8fc1b8045f43d188b751f81dc2964dcf2d15dbfd3ed58efe8598ba40bd875e19a3597de1308e32c066466713b06af8e79fea55a
7
- data.tar.gz: 3f5f8e5f78bc8db0566102f686ce1b66e752f9f81faba9dae09adfa49883aaa67c621b108b9a60cb573d695cae4874567e2ebf1c69b90a3971a6686f0316c288
6
+ metadata.gz: 0b46cf882af6fb5810c320ef5ec5276e7f89c7df6050fa9dd3f953ce6264264c160a9347a91cf79dfc443d882fc1c1304dd26e6a63ae6bd4ae91a18fe8274635
7
+ data.tar.gz: 7efc84dbb4acbc637914c4839f0ae269d132d3e91aec48253d09f7ae6efd9b44fa7692a4feed86ec36fdbd303e461533f31fbefe66243c683daa4f5f2857ab45
data/.rubocop.yml CHANGED
@@ -9,8 +9,8 @@ plugins:
9
9
  - rubocop-rake
10
10
 
11
11
  AllCops:
12
- # Target Ruby 3.1+ (matches gemspec requirement)
13
- TargetRubyVersion: 3.1
12
+ # Target Ruby 3.2+ (matches gemspec requirement)
13
+ TargetRubyVersion: 3.2
14
14
  # Use new cops by default
15
15
  NewCops: enable
16
16
  # Exclude common directories
@@ -78,4 +78,4 @@ Metrics/CyclomaticComplexity:
78
78
  Max: 10
79
79
 
80
80
  Style/DirectiveScope:
81
- Enabled: false
81
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## [0.8.2] - 2026-11-09
6
+
7
+ ### Breaking changes
8
+
9
+ - Dropped support for Ruby 3.1. Ruby 3.2 or newer is now required.
10
+
11
+ ## [0.8.1] - 2025-09-10
12
+
13
+ ### Fixed
14
+
15
+ - Fixed the Agent audit-log load path to avoid a circular-require warning while
16
+ preserving lazy loading of the optional Agent implementation.
17
+ - Replaced the Agent parser's polynomial-time fenced-JSON regular expression
18
+ with a linear scanner to prevent ReDoS behavior on untrusted provider output.
19
+ - Added Agent parser regression coverage for nested objects, escaped string
20
+ values, malformed fences, and repeated unterminated action prefixes.
21
+
5
22
  ## [0.8.0] - 2025-08-31
6
23
 
7
24
  ### Added
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,192 @@
1
+ # Contributing to Prescient
2
+
3
+ Thank you for your interest in contributing to Prescient! Prescient is a Ruby gem that provides a unified, predictable gateway to multiple AI providers, including Ollama, OpenAI, Anthropic, Google Gemini, Mistral, Hugging Face, DeepSeek, and xAI.
4
+
5
+ Prescient is designed as a reusable library rather than an application. The library values explicit provider boundaries, predictable error handling, modern idiomatic Ruby, and stable public APIs.
6
+
7
+ This guide explains how to set up your local development environment, run tests, follow coding standards, and submit pull requests.
8
+
9
+ ---
10
+
11
+ ## Code of conduct
12
+
13
+ By participating in this project, you agree to follow the project's [Code of Conduct](CODE_OF_CONDUCT.md).
14
+
15
+ ---
16
+
17
+ ## Development setup
18
+
19
+ ### Prerequisites
20
+
21
+ Ensure you have the following installed on your local system:
22
+
23
+ - **Ruby**: Version 3.2 or higher (the CI matrix tests Ruby 3.2, 3.3, 3.4, and 4.0).
24
+ - **Bundler**: Version 2.0 or higher.
25
+ - **Docker & Docker Compose** *(optional, for local service testing)*: Required if you test against local instances of Ollama, SearXNG, or PostgreSQL with pgvector.
26
+
27
+ ### Setting up the workspace
28
+
29
+ 1. Fork and clone the repository:
30
+
31
+ ```bash
32
+ git clone https://github.com/kanutocd/prescient.git
33
+ cd prescient
34
+ ```
35
+
36
+ 2. Install development dependencies:
37
+
38
+ ```bash
39
+ bundle install
40
+ ```
41
+
42
+ 3. *(Optional)* Start local services using Docker Compose:
43
+
44
+ ```bash
45
+ docker compose up -d ollama postgres searxng
46
+ docker compose run --rm ollama-init
47
+ ```
48
+
49
+ ---
50
+
51
+ ## Running tests and checks
52
+
53
+ Prescient uses Minitest for unit and integration testing, RuboCop for code linting, and RBS/Steep for type checking. Establish a green test baseline before making changes.
54
+
55
+ ### Running unit and integration tests
56
+
57
+ Run the complete test suite:
58
+
59
+ ```bash
60
+ bundle exec rake test
61
+ ```
62
+
63
+ To run a single test file during development, execute:
64
+
65
+ ```bash
66
+ bundle exec ruby -Itest test/prescient/client_test.rb
67
+ ```
68
+
69
+ To run a specific test by name, pass the line or name filter:
70
+
71
+ ```bash
72
+ bundle exec ruby -Itest test/prescient/client_test.rb -n /generate_response/
73
+ ```
74
+
75
+ ### Running code linter
76
+
77
+ Prescient enforces RuboCop styling rules:
78
+
79
+ ```bash
80
+ bundle exec rubocop
81
+ ```
82
+
83
+ Fix safe auto-correctable offenses with:
84
+
85
+ ```bash
86
+ bundle exec rubocop -A
87
+ ```
88
+
89
+ ### Type validation with RBS and Steep
90
+
91
+ Validate core API type signatures with:
92
+
93
+ ```bash
94
+ bundle exec rake rbs:validate
95
+ ```
96
+
97
+ To inspect generated RBS prototypes or differences:
98
+
99
+ ```bash
100
+ bundle exec rake rbs:prototype
101
+ bundle exec rake rbs:diff
102
+ ```
103
+
104
+ ### Opt-in live provider smoke tests
105
+
106
+ The default test suite uses WebMock and VCR to mock external HTTP requests. Tests never execute against live cloud providers by default.
107
+
108
+ To test against live provider APIs, set `PRESCIENT_LIVE_SMOKE=1` and specify the target providers in `PRESCIENT_LIVE_PROVIDERS`:
109
+
110
+ ```bash
111
+ PRESCIENT_LIVE_SMOKE=1 \
112
+ PRESCIENT_LIVE_PROVIDERS=openai \
113
+ OPENAI_API_KEY=your_actual_key \
114
+ bundle exec ruby -Itest test/prescient/live_provider_smoke_test.rb
115
+ ```
116
+
117
+ Supported live provider keys are `ollama`, `openai`, `anthropic`, `gemini`, `mistral`, `huggingface`, `deepseek`, and `xai`.
118
+
119
+ ---
120
+
121
+ ## Architecture guidelines
122
+
123
+ ### Provider abstraction
124
+
125
+ Provider-specific behavior belongs in provider adapters (`lib/prescient/provider/`). Common behavior belongs in base abstractions (`lib/prescient/base.rb`).
126
+
127
+ When creating or updating a provider adapter:
128
+
129
+ 1. **Inherit from `Prescient::Base`**: Subclass `Prescient::Base` and define required methods: `#generate_response`, `#generate_embedding` (if supported), and `#health_check`.
130
+ 2. **Isolate HTTP details**: Use HTTParty or standard-library HTTP tools inside the adapter. Keep raw network details out of caller code.
131
+ 3. **Normalize Responses**: Return standard response shapes from `#generate_response`:
132
+ ```ruby
133
+ {
134
+ response: "Generated text response",
135
+ model: "configured-model-name",
136
+ provider: "provider_name",
137
+ processing_time: nil,
138
+ metadata: { ... }
139
+ }
140
+ ```
141
+ 4. **Enforce embedding dimensions**: Validate vectors with `validate_embedding_dimensions`. Never pad or truncate vectors silently.
142
+ 5. **Map Provider Errors**: Wrap raw HTTP or API errors into standard Prescient exception classes.
143
+
144
+ ### Error mapping hierarchy
145
+
146
+ All gem exceptions inherit from `Prescient::Error`. Map provider-specific failures into the appropriate exception category:
147
+
148
+ | Exception Class | Primary Cause | Retryable by Fallback? |
149
+ | :--- | :--- | :--- |
150
+ | `Prescient::ConnectionError` | Network failures, HTTP timeouts, DNS resolution failures | Yes |
151
+ | `Prescient::RateLimitError` | Provider HTTP 429 status | Yes |
152
+ | `Prescient::ModelNotAvailableError` | Provider HTTP 404 status or unavailable model | Yes |
153
+ | `Prescient::ProviderError` | Provider HTTP 5xx server errors | Yes |
154
+ | `Prescient::AuthenticationError` | Provider HTTP 401/403 invalid API keys | No |
155
+ | `Prescient::InvalidResponseError` | Malformed JSON responses or vector dimension mismatches | No |
156
+ | `Prescient::ToolError` | Base class for external search tool adapter failures | Varies |
157
+
158
+ ### Retry vs fallback semantics
159
+
160
+ - **Retries**: Retries apply to transient network errors (`ConnectionError`, `RateLimitError`) within a single provider, governed by `config.retry_attempts` and `config.retry_delay`.
161
+ - **Fallback**: Provider fallback triggers when a provider encounters a persistent or service-side failure (`ConnectionError`, `RateLimitError`, `ModelNotAvailableError`, `ProviderError`). Fallback cascades in configured provider order. Caller misuse (invalid parameters, authentication failures) is never retried with fallback.
162
+
163
+ ---
164
+
165
+ ## Public API compatibility
166
+
167
+ Treat the following items as public API contracts:
168
+
169
+ - Methods on `Prescient`: `.configure`, `.client`, `.generate_embedding`, `.generate_response`, `.search_and_generate`, `.health_check`, `.load_configuration`.
170
+ - Configuration options: `Configuration` class properties (`default_provider`, `timeout`, `retry_attempts`, `retry_delay`, `fallback_providers`, `sensitive_keys`).
171
+ - Standard return shapes for generation, embeddings, health checks, and tool search results.
172
+
173
+ If your changes modify a public API contract:
174
+
175
+ 1. Update the corresponding unit and integration tests in `test/`.
176
+ 2. Update example scripts in `examples/`.
177
+ 3. Update documentation files (`README.md`, `INTEGRATION_GUIDE.md`, `VECTOR_SEARCH_GUIDE.md`).
178
+
179
+ ---
180
+
181
+ ## Submitting pull requests
182
+
183
+ Before opening a pull request:
184
+
185
+ 1. Ensure all tests pass: `bundle exec rake test`.
186
+ 2. Ensure RuboCop checks pass: `bundle exec rubocop`.
187
+ 3. Inspect your diff to ensure no sensitive credentials, VCR tokens, or temporary files are included:
188
+ ```bash
189
+ git diff
190
+ ```
191
+ 4. Write clear, imperative commit messages (for example, `Add Google Gemini model listing support`).
192
+ 5. Open a pull request against the `main` branch with a clear description of your changes and motivation.
data/INTEGRATION_GUIDE.md CHANGED
@@ -11,7 +11,7 @@ and [examples guide](examples/README.md).
11
11
 
12
12
  ```ruby
13
13
  # Add to your Gemfile
14
- gem 'prescient', '~> 0.8.0'
14
+ gem 'prescient', '~> 0.8.2'
15
15
  ```
16
16
 
17
17
  ### 2. Replace Existing AI Service
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Prescient
2
2
 
3
3
  [![Gem Version](https://img.shields.io/gem/v/prescient?logo=rubygems&logoColor=white)](https://rubygems.org/gems/prescient)
4
- [![Requires Ruby 3.1+](https://img.shields.io/badge/Requires-Ruby%203.1%2B-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
4
+ [![Requires Ruby 3.2+](https://img.shields.io/badge/Requires-Ruby%203.2%2B-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
5
5
  [![Docker](https://img.shields.io/badge/Docker-GHCR-2496ED?logo=docker&logoColor=white)](https://github.com/kanutocd/prescient/pkgs/container/prescient)
6
6
  [![CI](https://github.com/kanutocd/prescient/actions/workflows/ci.yml/badge.svg)](https://github.com/kanutocd/prescient/actions/workflows/ci.yml)
7
7
  [![Security](https://img.shields.io/github/actions/workflow/status/kanutocd/prescient/security.yml?branch=main&event=push&label=Security)](https://github.com/kanutocd/prescient/actions/workflows/security.yml)
@@ -4,8 +4,12 @@
4
4
  module Prescient::Agent
5
5
  # Extracts and validates one tool action from provider text.
6
6
  class Parser
7
- # @return [Regexp] Fenced JSON action matcher
8
- ACTION_PATTERN = /```json\s*(\{.*?\})\s*```/m
7
+ # @return [String] Opening fenced JSON marker
8
+ JSON_FENCE = "```json".b.freeze
9
+ # @return [String] Closing fenced JSON marker
10
+ CLOSING_FENCE = "```".b.freeze
11
+ # @return [Array<Integer>] JSON whitespace byte values
12
+ WHITESPACE_BYTES = [9, 10, 11, 12, 13, 32].freeze
9
13
 
10
14
  # Parse one optional action from provider output.
11
15
  # @param text [String] Provider response text
@@ -23,11 +27,11 @@ module Prescient::Agent
23
27
  # @param text [String] Provider response text
24
28
  # @return [Hash, nil] Parsed action or nil for a final response
25
29
  def parse(text)
26
- match = text.to_s.match(ACTION_PATTERN)
27
- return nil unless match
28
- raise MalformedActionError, "agent action exceeds configured size limit" if match[1].bytesize > @max_bytes
30
+ action_json = extract_action_json(text.to_s)
31
+ return nil unless action_json
32
+ raise MalformedActionError, "agent action exceeds configured size limit" if action_json.bytesize > @max_bytes
29
33
 
30
- payload = JSON.parse(match[1])
34
+ payload = JSON.parse(action_json)
31
35
  validate_payload(payload)
32
36
  { name: payload.fetch("action").to_sym, arguments: payload.fetch("args") }
33
37
  rescue JSON::ParserError => e
@@ -36,6 +40,84 @@ module Prescient::Agent
36
40
 
37
41
  private
38
42
 
43
+ def extract_action_json(text)
44
+ source = text.b
45
+ cursor = 0
46
+
47
+ while (fence_start = source.index(JSON_FENCE, cursor))
48
+ content_start = skip_whitespace(source, fence_start + JSON_FENCE.bytesize)
49
+ unless source.getbyte(content_start) == 123
50
+ cursor = content_start
51
+ next
52
+ end
53
+
54
+ action_json, next_cursor = scan_json_object(source, content_start)
55
+ return action_json if action_json
56
+
57
+ cursor = next_cursor
58
+ end
59
+
60
+ nil
61
+ end
62
+
63
+ def scan_json_object(source, start)
64
+ depth = 0
65
+ escaped = false
66
+ in_string = false
67
+ index = start
68
+
69
+ while index < source.bytesize
70
+ byte = source.getbyte(index)
71
+ if in_string
72
+ in_string, escaped = string_state(byte, escaped)
73
+ elsif fence_at?(source, index)
74
+ return [nil, index]
75
+ else
76
+ in_string = byte == 34
77
+ depth = depth_after(byte, depth)
78
+ return completed_action(source, start, index) if byte == 125 && depth.zero?
79
+ end
80
+ index += 1
81
+ end
82
+
83
+ [nil, source.bytesize]
84
+ end
85
+
86
+ def string_state(byte, escaped)
87
+ return [true, false] if escaped
88
+ return [true, true] if byte == 92
89
+ return [false, false] if byte == 34
90
+
91
+ [true, false]
92
+ end
93
+
94
+ def fence_at?(source, index)
95
+ byte = source.getbyte(index)
96
+ byte == 96 && source.byteslice(index, JSON_FENCE.bytesize) == JSON_FENCE
97
+ end
98
+
99
+ def depth_after(byte, depth)
100
+ case byte
101
+ when 123 then depth + 1
102
+ when 125 then depth - 1
103
+ else depth
104
+ end
105
+ end
106
+
107
+ def completed_action(source, start, index)
108
+ closing_start = skip_whitespace(source, index + 1)
109
+ return [nil, closing_start] unless source.byteslice(closing_start, CLOSING_FENCE.bytesize) == CLOSING_FENCE
110
+
111
+ length = index + 1 - start
112
+ [source.byteslice(start, length).force_encoding(Encoding::UTF_8), index + 1]
113
+ end
114
+
115
+ def skip_whitespace(source, start)
116
+ index = start
117
+ index += 1 while index < source.bytesize && WHITESPACE_BYTES.include?(source.getbyte(index))
118
+ index
119
+ end
120
+
39
121
  def validate_payload(payload)
40
122
  unless payload.is_a?(Hash) && payload.keys.sort == %w[action args]
41
123
  raise MalformedActionError, "agent action must contain only action and args"
@@ -5,11 +5,11 @@ require "json"
5
5
  module Prescient
6
6
  # Optional bounded agent orchestration over Prescient Core.
7
7
  module Agent
8
+ autoload :AuditLog, "prescient/agent/audit_log"
8
9
  end
9
10
  end
10
11
 
11
12
  require_relative "agent/configuration"
12
- require_relative "agent/audit_log"
13
13
  require_relative "agent/errors"
14
14
  require_relative "agent/error_serializer"
15
15
  require_relative "agent/schema_validator"
@@ -232,8 +232,8 @@ module Prescient
232
232
  # @param provider [Symbol, nil] Provider to use
233
233
  # @param enable_fallback [Boolean] Whether provider fallback is enabled
234
234
  # @return [Array<Float>] Embedding vector
235
- def self.generate_embedding(text, provider: nil, enable_fallback: true, **options)
236
- client(provider, enable_fallback: enable_fallback).generate_embedding(text, **options)
235
+ def self.generate_embedding(text, provider: nil, enable_fallback: true, **)
236
+ client(provider, enable_fallback: enable_fallback).generate_embedding(text, **)
237
237
  end
238
238
 
239
239
  # Generate a response through a configured provider.
@@ -244,8 +244,8 @@ module Prescient
244
244
  # @param enable_fallback [Boolean] Whether provider fallback is enabled
245
245
  # @return [Hash] Normalized provider response with :response, :model, :provider
246
246
  # and optional metadata
247
- def self.generate_response(prompt, context_items = [], provider: nil, enable_fallback: true, **options)
248
- client(provider, enable_fallback: enable_fallback).generate_response(prompt, context_items, **options)
247
+ def self.generate_response(prompt, context_items = [], provider: nil, enable_fallback: true, **)
248
+ client(provider, enable_fallback: enable_fallback).generate_response(prompt, context_items, **)
249
249
  end
250
250
 
251
251
  # Search with an explicit external tool and optionally use the normalized
@@ -259,7 +259,7 @@ module Prescient
259
259
  # @param provider_options [Hash] Temporary provider configuration overrides
260
260
  # @return [Hash] Normalized provider response
261
261
  def self.search_and_generate(query, tool: :web_search, provider: nil, limit: nil,
262
- enable_fallback: true, provider_options: {}, **options)
262
+ enable_fallback: true, provider_options: {}, **)
263
263
  search_tool = self.tool(tool)
264
264
  raise Prescient::ToolConfigurationError, "tool not configured: #{tool}" unless search_tool
265
265
 
@@ -270,7 +270,7 @@ module Prescient
270
270
  client(provider, enable_fallback:, provider_options:).generate_response(
271
271
  query,
272
272
  context_items,
273
- **options
273
+ **
274
274
  )
275
275
  end
276
276
 
@@ -53,8 +53,8 @@ module Prescient
53
53
  # Validate and bound application-provided JSON documents.
54
54
  class Memory < Base
55
55
  # @param documents [Array<Hash>, Hash] Documents to validate
56
- def initialize(documents:, **options)
57
- super(**options)
56
+ def initialize(documents:, **)
57
+ super(**)
58
58
  @documents = documents
59
59
  end
60
60
 
@@ -67,8 +67,8 @@ module Prescient
67
67
  # Read JSON documents from a local file.
68
68
  class JsonFile < Base
69
69
  # @param path [String] JSON file path
70
- def initialize(path:, **options)
71
- super(**options)
70
+ def initialize(path:, **)
71
+ super(**)
72
72
  @path = path
73
73
  return if @path.is_a?(String) && !@path.empty?
74
74
 
@@ -91,8 +91,8 @@ module Prescient
91
91
  class RedisJson < Base
92
92
  # @param client [#get] Redis-compatible client
93
93
  # @param key [String] Redis key containing a JSON document or array
94
- def initialize(client:, key:, **options)
95
- super(**options)
94
+ def initialize(client:, key:, **)
95
+ super(**)
96
96
  @client = client
97
97
  @key = key
98
98
  return if @key.is_a?(String) && !@key.empty?
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Prescient
4
4
  # Current Prescient gem version.
5
- VERSION = "0.8.0"
5
+ VERSION = "0.8.2"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prescient
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.8.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa
@@ -41,6 +41,7 @@ files:
41
41
  - ".yardopts"
42
42
  - CHANGELOG.md
43
43
  - CODE_OF_CONDUCT.md
44
+ - CONTRIBUTING.md
44
45
  - Dockerfile
45
46
  - Dockerfile.example
46
47
  - INTEGRATION_GUIDE.md
@@ -125,14 +126,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
125
126
  requirements:
126
127
  - - ">="
127
128
  - !ruby/object:Gem::Version
128
- version: 3.1.0
129
+ version: '3.2'
129
130
  required_rubygems_version: !ruby/object:Gem::Requirement
130
131
  requirements:
131
132
  - - ">="
132
133
  - !ruby/object:Gem::Version
133
134
  version: '0'
134
135
  requirements: []
135
- rubygems_version: 4.0.16
136
+ rubygems_version: 4.0.20
136
137
  specification_version: 4
137
138
  summary: A boring AI provider gateway for Ruby
138
139
  test_files: []