@oh-my-pi/exa 1.3.371 → 1.3.372

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.
package/README.md CHANGED
@@ -59,10 +59,10 @@ Get your API key from: https://dashboard.exa.ai/api-keys
59
59
 
60
60
  | Tool | Description |
61
61
  | ------------------------- | ---------------------------------------------------- |
62
- | `web_search_general` | Real-time web searches with content extraction |
62
+ | `web_search` | Real-time web searches with content extraction |
63
63
  | `web_search_deep` | Natural language web search with synthesized results |
64
64
  | `web_search_code_context` | Search code snippets, docs, and examples |
65
- | `web_search_crawl_url` | Extract content from specific URLs |
65
+ | `web_search_crawl` | Extract content from specific URLs |
66
66
 
67
67
  ### linkedin
68
68
 
@@ -72,16 +72,16 @@ Get your API key from: https://dashboard.exa.ai/api-keys
72
72
 
73
73
  ### company
74
74
 
75
- | Tool | Description |
76
- | ----------------------------- | ------------------------------ |
77
- | `web_search_company_research` | Comprehensive company research |
75
+ | Tool | Description |
76
+ | -------------------- | ------------------------------ |
77
+ | `web_search_company` | Comprehensive company research |
78
78
 
79
79
  ### researcher
80
80
 
81
81
  | Tool | Description |
82
82
  | ----------------------------- | -------------------------------------------- |
83
83
  | `web_search_researcher_start` | Start comprehensive AI-powered research task |
84
- | `web_search_researcher_check` | Check research task status and get results |
84
+ | `web_search_researcher_poll` | Check research task status and get results |
85
85
 
86
86
  ### websets
87
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/exa",
3
- "version": "1.3.371",
3
+ "version": "1.3.372",
4
4
  "description": "Exa AI web search and websets tools for pi",
5
5
  "keywords": [
6
6
  "omp-plugin",
package/tools/company.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Exa Company Research Tool
3
3
  *
4
4
  * Tools:
5
- * - web_search_company_research: Comprehensive company research
5
+ * - web_search_company: Comprehensive company research
6
6
  */
7
7
 
8
8
  import type { CustomAgentTool, CustomToolFactory, ToolAPI } from '@mariozechner/pi-coding-agent'
@@ -14,7 +14,7 @@ const TOOL_NAMES = ['company_research_exa']
14
14
 
15
15
  // Tool name mapping: MCP name -> exposed name
16
16
  const NAME_MAP: Record<string, string> = {
17
- company_research_exa: 'web_search_company_research',
17
+ company_research_exa: 'web_search_company',
18
18
  }
19
19
 
20
20
  const factory: CustomToolFactory = async (_toolApi: ToolAPI): Promise<CustomAgentTool<TSchema, unknown>[] | null> => {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Tools:
5
5
  * - web_search_researcher_start: Start comprehensive AI research tasks
6
- * - web_search_researcher_check: Check research task status
6
+ * - web_search_researcher_poll: Check research task status
7
7
  */
8
8
 
9
9
  import type { CustomAgentTool, CustomToolFactory, ToolAPI } from '@mariozechner/pi-coding-agent'
@@ -16,7 +16,7 @@ const TOOL_NAMES = ['deep_researcher_start', 'deep_researcher_check']
16
16
  // Tool name mapping: MCP name -> exposed name
17
17
  const NAME_MAP: Record<string, string> = {
18
18
  deep_researcher_start: 'web_search_researcher_start',
19
- deep_researcher_check: 'web_search_researcher_check',
19
+ deep_researcher_check: 'web_search_researcher_poll',
20
20
  }
21
21
 
22
22
  const factory: CustomToolFactory = async (_toolApi: ToolAPI): Promise<CustomAgentTool<TSchema, unknown>[] | null> => {
package/tools/search.ts CHANGED
@@ -2,10 +2,10 @@
2
2
  * Exa Search Tools - Core web search capabilities
3
3
  *
4
4
  * Tools:
5
- * - web_search_general: Real-time web searches
5
+ * - web_search: Real-time web searches
6
6
  * - web_search_deep: Natural language web search with synthesis
7
7
  * - web_search_code_context: Code search for libraries, docs, examples
8
- * - web_search_crawl_url: Extract content from specific URLs
8
+ * - web_search_crawl: Extract content from specific URLs
9
9
  */
10
10
 
11
11
  import type { CustomAgentTool, CustomToolFactory, ToolAPI } from '@mariozechner/pi-coding-agent'
@@ -17,10 +17,10 @@ const TOOL_NAMES = ['web_search_exa', 'deep_search_exa', 'get_code_context_exa',
17
17
 
18
18
  // Tool name mapping: MCP name -> exposed name
19
19
  const NAME_MAP: Record<string, string> = {
20
- web_search_exa: 'web_search_general',
20
+ web_search_exa: 'web_search',
21
21
  deep_search_exa: 'web_search_deep',
22
22
  get_code_context_exa: 'web_search_code_context',
23
- crawling_exa: 'web_search_crawl_url',
23
+ crawling_exa: 'web_search_crawl',
24
24
  }
25
25
 
26
26
  const factory: CustomToolFactory = async (_toolApi: ToolAPI): Promise<CustomAgentTool<TSchema, unknown>[] | null> => {
package/tools/shared.ts CHANGED
@@ -6,12 +6,25 @@ import * as fs from 'node:fs'
6
6
  import * as os from 'node:os'
7
7
  import * as path from 'node:path'
8
8
  import type { CustomAgentTool } from '@mariozechner/pi-coding-agent'
9
+ import { Text } from '@mariozechner/pi-tui'
9
10
  import type { TSchema } from '@sinclair/typebox'
10
11
 
11
12
  // MCP endpoints
12
13
  export const EXA_MCP_URL = 'https://mcp.exa.ai/mcp'
13
14
  export const WEBSETS_MCP_URL = 'https://websetsmcp.exa.ai/mcp'
14
15
 
16
+ // Log paths
17
+ const EXA_ERROR_LOG = path.join(os.homedir(), '.pi/exa_errors.log')
18
+ const VIEW_ERROR_LOG = path.join(os.homedir(), '.pi/view_errors.log')
19
+
20
+ function logExaError(msg: string): void {
21
+ fs.appendFileSync(EXA_ERROR_LOG, `[${new Date().toISOString()}] ${msg}\n`)
22
+ }
23
+
24
+ function logViewError(msg: string): void {
25
+ fs.appendFileSync(VIEW_ERROR_LOG, `[${new Date().toISOString()}] ${msg}\n`)
26
+ }
27
+
15
28
  export interface MCPTool {
16
29
  name: string
17
30
  description: string
@@ -164,7 +177,8 @@ export async function fetchExaTools(apiKey: string, toolNames: string[]): Promis
164
177
  }
165
178
  return response.result?.tools ?? []
166
179
  } catch (error) {
167
- console.error(`Failed to fetch Exa tools:`, error)
180
+ const msg = error instanceof Error ? error.message : String(error)
181
+ logExaError(`Failed to fetch Exa tools: ${msg}`)
168
182
  return []
169
183
  }
170
184
  }
@@ -182,7 +196,8 @@ export async function fetchWebsetsTools(apiKey: string): Promise<MCPTool[]> {
182
196
  }
183
197
  return response.result?.tools ?? []
184
198
  } catch (error) {
185
- console.error(`Failed to fetch Websets tools:`, error)
199
+ const msg = error instanceof Error ? error.message : String(error)
200
+ logExaError(`Failed to fetch Websets tools: ${msg}`)
186
201
  return []
187
202
  }
188
203
  }
@@ -237,6 +252,178 @@ async function callMCPTool(url: string, toolName: string, args: Record<string, u
237
252
  return response.result
238
253
  }
239
254
 
255
+ interface SearchResult {
256
+ id?: string
257
+ title?: string
258
+ url?: string
259
+ author?: string
260
+ publishedDate?: string
261
+ text?: string
262
+ image?: string
263
+ favicon?: string
264
+ }
265
+
266
+ interface SearchResponse {
267
+ results?: SearchResult[]
268
+ statuses?: Array<{ id: string; status: string; source?: string }>
269
+ costDollars?: { total: number }
270
+ searchTime?: number
271
+ requestId?: string
272
+ }
273
+
274
+ /**
275
+ * Format search results as readable markdown (for LLM consumption)
276
+ */
277
+ function formatSearchResults(data: SearchResponse): string {
278
+ const lines: string[] = []
279
+
280
+ if (data.results && data.results.length > 0) {
281
+ for (const result of data.results) {
282
+ // Title with link
283
+ if (result.title && result.url) {
284
+ lines.push(`### [${result.title}](${result.url})`)
285
+ } else if (result.title) {
286
+ lines.push(`### ${result.title}`)
287
+ } else if (result.url) {
288
+ lines.push(`### ${result.url}`)
289
+ }
290
+
291
+ // Author if present
292
+ if (result.author) {
293
+ lines.push(`*by ${result.author}*`)
294
+ }
295
+
296
+ lines.push('')
297
+
298
+ // Content - truncate if very long
299
+ if (result.text) {
300
+ const text = result.text.trim()
301
+ const maxLen = 2000
302
+ if (text.length > maxLen) {
303
+ lines.push(`${text.slice(0, maxLen)}...`)
304
+ } else {
305
+ lines.push(text)
306
+ }
307
+ }
308
+
309
+ lines.push('')
310
+ lines.push('---')
311
+ lines.push('')
312
+ }
313
+ }
314
+
315
+ // Footer with metadata
316
+ const meta: string[] = []
317
+ if (data.results) meta.push(`${data.results.length} result(s)`)
318
+ if (data.searchTime) meta.push(`${(data.searchTime / 1000).toFixed(2)}s`)
319
+ if (data.costDollars?.total) meta.push(`$${data.costDollars.total.toFixed(4)}`)
320
+
321
+ if (meta.length > 0) {
322
+ lines.push(`*${meta.join(' • ')}*`)
323
+ }
324
+
325
+ return lines.join('\n')
326
+ }
327
+
328
+ /**
329
+ * Check if result looks like a search response
330
+ */
331
+ function isSearchResponse(data: unknown): data is SearchResponse {
332
+ if (!data || typeof data !== 'object') return false
333
+ const obj = data as Record<string, unknown>
334
+ return Array.isArray(obj.results) || 'searchTime' in obj || 'costDollars' in obj
335
+ }
336
+
337
+ /**
338
+ * Parse Exa's markdown text format into a SearchResponse structure
339
+ * Format: Title: ...\nURL: ...\nAuthor: ...\nPublished Date: ...\nText: ...\n\n (repeated)
340
+ */
341
+ function parseExaMarkdown(text: string): SearchResponse | null {
342
+ const results: SearchResult[] = []
343
+
344
+ // Split by double newlines to separate results, but be careful with Text: blocks
345
+ // Each result starts with "Title:"
346
+ const parts = text.split(/\n(?=Title:)/g)
347
+
348
+ for (const part of parts) {
349
+ if (!part.trim()) continue
350
+
351
+ const result: SearchResult = {}
352
+ const lines = part.split('\n')
353
+
354
+ let currentField: string | null = null
355
+ let textLines: string[] = []
356
+
357
+ for (const line of lines) {
358
+ // Check for field prefixes
359
+ if (line.startsWith('Title: ')) {
360
+ result.title = line.slice(7).trim()
361
+ currentField = null
362
+ } else if (line.startsWith('URL: ')) {
363
+ result.url = line.slice(5).trim()
364
+ currentField = null
365
+ } else if (line.startsWith('Author: ')) {
366
+ result.author = line.slice(8).trim()
367
+ currentField = null
368
+ } else if (line.startsWith('Published Date: ')) {
369
+ result.publishedDate = line.slice(16).trim()
370
+ currentField = null
371
+ } else if (line.startsWith('Text: ')) {
372
+ textLines = [line.slice(6)]
373
+ currentField = 'text'
374
+ } else if (currentField === 'text') {
375
+ textLines.push(line)
376
+ }
377
+ }
378
+
379
+ if (textLines.length > 0) {
380
+ result.text = textLines.join('\n').trim()
381
+ }
382
+
383
+ if (result.title || result.url) {
384
+ results.push(result)
385
+ }
386
+ }
387
+
388
+ if (results.length === 0) return null
389
+ return { results }
390
+ }
391
+
392
+ // Tree formatting helpers
393
+ const TREE_MID = '├─'
394
+ const TREE_END = '└─'
395
+ const TREE_PIPE = '│'
396
+ const TREE_SPACE = ' '
397
+ const TREE_HOOK = '⎿'
398
+
399
+ /**
400
+ * Truncate text to max length with ellipsis
401
+ */
402
+ function truncate(text: string, maxLen: number): string {
403
+ if (text.length <= maxLen) return text
404
+ return `${text.slice(0, maxLen - 1)}…`
405
+ }
406
+
407
+ /**
408
+ * Extract domain from URL
409
+ */
410
+ function getDomain(url: string): string {
411
+ try {
412
+ const u = new URL(url)
413
+ return u.hostname.replace(/^www\./, '')
414
+ } catch {
415
+ return url
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Get first N lines of text as preview
421
+ */
422
+ function getPreviewLines(text: string, maxLines: number, maxLineLen: number): string[] {
423
+ const lines = text.split('\n').filter(l => l.trim())
424
+ return lines.slice(0, maxLines).map(l => truncate(l.trim(), maxLineLen))
425
+ }
426
+
240
427
  /**
241
428
  * Create a tool wrapper for an MCP tool
242
429
  */
@@ -244,16 +431,28 @@ export function createToolWrapper(
244
431
  mcpTool: MCPTool,
245
432
  renamedName: string,
246
433
  callFn: (toolName: string, args: Record<string, unknown>) => Promise<unknown>
247
- ): CustomAgentTool<TSchema, unknown> {
434
+ ): CustomAgentTool<TSchema, SearchResponse | { error: string } | unknown> {
248
435
  return {
249
436
  name: renamedName,
250
437
  label: renamedName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
251
438
  description: mcpTool.description,
252
439
  parameters: normalizeInputSchema(mcpTool.inputSchema) as TSchema,
440
+
253
441
  async execute(_toolCallId, params) {
254
442
  try {
255
443
  const result = await callFn(mcpTool.name, (params ?? {}) as Record<string, unknown>)
256
- const text = typeof result === 'string' ? result : result == null ? 'null' : (JSON.stringify(result, null, 2) ?? String(result))
444
+
445
+ let text: string
446
+ if (typeof result === 'string') {
447
+ text = result
448
+ } else if (result == null) {
449
+ text = 'No results'
450
+ } else if (isSearchResponse(result)) {
451
+ text = formatSearchResults(result)
452
+ } else {
453
+ text = JSON.stringify(result, null, 2) ?? String(result)
454
+ }
455
+
257
456
  return {
258
457
  content: [{ type: 'text' as const, text }],
259
458
  details: result,
@@ -266,5 +465,123 @@ export function createToolWrapper(
266
465
  }
267
466
  }
268
467
  },
468
+
469
+ renderResult(result, { expanded }, theme) {
470
+ let { details } = result
471
+
472
+ // Handle error case
473
+ if (details && typeof details === 'object' && 'error' in details) {
474
+ const errDetails = details as { error: string }
475
+ return new Text(theme.fg('error', `Error: ${errDetails.error}`), 0, 0)
476
+ }
477
+
478
+ // If details is a string (Exa markdown format), try to parse it
479
+ if (typeof details === 'string') {
480
+ const parsed = parseExaMarkdown(details)
481
+ if (parsed) {
482
+ details = parsed
483
+ }
484
+ }
485
+
486
+ // Handle non-search responses (plain text/JSON)
487
+ if (!isSearchResponse(details)) {
488
+ const text = result.content[0]
489
+ if (text?.type === 'text') {
490
+ // For non-search content, show truncated in collapsed, full in expanded
491
+ if (expanded) {
492
+ return new Text(text.text, 0, 0)
493
+ }
494
+ const preview = getPreviewLines(text.text, 5, 100)
495
+ const lines = preview.map(l => theme.fg('dim', l)).join('\n')
496
+ return new Text(lines + (text.text.split('\n').length > 5 ? theme.fg('muted', '\n …') : ''), 0, 0)
497
+ }
498
+ return new Text('', 0, 0)
499
+ }
500
+
501
+ // Search response - render as tree
502
+ const data = details as SearchResponse
503
+ const resultCount = data.results?.length ?? 0
504
+
505
+ // Build header with metadata
506
+ const meta: string[] = []
507
+ meta.push(`${resultCount} result${resultCount !== 1 ? 's' : ''}`)
508
+ if (data.searchTime) meta.push(`${(data.searchTime / 1000).toFixed(2)}s`)
509
+ if (data.costDollars?.total) meta.push(`$${data.costDollars.total.toFixed(4)}`)
510
+
511
+ const icon = resultCount > 0 ? theme.fg('success', '●') : theme.fg('warning', '●')
512
+ const expandHint = expanded ? '' : theme.fg('dim', ' (Ctrl+O to expand)')
513
+ let text = `${icon} ${theme.fg('toolTitle', 'Web Search')} ${theme.fg('dim', meta.join(' • '))}${expandHint}`
514
+
515
+ if (!data.results || data.results.length === 0) {
516
+ text += `\n ${theme.fg('dim', TREE_END)} ${theme.fg('muted', 'No results found')}`
517
+ return new Text(text, 0, 0)
518
+ }
519
+
520
+ // Render each result
521
+ try {
522
+ for (let i = 0; i < data.results.length; i++) {
523
+ const r = data.results[i]
524
+ const isLast = i === data.results.length - 1
525
+ const branch = isLast ? TREE_END : TREE_MID
526
+ const cont = isLast ? TREE_SPACE : TREE_PIPE
527
+
528
+ // Title line
529
+ const title = r.title ? truncate(r.title, 80) : 'Untitled'
530
+ const domain = r.url ? getDomain(r.url) : ''
531
+ text += `\n ${theme.fg('dim', branch)} ${theme.fg('accent', title)}`
532
+ if (domain) {
533
+ text += theme.fg('dim', ` (${domain})`)
534
+ }
535
+
536
+ // URL line (if different from domain)
537
+ if (r.url) {
538
+ text += `\n ${theme.fg('dim', `${cont} ${TREE_HOOK} `)}${theme.fg('link', r.url)}`
539
+ }
540
+
541
+ // Author/date metadata
542
+ const metaParts: string[] = []
543
+ if (r.author) metaParts.push(`by ${r.author}`)
544
+ if (r.publishedDate) {
545
+ try {
546
+ const date = new Date(r.publishedDate)
547
+ metaParts.push(date.toLocaleDateString())
548
+ } catch {
549
+ // ignore invalid dates
550
+ }
551
+ }
552
+ if (metaParts.length > 0) {
553
+ text += `\n ${theme.fg('dim', `${cont} `)}${theme.fg('muted', metaParts.join(' • '))}`
554
+ }
555
+
556
+ // Content preview (collapsed) or full content (expanded)
557
+ if (r.text) {
558
+ if (expanded) {
559
+ // Show full content with proper indentation
560
+ const lines = r.text.split('\n')
561
+ for (const line of lines) {
562
+ if (line.trim()) {
563
+ text += `\n ${theme.fg('dim', `${cont} `)}${theme.fg('dim', line)}`
564
+ }
565
+ }
566
+ } else {
567
+ // Show preview (first 2 non-empty lines)
568
+ const preview = getPreviewLines(r.text, 2, 100)
569
+ for (const line of preview) {
570
+ text += `\n ${theme.fg('dim', `${cont} `)}${theme.fg('dim', line)}`
571
+ }
572
+ const totalLines = r.text.split('\n').filter(l => l.trim()).length
573
+ if (totalLines > 2) {
574
+ text += `\n ${theme.fg('dim', `${cont} `)}${theme.fg('muted', `… ${totalLines - 2} more lines`)}`
575
+ }
576
+ }
577
+ }
578
+ }
579
+ } catch (err) {
580
+ const msg = err instanceof Error ? err.message : String(err)
581
+ logViewError(`exa renderResult error: ${msg}`)
582
+ }
583
+
584
+ return new Text(text, 0, 0)
585
+ },
269
586
  }
270
587
  }