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.
- checksums.yaml +4 -4
- data/.rubocop.yml +21 -268
- data/CHANGELOG.md +37 -0
- data/INTEGRATION_GUIDE.md +7 -1
- data/README.md +210 -1
- data/Steepfile +12 -12
- data/db/migrate/001_create_prescient_tables.rb +15 -16
- data/examples/README.md +2 -1
- data/examples/custom_contexts.rb +4 -4
- data/exe/prescient +2 -2
- data/exe/prescient-mcp +7 -0
- data/lib/prescient/agent/audit_log.rb +37 -0
- data/lib/prescient/agent/cli_adapter.rb +29 -0
- data/lib/prescient/agent/configuration.rb +57 -0
- data/lib/prescient/agent/context.rb +56 -0
- data/lib/prescient/agent/error_serializer.rb +47 -0
- data/lib/prescient/agent/errors.rb +25 -0
- data/lib/prescient/agent/parser.rb +49 -0
- data/lib/prescient/agent/prompt_builder.rb +31 -0
- data/lib/prescient/agent/result.rb +36 -0
- data/lib/prescient/agent/runtime.rb +175 -0
- data/lib/prescient/agent/schema_validator.rb +215 -0
- data/lib/prescient/agent/tool_registry.rb +89 -0
- data/lib/prescient/agent.rb +22 -0
- data/lib/prescient/api.rb +337 -274
- data/lib/prescient/base.rb +370 -372
- data/lib/prescient/cli.rb +586 -526
- data/lib/prescient/client.rb +7 -6
- data/lib/prescient/configuration_loader.rb +492 -488
- data/lib/prescient/document_source.rb +114 -0
- data/lib/prescient/errors.rb +1 -3
- data/lib/prescient/mcp/authentication.rb +39 -0
- data/lib/prescient/mcp/configuration.rb +38 -0
- data/lib/prescient/mcp/rack.rb +243 -0
- data/lib/prescient/mcp/server.rb +202 -0
- data/lib/prescient/mcp/stdio.rb +42 -0
- data/lib/prescient/mcp.rb +8 -0
- data/lib/prescient/pgvector.rb +193 -189
- data/lib/prescient/provider/anthropic.rb +129 -125
- data/lib/prescient/provider/deepseek.rb +122 -118
- data/lib/prescient/provider/gemini.rb +153 -149
- data/lib/prescient/provider/huggingface.rb +191 -187
- data/lib/prescient/provider/mistral.rb +151 -147
- data/lib/prescient/provider/ollama.rb +168 -165
- data/lib/prescient/provider/openai.rb +174 -169
- data/lib/prescient/provider/xai.rb +122 -118
- data/lib/prescient/tool/search_api.rb +125 -121
- data/lib/prescient/tool/searxng.rb +123 -119
- data/lib/prescient/tool.rb +100 -98
- data/lib/prescient/version.rb +1 -1
- data/lib/prescient.rb +68 -62
- data/sig/prescient.rbs +176 -1
- metadata +23 -1
data/lib/prescient/cli.rb
CHANGED
|
@@ -1,609 +1,669 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require_relative
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
#
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
194
|
-
|
|
208
|
+
run_command(command)
|
|
209
|
+
end
|
|
195
210
|
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
+
private
|
|
217
224
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
225
|
+
def providers
|
|
226
|
+
options = parse_options("List configured providers")
|
|
227
|
+
return options if options.is_a?(Integer)
|
|
221
228
|
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
241
|
+
def health
|
|
242
|
+
options = parse_options("Check provider health")
|
|
243
|
+
return options if options.is_a?(Integer)
|
|
237
244
|
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
266
|
+
options[:format] == "json" ? print_json(response) : @output.puts(response[:response])
|
|
267
|
+
0
|
|
268
|
+
end
|
|
257
269
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
270
|
+
def embed
|
|
271
|
+
options = parse_options("Generate an embedding", fallback: true)
|
|
272
|
+
return options if options.is_a?(Integer)
|
|
261
273
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
274
|
+
text = read_text(options[:arguments], "text")
|
|
275
|
+
client = client_for(options)
|
|
276
|
+
embedding = client.generate_embedding(text, **model_options(options))
|
|
265
277
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
359
|
+
def validate_config_command
|
|
360
|
+
options = parse_options("Validate the current configuration")
|
|
361
|
+
return options if options.is_a?(Integer)
|
|
333
362
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
358
|
-
configuration.
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
398
|
-
|
|
410
|
+
result = catch(:help_shown) { parse_arguments(parser) }
|
|
411
|
+
return result unless result.nil?
|
|
399
412
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
options[:config] = value
|
|
413
|
+
options[:arguments] = @arguments
|
|
414
|
+
options
|
|
403
415
|
end
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
options
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
end
|
|
434
|
+
def add_tool_options(parser, options, tool:, limit:, generate:)
|
|
435
|
+
return unless tool || limit || generate
|
|
426
436
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
options[:
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
458
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
options[:api_key] = value
|
|
469
|
+
def model_options(options)
|
|
470
|
+
options[:model] ? { model: options[:model] } : {}
|
|
465
471
|
end
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
478
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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
|
-
|
|
519
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
536
|
-
|
|
570
|
+
nil
|
|
571
|
+
end
|
|
537
572
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
filtered_arguments = []
|
|
583
|
-
index = 0
|
|
587
|
+
raise UsageError, "missing #{label}; provide it as an argument or through stdin"
|
|
588
|
+
end
|
|
584
589
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
597
|
-
|
|
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
|
-
|
|
603
|
-
|
|
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
|