exa-ai 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca5a6bcb0b981d51fcc93e1dc9f1e7037d1d6346e082826e1b1c003d586cce45
4
- data.tar.gz: e598e0c91c2815a5abea958adb9f5112def140b78f13ba29872d07009c4056cc
3
+ metadata.gz: ae8ad5762410325124acdcada89aed1a88b18dcbecc8b85431b99b0c05e0f354
4
+ data.tar.gz: b1d8ec27baf9bdf324af7183a6088e22e2437ef5d0bb0c97edee47eadeeed7de
5
5
  SHA512:
6
- metadata.gz: 4b648ebe500a28dbb8d31aa936fd0d30a09ff94c372a3f3caec463d648993ae26473fc9724aea0157395b8e78ac9a25fc3f2752c8d6fd1cc68745bb8758b0ca7
7
- data.tar.gz: 512d79543838b4b82ba263586c9cd029f8e19f350ff9c744b85dcb5b8f4e62236b442b70d07316721bc844f6ca9ac79bac6c1b42d57d5f0ef1214c5be407818e
6
+ metadata.gz: 1803b1811bc368be4d83bd9b02aa095c4f77f0dd97e05f3bd96ab9bc380d54b9a9bb7e17f5e3fca78c7a2f7348000f61bc55ec4875273eafa6dd0762fb09f7aa
7
+ data.tar.gz: 0ec026a9d5b4d05c8779aa3e10df8c25c48e5a728f92608941e28f2b18a46238908af53837dfa234b22f2ad595922f03d1ce25f98f6b9f932ecce8570914a8aa
data/README.md CHANGED
@@ -214,16 +214,150 @@ exa-ai search "tutorials" \
214
214
  exa-ai search "AI" --output-format pretty
215
215
  ```
216
216
 
217
- **Options:**
217
+ **Basic Options:**
218
218
  - `QUERY` - Search query (required)
219
219
  - `--num-results N` - Number of results (default: 10)
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
 
226
+ #### Advanced Search Options
227
+
228
+ **Date Filtering:**
229
+ ```bash
230
+ # Filter by published date
231
+ exa-ai search "AI research" \
232
+ --start-published-date "2025-01-01T00:00:00.000Z" \
233
+ --end-published-date "2025-12-31T23:59:59.999Z"
234
+
235
+ # Filter by crawl date
236
+ exa-ai search "news" \
237
+ --start-crawl-date "2025-10-01T00:00:00.000Z" \
238
+ --end-crawl-date "2025-10-31T23:59:59.999Z"
239
+ ```
240
+
241
+ **Text Filtering:**
242
+ ```bash
243
+ # Results must include specific phrase
244
+ exa-ai search "machine learning" --include-text "neural networks"
245
+
246
+ # Results must exclude specific phrase
247
+ exa-ai search "programming" --exclude-text "paid-partnership"
248
+
249
+ # Combine inclusion and exclusion
250
+ exa-ai search "Python" \
251
+ --include-text "open source" \
252
+ --exclude-text "deprecated"
253
+ ```
254
+
255
+ **Content Extraction:**
256
+ ```bash
257
+ # Extract full webpage text
258
+ exa-ai search "Ruby" --text
259
+
260
+ # Extract text with options
261
+ exa-ai search "AI" \
262
+ --text \
263
+ --text-max-characters 3000 \
264
+ --include-html-tags
265
+
266
+ # Generate AI summaries
267
+ exa-ai search "climate change" \
268
+ --summary \
269
+ --summary-query "What are the main points?"
270
+
271
+ # Format results as context for LLM RAG
272
+ exa-ai search "kubernetes" \
273
+ --context \
274
+ --context-max-characters 5000
275
+
276
+ # Crawl subpages
277
+ exa-ai search "documentation" \
278
+ --subpages 1 \
279
+ --subpage-target about \
280
+ --subpage-target docs
281
+
282
+ # Extract links from results
283
+ exa-ai search "web development" \
284
+ --links 3 \
285
+ --image-links 2
286
+ ```
287
+
288
+ **Advanced Ruby API:**
289
+ ```ruby
290
+ client = Exa::Client.new(api_key: "your-key")
291
+
292
+ # Date range filtering
293
+ results = client.search("AI research",
294
+ start_published_date: "2025-01-01T00:00:00.000Z",
295
+ end_published_date: "2025-12-31T23:59:59.999Z"
296
+ )
297
+
298
+ # Text filtering
299
+ results = client.search("machine learning",
300
+ include_text: ["neural networks"],
301
+ exclude_text: ["cryptocurrency"]
302
+ )
303
+
304
+ # Full webpage text extraction
305
+ results = client.search("Ruby",
306
+ text: {
307
+ max_characters: 3000,
308
+ include_html_tags: true
309
+ }
310
+ )
311
+
312
+ # AI-powered summaries
313
+ results = client.search("climate change",
314
+ summary: {
315
+ query: "What are the main points?"
316
+ }
317
+ )
318
+
319
+ # Context for RAG pipelines
320
+ results = client.search("kubernetes",
321
+ context: {
322
+ max_characters: 5000
323
+ }
324
+ )
325
+
326
+ # Subpage crawling
327
+ results = client.search("documentation",
328
+ subpages: 1,
329
+ subpage_target: ["about", "docs", "guide"]
330
+ )
331
+
332
+ # Links and image extraction
333
+ results = client.search("web development",
334
+ extras: {
335
+ links: 3,
336
+ image_links: 2
337
+ }
338
+ )
339
+
340
+ # Combine multiple features
341
+ results = client.search("AI",
342
+ num_results: 5,
343
+ start_published_date: "2025-01-01T00:00:00.000Z",
344
+ text: { max_characters: 3000 },
345
+ summary: { query: "Main developments?" },
346
+ context: { max_characters: 5000 },
347
+ subpages: 1,
348
+ subpage_target: ["research"],
349
+ extras: { links: 3, image_links: 2 }
350
+ )
351
+
352
+ # Access extracted content
353
+ results.results.each do |result|
354
+ puts result["title"]
355
+ puts result["text"] if result["text"] # Full webpage text
356
+ puts result["summary"] if result["summary"] # AI summary
357
+ puts result["links"] if result["links"] # Extracted links
358
+ end
359
+ ```
360
+
227
361
  ### Answer Command
228
362
 
229
363
  Generate comprehensive answers to questions using Exa's answer generation feature:
@@ -484,8 +618,7 @@ client = Exa::Client.new
484
618
  results = client.search("kubernetes tutorial",
485
619
  num_results: 20,
486
620
  type: "neural",
487
- include_domains: ["kubernetes.io", "github.com"],
488
- use_autoprompt: true
621
+ include_domains: ["kubernetes.io", "github.com"]
489
622
  )
490
623
 
491
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
- result = client.answer(args[:query], **answer_params)
105
-
106
- # Format and output result
107
- output = Exa::CLI::Formatters::AnswerFormatter.format(result, output_format)
108
- puts output
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}"
@@ -11,8 +11,18 @@ def parse_args(args)
11
11
  ids = []
12
12
  api_key = nil
13
13
  text = false
14
- highlights = false
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
- when "--highlights"
29
- highlights = true
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
- i += 1
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
- { ids: ids, api_key: api_key, text: text, highlights: highlights, summary: summary, output_format: output_format }
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 "Exa Get-Contents - Retrieve page contents"
54
- puts ""
55
- puts "Usage: exa-api get-contents <ids> [options]"
56
- puts ""
57
- puts "Arguments:"
58
- puts " ids Comma-separated list of IDs or URLs (required)"
59
- puts ""
60
- puts "Options:"
61
- puts " --text Include page text in response"
62
- puts " --highlights Include highlights in response"
63
- puts " --summary Include summary in response"
64
- puts " --api-key KEY Exa API key (or set EXA_API_KEY env var)"
65
- puts " --output-format FMT Output format: json, pretty, or text (default: json)"
66
- puts " --help, -h Show this help message"
67
- puts ""
68
- puts "Examples:"
69
- puts " exa-api get-contents 'https://example.com'"
70
- puts " exa-api get-contents 'id1,id2,id3' --text"
71
- puts " exa-api get-contents 'https://example.com' --highlights --output-format pretty"
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: IDs argument required"
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[:text] = true if options[:text]
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
@@ -47,7 +47,7 @@ def parse_args(argv)
47
47
 
48
48
  Options:
49
49
  --instructions TEXT Research instructions (required)
50
- --model MODEL Model to use (e.g., gpt-4, gpt-3.5-turbo)
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 gpt-4 --wait
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
@@ -24,21 +24,98 @@ def parse_args(argv)
24
24
  when "--type"
25
25
  args[:type] = argv[i + 1]
26
26
  i += 2
27
+ when "--category"
28
+ category = argv[i + 1]
29
+ valid_categories = ["company", "research paper", "news", "pdf", "github", "tweet", "personal site", "linkedin profile", "financial report"]
30
+ unless valid_categories.include?(category)
31
+ $stderr.puts "Error: Category must be one of: #{valid_categories.map { |c| "\"#{c}\"" }.join(', ')}"
32
+ exit 1
33
+ end
34
+ args[:category] = category
35
+ i += 2
27
36
  when "--include-domains"
28
37
  args[:include_domains] = argv[i + 1].split(",").map(&:strip)
29
38
  i += 2
30
39
  when "--exclude-domains"
31
40
  args[:exclude_domains] = argv[i + 1].split(",").map(&:strip)
32
41
  i += 2
33
- when "--use-autoprompt"
34
- args[:use_autoprompt] = true
35
- i += 1
36
42
  when "--api-key"
37
43
  args[:api_key] = argv[i + 1]
38
44
  i += 2
39
45
  when "--output-format"
40
46
  args[:output_format] = argv[i + 1]
41
47
  i += 2
48
+ when "--linkedin"
49
+ linkedin_type = argv[i + 1]
50
+ valid_types = ["company", "person", "all"]
51
+ unless valid_types.include?(linkedin_type)
52
+ $stderr.puts "Error: LinkedIn type must be one of: #{valid_types.join(', ')}"
53
+ exit 1
54
+ end
55
+ args[:linkedin] = linkedin_type
56
+ i += 2
57
+ when "--start-published-date"
58
+ args[:start_published_date] = argv[i + 1]
59
+ i += 2
60
+ when "--end-published-date"
61
+ args[:end_published_date] = argv[i + 1]
62
+ i += 2
63
+ when "--start-crawl-date"
64
+ args[:start_crawl_date] = argv[i + 1]
65
+ i += 2
66
+ when "--end-crawl-date"
67
+ args[:end_crawl_date] = argv[i + 1]
68
+ i += 2
69
+ when "--include-text"
70
+ args[:include_text] ||= []
71
+ args[:include_text] << argv[i + 1]
72
+ i += 2
73
+ when "--exclude-text"
74
+ args[:exclude_text] ||= []
75
+ args[:exclude_text] << argv[i + 1]
76
+ i += 2
77
+ when "--text"
78
+ args[:text] = true
79
+ i += 1
80
+ when "--text-max-characters"
81
+ args[:text_max_characters] = argv[i + 1].to_i
82
+ i += 2
83
+ when "--include-html-tags"
84
+ args[:include_html_tags] = true
85
+ i += 1
86
+ when "--summary"
87
+ args[:summary] = true
88
+ i += 1
89
+ when "--summary-query"
90
+ args[:summary_query] = argv[i + 1]
91
+ i += 2
92
+ when "--summary-schema"
93
+ schema_arg = argv[i + 1]
94
+ args[:summary_schema] = if schema_arg.start_with?("@")
95
+ JSON.parse(File.read(schema_arg[1..]))
96
+ else
97
+ JSON.parse(schema_arg)
98
+ end
99
+ i += 2
100
+ when "--context"
101
+ args[:context] = true
102
+ i += 1
103
+ when "--context-max-characters"
104
+ args[:context_max_characters] = argv[i + 1].to_i
105
+ i += 2
106
+ when "--subpages"
107
+ args[:subpages] = argv[i + 1].to_i
108
+ i += 2
109
+ when "--subpage-target"
110
+ args[:subpage_target] ||= []
111
+ args[:subpage_target] << argv[i + 1]
112
+ i += 2
113
+ when "--links"
114
+ args[:links] = argv[i + 1].to_i
115
+ i += 2
116
+ when "--image-links"
117
+ args[:image_links] = argv[i + 1].to_i
118
+ i += 2
42
119
  when "--help", "-h"
43
120
  puts <<~HELP
44
121
  Usage: exa-api search QUERY [OPTIONS]
@@ -49,18 +126,49 @@ def parse_args(argv)
49
126
  QUERY Search query (required)
50
127
 
51
128
  Options:
52
- --num-results N Number of results to return (default: 10)
53
- --type TYPE Search type: keyword, neural, or auto (default: auto)
54
- --include-domains D Comma-separated list of domains to include
55
- --exclude-domains D Comma-separated list of domains to exclude
56
- --use-autoprompt Use Exa's autoprompt feature
57
- --api-key KEY Exa API key (or set EXA_API_KEY env var)
58
- --output-format FMT Output format: json, pretty, or text (default: json)
59
- --help, -h Show this help message
129
+ --num-results N Number of results to return (default: 10)
130
+ --type TYPE Search type: keyword, neural, fast, or auto (default: auto)
131
+ --category CAT Focus on specific data category
132
+ Options: "company", "research paper", "news", "pdf",
133
+ "github", "tweet", "personal site", "linkedin profile",
134
+ "financial report"
135
+ --include-domains D Comma-separated list of domains to include
136
+ --exclude-domains D Comma-separated list of domains to exclude
137
+ --start-published-date DATE Filter by published date (ISO 8601 format)
138
+ --end-published-date DATE Filter by published date (ISO 8601 format)
139
+ --start-crawl-date DATE Filter by crawl date (ISO 8601 format)
140
+ --end-crawl-date DATE Filter by crawl date (ISO 8601 format)
141
+ --include-text PHRASE Include results with exact phrase (repeatable)
142
+ --exclude-text PHRASE Exclude results with exact phrase (repeatable)
143
+
144
+ Content Extraction:
145
+ --text Include full webpage text
146
+ --text-max-characters N Max characters for webpage text
147
+ --include-html-tags Include HTML tags in text extraction
148
+ --summary Include AI-generated summary
149
+ --summary-query PROMPT Custom prompt for summary generation
150
+ --summary-schema FILE JSON schema for summary structure (@file syntax)
151
+ --context Format results as context for LLM RAG
152
+ --context-max-characters N Max characters for context string
153
+ --subpages N Number of subpages to crawl
154
+ --subpage-target PHRASE Subpage target phrases (repeatable)
155
+ --links N Number of links to extract per result
156
+ --image-links N Number of image links to extract
157
+
158
+ General Options:
159
+ --linkedin TYPE Search LinkedIn: company, person, or all
160
+ --api-key KEY Exa API key (or set EXA_API_KEY env var)
161
+ --output-format FMT Output format: json, pretty, or text (default: json)
162
+ --help, -h Show this help message
60
163
 
61
164
  Examples:
62
165
  exa-api search "ruby programming"
63
166
  exa-api search "machine learning" --num-results 5 --type keyword
167
+ exa-api search "Latest LLM research" --category "research paper"
168
+ exa-api search "AI startups" --category company
169
+ exa-api search "Anthropic" --linkedin company
170
+ exa-api search "Dario Amodei" --linkedin person
171
+ exa-api search "AI" --linkedin all
64
172
  exa-api search "AI research" --include-domains arxiv.org,scholar.google.com
65
173
  exa-api search "tutorials" --output-format pretty
66
174
  HELP
@@ -75,6 +183,55 @@ def parse_args(argv)
75
183
  args
76
184
  end
77
185
 
186
+ # Build contents parameter from extracted flags
187
+ def build_contents(args)
188
+ contents = {}
189
+
190
+ # Text options
191
+ if args[:text]
192
+ if args[:text_max_characters] || args[:include_html_tags]
193
+ contents[:text] = {}
194
+ contents[:text][:max_characters] = args[:text_max_characters] if args[:text_max_characters]
195
+ contents[:text][:include_html_tags] = args[:include_html_tags] if args[:include_html_tags]
196
+ else
197
+ contents[:text] = true
198
+ end
199
+ end
200
+
201
+ # Summary options
202
+ if args[:summary]
203
+ if args[:summary_query] || args[:summary_schema]
204
+ contents[:summary] = {}
205
+ contents[:summary][:query] = args[:summary_query] if args[:summary_query]
206
+ contents[:summary][:schema] = args[:summary_schema] if args[:summary_schema]
207
+ else
208
+ contents[:summary] = true
209
+ end
210
+ end
211
+
212
+ # Context options
213
+ if args[:context]
214
+ if args[:context_max_characters]
215
+ contents[:context] = { max_characters: args[:context_max_characters] }
216
+ else
217
+ contents[:context] = true
218
+ end
219
+ end
220
+
221
+ # Subpages options
222
+ contents[:subpages] = args[:subpages] if args[:subpages]
223
+ contents[:subpage_target] = args[:subpage_target] if args[:subpage_target]
224
+
225
+ # Extras options
226
+ if args[:links] || args[:image_links]
227
+ contents[:extras] = {}
228
+ contents[:extras][:links] = args[:links] if args[:links]
229
+ contents[:extras][:image_links] = args[:image_links] if args[:image_links]
230
+ end
231
+
232
+ contents.empty? ? nil : contents
233
+ end
234
+
78
235
  # Main execution
79
236
  begin
80
237
  args = parse_args(ARGV)
@@ -97,14 +254,31 @@ begin
97
254
 
98
255
  # Prepare search parameters
99
256
  search_params = {}
100
- search_params[:num_results] = args[:num_results] if args[:num_results]
257
+ search_params[:numResults] = args[:num_results] if args[:num_results]
101
258
  search_params[:type] = args[:type] if args[:type]
102
- search_params[:include_domains] = args[:include_domains] if args[:include_domains]
103
- search_params[:exclude_domains] = args[:exclude_domains] if args[:exclude_domains]
104
- search_params[:use_autoprompt] = args[:use_autoprompt] if args[:use_autoprompt]
259
+ search_params[:category] = args[:category] if args[:category]
260
+ search_params[:includeDomains] = args[:include_domains] if args[:include_domains]
261
+ search_params[:excludeDomains] = args[:exclude_domains] if args[:exclude_domains]
262
+ search_params[:start_published_date] = args[:start_published_date] if args[:start_published_date]
263
+ search_params[:end_published_date] = args[:end_published_date] if args[:end_published_date]
264
+ search_params[:start_crawl_date] = args[:start_crawl_date] if args[:start_crawl_date]
265
+ search_params[:end_crawl_date] = args[:end_crawl_date] if args[:end_crawl_date]
266
+ search_params[:include_text] = args[:include_text] if args[:include_text]
267
+ search_params[:exclude_text] = args[:exclude_text] if args[:exclude_text]
268
+ contents = build_contents(args)
269
+ search_params.merge!(contents) if contents
105
270
 
106
- # Execute search
107
- result = client.search(args[:query], **search_params)
271
+ # Execute search based on LinkedIn type
272
+ result = case args[:linkedin]
273
+ when "company"
274
+ client.linkedin_company(args[:query], **search_params)
275
+ when "person"
276
+ client.linkedin_person(args[:query], **search_params)
277
+ when "all"
278
+ client.search(args[:query], includeDomains: ["linkedin.com"], **search_params)
279
+ else
280
+ client.search(args[:query], **search_params)
281
+ end
108
282
 
109
283
  # Format and output result
110
284
  output = Exa::CLI::Formatters::SearchFormatter.format(result, output_format)
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
@@ -107,6 +122,32 @@ module Exa
107
122
  Services::Context.new(connection, query: query, **params).call
108
123
  end
109
124
 
125
+ # Search for LinkedIn company pages
126
+ #
127
+ # Convenience method that restricts search to LinkedIn company profiles
128
+ # using keyword search for precise name matching.
129
+ #
130
+ # @param query [String] Company name to search
131
+ # @param params [Hash] Additional search parameters
132
+ # @option params [Integer] :numResults Number of results to return
133
+ # @return [Resources::SearchResult] LinkedIn company results
134
+ def linkedin_company(query, **params)
135
+ search(query, type: "keyword", includeDomains: ["linkedin.com/company"], **params)
136
+ end
137
+
138
+ # Search for LinkedIn profiles
139
+ #
140
+ # Convenience method that restricts search to LinkedIn individual profiles
141
+ # using keyword search for precise name matching.
142
+ #
143
+ # @param query [String] Person name to search
144
+ # @param params [Hash] Additional search parameters
145
+ # @option params [Integer] :numResults Number of results to return
146
+ # @return [Resources::SearchResult] LinkedIn profile results
147
+ def linkedin_person(query, **params)
148
+ search(query, type: "keyword", includeDomains: ["linkedin.com/in"], **params)
149
+ end
150
+
110
151
  private
111
152
 
112
153
  def connection
@@ -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
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exa
4
+ module Services
5
+ # Converts Ruby parameter names (snake_case) to API format (camelCase)
6
+ # Handles both simple parameters and nested content parameters
7
+ class ParameterConverter
8
+ def self.convert(params)
9
+ new.convert(params)
10
+ end
11
+
12
+ def convert(params)
13
+ converted = {}
14
+ contents = {}
15
+
16
+ params.each do |key, value|
17
+ if content_key?(key)
18
+ contents[convert_content_key(key)] = convert_content_value(key, value)
19
+ else
20
+ converted[convert_key(key)] = value
21
+ end
22
+ end
23
+
24
+ converted[:contents] = contents if contents.any?
25
+ converted
26
+ end
27
+
28
+ private
29
+
30
+ def convert_key(key)
31
+ case key
32
+ when :start_published_date then :startPublishedDate
33
+ when :end_published_date then :endPublishedDate
34
+ when :start_crawl_date then :startCrawlDate
35
+ when :end_crawl_date then :endCrawlDate
36
+ when :include_text then :includeText
37
+ when :exclude_text then :excludeText
38
+ else
39
+ key
40
+ end
41
+ end
42
+
43
+ def content_key?(key)
44
+ %i[text summary context subpages subpage_target extras].include?(key)
45
+ end
46
+
47
+ def convert_content_key(key)
48
+ case key
49
+ when :subpage_target then :subpageTarget
50
+ else
51
+ key
52
+ end
53
+ end
54
+
55
+ def convert_content_value(key, value)
56
+ case key
57
+ when :text
58
+ if value.is_a?(Hash)
59
+ convert_hash_value(value, text_hash_mappings)
60
+ else
61
+ value
62
+ end
63
+ when :summary
64
+ if value.is_a?(Hash)
65
+ convert_hash_value(value, summary_hash_mappings)
66
+ else
67
+ value
68
+ end
69
+ when :context
70
+ if value.is_a?(Hash)
71
+ convert_hash_value(value, context_hash_mappings)
72
+ else
73
+ value
74
+ end
75
+ when :extras
76
+ if value.is_a?(Hash)
77
+ convert_hash_value(value, extras_hash_mappings)
78
+ else
79
+ value
80
+ end
81
+ else
82
+ value
83
+ end
84
+ end
85
+
86
+ def convert_hash_value(hash, mappings)
87
+ converted = {}
88
+ hash.each do |k, v|
89
+ converted_key = mappings[k] || k
90
+ converted[converted_key] = v
91
+ end
92
+ converted
93
+ end
94
+
95
+ def text_hash_mappings
96
+ {
97
+ max_characters: :maxCharacters,
98
+ include_html_tags: :includeHtmlTags
99
+ }
100
+ end
101
+
102
+ def summary_hash_mappings
103
+ {
104
+ query: :query,
105
+ schema: :schema
106
+ }
107
+ end
108
+
109
+ def context_hash_mappings
110
+ {
111
+ max_characters: :maxCharacters
112
+ }
113
+ end
114
+
115
+ def extras_hash_mappings
116
+ {
117
+ image_links: :imageLinks
118
+ }
119
+ end
120
+ end
121
+ end
122
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "parameter_converter"
4
+
3
5
  module Exa
4
6
  module Services
5
7
  class Search
@@ -9,7 +11,7 @@ module Exa
9
11
  end
10
12
 
11
13
  def call
12
- response = @connection.post("/search", @params)
14
+ response = @connection.post("/search", ParameterConverter.convert(@params))
13
15
  body = response.body
14
16
 
15
17
  Resources::SearchResult.new(
data/lib/exa/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exa
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
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.1.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,9 +153,11 @@ 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
160
+ - lib/exa/services/parameter_converter.rb
145
161
  - lib/exa/services/research_get.rb
146
162
  - lib/exa/services/research_list.rb
147
163
  - lib/exa/services/research_start.rb