Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/cli/src/acp/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyA
case "webfetch":
return stringValue(input.url)
case "websearch":
case "devsearch":
return stringValue(input.query)
case "grep":
case "glob":
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/plugin/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export const Plugin = define({
{ action: "glob", resource: "*", effect: "allow" },
{ action: "webfetch", resource: "*", effect: "allow" },
{ action: "websearch", resource: "*", effect: "allow" },
{ action: "devsearch", resource: "*", effect: "allow" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/plugin/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { Skill } from "../skill.js"
import { SkillDiscovery } from "../skill/discovery.js"
import { Watcher } from "../filesystem/watcher.js"
import { PatchTool } from "../tool/plugin/patch.js"
import { DevSearchTool } from "../tool/plugin/devsearch.js"
import { EditTool } from "../tool/plugin/edit.js"
import { GlobTool } from "../tool/plugin/glob.js"
import { GrepTool } from "../tool/plugin/grep.js"
Expand Down Expand Up @@ -216,6 +217,7 @@ const pre = [
PatchTool.Plugin,
// Render model prompts after the patch plugin selects the available editing tools.
...OptimizePlugin.Plugins,
DevSearchTool.Plugin,
EditTool.Plugin,
GlobTool.Plugin,
GrepTool.Plugin,
Expand Down
44 changes: 25 additions & 19 deletions packages/core/src/plugin/websearch/firecrawl.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
export * as WebSearchFirecrawl from "./firecrawl.js"

import { define } from "@opencode/plugin/effect/plugin"
import { define, type Context } from "@opencode/plugin/effect/plugin"
import { Effect, Option, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { App } from "../../app.js"
import { WebSearchMcp } from "./mcp.js"

export const endpoint = "https://mcp.firecrawl.dev/v2/mcp"
export const integrationID = "firecrawl"

const McpInput = Schema.Struct({
query: Schema.String,
Expand All @@ -33,18 +34,36 @@ const SearchResponse = Schema.fromJsonString(
)
const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse)

/** Calls a Firecrawl MCP tool with the active connection's credential and returns its text content. */
export const call = <F extends Schema.Struct.Fields>(
ctx: Context,
http: HttpClient.HttpClient,
tool: string,
input: Schema.Struct<F>,
value: Schema.Struct.Type<F>,
) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active(integrationID)
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const result = yield* WebSearchMcp.call(http, endpoint, tool, { input, output: McpOutput }, value, {
"User-Agent": App.useragent(ctx.app),
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
})
return result?.content.find((item) => item.text)?.text
})

export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.firecrawl",
effect: Effect.fn("WebSearchFirecrawl.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((editor) => {
editor.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
editor.update(integrationID, (integration) => (integration.name = "Firecrawl"))
editor.method.update({
integrationID: "firecrawl",
integrationID,
method: { type: "key" },
})
editor.method.update({
integrationID: "firecrawl",
integrationID,
method: { type: "env", names: ["FIRECRAWL_API_KEY"] },
})
})
Expand All @@ -54,21 +73,8 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
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
const text = yield* call(ctx, http, "firecrawl_search", McpInput, { query: input.query, limit: 8 })
const response = text ? Option.getOrUndefined(decodeSearchResponse(text)) : undefined
return (
response?.data.web.map((item) => ({
url: item.url,
Expand Down
103 changes: 103 additions & 0 deletions packages/core/src/tool/plugin/devsearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
export * as DevSearchTool from "./devsearch.js"

import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { ToolFailure } from "@opencode/ai"
import { Effect, Schema } from "effect"
import { HttpClient, HttpClientError } from "effect/unstable/http"
import { Permission } from "../../permission.js"
import { WebSearchFirecrawl } from "../../plugin/websearch/firecrawl.js"

export const name = "devsearch"
export const NO_RESULTS = "No developer search results found. Please try a different query."
export const NOT_CONNECTED =
"Developer search needs a Firecrawl connection. Connect the Firecrawl integration to enable it."
const httpErrors = new Map([
[429, "Developer search rate limited (HTTP 429)"],
[401, "Developer search authentication failed (HTTP 401)"],
])

export const description = `Search an index built for coding agents: repositories, GitHub issues, merged pull requests, READMEs, and curated documentation sites. Use this for how a library or API behaves, what an error message means, or whether a bug was fixed. Results include the matched passages.

Prefer this over websearch for programming questions. Use websearch for current events and anything outside software.`

export const Input = Schema.Struct({
query: Schema.String.annotate({
description: "Developer question or search phrase, including the library, error message, or API involved",
}),
})

const McpInput = Schema.Struct({
query: Schema.String,
k: Schema.Number.pipe(Schema.optional),
})

const Output = Schema.Struct({
output: Schema.String,
})

export const Plugin = {
id: "opencode.tool.devsearch",
effect: Effect.fn("DevSearchTool.Plugin")(function* (ctx: Context) {
const http = yield* HttpClient.HttpClient
const permission = yield* Permission.Service
// An exported FIRECRAWL_API_KEY resolves as an active connection, so it counts as connected too.
const connected = ctx.integration.connection.active(WebSearchFirecrawl.integrationID).pipe(
Effect.map((connection) => connection !== undefined),
Effect.orElseSucceed(() => false),
)

yield* ctx.tool
.transform((editor) =>
editor.add({
name,
options: { codemode: false },
description,
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
if (!(yield* connected)) return yield* new ToolFailure({ message: NOT_CONNECTED })
yield* permission.assert({
action: name,
resources: [input.query],
save: ["*"],
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const text = yield* WebSearchFirecrawl.call(ctx, http, "firecrawl_developer_search", McpInput, {
query: input.query,
k: 8,
})
const output = text?.trim() || NO_RESULTS
return { output: { output }, content: output }
}).pipe(
Effect.mapError((error) => {
if (error instanceof ToolFailure) return error
const status = HttpClientError.isHttpClientError(error) ? error.response?.status : undefined
return new ToolFailure({
message:
status === undefined
? `Unable to search developer sources for ${input.query}`
: (httpErrors.get(status) ?? `Developer search request failed (HTTP ${status})`),
error,
})
}),
),
}),
)
.pipe(Effect.orDie)

// Sessions only see the tool once Firecrawl is connected; a stale tool list still fails clearly above.
const hook = (event: SessionHooks["context"]) =>
Effect.gen(function* () {
if (yield* connected) return
delete event.tools[name]
})
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
yield* ctx.session.hook("generate", hook)
}),
}
1 change: 1 addition & 0 deletions packages/core/src/v1/config/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const InputObject = Schema.StructWithRest(
question: Schema.optional(Action),
webfetch: Schema.optional(Rule),
websearch: Schema.optional(Action),
devsearch: Schema.optional(Action),
lsp: Schema.optional(Rule),
doom_loop: Schema.optional(Action),
skill: Schema.optional(Rule),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/test/location-layer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,7 @@ describe("LocationServiceMap", () => {
expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false)
const blockedTools = blockedState.tools.map((tool) => tool.name)
expect(blockedTools.filter((name) => name !== "execute").sort()).toEqual([
"devsearch",
"edit",
"glob",
"grep",
Expand All @@ -502,6 +503,7 @@ describe("LocationServiceMap", () => {
const allowedTools = allowedState.tools.map((tool) => tool.name)
expect(blockedTools.includes("execute")).toBe(allowedTools.includes("execute"))
expect(allowedTools.filter((name) => name !== "execute").sort()).toEqual([
"devsearch",
"edit",
"glob",
"grep",
Expand Down
Loading
Loading