exa-ai 0.2.0 → 0.3.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/README.md +1 -3
- data/exe/exa-ai-answer +31 -7
- data/exe/exa-ai-get-contents +190 -34
- data/exe/exa-ai-research-start +2 -2
- data/exe/exa-ai-search +0 -5
- data/lib/exa/client.rb +15 -0
- data/lib/exa/services/answer_stream.rb +90 -0
- data/lib/exa/version.rb +1 -1
- data/lib/exa.rb +1 -0
- metadata +16 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ae8ad5762410325124acdcada89aed1a88b18dcbecc8b85431b99b0c05e0f354
|
4
|
+
data.tar.gz: b1d8ec27baf9bdf324af7183a6088e22e2437ef5d0bb0c97edee47eadeeed7de
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1803b1811bc368be4d83bd9b02aa095c4f77f0dd97e05f3bd96ab9bc380d54b9a9bb7e17f5e3fca78c7a2f7348000f61bc55ec4875273eafa6dd0762fb09f7aa
|
7
|
+
data.tar.gz: 0ec026a9d5b4d05c8779aa3e10df8c25c48e5a728f92608941e28f2b18a46238908af53837dfa234b22f2ad595922f03d1ce25f98f6b9f932ecce8570914a8aa
|
data/README.md
CHANGED
@@ -220,7 +220,6 @@ exa-ai search "AI" --output-format pretty
|
|
220
220
|
- `--type TYPE` - Search type: keyword, neural, or auto (default: auto)
|
221
221
|
- `--include-domains DOMAINS` - Comma-separated domains to include
|
222
222
|
- `--exclude-domains DOMAINS` - Comma-separated domains to exclude
|
223
|
-
- `--use-autoprompt` - Use Exa's autoprompt feature
|
224
223
|
- `--output-format FORMAT` - json or pretty (default: json)
|
225
224
|
- `--api-key KEY` - API key (or set EXA_API_KEY env var)
|
226
225
|
|
@@ -619,8 +618,7 @@ client = Exa::Client.new
|
|
619
618
|
results = client.search("kubernetes tutorial",
|
620
619
|
num_results: 20,
|
621
620
|
type: "neural",
|
622
|
-
include_domains: ["kubernetes.io", "github.com"]
|
623
|
-
use_autoprompt: true
|
621
|
+
include_domains: ["kubernetes.io", "github.com"]
|
624
622
|
)
|
625
623
|
|
626
624
|
results.results.each do |item|
|
data/exe/exa-ai-answer
CHANGED
@@ -12,7 +12,8 @@ def parse_args(argv)
|
|
12
12
|
args = {
|
13
13
|
output_format: "json",
|
14
14
|
api_key: nil,
|
15
|
-
text: false
|
15
|
+
text: false,
|
16
|
+
stream: false
|
16
17
|
}
|
17
18
|
|
18
19
|
# Extract query (first non-flag argument)
|
@@ -24,9 +25,15 @@ def parse_args(argv)
|
|
24
25
|
when "--text"
|
25
26
|
args[:text] = true
|
26
27
|
i += 1
|
28
|
+
when "--stream"
|
29
|
+
args[:stream] = true
|
30
|
+
i += 1
|
27
31
|
when "--output-schema"
|
28
32
|
args[:output_schema] = argv[i + 1]
|
29
33
|
i += 2
|
34
|
+
when "--system-prompt"
|
35
|
+
args[:system_prompt] = argv[i + 1]
|
36
|
+
i += 2
|
30
37
|
when "--api-key"
|
31
38
|
args[:api_key] = argv[i + 1]
|
32
39
|
i += 2
|
@@ -43,17 +50,21 @@ def parse_args(argv)
|
|
43
50
|
QUERY Question or query to answer (required)
|
44
51
|
|
45
52
|
Options:
|
53
|
+
--stream Stream answer chunks in real-time
|
46
54
|
--text Include full text content from sources
|
47
55
|
--output-schema JSON JSON schema for structured output
|
56
|
+
--system-prompt TEXT System prompt to guide answer generation
|
48
57
|
--api-key KEY Exa API key (or set EXA_API_KEY env var)
|
49
58
|
--output-format FMT Output format: json, pretty, or text (default: json)
|
50
59
|
--help, -h Show this help message
|
51
60
|
|
52
61
|
Examples:
|
53
62
|
exa-api answer "What is the capital of France?"
|
63
|
+
exa-api answer "Latest AI breakthroughs" --stream
|
54
64
|
exa-api answer "Latest AI breakthroughs" --text
|
55
65
|
exa-api answer "Ruby best practices" --output-format pretty
|
56
66
|
exa-api answer "What is the capital of France?" --output-schema '{"type":"object","properties":{"city":{"type":"string"},"state":{"type":"string"}}}'
|
67
|
+
exa-api answer "What is Paris?" --system-prompt "Respond in the voice of a pirate"
|
57
68
|
HELP
|
58
69
|
exit 0
|
59
70
|
else
|
@@ -89,6 +100,7 @@ begin
|
|
89
100
|
# Prepare answer parameters
|
90
101
|
answer_params = {}
|
91
102
|
answer_params[:text] = args[:text] if args[:text]
|
103
|
+
answer_params[:system_prompt] = args[:system_prompt] if args[:system_prompt]
|
92
104
|
|
93
105
|
# Parse output_schema as JSON if provided
|
94
106
|
if args[:output_schema]
|
@@ -100,12 +112,24 @@ begin
|
|
100
112
|
end
|
101
113
|
end
|
102
114
|
|
103
|
-
# Execute answer
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
115
|
+
# Execute answer or streaming answer
|
116
|
+
if args[:stream]
|
117
|
+
# Streaming mode - output chunks in real-time
|
118
|
+
client.answer_stream(args[:query], **answer_params) do |chunk|
|
119
|
+
# Extract content from the streaming response format
|
120
|
+
# API returns: {"choices":[{"delta":{"content":"..."}}]}
|
121
|
+
if chunk["choices"]&.first&.dig("delta", "content")
|
122
|
+
print chunk["choices"][0]["delta"]["content"]
|
123
|
+
$stdout.flush
|
124
|
+
end
|
125
|
+
end
|
126
|
+
puts # Add newline at the end
|
127
|
+
else
|
128
|
+
# Non-streaming mode - collect full response and format
|
129
|
+
result = client.answer(args[:query], **answer_params)
|
130
|
+
output = Exa::CLI::Formatters::AnswerFormatter.format(result, output_format)
|
131
|
+
puts output
|
132
|
+
end
|
109
133
|
|
110
134
|
rescue Exa::ConfigurationError => e
|
111
135
|
$stderr.puts "Configuration error: #{e.message}"
|
data/exe/exa-ai-get-contents
CHANGED
@@ -11,8 +11,18 @@ def parse_args(args)
|
|
11
11
|
ids = []
|
12
12
|
api_key = nil
|
13
13
|
text = false
|
14
|
-
|
14
|
+
text_max_characters = nil
|
15
|
+
include_html_tags = false
|
15
16
|
summary = false
|
17
|
+
summary_query = nil
|
18
|
+
summary_schema = nil
|
19
|
+
subpages = nil
|
20
|
+
subpage_target = []
|
21
|
+
links = nil
|
22
|
+
image_links = nil
|
23
|
+
context = false
|
24
|
+
context_max_characters = nil
|
25
|
+
livecrawl_timeout = nil
|
16
26
|
output_format = nil
|
17
27
|
|
18
28
|
i = 0
|
@@ -25,10 +35,48 @@ def parse_args(args)
|
|
25
35
|
api_key = args[i]
|
26
36
|
when "--text"
|
27
37
|
text = true
|
28
|
-
|
29
|
-
|
38
|
+
i += 1
|
39
|
+
when "--text-max-characters"
|
40
|
+
i += 1
|
41
|
+
text_max_characters = args[i].to_i
|
42
|
+
when "--include-html-tags"
|
43
|
+
include_html_tags = true
|
44
|
+
i += 1
|
30
45
|
when "--summary"
|
31
46
|
summary = true
|
47
|
+
i += 1
|
48
|
+
when "--summary-query"
|
49
|
+
i += 1
|
50
|
+
summary_query = args[i]
|
51
|
+
when "--summary-schema"
|
52
|
+
i += 1
|
53
|
+
schema_arg = args[i]
|
54
|
+
summary_schema = if schema_arg.start_with?("@")
|
55
|
+
JSON.parse(File.read(schema_arg[1..]))
|
56
|
+
else
|
57
|
+
JSON.parse(schema_arg)
|
58
|
+
end
|
59
|
+
when "--subpages"
|
60
|
+
i += 1
|
61
|
+
subpages = args[i].to_i
|
62
|
+
when "--subpage-target"
|
63
|
+
i += 1
|
64
|
+
subpage_target << args[i]
|
65
|
+
when "--links"
|
66
|
+
i += 1
|
67
|
+
links = args[i].to_i
|
68
|
+
when "--image-links"
|
69
|
+
i += 1
|
70
|
+
image_links = args[i].to_i
|
71
|
+
when "--context"
|
72
|
+
context = true
|
73
|
+
i += 1
|
74
|
+
when "--context-max-characters"
|
75
|
+
i += 1
|
76
|
+
context_max_characters = args[i].to_i
|
77
|
+
when "--livecrawl-timeout"
|
78
|
+
i += 1
|
79
|
+
livecrawl_timeout = args[i].to_i
|
32
80
|
when "--output-format"
|
33
81
|
i += 1
|
34
82
|
output_format = args[i]
|
@@ -41,34 +89,130 @@ def parse_args(args)
|
|
41
89
|
ids_arg = arg
|
42
90
|
ids = ids_arg.include?(",") ? ids_arg.split(",").map(&:strip) : [ids_arg]
|
43
91
|
end
|
92
|
+
i += 1
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
{
|
97
|
+
ids: ids,
|
98
|
+
api_key: api_key,
|
99
|
+
text: text,
|
100
|
+
text_max_characters: text_max_characters,
|
101
|
+
include_html_tags: include_html_tags,
|
102
|
+
summary: summary,
|
103
|
+
summary_query: summary_query,
|
104
|
+
summary_schema: summary_schema,
|
105
|
+
subpages: subpages,
|
106
|
+
subpage_target: subpage_target,
|
107
|
+
links: links,
|
108
|
+
image_links: image_links,
|
109
|
+
context: context,
|
110
|
+
context_max_characters: context_max_characters,
|
111
|
+
livecrawl_timeout: livecrawl_timeout,
|
112
|
+
output_format: output_format
|
113
|
+
}
|
114
|
+
end
|
115
|
+
|
116
|
+
# Build contents parameters from extracted flags
|
117
|
+
def build_contents_params(args)
|
118
|
+
params = {}
|
119
|
+
|
120
|
+
# Text options
|
121
|
+
if args[:text]
|
122
|
+
if args[:text_max_characters] || args[:include_html_tags]
|
123
|
+
params[:text] = {}
|
124
|
+
params[:text][:max_characters] = args[:text_max_characters] if args[:text_max_characters]
|
125
|
+
params[:text][:include_html_tags] = args[:include_html_tags] if args[:include_html_tags]
|
126
|
+
else
|
127
|
+
params[:text] = true
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# Summary options
|
132
|
+
if args[:summary]
|
133
|
+
if args[:summary_query] || args[:summary_schema]
|
134
|
+
params[:summary] = {}
|
135
|
+
params[:summary][:query] = args[:summary_query] if args[:summary_query]
|
136
|
+
params[:summary][:schema] = args[:summary_schema] if args[:summary_schema]
|
137
|
+
else
|
138
|
+
params[:summary] = true
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
# Context options
|
143
|
+
if args[:context]
|
144
|
+
if args[:context_max_characters]
|
145
|
+
params[:context] = { max_characters: args[:context_max_characters] }
|
146
|
+
else
|
147
|
+
params[:context] = true
|
44
148
|
end
|
149
|
+
end
|
150
|
+
|
151
|
+
# Subpages options
|
152
|
+
params[:subpages] = args[:subpages] if args[:subpages]
|
153
|
+
params[:subpage_target] = args[:subpage_target] if args[:subpage_target].any?
|
45
154
|
|
46
|
-
|
155
|
+
# Extras options
|
156
|
+
if args[:links] || args[:image_links]
|
157
|
+
params[:extras] = {}
|
158
|
+
params[:extras][:links] = args[:links] if args[:links]
|
159
|
+
params[:extras][:image_links] = args[:image_links] if args[:image_links]
|
47
160
|
end
|
48
161
|
|
49
|
-
|
162
|
+
# Livecrawl options
|
163
|
+
params[:livecrawl_timeout] = args[:livecrawl_timeout] if args[:livecrawl_timeout]
|
164
|
+
|
165
|
+
params.empty? ? nil : params
|
50
166
|
end
|
51
167
|
|
52
168
|
def print_help
|
53
|
-
puts
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
169
|
+
puts <<~HELP
|
170
|
+
Usage: exa-api get-contents <urls> [options]
|
171
|
+
|
172
|
+
Retrieve full page contents from URLs
|
173
|
+
|
174
|
+
Arguments:
|
175
|
+
urls Comma-separated list of URLs (required)
|
176
|
+
|
177
|
+
Options:
|
178
|
+
Text Extraction:
|
179
|
+
--text Include page text in response
|
180
|
+
--text-max-characters N Max characters for page text
|
181
|
+
--include-html-tags Include HTML tags in text extraction
|
182
|
+
|
183
|
+
Summary:
|
184
|
+
--summary Include AI-generated summary
|
185
|
+
--summary-query PROMPT Custom prompt for summary generation
|
186
|
+
--summary-schema FILE JSON schema for summary structure (@file syntax)
|
187
|
+
|
188
|
+
Context:
|
189
|
+
--context Format results as context for LLM RAG
|
190
|
+
--context-max-characters N Max characters for context string
|
191
|
+
|
192
|
+
Subpages:
|
193
|
+
--subpages N Number of subpages to crawl
|
194
|
+
--subpage-target PHRASE Subpage target phrases (repeatable)
|
195
|
+
|
196
|
+
Extras:
|
197
|
+
--links N Number of links to extract per result
|
198
|
+
--image-links N Number of image links to extract
|
199
|
+
|
200
|
+
Livecrawl:
|
201
|
+
--livecrawl-timeout N Timeout for livecrawling in milliseconds
|
202
|
+
|
203
|
+
General:
|
204
|
+
--api-key KEY Exa API key (or set EXA_API_KEY env var)
|
205
|
+
--output-format FMT Output format: json, pretty, or text (default: json)
|
206
|
+
--help, -h Show this help message
|
207
|
+
|
208
|
+
Examples:
|
209
|
+
exa-api get-contents 'https://example.com'
|
210
|
+
exa-api get-contents 'https://example.com' --text
|
211
|
+
exa-api get-contents 'https://example.com' --text --text-max-characters 3000 --include-html-tags
|
212
|
+
exa-api get-contents 'url1,url2' --summary --summary-query "Be terse"
|
213
|
+
exa-api get-contents 'https://example.com' --subpages 1 --subpage-target about
|
214
|
+
exa-api get-contents 'https://example.com' --links 5 --image-links 10
|
215
|
+
HELP
|
72
216
|
end
|
73
217
|
|
74
218
|
begin
|
@@ -77,9 +221,9 @@ begin
|
|
77
221
|
|
78
222
|
# Validate IDs
|
79
223
|
if options[:ids].empty?
|
80
|
-
puts "Error:
|
81
|
-
puts ""
|
82
|
-
puts "Run 'exa-api get-contents --help' for usage information."
|
224
|
+
$stderr.puts "Error: URLs argument required"
|
225
|
+
$stderr.puts ""
|
226
|
+
$stderr.puts "Run 'exa-api get-contents --help' for usage information."
|
83
227
|
exit 1
|
84
228
|
end
|
85
229
|
|
@@ -93,10 +237,8 @@ begin
|
|
93
237
|
client = Exa::CLI::Base.build_client(api_key)
|
94
238
|
|
95
239
|
# Build request parameters
|
96
|
-
params =
|
97
|
-
params
|
98
|
-
params[:highlights] = true if options[:highlights]
|
99
|
-
params[:summary] = true if options[:summary]
|
240
|
+
params = build_contents_params(options)
|
241
|
+
params ||= {}
|
100
242
|
|
101
243
|
# Call API
|
102
244
|
result = client.get_contents(options[:ids], **params)
|
@@ -104,11 +246,25 @@ begin
|
|
104
246
|
# Format and output
|
105
247
|
output = Exa::CLI::Formatters::ContentsFormatter.format(result, output_format)
|
106
248
|
puts output
|
249
|
+
rescue Exa::ConfigurationError => e
|
250
|
+
$stderr.puts "Configuration error: #{e.message}"
|
251
|
+
exit 1
|
252
|
+
rescue Exa::Unauthorized => e
|
253
|
+
$stderr.puts "Authentication error: #{e.message}"
|
254
|
+
$stderr.puts "Check your API key (set EXA_API_KEY or use --api-key)"
|
255
|
+
exit 1
|
256
|
+
rescue Exa::ClientError => e
|
257
|
+
$stderr.puts "Client error: #{e.message}"
|
258
|
+
exit 1
|
259
|
+
rescue Exa::ServerError => e
|
260
|
+
$stderr.puts "Server error: #{e.message}"
|
261
|
+
$stderr.puts "The Exa API may be experiencing issues. Please try again later."
|
262
|
+
exit 1
|
107
263
|
rescue Exa::Error => e
|
108
|
-
puts "Error: #{e.message}"
|
264
|
+
$stderr.puts "Error: #{e.message}"
|
109
265
|
exit 1
|
110
266
|
rescue StandardError => e
|
111
|
-
puts "Unexpected error: #{e.message}"
|
112
|
-
puts e.backtrace.first(5) if ENV["DEBUG"]
|
267
|
+
$stderr.puts "Unexpected error: #{e.message}"
|
268
|
+
$stderr.puts e.backtrace.first(5) if ENV["DEBUG"]
|
113
269
|
exit 1
|
114
270
|
end
|
data/exe/exa-ai-research-start
CHANGED
@@ -47,7 +47,7 @@ def parse_args(argv)
|
|
47
47
|
|
48
48
|
Options:
|
49
49
|
--instructions TEXT Research instructions (required)
|
50
|
-
--model MODEL
|
50
|
+
--model MODEL Research model: exa-research (default), exa-research-pro, exa-research-fast
|
51
51
|
--output-schema JSON JSON schema string for structured output
|
52
52
|
--wait Wait for task to complete (polls until done)
|
53
53
|
--events Include event log in output (only with --wait)
|
@@ -58,7 +58,7 @@ def parse_args(argv)
|
|
58
58
|
Examples:
|
59
59
|
exa-api research-start --instructions "Find Ruby performance tips"
|
60
60
|
exa-api research-start --instructions "Analyze AI trends" --wait --events
|
61
|
-
exa-api research-start --instructions "Summarize papers" --model
|
61
|
+
exa-api research-start --instructions "Summarize papers" --model exa-research-pro --wait
|
62
62
|
exa-api research-start --instructions "Find stats" --output-schema '{"type":"object"}'
|
63
63
|
HELP
|
64
64
|
exit 0
|
data/exe/exa-ai-search
CHANGED
@@ -39,9 +39,6 @@ def parse_args(argv)
|
|
39
39
|
when "--exclude-domains"
|
40
40
|
args[:exclude_domains] = argv[i + 1].split(",").map(&:strip)
|
41
41
|
i += 2
|
42
|
-
when "--use-autoprompt"
|
43
|
-
args[:use_autoprompt] = true
|
44
|
-
i += 1
|
45
42
|
when "--api-key"
|
46
43
|
args[:api_key] = argv[i + 1]
|
47
44
|
i += 2
|
@@ -159,7 +156,6 @@ def parse_args(argv)
|
|
159
156
|
--image-links N Number of image links to extract
|
160
157
|
|
161
158
|
General Options:
|
162
|
-
--use-autoprompt Use Exa's autoprompt feature
|
163
159
|
--linkedin TYPE Search LinkedIn: company, person, or all
|
164
160
|
--api-key KEY Exa API key (or set EXA_API_KEY env var)
|
165
161
|
--output-format FMT Output format: json, pretty, or text (default: json)
|
@@ -271,7 +267,6 @@ begin
|
|
271
267
|
search_params[:exclude_text] = args[:exclude_text] if args[:exclude_text]
|
272
268
|
contents = build_contents(args)
|
273
269
|
search_params.merge!(contents) if contents
|
274
|
-
search_params[:useAutoprompt] = args[:use_autoprompt] if args[:use_autoprompt]
|
275
270
|
|
276
271
|
# Execute search based on LinkedIn type
|
277
272
|
result = case args[:linkedin]
|
data/lib/exa/client.rb
CHANGED
@@ -71,6 +71,21 @@ module Exa
|
|
71
71
|
Services::Answer.new(connection, query: query, **options).call
|
72
72
|
end
|
73
73
|
|
74
|
+
# Stream AI-generated answers to a query
|
75
|
+
#
|
76
|
+
# Returns partial answer chunks as they are generated by the API.
|
77
|
+
#
|
78
|
+
# @param query [String] Question or query
|
79
|
+
# @param options [Hash] Answer options
|
80
|
+
# @option options [Boolean] :text Include full text content (default: false)
|
81
|
+
# @option options [Hash] :output_schema JSON schema for structured output
|
82
|
+
# @yield [chunk] Yields each answer chunk as it arrives
|
83
|
+
# @yieldparam chunk [Hash] Partial answer data with {"answer" => "text"}
|
84
|
+
# @return [void]
|
85
|
+
def answer_stream(query, **options, &block)
|
86
|
+
Services::AnswerStream.new(connection, query: query, **options).call(&block)
|
87
|
+
end
|
88
|
+
|
74
89
|
# Start an asynchronous research task
|
75
90
|
#
|
76
91
|
# @param params [Hash] Research parameters
|
@@ -0,0 +1,90 @@
|
|
1
|
+
require "json"
|
2
|
+
|
3
|
+
module Exa
|
4
|
+
module Services
|
5
|
+
class AnswerStream
|
6
|
+
def initialize(connection, **params)
|
7
|
+
@connection = connection
|
8
|
+
@params = params.merge(stream: true)
|
9
|
+
end
|
10
|
+
|
11
|
+
def call(&block)
|
12
|
+
raise ArgumentError, "block required for streaming" unless block_given?
|
13
|
+
|
14
|
+
# Use instance variable to track buffer across on_data callbacks
|
15
|
+
@buffer = ""
|
16
|
+
|
17
|
+
# Configure the request to stream chunks via on_data callback
|
18
|
+
@connection.post("/answer", @params) do |req|
|
19
|
+
req.options.on_data = proc do |chunk|
|
20
|
+
# Add chunk to buffer and process complete SSE events
|
21
|
+
@buffer += chunk
|
22
|
+
process_sse_buffer(&block)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Process any remaining data in buffer after stream ends
|
27
|
+
process_remaining_buffer(&block) if @buffer.length.positive?
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def process_sse_buffer
|
33
|
+
# Extract and process complete SSE events (separated by \n\n)
|
34
|
+
# If buffer ends with \n\n, all parts are complete.
|
35
|
+
# Otherwise, keep the last part in buffer for next chunk.
|
36
|
+
|
37
|
+
return if @buffer.empty?
|
38
|
+
|
39
|
+
parts = @buffer.split("\n\n")
|
40
|
+
|
41
|
+
# When split by a delimiter, if the string ends with the delimiter,
|
42
|
+
# split doesn't add a trailing empty string. So we need to track
|
43
|
+
# whether the buffer ended with \n\n to know if the last part is incomplete.
|
44
|
+
if @buffer.end_with?("\n\n")
|
45
|
+
# All parts are complete, clear buffer
|
46
|
+
complete_parts = parts
|
47
|
+
@buffer = ""
|
48
|
+
else
|
49
|
+
# Last part is incomplete, keep it for next chunk
|
50
|
+
complete_parts = parts[0...-1]
|
51
|
+
@buffer = parts.last || ""
|
52
|
+
end
|
53
|
+
|
54
|
+
# Process all complete events
|
55
|
+
complete_parts.each do |event|
|
56
|
+
next if event.empty?
|
57
|
+
|
58
|
+
lines = event.split("\n")
|
59
|
+
lines.each do |line|
|
60
|
+
if line.start_with?("data: ")
|
61
|
+
json_str = line.sub(/^data: /, "").strip
|
62
|
+
begin
|
63
|
+
data = JSON.parse(json_str)
|
64
|
+
yield(data)
|
65
|
+
rescue JSON::ParserError
|
66
|
+
# Skip lines that aren't valid JSON
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def process_remaining_buffer
|
74
|
+
# Process any remaining incomplete buffer
|
75
|
+
lines = @buffer.split("\n")
|
76
|
+
lines.each do |line|
|
77
|
+
if line.start_with?("data: ")
|
78
|
+
json_str = line.sub(/^data: /, "").strip
|
79
|
+
begin
|
80
|
+
data = JSON.parse(json_str)
|
81
|
+
yield(data)
|
82
|
+
rescue JSON::ParserError
|
83
|
+
# Skip lines that aren't valid JSON
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
data/lib/exa/version.rb
CHANGED
data/lib/exa.rb
CHANGED
@@ -18,6 +18,7 @@ require_relative "exa/services/research_get"
|
|
18
18
|
require_relative "exa/services/research_start"
|
19
19
|
require_relative "exa/services/research_list"
|
20
20
|
require_relative "exa/services/answer"
|
21
|
+
require_relative "exa/services/answer_stream"
|
21
22
|
require_relative "exa/services/context"
|
22
23
|
require_relative "exa/client"
|
23
24
|
require_relative "exa/cli/base"
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: exa-ai
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Benjamin Jackson
|
@@ -23,6 +23,20 @@ dependencies:
|
|
23
23
|
- - "~>"
|
24
24
|
- !ruby/object:Gem::Version
|
25
25
|
version: '2.0'
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: ld-eventsource
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - "~>"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '2.0'
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '2.0'
|
26
40
|
- !ruby/object:Gem::Dependency
|
27
41
|
name: minitest
|
28
42
|
requirement: !ruby/object:Gem::Requirement
|
@@ -139,6 +153,7 @@ files:
|
|
139
153
|
- lib/exa/resources/research_task.rb
|
140
154
|
- lib/exa/resources/search_result.rb
|
141
155
|
- lib/exa/services/answer.rb
|
156
|
+
- lib/exa/services/answer_stream.rb
|
142
157
|
- lib/exa/services/context.rb
|
143
158
|
- lib/exa/services/find_similar.rb
|
144
159
|
- lib/exa/services/get_contents.rb
|