feat(core): add integration-backed search

This commit is contained in:
Shoubhit Dash 2026-07-06 19:33:13 +05:30
commit 129012c3ee
50 changed files with 1745 additions and 478 deletions

View file

@ -163,6 +163,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
integration: {
list: () => response(integration.list()),
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
selectCapability: (input) => integration.capability.search.select(Integration.ID.make(input.integrationID)),
connectKey: (input) =>
integration.connection.key({
integrationID: Integration.ID.make(input.integrationID),
@ -268,6 +269,29 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
remove: (id, method) =>
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
},
capability: {
search: {
list: () =>
draft.capability.search.list().map((provider) => ({
integrationID: provider.integrationID,
capability: provider.capability,
execute: (input, context) =>
provider.execute(input, {
...context,
credential: context.credential
? Schema.decodeUnknownSync(Credential.Value)(context.credential)
: undefined,
}),
})),
update: (input) =>
draft.capability.search.update({
integrationID: Integration.ID.make(input.integrationID),
capability: input.capability,
execute: input.execute,
}),
remove: (id) => draft.capability.search.remove(Integration.ID.make(id)),
},
},
})
}),
},

View file

@ -26,6 +26,7 @@ import { ModelsDev } from "../models-dev"
import { Npm } from "../npm"
import { PermissionV2 } from "../permission"
import { Reference } from "../reference"
import { Search } from "../search"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { SessionTodo } from "../session/todo"
@ -50,6 +51,7 @@ import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { SearchPlugins } from "./search"
import { PluginRuntime } from "./runtime"
import { SkillPlugin } from "./skill"
import { VariantPlugin } from "./variant"
@ -76,13 +78,13 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const form = yield* Form.Service
const read = yield* ReadToolFileSystem.Service
const reference = yield* Reference.Service
const search = yield* Search.Service
const ripgrep = yield* Ripgrep.Service
const instructions = yield* SessionInstructions.Service
const todo = yield* SessionTodo.Service
const shell = yield* Shell.Service
const skill = yield* SkillV2.Service
const tools = yield* Tools.Service
const websearch = yield* WebSearchTool.ConfigService
return Context.mergeAll(
Context.make(AgentV2.Service, agent),
Context.make(Catalog.Service, catalog),
@ -105,13 +107,13 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Form.Service, form),
Context.make(ReadToolFileSystem.Service, read),
Context.make(Reference.Service, reference),
Context.make(Search.Service, search),
Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionInstructions.Service, instructions),
Context.make(SessionTodo.Service, todo),
Context.make(Shell.Service, shell),
Context.make(SkillV2.Service, skill),
Context.make(Tools.Service, tools),
Context.make(WebSearchTool.ConfigService, websearch),
)
})
@ -127,6 +129,7 @@ const pre = [
SkillPlugin.Plugin,
ModelsDevPlugin,
...ProviderPlugins,
...SearchPlugins,
ApplyPatchTool.Plugin,
EditTool.Plugin,
GlobTool.Plugin,

View file

@ -79,12 +79,42 @@ export function fromPromise(plugin: Plugin) {
integration: {
list: (input) => run(host.integration.list(input)),
get: (input) => run(host.integration.get(input)),
selectCapability: (input) => run(host.integration.selectCapability(input)),
connectKey: (input) => run(host.integration.connectKey(input)),
connectOauth: (input) => run(host.integration.connectOauth(input)),
attemptStatus: (input) => run(host.integration.attemptStatus(input)),
attemptComplete: (input) => run(host.integration.attemptComplete(input)),
attemptCancel: (input) => run(host.integration.attemptCancel(input)),
transform: transform(host.integration),
transform: (callback) =>
register(
host.integration.transform((draft) => {
callback({
...draft,
capability: {
search: {
list: () =>
draft.capability.search.list().map((provider) => ({
integrationID: provider.integrationID,
capability: provider.capability,
execute: (input, execution) =>
Effect.runPromiseWith(context)(provider.execute(input, execution)),
})),
update: (input) =>
draft.capability.search.update({
integrationID: input.integrationID,
capability: input.capability,
execute: (query, execution) =>
Effect.tryPromise({
try: (signal) => input.execute(query, { ...execution, signal }),
catch: (cause) => cause,
}),
}),
remove: draft.capability.search.remove,
},
},
})
}),
),
reload: () => run(host.integration.reload()),
connection: {
active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)),

View file

@ -0,0 +1,53 @@
export * as SearchExa from "./exa"
import { define } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { SearchMcp } from "./mcp"
export const endpoint = "https://mcp.exa.ai/mcp"
const Args = Schema.Struct({
query: Schema.String,
type: Schema.String,
numResults: Schema.Number,
livecrawl: Schema.String,
contextMaxCharacters: Schema.optional(Schema.Number),
})
const url = (apiKey: string | undefined) => {
if (!apiKey) return endpoint
const value = new URL(endpoint)
value.searchParams.set("exaApiKey", apiKey)
return value.toString()
}
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.search.exa",
effect: Effect.fn("SearchExa.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("exa", (integration) => (integration.name = "Exa"))
draft.method.update({ integrationID: "exa", method: { type: "key", label: "API key (optional)" } })
draft.method.update({ integrationID: "exa", method: { type: "env", names: ["EXA_API_KEY"] } })
draft.capability.search.update({
integrationID: "exa",
capability: { type: "search", connection: "optional" },
execute: (input, context) =>
SearchMcp.call(
http,
url(context.credential?.type === "key" ? context.credential.key : undefined),
"web_search_exa",
Args,
{
query: input.query,
type: input.type ?? "auto",
numResults: input.numResults ?? 8,
livecrawl: input.livecrawl ?? "fallback",
contextMaxCharacters: input.contextMaxCharacters,
},
).pipe(Effect.map((text) => ({ text: text ?? "" }))),
})
})
}),
})

View file

@ -0,0 +1,4 @@
import { SearchExa } from "./exa"
import { SearchParallel } from "./parallel"
export const SearchPlugins = [SearchExa.Plugin, SearchParallel.Plugin] as const

View file

@ -0,0 +1,75 @@
export * as SearchMcp from "./mcp"
import { Duration, Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { collectBoundedResponseBody } from "../../tool/http-body"
export const MAX_RESPONSE_BYTES = 256 * 1024
const Result = Schema.Struct({
result: Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.String })),
}),
})
const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result))
const parsePayload = (payload: string) =>
Effect.gen(function* () {
const trimmed = payload.trim()
if (!trimmed.startsWith("{")) return undefined
return (yield* decodeResult(trimmed)).result.content.find((item) => item.text)?.text
})
export const parseResponse = Effect.fn("SearchMcp.parseResponse")(function* (body: string) {
const trimmed = body.trim()
const direct = trimmed ? yield* parsePayload(trimmed) : undefined
if (direct) return direct
for (const line of body.split("\n")) {
if (!line.startsWith("data: ")) continue
const data = yield* parsePayload(line.substring(6))
if (data) return data
}
})
const Request = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
Schema.Struct({
jsonrpc: Schema.Literal("2.0"),
id: Schema.Literal(1),
method: Schema.Literal("tools/call"),
params: Schema.Struct({ name: Schema.String, arguments: args }),
})
export const call = <F extends Schema.Struct.Fields>(
http: HttpClient.HttpClient,
url: string,
tool: string,
args: Schema.Struct<F>,
value: Schema.Struct.Type<F>,
headers: Record<string, string> = {},
) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.post(url).pipe(
HttpClientRequest.accept("application/json, text/event-stream"),
HttpClientRequest.setHeaders(headers),
HttpClientRequest.schemaBodyJson(Request(args))({
jsonrpc: "2.0" as const,
id: 1 as const,
method: "tools/call" as const,
params: { name: tool, arguments: value },
}),
)
return yield* Effect.gen(function* () {
const response = yield* HttpClient.filterStatusOk(http).execute(request)
const body = yield* collectBoundedResponseBody(
response,
MAX_RESPONSE_BYTES,
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
)
return yield* parseResponse(body.toString("utf8"))
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
}),
)
})

View file

@ -0,0 +1,47 @@
export * as SearchParallel from "./parallel"
import { define } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { InstallationVersion } from "../../installation/version"
import { SearchMcp } from "./mcp"
export const endpoint = "https://search.parallel.ai/mcp"
const Args = Schema.Struct({
objective: Schema.String,
search_queries: Schema.Array(Schema.String),
session_id: Schema.String,
})
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.search.parallel",
effect: Effect.fn("SearchParallel.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("parallel", (integration) => (integration.name = "Parallel"))
draft.method.update({ integrationID: "parallel", method: { type: "key", label: "API key (optional)" } })
draft.method.update({ integrationID: "parallel", method: { type: "env", names: ["PARALLEL_API_KEY"] } })
draft.capability.search.update({
integrationID: "parallel",
capability: { type: "search", connection: "optional" },
execute: (input, context) =>
SearchMcp.call(
http,
endpoint,
"web_search",
Args,
{
objective: input.query,
search_queries: [input.query],
session_id: context.sessionID ?? "opencode",
},
{
"User-Agent": `opencode/${InstallationVersion}`,
...(context.credential?.type === "key" ? { Authorization: `Bearer ${context.credential.key}` } : {}),
},
).pipe(Effect.map((text) => ({ text: text ?? "" }))),
})
})
}),
})