diff --git a/packages/core/src/plugin/websearch/firecrawl.ts b/packages/core/src/plugin/websearch/firecrawl.ts index 1ea260e0c5da..6d8a01fc319c 100644 --- a/packages/core/src/plugin/websearch/firecrawl.ts +++ b/packages/core/src/plugin/websearch/firecrawl.ts @@ -1,6 +1,7 @@ export * as WebSearchFirecrawl from "./firecrawl.js" import { define } from "@opencode/plugin/effect/plugin" +import type { WebSearch } from "@opencode/schema/websearch" import { Effect, Option, Schema, Scope } from "effect" import { HttpClient } from "effect/unstable/http" import { App } from "../../app.js" @@ -11,6 +12,7 @@ export const endpoint = "https://mcp.firecrawl.dev/v2/mcp" const McpInput = Schema.Struct({ query: Schema.String, limit: Schema.Number.pipe(Schema.optional), + categories: Schema.Array(Schema.String).pipe(Schema.optional), }) const McpOutput = Schema.Struct({ @@ -48,36 +50,47 @@ export const Plugin = define({ method: { type: "env", names: ["FIRECRAWL_API_KEY"] }, }) }) + const search = + (categories?: readonly string[]) => + (input: WebSearch.ProviderInput): Effect.Effect => + Effect.gen(function* () { + const connection = yield* ctx.integration.connection.active("firecrawl") + const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined + const result = yield* WebSearchMcp.call( + http, + endpoint, + "firecrawl_search", + { input: McpInput, output: McpOutput }, + { query: input.query, limit: 8, ...(categories ? { categories } : {}) }, + { + "User-Agent": App.useragent(ctx.app), + ...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}), + }, + ) + const content = result?.content.find((item) => item.text) + const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined + return ( + response?.data.web.map((item) => ({ + url: item.url, + ...(item.title ? { title: item.title } : {}), + ...(item.description ? { content: item.description } : {}), + time: {}, + })) ?? [] + ) + }) + yield* ctx.websearch.transform((editor) => { editor.add({ id: "firecrawl", name: "Firecrawl", - execute: (input) => - Effect.gen(function* () { - const connection = yield* ctx.integration.connection.active("firecrawl") - const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined - const result = yield* WebSearchMcp.call( - http, - endpoint, - "firecrawl_search", - { input: McpInput, output: McpOutput }, - { query: input.query, limit: 8 }, - { - "User-Agent": App.useragent(ctx.app), - ...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}), - }, - ) - const content = result?.content.find((item) => item.text) - const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined - return ( - response?.data.web.map((item) => ({ - url: item.url, - ...(item.title ? { title: item.title } : {}), - ...(item.description ? { content: item.description } : {}), - time: {}, - })) ?? [] - ) - }), + execute: search(), + }) + // The developer category resolves against an index of GitHub issues, merged + // pull requests, READMEs, and curated documentation rather than the open web. + editor.add({ + id: "firecrawl-developer", + name: "Firecrawl Developer", + execute: search(["developer"]), }) }) }), diff --git a/packages/core/test/plugin/websearch.test.ts b/packages/core/test/plugin/websearch.test.ts index 775f576df5c5..92ff7bdeb4dd 100644 --- a/packages/core/test/plugin/websearch.test.ts +++ b/packages/core/test/plugin/websearch.test.ts @@ -46,8 +46,10 @@ describe("built-in web search providers", () => { yield* plugin.effect(host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) })) yield* websearch.select("random") expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError) - expect(signals).toHaveLength(1) - expect(signals[0]?.aborted).toBe(true) + // A rate-limited provider is put on cooldown and the query fails over to the + // next one, so a plugin that registers several providers makes several requests. + expect(signals).toHaveLength((yield* websearch.providers()).length) + expect(signals.every((signal) => signal.aborted)).toBe(true) }), ) }) diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index c2ae0a2ba6b0..311ac6bf3f9b 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -265,9 +265,11 @@ function webSearchProviderLabel(provider: unknown, i18n: ReturnType, omit: read return `[${entries.map(([key, value]) => `${key}=${String(value)}`).join(", ")}]` } +// Capitalizing the id is enough for single-word providers; anything hyphenated +// needs a spelling here or it renders as "Firecrawl-developer". +const WEB_SEARCH_PROVIDER_NAMES: Record = { + "firecrawl-developer": "Firecrawl Developer", +} + export function webSearchProviderName(provider: unknown) { if (typeof provider !== "string" || !provider) return "" - return `${provider[0].toUpperCase()}${provider.slice(1)}` + return WEB_SEARCH_PROVIDER_NAMES[provider] ?? `${provider[0].toUpperCase()}${provider.slice(1)}` } export function webSearchProviderLabel(provider: unknown) { diff --git a/packages/tui/test/util/tool-display.test.ts b/packages/tui/test/util/tool-display.test.ts index 1cd28b15d156..4d74414a2466 100644 --- a/packages/tui/test/util/tool-display.test.ts +++ b/packages/tui/test/util/tool-display.test.ts @@ -5,6 +5,7 @@ import { primitiveInputSummary, toolDisplayMetadata, webSearchProviderLabel, + webSearchProviderName, } from "../../src/util/tool-display" test("normalizes shared tool primitives", () => { @@ -19,11 +20,18 @@ test("normalizes shared tool primitives", () => { expect(primitiveInputSummary({ path: "src/a.ts", line: 2 }, ["path"])).toBe("[line=2]") }) +test("webSearchProviderName spells hyphenated providers", () => { + expect(webSearchProviderName("firecrawl")).toBe("Firecrawl") + expect(webSearchProviderName("firecrawl-developer")).toBe("Firecrawl Developer") + expect(webSearchProviderName("")).toBe("") +}) + describe("webSearchProviderLabel", () => { test("labels known providers", () => { expect(webSearchProviderLabel("parallel")).toBe("Web Search via Parallel") expect(webSearchProviderLabel("exa")).toBe("Web Search via Exa") expect(webSearchProviderLabel("firecrawl")).toBe("Web Search via Firecrawl") + expect(webSearchProviderLabel("firecrawl-developer")).toBe("Web Search via Firecrawl Developer") expect(webSearchProviderLabel("tavily")).toBe("Web Search via Tavily") })