feat(core): add pluggable web search (#35558)
Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
parent
2ddc91a0e8
commit
efb629a33a
57 changed files with 1700 additions and 587 deletions
|
|
@ -1,6 +1,8 @@
|
|||
export * as PluginHost from "./host"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { App } from "../app"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
|
|
@ -23,6 +25,7 @@ import { Tool } from "../tool/tool"
|
|||
import { Tools } from "../tool/tools"
|
||||
import { ToolHooks } from "../tool/hooks"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { PluginHooks } from "./hooks"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
|
|
@ -38,6 +41,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
const reference = yield* Reference.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
|
|
@ -247,79 +251,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
remove: (id) => draft.remove(Integration.ID.make(id)),
|
||||
method: {
|
||||
list: (id) => mutable(draft.method.list(Integration.ID.make(id))),
|
||||
update: (input) => {
|
||||
if ("authorize" in input) {
|
||||
const methodID = Integration.MethodID.make(input.method.id)
|
||||
const refresh = input.refresh
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, id: methodID },
|
||||
authorize: (inputs) =>
|
||||
input.authorize(inputs).pipe(
|
||||
Effect.map((authorization) => {
|
||||
if (authorization.mode === "auto") {
|
||||
return {
|
||||
...authorization,
|
||||
callback: authorization.callback.pipe(
|
||||
Effect.map((credential) =>
|
||||
Credential.OAuth.make({
|
||||
...credential,
|
||||
methodID: Integration.MethodID.make(credential.methodID),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...authorization,
|
||||
callback: (code: string) =>
|
||||
authorization.callback(code).pipe(
|
||||
Effect.map((credential) =>
|
||||
Credential.OAuth.make({
|
||||
...credential,
|
||||
methodID: Integration.MethodID.make(credential.methodID),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
),
|
||||
...(refresh
|
||||
? {
|
||||
refresh: (value: Credential.OAuth) =>
|
||||
refresh(value).pipe(
|
||||
Effect.map((next) =>
|
||||
Credential.OAuth.make({
|
||||
...next,
|
||||
methodID: Integration.MethodID.make(next.methodID),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(input.label ? { label: input.label } : {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (input.method.type === "env") {
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "env", names: input.method.names },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (input.method.type === "command") {
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method),
|
||||
})
|
||||
return
|
||||
}
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "key", label: input.method.label },
|
||||
})
|
||||
},
|
||||
update: (input) => draft.method.update(methodImplementation(input)),
|
||||
remove: (id, method) =>
|
||||
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
|
||||
},
|
||||
|
|
@ -430,6 +362,32 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
})
|
||||
},
|
||||
},
|
||||
websearch: {
|
||||
providers: () => response(websearch.providers()),
|
||||
query: (input) =>
|
||||
response(
|
||||
websearch.query({
|
||||
query: input.query,
|
||||
providerID: input.providerID === undefined ? undefined : WebSearch.ID.make(input.providerID),
|
||||
}),
|
||||
),
|
||||
reload: websearch.reload,
|
||||
transform: (callback) =>
|
||||
websearch.transform((draft) => {
|
||||
callback({
|
||||
add: (definition) =>
|
||||
draft.add({
|
||||
id: WebSearch.ID.make(definition.id),
|
||||
name: definition.name,
|
||||
execute: definition.execute,
|
||||
}),
|
||||
default: {
|
||||
get: draft.default.get,
|
||||
set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)),
|
||||
},
|
||||
})
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback) => hooks.register("session", name, callback),
|
||||
create: (input) =>
|
||||
|
|
@ -449,3 +407,50 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
},
|
||||
} satisfies Plugin.Context
|
||||
})
|
||||
|
||||
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {
|
||||
if ("authorize" in input) {
|
||||
const refresh = input.refresh
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
|
||||
authorize: (inputs) =>
|
||||
input.authorize(inputs).pipe(
|
||||
Effect.map((authorization) => {
|
||||
if (authorization.mode === "auto") {
|
||||
return {
|
||||
...authorization,
|
||||
callback: authorization.callback.pipe(Effect.map(credential)),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...authorization,
|
||||
callback: (code: string) => authorization.callback(code).pipe(Effect.map(credential)),
|
||||
}
|
||||
}),
|
||||
),
|
||||
...(refresh ? { refresh: (value: Credential.OAuth) => refresh(value).pipe(Effect.map(credential)) } : {}),
|
||||
...(input.label ? { label: input.label } : {}),
|
||||
}
|
||||
}
|
||||
if (input.method.type === "env") {
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "env", names: input.method.names },
|
||||
}
|
||||
}
|
||||
if (input.method.type === "command") {
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method),
|
||||
}
|
||||
}
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "key", label: input.method.label },
|
||||
}
|
||||
}
|
||||
|
||||
function credential(value: CredentialOAuth) {
|
||||
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { ConfigProviderPlugin } from "../config/plugin/provider"
|
|||
import { ConfigPolicyPlugin } from "../config/plugin/policy"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
|
||||
import { EventV2 } from "../event"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Form } from "../form"
|
||||
|
|
@ -21,12 +22,14 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
|||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "../image"
|
||||
import { Integration } from "../integration"
|
||||
import { KV } from "../kv"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Reference } from "../reference"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { Shell } from "../shell"
|
||||
|
|
@ -50,6 +53,7 @@ import { AgentPlugin } from "./agent"
|
|||
import { CommandPlugin } from "./command"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { WebSearchPlugins } from "./websearch"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
import { SkillPlugin } from "./skill"
|
||||
import { SystemPromptPlugin } from "./system-prompt"
|
||||
|
|
@ -70,6 +74,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||
const http = yield* HttpClient.HttpClient
|
||||
const image = yield* Image.Service
|
||||
const integration = yield* Integration.Service
|
||||
const kv = yield* KV.Service
|
||||
const location = yield* Location.Service
|
||||
const locationMutation = yield* LocationMutation.Service
|
||||
const models = yield* ModelsDev.Service
|
||||
|
|
@ -79,12 +84,12 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||
const form = yield* Form.Service
|
||||
const read = yield* ReadToolFileSystem.Service
|
||||
const reference = yield* Reference.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const instructions = yield* SessionInstructions.Service
|
||||
const shell = yield* Shell.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const websearch = yield* WebSearchTool.ConfigService
|
||||
const wellknown = yield* WellKnown.Service
|
||||
return Context.mergeAll(
|
||||
Context.make(AgentV2.Service, agent),
|
||||
|
|
@ -99,6 +104,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||
Context.make(HttpClient.HttpClient, http),
|
||||
Context.make(Image.Service, image),
|
||||
Context.make(Integration.Service, integration),
|
||||
Context.make(KV.Service, kv),
|
||||
Context.make(Location.Service, location),
|
||||
Context.make(LocationMutation.Service, locationMutation),
|
||||
Context.make(ModelsDev.Service, models),
|
||||
|
|
@ -108,12 +114,12 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||
Context.make(Form.Service, form),
|
||||
Context.make(ReadToolFileSystem.Service, read),
|
||||
Context.make(Reference.Service, reference),
|
||||
Context.make(WebSearch.Service, websearch),
|
||||
Context.make(Ripgrep.Service, ripgrep),
|
||||
Context.make(SessionInstructions.Service, instructions),
|
||||
Context.make(Shell.Service, shell),
|
||||
Context.make(SkillV2.Service, skill),
|
||||
Context.make(Tools.Service, tools),
|
||||
Context.make(WebSearchTool.ConfigService, websearch),
|
||||
Context.make(WellKnown.Service, wellknown),
|
||||
)
|
||||
})
|
||||
|
|
@ -132,6 +138,7 @@ const pre = [
|
|||
...SystemPromptPlugin.Plugins,
|
||||
ModelsDevPlugin,
|
||||
...ProviderPlugins,
|
||||
...WebSearchPlugins,
|
||||
PatchTool.Plugin,
|
||||
EditTool.Plugin,
|
||||
GlobTool.Plugin,
|
||||
|
|
@ -153,6 +160,7 @@ const post = [
|
|||
ConfigCommandPlugin.Plugin,
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
VariantPlugin.Plugin,
|
||||
ConfigPolicyPlugin.Plugin,
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { AbsolutePath } from "@opencode-ai/schema/schema"
|
|||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { DateTime, Effect, Scope, Stream } from "effect"
|
||||
import { Tool } from "../tool/tool"
|
||||
|
||||
|
|
@ -197,6 +198,31 @@ export function fromPromise(plugin: Plugin) {
|
|||
hook: (name, callback) =>
|
||||
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
},
|
||||
websearch: {
|
||||
providers: (input) => run(host.websearch.providers(input)),
|
||||
query: (input) =>
|
||||
run(
|
||||
host.websearch.query({
|
||||
...input,
|
||||
providerID: input.providerID === undefined ? undefined : WebSearch.ID.make(input.providerID),
|
||||
}),
|
||||
),
|
||||
reload: () => run(host.websearch.reload()),
|
||||
transform: (callback) =>
|
||||
register(
|
||||
host.websearch.transform((draft) => {
|
||||
callback({
|
||||
add: (definition) =>
|
||||
draft.add({
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
execute: (input) => attempt((signal) => definition.execute(input, { signal })),
|
||||
}),
|
||||
default: draft.default,
|
||||
})
|
||||
}),
|
||||
),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback) =>
|
||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
|
|
@ -270,6 +296,10 @@ export function fromPromise(plugin: Plugin) {
|
|||
})
|
||||
}
|
||||
|
||||
function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
|
||||
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause })
|
||||
}
|
||||
|
||||
function model(input: { readonly id: string; readonly providerID: string; readonly variant?: string }) {
|
||||
return Model.Ref.make({
|
||||
id: Model.ID.make(input.id),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
|||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "../image"
|
||||
import { Integration } from "../integration"
|
||||
import { KV } from "../kv"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
|
|
@ -34,7 +35,7 @@ import { Shell } from "../shell"
|
|||
import { SkillV2 } from "../skill"
|
||||
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { WebSearchTool } from "../tool/websearch"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { WellKnown } from "../wellknown"
|
||||
import { PluginInternal } from "./internal"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
|
|
@ -289,6 +290,7 @@ export const node = makeLocationNode({
|
|||
httpClient,
|
||||
Image.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
|
|
@ -303,7 +305,7 @@ export const node = makeLocationNode({
|
|||
Shell.node,
|
||||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
WebSearchTool.configNode,
|
||||
WebSearch.node,
|
||||
WellKnown.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
82
packages/core/src/plugin/websearch/exa.ts
Normal file
82
packages/core/src/plugin/websearch/exa.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
export * as WebSearchExa from "./exa"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { WebSearchMcp } from "./mcp"
|
||||
|
||||
export const endpoint = "https://mcp.exa.ai/mcp"
|
||||
|
||||
const McpInput = Schema.Struct({
|
||||
query: Schema.String,
|
||||
numResults: Schema.Number.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const McpOutput = Schema.Struct({
|
||||
content: Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
_meta: Schema.Struct({ searchTime: Schema.Number }).pipe(Schema.optional),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.websearch.exa",
|
||||
effect: Effect.fn("WebSearchExa.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"] },
|
||||
})
|
||||
})
|
||||
yield* ctx.websearch.transform((draft) => {
|
||||
draft.add({
|
||||
id: "exa",
|
||||
name: "Exa",
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("exa")
|
||||
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
|
||||
const url = new URL(endpoint)
|
||||
if (credential?.type === "key") url.searchParams.set("exaApiKey", credential.key)
|
||||
const result = yield* WebSearchMcp.call(
|
||||
http,
|
||||
url.toString(),
|
||||
"web_search_exa",
|
||||
{ input: McpInput, output: McpOutput },
|
||||
{ query: input.query, numResults: 8 },
|
||||
)
|
||||
const content = result?.content.find((item) => item.text)
|
||||
return content ? parseResults(content.text) : []
|
||||
}),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
function parseResults(text: string) {
|
||||
return text.split(/\n\n---\n\n/).flatMap((block) => {
|
||||
const url = block.match(/^URL:\s*(.+)$/m)?.[1]?.trim()
|
||||
if (!url) return []
|
||||
const title = block.match(/^Title:\s*(.+)$/m)?.[1]?.trim()
|
||||
const publishedText = block.match(/^Published:\s*(.+)$/m)?.[1]?.trim()
|
||||
const published = publishedText && publishedText !== "N/A" ? Date.parse(publishedText) : undefined
|
||||
const content = block.match(/^(?:Highlights|Text):\s*\n?([\s\S]*)$/m)?.[1]?.trim()
|
||||
return [
|
||||
{
|
||||
url,
|
||||
...(title && title !== "N/A" ? { title } : {}),
|
||||
...(content ? { content } : {}),
|
||||
time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) },
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
4
packages/core/src/plugin/websearch/index.ts
Normal file
4
packages/core/src/plugin/websearch/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { WebSearchExa } from "./exa"
|
||||
import { WebSearchParallel } from "./parallel"
|
||||
|
||||
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const
|
||||
68
packages/core/src/plugin/websearch/mcp.ts
Normal file
68
packages/core/src/plugin/websearch/mcp.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
export * as WebSearchMcp 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
|
||||
|
||||
export const parseResponse = <F extends Schema.Struct.Fields>(body: string, result: Schema.Struct<F>) => {
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ result })))
|
||||
const parse = (payload: string) => {
|
||||
const trimmed = payload.trim()
|
||||
if (!trimmed.startsWith("{")) return Effect.succeed(undefined)
|
||||
return decode(trimmed).pipe(Effect.map((response) => response.result))
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const trimmed = body.trim()
|
||||
const direct = trimmed ? yield* parse(trimmed) : undefined
|
||||
if (direct) return direct
|
||||
for (const line of body.split("\n")) {
|
||||
if (!line.startsWith("data: ")) continue
|
||||
const data = yield* parse(line.substring(6))
|
||||
if (data) return data
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fields>(
|
||||
http: HttpClient.HttpClient,
|
||||
url: string,
|
||||
tool: string,
|
||||
schema: { readonly input: Schema.Struct<F>; readonly output: Schema.Struct<R> },
|
||||
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(
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
id: Schema.Literal(1),
|
||||
method: Schema.Literal("tools/call"),
|
||||
params: Schema.Struct({ name: Schema.String, arguments: schema.input }),
|
||||
}),
|
||||
)({
|
||||
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"), schema.output)
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
103
packages/core/src/plugin/websearch/parallel.ts
Normal file
103
packages/core/src/plugin/websearch/parallel.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
export * as WebSearchParallel from "./parallel"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { App } from "../../app"
|
||||
import { WebSearchMcp } from "./mcp"
|
||||
|
||||
export const endpoint = "https://search.parallel.ai/mcp"
|
||||
|
||||
const McpInput = Schema.Struct({
|
||||
objective: Schema.String,
|
||||
search_queries: Schema.Array(Schema.String),
|
||||
model_name: Schema.String.check(Schema.isMaxLength(100)).pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const SearchResponse = Schema.Struct({
|
||||
search_id: Schema.String,
|
||||
results: Schema.Array(
|
||||
Schema.Struct({
|
||||
url: Schema.String,
|
||||
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
publish_date: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
excerpts: Schema.Array(Schema.String),
|
||||
}),
|
||||
),
|
||||
warnings: Schema.NullOr(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literals(["spec_validation_warning", "input_validation_warning", "warning"]),
|
||||
message: Schema.String,
|
||||
detail: Schema.NullOr(Schema.Record(Schema.String, Schema.Json)).pipe(Schema.optional),
|
||||
}),
|
||||
),
|
||||
).pipe(Schema.optional),
|
||||
usage: Schema.NullOr(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
name: Schema.String,
|
||||
count: Schema.Int,
|
||||
}),
|
||||
),
|
||||
).pipe(Schema.optional),
|
||||
session_id: Schema.String,
|
||||
})
|
||||
const McpOutput = Schema.Struct({
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
|
||||
structuredContent: SearchResponse,
|
||||
})
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.websearch.parallel",
|
||||
effect: Effect.fn("WebSearchParallel.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"] },
|
||||
})
|
||||
})
|
||||
yield* ctx.websearch.transform((draft) => {
|
||||
draft.add({
|
||||
id: "parallel",
|
||||
name: "Parallel",
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("parallel")
|
||||
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
|
||||
const result = yield* WebSearchMcp.call(
|
||||
http,
|
||||
endpoint,
|
||||
"web_search",
|
||||
{ input: McpInput, output: McpOutput },
|
||||
{
|
||||
objective: input.query,
|
||||
search_queries: [input.query],
|
||||
},
|
||||
{
|
||||
"User-Agent": App.useragent(ctx.app),
|
||||
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
|
||||
},
|
||||
)
|
||||
return (
|
||||
result?.structuredContent.results.map((item) => {
|
||||
const published = item.publish_date ? Date.parse(item.publish_date) : undefined
|
||||
return {
|
||||
url: item.url,
|
||||
...(item.title ? { title: item.title } : {}),
|
||||
...(item.excerpts.length ? { content: item.excerpts.join("\n\n") } : {}),
|
||||
time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) },
|
||||
}
|
||||
}) ?? []
|
||||
)
|
||||
}),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue