Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 39 additions & 26 deletions packages/core/src/plugin/websearch/firecrawl.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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({
Expand Down Expand Up @@ -48,36 +50,47 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
method: { type: "env", names: ["FIRECRAWL_API_KEY"] },
})
})
const search =
(categories?: readonly string[]) =>
(input: WebSearch.ProviderInput): Effect.Effect<readonly WebSearch.Result[], unknown> =>
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"]),
})
})
}),
Expand Down
6 changes: 4 additions & 2 deletions packages/core/test/plugin/websearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}),
)
})
Expand Down
8 changes: 5 additions & 3 deletions packages/session-ui/src/tools/tool-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,9 +265,11 @@ function webSearchProviderLabel(provider: unknown, i18n: ReturnType<typeof useI1
? "Exa"
: provider === "firecrawl"
? "Firecrawl"
: provider === "tavily"
? "Tavily"
: undefined
: provider === "firecrawl-developer"
? "Firecrawl Developer"
: provider === "tavily"
? "Tavily"
: undefined
if (name) return i18n.t("ui.tool.websearch.provider", { provider: name })
return i18n.t("ui.tool.websearch")
}
Expand Down
8 changes: 7 additions & 1 deletion packages/tui/src/util/tool-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,15 @@ export function primitiveInputSummary(input: Record<string, unknown>, 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<string, string> = {
"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) {
Expand Down
8 changes: 8 additions & 0 deletions packages/tui/test/util/tool-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
primitiveInputSummary,
toolDisplayMetadata,
webSearchProviderLabel,
webSearchProviderName,
} from "../../src/util/tool-display"

test("normalizes shared tool primitives", () => {
Expand All @@ -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")
})

Expand Down
Loading