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
data/lib/prescient/cli.rb CHANGED
@@ -1,609 +1,669 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
- require 'optparse'
5
- require 'yaml'
6
- require_relative '../prescient'
7
-
8
- # Command-line interface for common Prescient operations.
9
- class Prescient::CLI
10
- # Supported output formats.
11
- # @return [Array<String>] Output format names
12
- FORMATS = ['text', 'json'].freeze
13
-
14
- # Schema URL and annotated starter configuration for `config example`.
15
- CONFIGURATION_EXAMPLE = <<~YAML
16
- # yaml-language-server: $schema=https://raw.githubusercontent.com/kanutocd/prescient/refs/heads/main/schema/prescient.configuration.schema.json
3
+ require "json"
4
+ require "optparse"
5
+ require "yaml"
6
+ require_relative "../prescient"
7
+
8
+ module Prescient
9
+ # Command-line interface for common Prescient operations.
10
+ # rubocop:disable Metrics/ClassLength
11
+ class CLI
12
+ # @return [Hash<String, Symbol>] CLI command dispatch table
13
+ COMMAND_HANDLERS = {
14
+ "providers" => :providers,
15
+ "health" => :health,
16
+ "generate" => :generate,
17
+ "embed" => :embed,
18
+ "search" => :search,
19
+ "agent" => :agent,
20
+ "config" => :config
21
+ }.freeze
22
+ # Supported output formats.
23
+ # @return [Array<String>] Output format names
24
+ FORMATS = %w[text json].freeze
25
+
26
+ # Schema URL and annotated starter configuration for `config example`.
27
+ CONFIGURATION_EXAMPLE = <<~YAML
28
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/kanutocd/prescient/refs/heads/main/schema/prescient.configuration.schema.json
29
+ #
30
+ # Prescient configuration example.
31
+ #
32
+ # Precedence, from lowest to highest:
33
+ # 1. Built-in defaults and provider environment variables.
34
+ # 2. Values in this YAML file.
35
+ # 3. Per-operation CLI overrides such as --provider and --chat-model.
36
+ #
37
+ # Use `prescient config validate` after editing this file.
38
+ # Keep credentials out of source control; use *_env references instead.
39
+ version: 1
40
+
41
+ # Global behavior.
42
+ default_provider: ollama
43
+ timeout: 30
44
+ retry_attempts: 3
45
+ retry_delay: 1.0
46
+ fallback_providers: []
47
+ sensitive_keys:
48
+ - api_key
49
+ - password
50
+ - token
51
+ - secret
52
+
53
+ providers:
54
+ # Local Ollama requires no API key.
55
+ ollama:
56
+ type: ollama
57
+ url: http://localhost:11434
58
+ embedding_model: nomic-embed-text
59
+ chat_model: llama3.2:3b
60
+ # prompt_templates:
61
+ # system_prompt: You are a concise assistant.
62
+ # no_context_template: "%<system_prompt>s\x5Cn\x5CnUser: %<query>s"
63
+ # with_context_template: "%<system_prompt>s\x5Cn\x5CnContext:\x5Cn%<context>s\x5Cn\x5CnUser: %<query>s"
64
+
65
+ # Uncomment a cloud provider and set its credential in the environment.
66
+ # openai:
67
+ # type: openai
68
+ # api_key_env: OPENAI_API_KEY
69
+ # embedding_model: text-embedding-3-small
70
+ # chat_model: gpt-4.1-mini
71
+ # prompt_templates:
72
+ # system_prompt: You are a concise assistant.
73
+ # no_context_template: "%<system_prompt>s\x5Cn\x5CnUser: %<query>s"
74
+
75
+ # anthropic:
76
+ # type: anthropic
77
+ # api_key_env: ANTHROPIC_API_KEY
78
+ # model: claude-sonnet-4-20250514
79
+
80
+ # gemini:
81
+ # type: gemini
82
+ # api_key_env: GEMINI_API_KEY
83
+ # embedding_model: gemini-embedding-001
84
+ # chat_model: gemini-2.5-flash
85
+
86
+ # mistral:
87
+ # type: mistral
88
+ # api_key_env: MISTRAL_API_KEY
89
+ # embedding_model: mistral-embed
90
+ # chat_model: mistral-large-latest
91
+
92
+ # DeepSeek supports text generation, but not embeddings.
93
+ # deepseek:
94
+ # type: deepseek
95
+ # api_key_env: DEEPSEEK_API_KEY
96
+ # chat_model: deepseek-v4-flash
97
+
98
+ # xai:
99
+ # type: xai
100
+ # api_key_env: XAI_API_KEY
101
+ # chat_model: grok-4.5
102
+
103
+ # huggingface:
104
+ # type: huggingface
105
+ # api_key_env: HUGGINGFACE_API_KEY
106
+ # embedding_model: sentence-transformers/all-MiniLM-L6-v2
107
+ # chat_model: google/gemma-2-2b-it
108
+
109
+ # External tools are opt-in and separate from AI providers. They can be
110
+ # used directly with `prescient search`, or as context with
111
+ # `prescient search --generate`.
112
+ #
113
+ # The CLI also registers `web_search` automatically when SEARXNG_URL is
114
+ # set and no YAML tool configuration is provided.
115
+ tools:
116
+ # Local SearXNG example. Uncomment this block to configure a tool in YAML.
117
+ # web_search:
118
+ # type: searxng
119
+ # url: http://localhost:8080
120
+ # timeout: 5
121
+ # max_results: 5
122
+ # language: en
123
+ # categories:
124
+ # - general
125
+ # - science
126
+ # max_response_bytes: 1048576
127
+
128
+ # SearchApi example. It uses SearchApi's Google engine by default and
129
+ # authenticates with a Bearer token from the environment.
130
+ # searchapi_web:
131
+ # type: searchapi
132
+ # api_key_env: SEARCHAPI_API_KEY
133
+ # engine: google
134
+ # location: New York
135
+ # hl: en
136
+ # gl: us
137
+ # timeout: 10
138
+ # max_results: 5
139
+
140
+ # Capability fallback. Adapters are tried in order, and fallback occurs
141
+ # only for transient connection or rate-limit failures.
142
+ # resilient_search:
143
+ # adapters:
144
+ # - type: searxng
145
+ # url_env: SEARXNG_URL
146
+ # - type: searchapi
147
+ # api_key_env: SEARCHAPI_API_KEY
148
+ # engine: google
149
+
150
+ # Prefer an environment reference when the URL differs by environment
151
+ # or should not be committed. Use `--tool research_search` to select a
152
+ # tool with a custom name.
153
+ # research_search:
154
+ # type: searxng
155
+ # url_env: SEARXNG_URL
156
+ # timeout_env: SEARXNG_TIMEOUT
157
+ # max_results_env: SEARXNG_MAX_RESULTS
158
+ # language_env: SEARXNG_LANGUAGE
159
+ # categories_env: SEARXNG_CATEGORIES
160
+
161
+ # Search results are returned directly by default. Add `--generate` to
162
+ # feed normalized results to the selected AI provider. Omit `--generate`
163
+ # when the caller should handle the search results itself.
164
+ YAML
165
+
166
+ # Raised when command-line arguments are invalid or incomplete.
167
+ class UsageError < StandardError; end
168
+
169
+ # Run the CLI and return a process exit status.
17
170
  #
18
- # Prescient configuration example.
19
- #
20
- # Precedence, from lowest to highest:
21
- # 1. Built-in defaults and provider environment variables.
22
- # 2. Values in this YAML file.
23
- # 3. Per-operation CLI overrides such as --provider and --chat-model.
24
- #
25
- # Use `prescient config validate` after editing this file.
26
- # Keep credentials out of source control; use *_env references instead.
27
- version: 1
28
-
29
- # Global behavior.
30
- default_provider: ollama
31
- timeout: 30
32
- retry_attempts: 3
33
- retry_delay: 1.0
34
- fallback_providers: []
35
- sensitive_keys:
36
- - api_key
37
- - password
38
- - token
39
- - secret
40
-
41
- providers:
42
- # Local Ollama requires no API key.
43
- ollama:
44
- type: ollama
45
- url: http://localhost:11434
46
- embedding_model: nomic-embed-text
47
- chat_model: llama3.2:3b
48
- # prompt_templates:
49
- # system_prompt: You are a concise assistant.
50
- # no_context_template: "%<system_prompt>s\x5Cn\x5CnUser: %<query>s"
51
- # with_context_template: "%<system_prompt>s\x5Cn\x5CnContext:\x5Cn%<context>s\x5Cn\x5CnUser: %<query>s"
52
-
53
- # Uncomment a cloud provider and set its credential in the environment.
54
- # openai:
55
- # type: openai
56
- # api_key_env: OPENAI_API_KEY
57
- # embedding_model: text-embedding-3-small
58
- # chat_model: gpt-4.1-mini
59
- # prompt_templates:
60
- # system_prompt: You are a concise assistant.
61
- # no_context_template: "%<system_prompt>s\x5Cn\x5CnUser: %<query>s"
62
-
63
- # anthropic:
64
- # type: anthropic
65
- # api_key_env: ANTHROPIC_API_KEY
66
- # model: claude-sonnet-4-20250514
67
-
68
- # gemini:
69
- # type: gemini
70
- # api_key_env: GEMINI_API_KEY
71
- # embedding_model: gemini-embedding-001
72
- # chat_model: gemini-2.5-flash
73
-
74
- # mistral:
75
- # type: mistral
76
- # api_key_env: MISTRAL_API_KEY
77
- # embedding_model: mistral-embed
78
- # chat_model: mistral-large-latest
79
-
80
- # DeepSeek supports text generation, but not embeddings.
81
- # deepseek:
82
- # type: deepseek
83
- # api_key_env: DEEPSEEK_API_KEY
84
- # chat_model: deepseek-v4-flash
85
-
86
- # xai:
87
- # type: xai
88
- # api_key_env: XAI_API_KEY
89
- # chat_model: grok-4.5
90
-
91
- # huggingface:
92
- # type: huggingface
93
- # api_key_env: HUGGINGFACE_API_KEY
94
- # embedding_model: sentence-transformers/all-MiniLM-L6-v2
95
- # chat_model: google/gemma-2-2b-it
96
-
97
- # External tools are opt-in and separate from AI providers. They can be
98
- # used directly with `prescient search`, or as context with
99
- # `prescient search --generate`.
171
+ # @param arguments [Array<String>] Command-line arguments
172
+ # @param input [IO] Input stream used for stdin prompts
173
+ # @param output [IO] Output stream for command results
174
+ # @param errors [IO] Output stream for diagnostics
175
+ # @return [Integer] Process exit status
176
+ def self.run(arguments, input: $stdin, output: $stdout, errors: $stderr)
177
+ new(arguments, input:, output:, errors:).run
178
+ rescue UsageError, OptionParser::ParseError => e
179
+ errors.puts "prescient: #{e.message}"
180
+ 2
181
+ rescue Prescient::Error => e
182
+ errors.puts "prescient: #{e.message}"
183
+ 1
184
+ end
185
+
186
+ # Initialize a CLI runner with injectable streams.
100
187
  #
101
- # The CLI also registers `web_search` automatically when SEARXNG_URL is
102
- # set and no YAML tool configuration is provided.
103
- tools:
104
- # Local SearXNG example. Uncomment this block to configure a tool in YAML.
105
- # web_search:
106
- # type: searxng
107
- # url: http://localhost:8080
108
- # timeout: 5
109
- # max_results: 5
110
- # language: en
111
- # categories:
112
- # - general
113
- # - science
114
- # max_response_bytes: 1048576
115
-
116
- # SearchApi example. It uses SearchApi's Google engine by default and
117
- # authenticates with a Bearer token from the environment.
118
- # searchapi_web:
119
- # type: searchapi
120
- # api_key_env: SEARCHAPI_API_KEY
121
- # engine: google
122
- # location: New York
123
- # hl: en
124
- # gl: us
125
- # timeout: 10
126
- # max_results: 5
127
-
128
- # Capability fallback. Adapters are tried in order, and fallback occurs
129
- # only for transient connection or rate-limit failures.
130
- # resilient_search:
131
- # adapters:
132
- # - type: searxng
133
- # url_env: SEARXNG_URL
134
- # - type: searchapi
135
- # api_key_env: SEARCHAPI_API_KEY
136
- # engine: google
137
-
138
- # Prefer an environment reference when the URL differs by environment
139
- # or should not be committed. Use `--tool research_search` to select a
140
- # tool with a custom name.
141
- # research_search:
142
- # type: searxng
143
- # url_env: SEARXNG_URL
144
- # timeout_env: SEARXNG_TIMEOUT
145
- # max_results_env: SEARXNG_MAX_RESULTS
146
- # language_env: SEARXNG_LANGUAGE
147
- # categories_env: SEARXNG_CATEGORIES
148
-
149
- # Search results are returned directly by default. Add `--generate` to
150
- # feed normalized results to the selected AI provider. Omit `--generate`
151
- # when the caller should handle the search results itself.
152
- YAML
153
-
154
- # Raised when command-line arguments are invalid or incomplete.
155
- class UsageError < StandardError; end
156
-
157
- # Run the CLI and return a process exit status.
158
- #
159
- # @param arguments [Array<String>] Command-line arguments
160
- # @param input [IO] Input stream used for stdin prompts
161
- # @param output [IO] Output stream for command results
162
- # @param errors [IO] Output stream for diagnostics
163
- # @return [Integer] Process exit status
164
- def self.run(arguments, input: $stdin, output: $stdout, errors: $stderr)
165
- new(arguments, input:, output:, errors:).run
166
- rescue UsageError, OptionParser::ParseError => e
167
- errors.puts "prescient: #{e.message}"
168
- 2
169
- rescue Prescient::Error => e
170
- errors.puts "prescient: #{e.message}"
171
- 1
172
- end
188
+ # @param arguments [Array<String>] Command-line arguments
189
+ # @param input [IO] Input stream used for stdin prompts
190
+ # @param output [IO] Output stream for command results
191
+ # @param errors [IO] Output stream for diagnostics
192
+ def initialize(arguments, input:, output:, errors:)
193
+ @arguments = arguments.dup
194
+ @input = input
195
+ @output = output
196
+ @errors = errors
197
+ end
173
198
 
174
- # Initialize a CLI runner with injectable streams.
175
- #
176
- # @param arguments [Array<String>] Command-line arguments
177
- # @param input [IO] Input stream used for stdin prompts
178
- # @param output [IO] Output stream for command results
179
- # @param errors [IO] Output stream for diagnostics
180
- def initialize(arguments, input:, output:, errors:)
181
- @arguments = arguments.dup
182
- @input = input
183
- @output = output
184
- @errors = errors
185
- end
199
+ # Execute the CLI command and return its process status.
200
+ # @return [Integer] Process exit status
201
+ def run
202
+ config_path = extract_global_config_path
203
+ Prescient.load_configuration(config_path) if config_path || ENV["PRESCIENT_CONFIG"]
186
204
 
187
- # Execute the CLI command and return its process status.
188
- # @return [Integer] Process exit status
189
- def run
190
- config_path = extract_global_config_path
191
- Prescient.load_configuration(config_path) if config_path || ENV['PRESCIENT_CONFIG']
205
+ command = @arguments.shift
206
+ return print_help(2) unless command
192
207
 
193
- command = @arguments.shift
194
- return print_help(2) unless command
208
+ run_command(command)
209
+ end
195
210
 
196
- run_command(command)
197
- end
211
+ # Dispatch a parsed command to its handler.
212
+ # @param command [String] Command name
213
+ # @return [Integer] Process exit status
214
+ def run_command(command)
215
+ return print_help(0) if ["help", "--help", "-h"].include?(command)
216
+
217
+ handler = COMMAND_HANDLERS[command]
218
+ raise UsageError, "unknown command #{command.inspect}; run 'prescient help'" unless handler
198
219
 
199
- # Dispatch a parsed command to its handler.
200
- # @param command [String] Command name
201
- # @return [Integer] Process exit status
202
- def run_command(command)
203
- case command
204
- when 'providers' then providers
205
- when 'health' then health
206
- when 'generate' then generate
207
- when 'embed' then embed
208
- when 'search' then search
209
- when 'config' then config
210
- when 'help', '--help', '-h' then print_help(0)
211
- else
212
- raise UsageError, "unknown command #{command.inspect}; run 'prescient help'"
220
+ send(handler)
213
221
  end
214
- end
215
222
 
216
- private
223
+ private
217
224
 
218
- def providers
219
- options = parse_options('List configured providers')
220
- return options if options.is_a?(Integer)
225
+ def providers
226
+ options = parse_options("List configured providers")
227
+ return options if options.is_a?(Integer)
221
228
 
222
- provider_list = Prescient.configuration.providers.map { |name, registration|
223
- { name: name.to_s, class: registration[:class].name }
224
- }
229
+ provider_list = Prescient.configuration.providers.map do |name, registration|
230
+ { name: name.to_s, class: registration[:class].name }
231
+ end
225
232
 
226
- if options[:format] == 'json'
227
- print_json(providers: provider_list)
228
- else
229
- provider_list.each { |provider| @output.puts "#{provider[:name]}\t#{provider[:class]}" }
233
+ if options[:format] == "json"
234
+ print_json(providers: provider_list)
235
+ else
236
+ provider_list.each { |provider| @output.puts "#{provider[:name]}\t#{provider[:class]}" }
237
+ end
238
+ 0
230
239
  end
231
- 0
232
- end
233
240
 
234
- def health
235
- options = parse_options('Check provider health')
236
- return options if options.is_a?(Integer)
241
+ def health
242
+ options = parse_options("Check provider health")
243
+ return options if options.is_a?(Integer)
237
244
 
238
- names = options[:provider] ? [options[:provider].to_sym] : Prescient.configuration.providers.keys
239
- raise UsageError, 'no providers are configured' if names.empty?
245
+ names = options[:provider] ? [options[:provider].to_sym] : Prescient.configuration.providers.keys
246
+ raise UsageError, "no providers are configured" if names.empty?
240
247
 
241
- results = names.to_h { |name| [name.to_s, Prescient.health_check(provider: name)] }
242
- output_health(results, options[:format])
243
- results.values.all? { |result| result[:reachable] != false } ? 0 : 1
244
- end
248
+ results = names.to_h { |name| [name.to_s, Prescient.health_check(provider: name)] }
249
+ output_health(results, options[:format])
250
+ results.values.all? { |result| result[:reachable] != false } ? 0 : 1
251
+ end
245
252
 
246
- def generate
247
- options = parse_options('Generate a text response', fallback: true)
248
- return options if options.is_a?(Integer)
253
+ def generate
254
+ options = parse_options("Generate a text response", fallback: true, documents: true)
255
+ return options if options.is_a?(Integer)
249
256
 
250
- prompt = read_text(options[:arguments], 'prompt')
251
- client = client_for(options)
252
- response = client.generate_response(prompt, **model_options(options))
257
+ prompt = read_text(options[:arguments], "prompt")
258
+ client = client_for(options)
259
+ context = if options[:json_file]
260
+ Prescient::DocumentSource::JsonFile.new(path: options[:json_file]).fetch
261
+ else
262
+ []
263
+ end
264
+ response = client.generate_response(prompt, context, **model_options(options))
253
265
 
254
- options[:format] == 'json' ? print_json(response) : @output.puts(response[:response])
255
- 0
256
- end
266
+ options[:format] == "json" ? print_json(response) : @output.puts(response[:response])
267
+ 0
268
+ end
257
269
 
258
- def embed
259
- options = parse_options('Generate an embedding', fallback: true)
260
- return options if options.is_a?(Integer)
270
+ def embed
271
+ options = parse_options("Generate an embedding", fallback: true)
272
+ return options if options.is_a?(Integer)
261
273
 
262
- text = read_text(options[:arguments], 'text')
263
- client = client_for(options)
264
- embedding = client.generate_embedding(text, **model_options(options))
274
+ text = read_text(options[:arguments], "text")
275
+ client = client_for(options)
276
+ embedding = client.generate_embedding(text, **model_options(options))
265
277
 
266
- if options[:format] == 'json'
267
- print_json(embedding: embedding, dimensions: embedding.length, provider: client.provider_name.to_s)
268
- else
269
- @output.puts JSON.generate(embedding)
278
+ if options[:format] == "json"
279
+ print_json(embedding: embedding, dimensions: embedding.length, provider: client.provider_name.to_s)
280
+ else
281
+ @output.puts JSON.generate(embedding)
282
+ end
283
+ 0
270
284
  end
271
- 0
272
- end
273
-
274
- def search
275
- options = parse_options(
276
- 'Search with a configured external tool',
277
- fallback: true, tool: true, limit: true, generate: true,
278
- )
279
- return options if options.is_a?(Integer)
280
285
 
281
- query = read_text(options[:arguments], 'query')
282
- tool_name = (options[:tool] || 'web_search').to_sym
283
- return generate_search_response(query, tool_name, options) if options[:generate]
284
-
285
- tool = Prescient.tool(tool_name)
286
- raise UsageError, "tool not configured: #{tool_name}" unless tool
286
+ def search
287
+ options = parse_options(
288
+ "Search with a configured external tool",
289
+ fallback: true, tool: true, limit: true, generate: true
290
+ )
291
+ return options if options.is_a?(Integer)
292
+
293
+ query = read_text(options[:arguments], "query")
294
+ tool_name = (options[:tool] || "web_search").to_sym
295
+ return generate_search_response(query, tool_name, options) if options[:generate]
296
+
297
+ tool = Prescient.tool(tool_name)
298
+ raise UsageError, "tool not configured: #{tool_name}" unless tool
299
+
300
+ result = tool.search(query, limit: options[:limit])
301
+ if options[:format] == "json"
302
+ print_json(result)
303
+ else
304
+ print_search_results(result[:results])
305
+ end
306
+ 0
307
+ end
287
308
 
288
- result = tool.search(query, limit: options[:limit])
289
- if options[:format] == 'json'
290
- print_json(result)
291
- else
292
- print_search_results(result[:results])
309
+ def agent
310
+ require "prescient/agent"
311
+ options = parse_options("Run a bounded agent task", agent: true)
312
+ return options if options.is_a?(Integer)
313
+
314
+ task = read_text(options[:arguments], "task")
315
+ Prescient::Agent::CLIAdapter.new(output: @output, errors: @errors).run(
316
+ task:,
317
+ provider: options[:provider]&.to_sym,
318
+ tool_names: options.fetch(:tools, []),
319
+ max_loops: options[:max_loops] || Prescient::Agent::Configuration::DEFAULT_MAX_LOOPS,
320
+ format: options[:format],
321
+ provider_options: provider_options(options),
322
+ generation_options: model_options(options)
323
+ )
293
324
  end
294
- 0
295
- end
296
325
 
297
- def generate_search_response(query, tool_name, options)
298
- response = Prescient.search_and_generate(
299
- query,
300
- tool: tool_name,
301
- provider: options[:provider]&.to_sym,
302
- limit: options[:limit],
303
- enable_fallback: options[:fallback],
304
- provider_options: provider_options(options),
305
- **model_options(options),
306
- )
307
- options[:format] == 'json' ? print_json(response) : @output.puts(response[:response])
308
- 0
309
- end
326
+ def generate_search_response(query, tool_name, options)
327
+ response = Prescient.search_and_generate(
328
+ query,
329
+ tool: tool_name,
330
+ provider: options[:provider]&.to_sym,
331
+ limit: options[:limit],
332
+ enable_fallback: options[:fallback],
333
+ provider_options: provider_options(options),
334
+ **model_options(options)
335
+ )
336
+ options[:format] == "json" ? print_json(response) : @output.puts(response[:response])
337
+ 0
338
+ end
310
339
 
311
- def print_search_results(results)
312
- results.each do |item|
313
- @output.puts item[:title]
314
- @output.puts item[:url]
315
- @output.puts item[:snippet] unless item[:snippet].empty?
316
- @output.puts
340
+ def print_search_results(results)
341
+ results.each do |item|
342
+ @output.puts item[:title]
343
+ @output.puts item[:url]
344
+ @output.puts item[:snippet] unless item[:snippet].empty?
345
+ @output.puts
346
+ end
317
347
  end
318
- end
319
348
 
320
- def config
321
- subcommand = @arguments.shift
322
- case subcommand
323
- when 'validate' then validate_config_command
324
- when 'example' then configuration_example_command
325
- else
326
- raise UsageError, "unknown config command #{subcommand.inspect}"
349
+ def config
350
+ subcommand = @arguments.shift
351
+ case subcommand
352
+ when "validate" then validate_config_command
353
+ when "example" then configuration_example_command
354
+ else
355
+ raise UsageError, "unknown config command #{subcommand.inspect}"
356
+ end
327
357
  end
328
- end
329
358
 
330
- def validate_config_command
331
- options = parse_options('Validate the current configuration')
332
- return options if options.is_a?(Integer)
359
+ def validate_config_command
360
+ options = parse_options("Validate the current configuration")
361
+ return options if options.is_a?(Integer)
333
362
 
334
- validate_configuration
335
- if options[:format] == 'json'
336
- print_json(valid: true, providers: Prescient.configuration.providers.keys.map(&:to_s))
337
- else
338
- @output.puts 'configuration valid'
363
+ validate_configuration
364
+ if options[:format] == "json"
365
+ print_json(valid: true, providers: Prescient.configuration.providers.keys.map(&:to_s))
366
+ else
367
+ @output.puts "configuration valid"
368
+ end
369
+ 0
339
370
  end
340
- 0
341
- end
342
-
343
- def configuration_example_command
344
- options = parse_options('Generate an annotated YAML configuration example')
345
- return options if options.is_a?(Integer)
346
371
 
347
- @output.write(CONFIGURATION_EXAMPLE)
348
- 0
349
- end
372
+ def configuration_example_command
373
+ options = parse_options("Generate an annotated YAML configuration example")
374
+ return options if options.is_a?(Integer)
350
375
 
351
- def validate_configuration
352
- configuration = Prescient.configuration
353
- unless configuration.provider(configuration.default_provider)
354
- raise Prescient::Error, 'default provider is not configured'
376
+ @output.write(CONFIGURATION_EXAMPLE)
377
+ 0
355
378
  end
356
379
 
357
- configuration.providers.each_key do |name|
358
- configuration.provider(name)
359
- end
360
- configuration.tools.each_key do |name|
361
- configuration.tool(name)
362
- end
363
- end
380
+ def validate_configuration
381
+ configuration = Prescient.configuration
382
+ unless configuration.provider(configuration.default_provider)
383
+ raise Prescient::Error, "default provider is not configured"
384
+ end
364
385
 
365
- def parse_options(description, fallback: false, tool: false, limit: false, generate: false)
366
- options = { format: 'text', fallback: fallback }
367
- parser = OptionParser.new do |parser|
368
- parser.banner = "Usage: prescient #{@arguments.first || 'command'} [options]"
369
- parser.separator description
370
- parser.separator ''
371
- parser.separator 'Global options:'
372
- add_common_options(parser, options)
373
- parser.on('--no-fallback', 'Disable provider fallback') { options[:fallback] = false } if fallback
374
- add_tool_options(parser, options, tool:, limit:, generate:)
375
- parser.on('-h', '--help', 'Show command help') do
376
- @output.puts parser
377
- throw :help_shown, 0
386
+ configuration.providers.each_key do |name|
387
+ configuration.provider(name)
388
+ end
389
+ configuration.tools.each_key do |name|
390
+ configuration.tool(name)
378
391
  end
379
392
  end
380
393
 
381
- result = catch(:help_shown) { parse_arguments(parser) }
382
- return result unless result.nil?
383
-
384
- options[:arguments] = @arguments
385
- options
386
- end
387
-
388
- def add_tool_options(parser, options, tool:, limit:, generate:)
389
- return unless tool || limit || generate
390
-
391
- parser.separator ''
392
- parser.separator 'Search options:'
393
- parser.on('--tool NAME', 'Use a configured external tool') { |value| options[:tool] = value } if tool
394
- parser.on('--generate', 'Use search results as AI provider context') { options[:generate] = true } if generate
395
- return unless limit
394
+ def parse_options(description, fallback: false, tool: false, limit: false, generate: false,
395
+ documents: false, agent: false)
396
+ options = { format: "text", fallback: fallback }
397
+ parser = OptionParser.new do |parser|
398
+ parser.banner = "Usage: prescient #{@arguments.first || "command"} [options]"
399
+ parser.separator description
400
+ parser.separator ""
401
+ parser.separator "Global options:"
402
+ add_common_options(parser, options)
403
+ add_optional_options(parser, options, fallback:, tool:, limit:, generate:, documents:, agent:)
404
+ parser.on("-h", "--help", "Show command help") do
405
+ @output.puts parser
406
+ throw :help_shown, 0
407
+ end
408
+ end
396
409
 
397
- parser.on('--limit COUNT', Integer, 'Limit the number of results') { |value| options[:limit] = value }
398
- end
410
+ result = catch(:help_shown) { parse_arguments(parser) }
411
+ return result unless result.nil?
399
412
 
400
- def add_common_options(parser, options)
401
- parser.on('--config PATH', 'Load configuration from a YAML file') do |value|
402
- options[:config] = value
413
+ options[:arguments] = @arguments
414
+ options
403
415
  end
404
- parser.on('--format FORMAT', FORMATS, "Output format (#{FORMATS.join(', ')})") do |value|
405
- options[:format] = value
406
- end
407
- parser.on('--provider NAME', 'Use a specific provider') do |value|
408
- options[:provider] = value
416
+
417
+ def add_optional_options(parser, options, fallback:, tool:, limit:, generate:, documents:, agent:)
418
+ add_document_options(parser, options) if documents
419
+ parser.on("--no-fallback", "Disable provider fallback") { options[:fallback] = false } if fallback
420
+ add_tool_options(parser, options, tool:, limit:, generate:)
421
+ add_agent_options(parser, options) if agent
409
422
  end
410
- add_model_options(parser, options)
411
- add_credential_options(parser, options)
412
- end
413
423
 
414
- # Parse command arguments and return nil when parsing completes.
415
- #
416
- # @param parser [OptionParser] Configured command option parser
417
- # @return [nil]
418
- def parse_arguments(parser)
419
- parser.parse!(@arguments)
420
- nil
421
- end
424
+ def add_agent_options(parser, options)
425
+ parser.separator ""
426
+ parser.separator "Agent options:"
427
+ options[:tools] = []
428
+ parser.on("--tool NAME", "Allow a configured tool (repeatable)") do |value|
429
+ options[:tools] << value.to_sym
430
+ end
431
+ parser.on("--max-loops COUNT", Integer, "Maximum agent iterations") { |value| options[:max_loops] = value }
432
+ end
422
433
 
423
- def model_options(options)
424
- options[:model] ? { model: options[:model] } : {}
425
- end
434
+ def add_tool_options(parser, options, tool:, limit:, generate:)
435
+ return unless tool || limit || generate
426
436
 
427
- def client_for(options)
428
- validate_override_options(options)
429
- Prescient.client(
430
- options[:provider]&.to_sym,
431
- enable_fallback: options[:fallback],
432
- provider_options: provider_options(options),
433
- )
434
- rescue KeyError => e
435
- raise UsageError, "environment variable not set: #{e.key}"
436
- end
437
+ parser.separator ""
438
+ parser.separator "Search options:"
439
+ parser.on("--tool NAME", "Use a configured external tool") { |value| options[:tool] = value } if tool
440
+ parser.on("--generate", "Use search results as AI provider context") { options[:generate] = true } if generate
441
+ return unless limit
437
442
 
438
- def add_model_options(parser, options)
439
- parser.on('--model NAME', 'Override the configured model') do |value|
440
- options[:model] = value
441
- end
442
- parser.on('--embedding-model NAME', 'Override the embedding model') do |value|
443
- options[:embedding_model] = value
443
+ parser.on("--limit COUNT", Integer, "Limit the number of results") { |value| options[:limit] = value }
444
444
  end
445
- parser.on('--chat-model NAME', 'Override the chat model') do |value|
446
- options[:chat_model] = value
447
- end
448
- parser.on('--system-prompt TEXT', 'Override the system prompt') do |value|
449
- options[:system_prompt] = value
450
- end
451
- parser.on('--no-context-template TEXT', 'Override the no-context prompt template') do |value|
452
- options[:no_context_template] = value
453
- end
454
- parser.on('--with-context-template TEXT', 'Override the with-context prompt template') do |value|
455
- options[:with_context_template] = value
445
+
446
+ def add_common_options(parser, options)
447
+ parser.on("--config PATH", "Load configuration from a YAML file") do |value|
448
+ options[:config] = value
449
+ end
450
+ parser.on("--format FORMAT", FORMATS, "Output format (#{FORMATS.join(", ")})") do |value|
451
+ options[:format] = value
452
+ end
453
+ parser.on("--provider NAME", "Use a specific provider") do |value|
454
+ options[:provider] = value
455
+ end
456
+ add_model_options(parser, options)
457
+ add_credential_options(parser, options)
456
458
  end
457
- parser.on('--prompt-templates-file PATH', 'Load prompt templates from a YAML file') do |value|
458
- options[:prompt_templates_file] = value
459
+
460
+ # Parse command arguments and return nil when parsing completes.
461
+ #
462
+ # @param parser [OptionParser] Configured command option parser
463
+ # @return [nil]
464
+ def parse_arguments(parser)
465
+ parser.parse!(@arguments)
466
+ nil
459
467
  end
460
- end
461
468
 
462
- def add_credential_options(parser, options)
463
- parser.on('--api-key KEY', 'Use an API key for this operation') do |value|
464
- options[:api_key] = value
469
+ def model_options(options)
470
+ options[:model] ? { model: options[:model] } : {}
465
471
  end
466
- parser.on('--api-key-env NAME', 'Read the API key from this environment variable') do |value|
467
- options[:api_key_env] = value
472
+
473
+ def client_for(options)
474
+ validate_override_options(options)
475
+ Prescient.client(
476
+ options[:provider]&.to_sym,
477
+ enable_fallback: options[:fallback],
478
+ provider_options: provider_options(options)
479
+ )
480
+ rescue KeyError => e
481
+ raise UsageError, "environment variable not set: #{e.key}"
468
482
  end
469
- end
470
483
 
471
- def validate_override_options(options)
472
- if options[:model] && (options[:embedding_model] || options[:chat_model])
473
- raise UsageError, '--model cannot be combined with --embedding-model or --chat-model'
484
+ def add_model_options(parser, options)
485
+ parser.on("--model NAME", "Override the configured model") do |value|
486
+ options[:model] = value
487
+ end
488
+ parser.on("--embedding-model NAME", "Override the embedding model") do |value|
489
+ options[:embedding_model] = value
490
+ end
491
+ parser.on("--chat-model NAME", "Override the chat model") do |value|
492
+ options[:chat_model] = value
493
+ end
494
+ parser.on("--system-prompt TEXT", "Override the system prompt") do |value|
495
+ options[:system_prompt] = value
496
+ end
497
+ parser.on("--no-context-template TEXT", "Override the no-context prompt template") do |value|
498
+ options[:no_context_template] = value
499
+ end
500
+ parser.on("--with-context-template TEXT", "Override the with-context prompt template") do |value|
501
+ options[:with_context_template] = value
502
+ end
503
+ parser.on("--prompt-templates-file PATH", "Load prompt templates from a YAML file") do |value|
504
+ options[:prompt_templates_file] = value
505
+ end
474
506
  end
475
- return unless options[:api_key] && options[:api_key_env]
476
507
 
477
- raise UsageError, '--api-key cannot be combined with --api-key-env'
478
- end
508
+ def add_credential_options(parser, options)
509
+ parser.on("--api-key KEY", "Use an API key for this operation") do |value|
510
+ options[:api_key] = value
511
+ end
512
+ parser.on("--api-key-env NAME", "Read the API key from this environment variable") do |value|
513
+ options[:api_key_env] = value
514
+ end
515
+ end
479
516
 
480
- def provider_options(options)
481
- {
482
- api_key: api_key_override(options),
483
- embedding_model: options[:embedding_model],
484
- chat_model: options[:chat_model],
485
- prompt_templates: prompt_templates(options),
486
- }.compact
487
- end
517
+ def add_document_options(parser, options)
518
+ parser.on("--json-file PATH", "Load JSON documents as generation context") do |value|
519
+ options[:json_file] = value
520
+ end
521
+ end
488
522
 
489
- def prompt_templates(options)
490
- templates = if options[:prompt_templates_file]
491
- data = YAML.safe_load_file(
492
- options[:prompt_templates_file],
493
- permitted_classes: [],
494
- permitted_symbols: [],
495
- aliases: true,
496
- )
497
- raise UsageError, 'prompt templates file must contain a mapping' unless data.is_a?(Hash)
498
-
499
- data.transform_keys(&:to_sym)
500
- else
501
- {}
502
- end
523
+ def validate_override_options(options)
524
+ if options[:model] && (options[:embedding_model] || options[:chat_model])
525
+ raise UsageError, "--model cannot be combined with --embedding-model or --chat-model"
526
+ end
527
+ return unless options[:api_key] && options[:api_key_env]
503
528
 
504
- [:system_prompt, :no_context_template, :with_context_template].each do |key|
505
- templates[key] = options[key] if options[key]
529
+ raise UsageError, "--api-key cannot be combined with --api-key-env"
506
530
  end
507
- templates.empty? ? nil : templates
508
- rescue Errno::ENOENT
509
- raise UsageError, "prompt templates file not found: #{options[:prompt_templates_file]}"
510
- rescue Psych::SyntaxError => e
511
- raise UsageError, "invalid prompt templates YAML: #{e.message}"
512
- end
513
-
514
- def api_key_override(options)
515
- return options[:api_key] if options[:api_key]
516
- return ENV.fetch(options[:api_key_env]) if options[:api_key_env]
517
531
 
518
- nil
519
- end
532
+ def provider_options(options)
533
+ {
534
+ api_key: api_key_override(options),
535
+ embedding_model: options[:embedding_model],
536
+ chat_model: options[:chat_model],
537
+ prompt_templates: prompt_templates(options)
538
+ }.compact
539
+ end
520
540
 
521
- def output_health(results, format)
522
- if format == 'json'
523
- print_json(results)
524
- else
525
- results.each do |name, result|
526
- @output.puts '%<name>-12s %<status>s' % { name: name, status: result[:status] || 'unknown' }
541
+ def prompt_templates(options)
542
+ templates = if options[:prompt_templates_file]
543
+ data = YAML.safe_load_file(
544
+ options[:prompt_templates_file],
545
+ permitted_classes: [],
546
+ permitted_symbols: [],
547
+ aliases: true
548
+ )
549
+ raise UsageError, "prompt templates file must contain a mapping" unless data.is_a?(Hash)
550
+
551
+ data.transform_keys(&:to_sym)
552
+ else
553
+ {}
554
+ end
555
+
556
+ %i[system_prompt no_context_template with_context_template].each do |key|
557
+ templates[key] = options[key] if options[key]
527
558
  end
559
+ templates.empty? ? nil : templates
560
+ rescue Errno::ENOENT
561
+ raise UsageError, "prompt templates file not found: #{options[:prompt_templates_file]}"
562
+ rescue Psych::SyntaxError => e
563
+ raise UsageError, "invalid prompt templates YAML: #{e.message}"
528
564
  end
529
- end
530
565
 
531
- def read_text(arguments, label)
532
- return arguments.join(' ') unless arguments.empty?
533
- return @input.read unless @input.tty?
566
+ def api_key_override(options)
567
+ return options[:api_key] if options[:api_key]
568
+ return ENV.fetch(options[:api_key_env]) if options[:api_key_env]
534
569
 
535
- raise UsageError, "missing #{label}; provide it as an argument or through stdin"
536
- end
570
+ nil
571
+ end
537
572
 
538
- def print_json(value)
539
- @output.puts JSON.generate(value)
540
- end
573
+ def output_health(results, format)
574
+ if format == "json"
575
+ print_json(results)
576
+ else
577
+ results.each do |name, result|
578
+ @output.puts format("%<name>-12s %<status>s", name: name, status: result[:status] || "unknown")
579
+ end
580
+ end
581
+ end
541
582
 
542
- def print_help(status)
543
- @output.puts <<~HELP
544
- Usage: prescient COMMAND [options]
545
-
546
- Commands:
547
- providers List configured providers
548
- health Check provider health
549
- generate TEXT Generate a text response
550
- embed TEXT Generate an embedding
551
- search TEXT Search with a configured external tool
552
- config validate Validate the current configuration
553
- config example Generate an annotated YAML configuration example
554
-
555
- Global options:
556
- --config PATH Load configuration from a YAML file
557
- --provider NAME Select a provider
558
- --model NAME Override the selected operation's model
559
- --chat-model NAME Override the chat model
560
- --embedding-model NAME Override the embedding model
561
- --system-prompt TEXT Override the system prompt
562
- --no-context-template TEXT
563
- Override the no-context prompt template
564
- --with-context-template TEXT
565
- Override the with-context prompt template
566
- --prompt-templates-file PATH
567
- Load prompt templates from a YAML file
568
- --api-key KEY Use an API key for the operation
569
- --api-key-env NAME Read the API key from an environment variable
570
- --format FORMAT Use text or json output
571
-
572
- Search options:
573
- --tool NAME Select an external tool for search
574
- --generate Use search results as AI provider context
575
- --limit COUNT Limit search results
576
- HELP
577
- status
578
- end
583
+ def read_text(arguments, label)
584
+ return arguments.join(" ") unless arguments.empty?
585
+ return @input.read unless @input.tty?
579
586
 
580
- def extract_global_config_path
581
- config_path = nil
582
- filtered_arguments = []
583
- index = 0
587
+ raise UsageError, "missing #{label}; provide it as an argument or through stdin"
588
+ end
584
589
 
585
- while index < @arguments.length
586
- argument = @arguments[index]
587
- if argument == '--config'
588
- value = @arguments[index + 1]
589
- raise UsageError, '--config requires a path' unless value
590
+ def print_json(value)
591
+ @output.puts JSON.generate(value)
592
+ end
590
593
 
591
- config_path = value
592
- index += 2
593
- next
594
- end
594
+ def print_help(status)
595
+ @output.puts <<~HELP
596
+ Usage: prescient COMMAND [options]
597
+
598
+ Commands:
599
+ providers List configured providers
600
+ health Check provider health
601
+ generate TEXT Generate a text response
602
+ embed TEXT Generate an embedding
603
+ search TEXT Search with a configured external tool
604
+ agent TEXT Run a bounded agent task
605
+ config validate Validate the current configuration
606
+ config example Generate an annotated YAML configuration example
607
+
608
+ Global options:
609
+ --config PATH Load configuration from a YAML file
610
+ --provider NAME Select a provider
611
+ --model NAME Override the selected operation's model
612
+ --chat-model NAME Override the chat model
613
+ --embedding-model NAME Override the embedding model
614
+ --system-prompt TEXT Override the system prompt
615
+ --no-context-template TEXT
616
+ Override the no-context prompt template
617
+ --with-context-template TEXT
618
+ Override the with-context prompt template
619
+ --prompt-templates-file PATH
620
+ Load prompt templates from a YAML file
621
+ --api-key KEY Use an API key for the operation
622
+ --api-key-env NAME Read the API key from an environment variable
623
+ --format FORMAT Use text or json output
624
+ --json-file PATH Load JSON documents as generation context
625
+
626
+ Search options:
627
+ --tool NAME Select an external tool for search
628
+ --generate Use search results as AI provider context
629
+ --limit COUNT Limit search results
630
+
631
+ Agent options:
632
+ --tool NAME Allow a configured tool (repeatable)
633
+ --max-loops COUNT Maximum agent iterations
634
+ HELP
635
+ status
636
+ end
595
637
 
596
- if argument.start_with?('--config=')
597
- config_path = argument.split('=', 2).last
638
+ def extract_global_config_path
639
+ config_path = nil
640
+ filtered_arguments = []
641
+ index = 0
642
+
643
+ while index < @arguments.length
644
+ argument = @arguments[index]
645
+ if argument == "--config"
646
+ value = @arguments[index + 1]
647
+ raise UsageError, "--config requires a path" unless value
648
+
649
+ config_path = value
650
+ index += 2
651
+ next
652
+ end
653
+
654
+ if argument.start_with?("--config=")
655
+ config_path = argument.split("=", 2).last
656
+ index += 1
657
+ next
658
+ end
659
+
660
+ filtered_arguments << argument
598
661
  index += 1
599
- next
600
662
  end
601
663
 
602
- filtered_arguments << argument
603
- index += 1
664
+ @arguments = filtered_arguments
665
+ config_path
604
666
  end
605
-
606
- @arguments = filtered_arguments
607
- config_path
608
667
  end
668
+ # rubocop:enable Metrics/ClassLength
609
669
  end