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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "mcp/configuration"
5
+ require_relative "mcp/authentication"
6
+ require_relative "mcp/server"
7
+ require_relative "mcp/stdio"
8
+ require_relative "mcp/rack"
@@ -1,194 +1,198 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
-
5
- # PostgreSQL pgvector integration.
6
- #
7
- # The integration accepts a PG-compatible connection object, so applications
8
- # choose and manage their own PostgreSQL driver and connection lifecycle.
9
- # Stores provider embeddings and performs nearest-neighbor searches.
10
- class Prescient::Pgvector::Store
11
- # @return [Hash<Symbol, String>] Supported pgvector distance operators
12
- METRICS = {
13
- cosine: '<=>',
14
- euclidean: '<->',
15
- inner_product: '<#>',
16
- }.freeze
17
-
18
- # @return [Integer] Required vector dimensions
19
- attr_reader :dimensions
20
-
21
- # @return [String] Embeddings table name
22
- attr_reader :table
23
-
24
- # @param connection [Object] PG-compatible object responding to `exec`
25
- # and `exec_params`
26
- # @param dimensions [Integer] Required dimensions for every embedding
27
- # @param table [String, Symbol] Safe PostgreSQL table identifier
28
- def initialize(connection:, dimensions:, table: 'prescient_embeddings')
29
- @connection = connection
30
- @dimensions = validate_dimensions(dimensions)
31
- @table = validate_table(table)
32
- end
33
-
34
- # Create the pgvector extension and the embeddings table.
35
- #
36
- # @return [void]
37
- def install!
38
- @connection.exec('CREATE EXTENSION IF NOT EXISTS vector')
39
- @connection.exec(<<~SQL)
40
- CREATE TABLE IF NOT EXISTS #{table} (
41
- id text PRIMARY KEY,
42
- provider text NOT NULL,
43
- model text NOT NULL,
44
- dimensions integer NOT NULL CHECK (dimensions = #{dimensions}),
45
- embedding vector(#{dimensions}) NOT NULL,
46
- content text,
47
- metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
48
- created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
49
- updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
50
- )
51
- SQL
52
- end
53
-
54
- # Create an HNSW index for the selected distance metric.
55
- #
56
- # @param metric [Symbol] `:cosine`, `:euclidean`, or `:inner_product`
57
- # @return [void]
58
- def create_index!(metric: :cosine)
59
- metric_name = validate_metric(metric)
60
- @connection.exec(<<~SQL)
61
- CREATE INDEX IF NOT EXISTS #{table}_#{metric_name}_embedding_idx
62
- ON #{table} USING hnsw (embedding #{metric_operator_class(metric_name)})
63
- SQL
64
- end
65
-
66
- # Insert or replace an embedding record.
67
- #
68
- # @return [Hash] Stored record metadata
69
- def upsert(id:, embedding:, provider:, model:, content: nil, metadata: {})
70
- vector = serialize_vector(embedding)
71
- parameters = [id.to_s, provider.to_s, model.to_s, dimensions, vector, content, JSON.generate(metadata)]
72
- result = @connection.exec_params(<<~SQL, parameters)
73
- INSERT INTO #{table} (id, provider, model, dimensions, embedding, content, metadata)
74
- VALUES ($1, $2, $3, $4, $5::vector, $6, $7::jsonb)
75
- ON CONFLICT (id) DO UPDATE SET
76
- provider = EXCLUDED.provider,
77
- model = EXCLUDED.model,
78
- dimensions = EXCLUDED.dimensions,
79
- embedding = EXCLUDED.embedding,
80
- content = EXCLUDED.content,
81
- metadata = EXCLUDED.metadata,
82
- updated_at = CURRENT_TIMESTAMP
83
- RETURNING id, provider, model, dimensions, content, metadata
84
- SQL
85
-
86
- record_from(result.first)
87
- end
88
-
89
- # Find the nearest stored embeddings.
90
- #
91
- # @param embedding [Array<Numeric>] Query vector
92
- # @param limit [Integer] Maximum result count
93
- # @param metric [Symbol] Distance metric
94
- # @param provider [String, Symbol, nil] Optional provider filter
95
- # @param model [String, nil] Optional model filter
96
- # @return [Array<Hash>] Records ordered by ascending distance
97
- def search(embedding:, limit: 10, metric: :cosine, provider: nil, model: nil)
98
- vector = serialize_vector(embedding)
99
- limit = validate_limit(limit)
100
- metric = validate_metric(metric)
101
- filters, parameters = search_filters(provider, model)
102
- result = @connection.exec_params(search_query(metric, filters), [vector, limit, *parameters])
103
-
104
- result.map { |row| record_from(row) }
105
- end
106
-
107
- private
108
-
109
- def validate_dimensions(value)
110
- return value if value.is_a?(Integer) && value.positive?
111
-
112
- raise ArgumentError, 'dimensions must be a positive integer'
113
- end
114
-
115
- def validate_table(value)
116
- table = value.to_s
117
- return table if /\A[a-z_][a-z0-9_]*\z/.match?(table)
118
-
119
- raise ArgumentError, 'table must be a lowercase PostgreSQL identifier'
120
- end
121
-
122
- def serialize_vector(embedding)
123
- unless embedding.is_a?(Array) && embedding.length == dimensions
124
- raise Prescient::InvalidVectorError, "embedding must contain exactly #{dimensions} values"
125
- end
126
-
127
- values = embedding.map { |value| Float(value) }
128
- raise Prescient::InvalidVectorError, 'embedding values must be finite' unless values.all?(&:finite?)
129
-
130
- "[#{values.join(',')}]"
131
- rescue ArgumentError, TypeError
132
- raise Prescient::InvalidVectorError, 'embedding values must be numeric'
133
- end
134
-
135
- def validate_limit(value)
136
- return value if value.is_a?(Integer) && value.positive?
137
-
138
- raise ArgumentError, 'limit must be a positive integer'
139
- end
140
-
141
- def validate_metric(value)
142
- return value if METRICS.key?(value)
143
-
144
- raise ArgumentError, "unsupported distance metric: #{value}"
145
- end
146
-
147
- def metric_operator_class(metric)
148
- {
149
- cosine: 'vector_cosine_ops',
150
- euclidean: 'vector_l2_ops',
151
- inner_product: 'vector_ip_ops',
152
- }.fetch(metric)
153
- end
154
-
155
- def search_filters(provider, model)
156
- filters = []
157
- parameters = []
158
- if provider
159
- filters << "provider = $#{parameters.length + 3}"
160
- parameters << provider.to_s
3
+ require "json"
4
+
5
+ module Prescient
6
+ module Pgvector
7
+ # PostgreSQL pgvector integration.
8
+ #
9
+ # The integration accepts a PG-compatible connection object, so applications
10
+ # choose and manage their own PostgreSQL driver and connection lifecycle.
11
+ # Stores provider embeddings and performs nearest-neighbor searches.
12
+ class Store
13
+ # @return [Hash<Symbol, String>] Supported pgvector distance operators
14
+ METRICS = {
15
+ cosine: "<=>",
16
+ euclidean: "<->",
17
+ inner_product: "<#>"
18
+ }.freeze
19
+
20
+ # @return [Integer] Required vector dimensions
21
+ attr_reader :dimensions
22
+
23
+ # @return [String] Embeddings table name
24
+ attr_reader :table
25
+
26
+ # @param connection [Object] PG-compatible object responding to `exec`
27
+ # and `exec_params`
28
+ # @param dimensions [Integer] Required dimensions for every embedding
29
+ # @param table [String, Symbol] Safe PostgreSQL table identifier
30
+ def initialize(connection:, dimensions:, table: "prescient_embeddings")
31
+ @connection = connection
32
+ @dimensions = validate_dimensions(dimensions)
33
+ @table = validate_table(table)
34
+ end
35
+
36
+ # Create the pgvector extension and the embeddings table.
37
+ #
38
+ # @return [void]
39
+ def install!
40
+ @connection.exec("CREATE EXTENSION IF NOT EXISTS vector")
41
+ @connection.exec(<<~SQL)
42
+ CREATE TABLE IF NOT EXISTS #{table} (
43
+ id text PRIMARY KEY,
44
+ provider text NOT NULL,
45
+ model text NOT NULL,
46
+ dimensions integer NOT NULL CHECK (dimensions = #{dimensions}),
47
+ embedding vector(#{dimensions}) NOT NULL,
48
+ content text,
49
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
50
+ created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
51
+ updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
52
+ )
53
+ SQL
54
+ end
55
+
56
+ # Create an HNSW index for the selected distance metric.
57
+ #
58
+ # @param metric [Symbol] `:cosine`, `:euclidean`, or `:inner_product`
59
+ # @return [void]
60
+ def create_index!(metric: :cosine)
61
+ metric_name = validate_metric(metric)
62
+ @connection.exec(<<~SQL)
63
+ CREATE INDEX IF NOT EXISTS #{table}_#{metric_name}_embedding_idx
64
+ ON #{table} USING hnsw (embedding #{metric_operator_class(metric_name)})
65
+ SQL
66
+ end
67
+
68
+ # Insert or replace an embedding record.
69
+ #
70
+ # @return [Hash] Stored record metadata
71
+ def upsert(id:, embedding:, provider:, model:, content: nil, metadata: {})
72
+ vector = serialize_vector(embedding)
73
+ parameters = [id.to_s, provider.to_s, model.to_s, dimensions, vector, content, JSON.generate(metadata)]
74
+ result = @connection.exec_params(<<~SQL, parameters)
75
+ INSERT INTO #{table} (id, provider, model, dimensions, embedding, content, metadata)
76
+ VALUES ($1, $2, $3, $4, $5::vector, $6, $7::jsonb)
77
+ ON CONFLICT (id) DO UPDATE SET
78
+ provider = EXCLUDED.provider,
79
+ model = EXCLUDED.model,
80
+ dimensions = EXCLUDED.dimensions,
81
+ embedding = EXCLUDED.embedding,
82
+ content = EXCLUDED.content,
83
+ metadata = EXCLUDED.metadata,
84
+ updated_at = CURRENT_TIMESTAMP
85
+ RETURNING id, provider, model, dimensions, content, metadata
86
+ SQL
87
+
88
+ record_from(result.first)
89
+ end
90
+
91
+ # Find the nearest stored embeddings.
92
+ #
93
+ # @param embedding [Array<Numeric>] Query vector
94
+ # @param limit [Integer] Maximum result count
95
+ # @param metric [Symbol] Distance metric
96
+ # @param provider [String, Symbol, nil] Optional provider filter
97
+ # @param model [String, nil] Optional model filter
98
+ # @return [Array<Hash>] Records ordered by ascending distance
99
+ def search(embedding:, limit: 10, metric: :cosine, provider: nil, model: nil)
100
+ vector = serialize_vector(embedding)
101
+ limit = validate_limit(limit)
102
+ metric = validate_metric(metric)
103
+ filters, parameters = search_filters(provider, model)
104
+ result = @connection.exec_params(search_query(metric, filters), [vector, limit, *parameters])
105
+
106
+ result.map { |row| record_from(row) }
107
+ end
108
+
109
+ private
110
+
111
+ def validate_dimensions(value)
112
+ return value if value.is_a?(Integer) && value.positive?
113
+
114
+ raise ArgumentError, "dimensions must be a positive integer"
115
+ end
116
+
117
+ def validate_table(value)
118
+ table = value.to_s
119
+ return table if /\A[a-z_][a-z0-9_]*\z/.match?(table)
120
+
121
+ raise ArgumentError, "table must be a lowercase PostgreSQL identifier"
122
+ end
123
+
124
+ def serialize_vector(embedding)
125
+ unless embedding.is_a?(Array) && embedding.length == dimensions
126
+ raise Prescient::InvalidVectorError, "embedding must contain exactly #{dimensions} values"
127
+ end
128
+
129
+ values = embedding.map { |value| Float(value) }
130
+ raise Prescient::InvalidVectorError, "embedding values must be finite" unless values.all?(&:finite?)
131
+
132
+ "[#{values.join(",")}]"
133
+ rescue ArgumentError, TypeError
134
+ raise Prescient::InvalidVectorError, "embedding values must be numeric"
135
+ end
136
+
137
+ def validate_limit(value)
138
+ return value if value.is_a?(Integer) && value.positive?
139
+
140
+ raise ArgumentError, "limit must be a positive integer"
141
+ end
142
+
143
+ def validate_metric(value)
144
+ return value if METRICS.key?(value)
145
+
146
+ raise ArgumentError, "unsupported distance metric: #{value}"
147
+ end
148
+
149
+ def metric_operator_class(metric)
150
+ {
151
+ cosine: "vector_cosine_ops",
152
+ euclidean: "vector_l2_ops",
153
+ inner_product: "vector_ip_ops"
154
+ }.fetch(metric)
155
+ end
156
+
157
+ def search_filters(provider, model)
158
+ filters = []
159
+ parameters = []
160
+ if provider
161
+ filters << "provider = $#{parameters.length + 3}"
162
+ parameters << provider.to_s
163
+ end
164
+ if model
165
+ filters << "model = $#{parameters.length + 3}"
166
+ parameters << model
167
+ end
168
+
169
+ [filters, parameters]
170
+ end
171
+
172
+ def search_query(metric, filters)
173
+ where = filters.empty? ? "" : "WHERE #{filters.join(" AND ")}"
174
+
175
+ <<~SQL
176
+ SELECT id, provider, model, dimensions, content, metadata,
177
+ embedding #{METRICS.fetch(metric)} $1::vector AS distance
178
+ FROM #{table}
179
+ #{where}
180
+ ORDER BY embedding #{METRICS.fetch(metric)} $1::vector
181
+ LIMIT $2
182
+ SQL
183
+ end
184
+
185
+ def record_from(row)
186
+ {
187
+ id: row.fetch("id"),
188
+ provider: row.fetch("provider"),
189
+ model: row.fetch("model"),
190
+ dimensions: Integer(row.fetch("dimensions")),
191
+ content: row.fetch("content"),
192
+ metadata: JSON.parse(row.fetch("metadata")),
193
+ distance: row["distance"]&.to_f
194
+ }
195
+ end
161
196
  end
162
- if model
163
- filters << "model = $#{parameters.length + 3}"
164
- parameters << model
165
- end
166
-
167
- [filters, parameters]
168
- end
169
-
170
- def search_query(metric, filters)
171
- where = filters.empty? ? '' : "WHERE #{filters.join(' AND ')}"
172
-
173
- <<~SQL
174
- SELECT id, provider, model, dimensions, content, metadata,
175
- embedding #{METRICS.fetch(metric)} $1::vector AS distance
176
- FROM #{table}
177
- #{where}
178
- ORDER BY embedding #{METRICS.fetch(metric)} $1::vector
179
- LIMIT $2
180
- SQL
181
- end
182
-
183
- def record_from(row)
184
- {
185
- id: row.fetch('id'),
186
- provider: row.fetch('provider'),
187
- model: row.fetch('model'),
188
- dimensions: Integer(row.fetch('dimensions')),
189
- content: row.fetch('content'),
190
- metadata: JSON.parse(row.fetch('metadata')),
191
- distance: row['distance']&.to_f,
192
- }
193
197
  end
194
198
  end