(query: string, opts: SearchOptions)
| 28 | readonly requiresAuth = true; |
| 29 | |
| 30 | async search(query: string, opts: SearchOptions): Promise<WebSearchResult[]> { |
| 31 | const apiKey = process.env.FIRECRAWL_API_KEY; |
| 32 | if (!apiKey) { |
| 33 | throw new WebSearchError( |
| 34 | 'Firecrawl backend selected but FIRECRAWL_API_KEY is not set. ' + |
| 35 | 'Get a free key at https://www.firecrawl.dev — then paste it in chat (the agent saves it via save_api_key and retries), ' + |
| 36 | 'or add FIRECRAWL_API_KEY=fc-... to ~/.qodex/.env.', |
| 37 | this.name, |
| 38 | ); |
| 39 | } |
| 40 | |
| 41 | const scrapeContent = process.env.FIRECRAWL_SCRAPE_CONTENT === '1'; |
| 42 | const timeoutMs = opts.timeoutMs ?? (scrapeContent ? 40_000 : 20_000); // scraping is slower |
| 43 | const internalAbort = new AbortController(); |
| 44 | const timer = setTimeout(() => internalAbort.abort(), timeoutMs); |
| 45 | const onOuterAbort = (): void => internalAbort.abort(); |
| 46 | opts.signal?.addEventListener('abort', onOuterAbort); |
| 47 | |
| 48 | try { |
| 49 | const body: Record<string, unknown> = { query, limit: Math.min(opts.limit, 20) }; |
| 50 | if (scrapeContent) body.scrapeOptions = { formats: ['markdown'] }; |
| 51 | |
| 52 | const res = await proxyFetch(FIRECRAWL_URL, { |
| 53 | method: 'POST', |
| 54 | headers: { |
| 55 | 'Content-Type': 'application/json', |
| 56 | 'Authorization': `Bearer ${apiKey}`, |
| 57 | }, |
| 58 | body: JSON.stringify(body), |
| 59 | signal: internalAbort.signal, |
| 60 | }); |
| 61 | if (!res.ok) { |
| 62 | const text = await res.text().catch(() => ''); |
| 63 | throw new WebSearchError(`Firecrawl HTTP ${res.status}: ${text.slice(0, 300)}`, this.name); |
| 64 | } |
| 65 | const payload = await res.json() as { success?: boolean; data?: Array<{ title?: string; url?: string; description?: string; markdown?: string }>; error?: string }; |
| 66 | if (payload.success === false) { |
| 67 | throw new WebSearchError(`Firecrawl error: ${payload.error ?? 'unknown'}`, this.name); |
| 68 | } |
| 69 | // Trim scraped markdown to the passages most relevant to the search query (semantic), and |
| 70 | // record how often that beat the positional fallback — surfaced in the dashboard. |
| 71 | const { recordExtract } = await import('./extract-metrics.js'); |
| 72 | return mapFirecrawlResults(payload, opts.limit, { query, onExtract: (mode) => { void recordExtract(mode); } }); |
| 73 | } catch (e: any) { |
| 74 | if (e instanceof WebSearchError) throw e; |
| 75 | if (e?.name === 'AbortError') throw new WebSearchError('Request aborted (timeout or cancellation)', this.name, e); |
| 76 | throw new WebSearchError(`Network error: ${e?.message ?? e}`, this.name, e); |
| 77 | } finally { |
| 78 | clearTimeout(timer); |
| 79 | opts.signal?.removeEventListener('abort', onOuterAbort); |
| 80 | } |
| 81 | } |
| 82 | } |
nothing calls this directly
no test coverage detected