feat(core): add integration-backed search
This commit is contained in:
parent
41d11a8a89
commit
129012c3ee
50 changed files with 1745 additions and 478 deletions
|
|
@ -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)),
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
53
packages/core/src/plugin/search/exa.ts
Normal file
53
packages/core/src/plugin/search/exa.ts
Normal 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 ?? "" }))),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
4
packages/core/src/plugin/search/index.ts
Normal file
4
packages/core/src/plugin/search/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { SearchExa } from "./exa"
|
||||
import { SearchParallel } from "./parallel"
|
||||
|
||||
export const SearchPlugins = [SearchExa.Plugin, SearchParallel.Plugin] as const
|
||||
75
packages/core/src/plugin/search/mcp.ts
Normal file
75
packages/core/src/plugin/search/mcp.ts
Normal 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`)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
47
packages/core/src/plugin/search/parallel.ts
Normal file
47
packages/core/src/plugin/search/parallel.ts
Normal 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 ?? "" }))),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue