mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 19:09:49 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69bdd18b6f | |||
| 7b8d8b8861 | |||
| 66220071b5 | |||
| f38e558910 | |||
| c8f86e2f34 | |||
| 56f44dbcaa | |||
| 5dc482a216 | |||
| 129012c3ee |
@@ -370,63 +370,77 @@ export type Endpoint9_1Input = {
|
|||||||
export type Endpoint9_1Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.get"]>>
|
export type Endpoint9_1Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.get"]>>
|
||||||
export type IntegrationGetOperation<E = never> = (input: Endpoint9_1Input) => Effect.Effect<Endpoint9_1Output, E>
|
export type IntegrationGetOperation<E = never> = (input: Endpoint9_1Input) => Effect.Effect<Endpoint9_1Output, E>
|
||||||
|
|
||||||
type Endpoint9_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
|
type Endpoint9_2Request = Parameters<RawClient["server.integration"]["integration.capability.select"]>[0]
|
||||||
export type Endpoint9_2Input = {
|
export type Endpoint9_2Input = {
|
||||||
readonly integrationID: Endpoint9_2Request["params"]["integrationID"]
|
readonly integrationID: Endpoint9_2Request["params"]["integrationID"]
|
||||||
readonly location?: Endpoint9_2Request["query"]["location"]
|
readonly location?: Endpoint9_2Request["query"]["location"]
|
||||||
readonly key: Endpoint9_2Request["payload"]["key"]
|
readonly capability: Endpoint9_2Request["payload"]["capability"]
|
||||||
readonly label?: Endpoint9_2Request["payload"]["label"]
|
|
||||||
}
|
}
|
||||||
export type Endpoint9_2Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.key"]>>
|
export type Endpoint9_2Output = EffectValue<
|
||||||
export type IntegrationConnectKeyOperation<E = never> = (input: Endpoint9_2Input) => Effect.Effect<Endpoint9_2Output, E>
|
ReturnType<RawClient["server.integration"]["integration.capability.select"]>
|
||||||
|
>
|
||||||
|
export type IntegrationSelectCapabilityOperation<E = never> = (
|
||||||
|
input: Endpoint9_2Input,
|
||||||
|
) => Effect.Effect<Endpoint9_2Output, E>
|
||||||
|
|
||||||
type Endpoint9_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
|
type Endpoint9_3Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
|
||||||
export type Endpoint9_3Input = {
|
export type Endpoint9_3Input = {
|
||||||
readonly integrationID: Endpoint9_3Request["params"]["integrationID"]
|
readonly integrationID: Endpoint9_3Request["params"]["integrationID"]
|
||||||
readonly location?: Endpoint9_3Request["query"]["location"]
|
readonly location?: Endpoint9_3Request["query"]["location"]
|
||||||
readonly methodID: Endpoint9_3Request["payload"]["methodID"]
|
readonly key: Endpoint9_3Request["payload"]["key"]
|
||||||
readonly inputs: Endpoint9_3Request["payload"]["inputs"]
|
|
||||||
readonly label?: Endpoint9_3Request["payload"]["label"]
|
readonly label?: Endpoint9_3Request["payload"]["label"]
|
||||||
}
|
}
|
||||||
export type Endpoint9_3Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.oauth"]>>
|
export type Endpoint9_3Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.key"]>>
|
||||||
export type IntegrationConnectOauthOperation<E = never> = (
|
export type IntegrationConnectKeyOperation<E = never> = (input: Endpoint9_3Input) => Effect.Effect<Endpoint9_3Output, E>
|
||||||
input: Endpoint9_3Input,
|
|
||||||
) => Effect.Effect<Endpoint9_3Output, E>
|
|
||||||
|
|
||||||
type Endpoint9_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
|
type Endpoint9_4Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
|
||||||
export type Endpoint9_4Input = {
|
export type Endpoint9_4Input = {
|
||||||
readonly attemptID: Endpoint9_4Request["params"]["attemptID"]
|
readonly integrationID: Endpoint9_4Request["params"]["integrationID"]
|
||||||
readonly location?: Endpoint9_4Request["query"]["location"]
|
readonly location?: Endpoint9_4Request["query"]["location"]
|
||||||
|
readonly methodID: Endpoint9_4Request["payload"]["methodID"]
|
||||||
|
readonly inputs: Endpoint9_4Request["payload"]["inputs"]
|
||||||
|
readonly label?: Endpoint9_4Request["payload"]["label"]
|
||||||
}
|
}
|
||||||
export type Endpoint9_4Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.status"]>>
|
export type Endpoint9_4Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.oauth"]>>
|
||||||
export type IntegrationAttemptStatusOperation<E = never> = (
|
export type IntegrationConnectOauthOperation<E = never> = (
|
||||||
input: Endpoint9_4Input,
|
input: Endpoint9_4Input,
|
||||||
) => Effect.Effect<Endpoint9_4Output, E>
|
) => Effect.Effect<Endpoint9_4Output, E>
|
||||||
|
|
||||||
type Endpoint9_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
|
type Endpoint9_5Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
|
||||||
export type Endpoint9_5Input = {
|
export type Endpoint9_5Input = {
|
||||||
readonly attemptID: Endpoint9_5Request["params"]["attemptID"]
|
readonly attemptID: Endpoint9_5Request["params"]["attemptID"]
|
||||||
readonly location?: Endpoint9_5Request["query"]["location"]
|
readonly location?: Endpoint9_5Request["query"]["location"]
|
||||||
readonly code?: Endpoint9_5Request["payload"]["code"]
|
|
||||||
}
|
}
|
||||||
export type Endpoint9_5Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.complete"]>>
|
export type Endpoint9_5Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.status"]>>
|
||||||
export type IntegrationAttemptCompleteOperation<E = never> = (
|
export type IntegrationAttemptStatusOperation<E = never> = (
|
||||||
input: Endpoint9_5Input,
|
input: Endpoint9_5Input,
|
||||||
) => Effect.Effect<Endpoint9_5Output, E>
|
) => Effect.Effect<Endpoint9_5Output, E>
|
||||||
|
|
||||||
type Endpoint9_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
|
type Endpoint9_6Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
|
||||||
export type Endpoint9_6Input = {
|
export type Endpoint9_6Input = {
|
||||||
readonly attemptID: Endpoint9_6Request["params"]["attemptID"]
|
readonly attemptID: Endpoint9_6Request["params"]["attemptID"]
|
||||||
readonly location?: Endpoint9_6Request["query"]["location"]
|
readonly location?: Endpoint9_6Request["query"]["location"]
|
||||||
|
readonly code?: Endpoint9_6Request["payload"]["code"]
|
||||||
}
|
}
|
||||||
export type Endpoint9_6Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.cancel"]>>
|
export type Endpoint9_6Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.complete"]>>
|
||||||
export type IntegrationAttemptCancelOperation<E = never> = (
|
export type IntegrationAttemptCompleteOperation<E = never> = (
|
||||||
input: Endpoint9_6Input,
|
input: Endpoint9_6Input,
|
||||||
) => Effect.Effect<Endpoint9_6Output, E>
|
) => Effect.Effect<Endpoint9_6Output, E>
|
||||||
|
|
||||||
|
type Endpoint9_7Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
|
||||||
|
export type Endpoint9_7Input = {
|
||||||
|
readonly attemptID: Endpoint9_7Request["params"]["attemptID"]
|
||||||
|
readonly location?: Endpoint9_7Request["query"]["location"]
|
||||||
|
}
|
||||||
|
export type Endpoint9_7Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.cancel"]>>
|
||||||
|
export type IntegrationAttemptCancelOperation<E = never> = (
|
||||||
|
input: Endpoint9_7Input,
|
||||||
|
) => Effect.Effect<Endpoint9_7Output, E>
|
||||||
|
|
||||||
export interface IntegrationApi<E = never> {
|
export interface IntegrationApi<E = never> {
|
||||||
readonly list: IntegrationListOperation<E>
|
readonly list: IntegrationListOperation<E>
|
||||||
readonly get: IntegrationGetOperation<E>
|
readonly get: IntegrationGetOperation<E>
|
||||||
|
readonly selectCapability: IntegrationSelectCapabilityOperation<E>
|
||||||
readonly connectKey: IntegrationConnectKeyOperation<E>
|
readonly connectKey: IntegrationConnectKeyOperation<E>
|
||||||
readonly connectOauth: IntegrationConnectOauthOperation<E>
|
readonly connectOauth: IntegrationConnectOauthOperation<E>
|
||||||
readonly attemptStatus: IntegrationAttemptStatusOperation<E>
|
readonly attemptStatus: IntegrationAttemptStatusOperation<E>
|
||||||
@@ -894,6 +908,23 @@ export interface DebugApi<E = never> {
|
|||||||
readonly location: DebugLocationOperation<E>
|
readonly location: DebugLocationOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Endpoint26_0Request = Parameters<RawClient["server.search"]["search.query"]>[0]
|
||||||
|
export type Endpoint26_0Input = {
|
||||||
|
readonly location?: Endpoint26_0Request["query"]["location"]
|
||||||
|
readonly query: Endpoint26_0Request["payload"]["query"]
|
||||||
|
readonly providerID?: Endpoint26_0Request["payload"]["providerID"]
|
||||||
|
readonly numResults?: Endpoint26_0Request["payload"]["numResults"]
|
||||||
|
readonly livecrawl?: Endpoint26_0Request["payload"]["livecrawl"]
|
||||||
|
readonly type?: Endpoint26_0Request["payload"]["type"]
|
||||||
|
readonly contextMaxCharacters?: Endpoint26_0Request["payload"]["contextMaxCharacters"]
|
||||||
|
}
|
||||||
|
export type Endpoint26_0Output = EffectValue<ReturnType<RawClient["server.search"]["search.query"]>>
|
||||||
|
export type SearchQueryOperation<E = never> = (input: Endpoint26_0Input) => Effect.Effect<Endpoint26_0Output, E>
|
||||||
|
|
||||||
|
export interface SearchApi<E = never> {
|
||||||
|
readonly query: SearchQueryOperation<E>
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppApi<E = never> {
|
export interface AppApi<E = never> {
|
||||||
readonly health: HealthApi<E>
|
readonly health: HealthApi<E>
|
||||||
readonly location: LocationApi<E>
|
readonly location: LocationApi<E>
|
||||||
@@ -921,4 +952,5 @@ export interface AppApi<E = never> {
|
|||||||
readonly projectCopy: ProjectCopyApi<E>
|
readonly projectCopy: ProjectCopyApi<E>
|
||||||
readonly vcs: VcsApi<E>
|
readonly vcs: VcsApi<E>
|
||||||
readonly debug: DebugApi<E>
|
readonly debug: DebugApi<E>
|
||||||
|
readonly search: SearchApi<E>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -450,65 +450,78 @@ const Endpoint9_1 = (raw: RawClient["server.integration"]) => (input: Endpoint9_
|
|||||||
query: { location: input["location"] },
|
query: { location: input["location"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint9_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
|
type Endpoint9_2Request = Parameters<RawClient["server.integration"]["integration.capability.select"]>[0]
|
||||||
type Endpoint9_2Input = {
|
type Endpoint9_2Input = {
|
||||||
readonly integrationID: Endpoint9_2Request["params"]["integrationID"]
|
readonly integrationID: Endpoint9_2Request["params"]["integrationID"]
|
||||||
readonly location?: Endpoint9_2Request["query"]["location"]
|
readonly location?: Endpoint9_2Request["query"]["location"]
|
||||||
readonly key: Endpoint9_2Request["payload"]["key"]
|
readonly capability: Endpoint9_2Request["payload"]["capability"]
|
||||||
readonly label?: Endpoint9_2Request["payload"]["label"]
|
|
||||||
}
|
}
|
||||||
const Endpoint9_2 = (raw: RawClient["server.integration"]) => (input: Endpoint9_2Input) =>
|
const Endpoint9_2 = (raw: RawClient["server.integration"]) => (input: Endpoint9_2Input) =>
|
||||||
|
raw["integration.capability.select"]({
|
||||||
|
params: { integrationID: input["integrationID"] },
|
||||||
|
query: { location: input["location"] },
|
||||||
|
payload: { capability: input["capability"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint9_3Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
|
||||||
|
type Endpoint9_3Input = {
|
||||||
|
readonly integrationID: Endpoint9_3Request["params"]["integrationID"]
|
||||||
|
readonly location?: Endpoint9_3Request["query"]["location"]
|
||||||
|
readonly key: Endpoint9_3Request["payload"]["key"]
|
||||||
|
readonly label?: Endpoint9_3Request["payload"]["label"]
|
||||||
|
}
|
||||||
|
const Endpoint9_3 = (raw: RawClient["server.integration"]) => (input: Endpoint9_3Input) =>
|
||||||
raw["integration.connect.key"]({
|
raw["integration.connect.key"]({
|
||||||
params: { integrationID: input["integrationID"] },
|
params: { integrationID: input["integrationID"] },
|
||||||
query: { location: input["location"] },
|
query: { location: input["location"] },
|
||||||
payload: { key: input["key"], label: input["label"] },
|
payload: { key: input["key"], label: input["label"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint9_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
|
type Endpoint9_4Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
|
||||||
type Endpoint9_3Input = {
|
type Endpoint9_4Input = {
|
||||||
readonly integrationID: Endpoint9_3Request["params"]["integrationID"]
|
readonly integrationID: Endpoint9_4Request["params"]["integrationID"]
|
||||||
readonly location?: Endpoint9_3Request["query"]["location"]
|
readonly location?: Endpoint9_4Request["query"]["location"]
|
||||||
readonly methodID: Endpoint9_3Request["payload"]["methodID"]
|
readonly methodID: Endpoint9_4Request["payload"]["methodID"]
|
||||||
readonly inputs: Endpoint9_3Request["payload"]["inputs"]
|
readonly inputs: Endpoint9_4Request["payload"]["inputs"]
|
||||||
readonly label?: Endpoint9_3Request["payload"]["label"]
|
readonly label?: Endpoint9_4Request["payload"]["label"]
|
||||||
}
|
}
|
||||||
const Endpoint9_3 = (raw: RawClient["server.integration"]) => (input: Endpoint9_3Input) =>
|
const Endpoint9_4 = (raw: RawClient["server.integration"]) => (input: Endpoint9_4Input) =>
|
||||||
raw["integration.connect.oauth"]({
|
raw["integration.connect.oauth"]({
|
||||||
params: { integrationID: input["integrationID"] },
|
params: { integrationID: input["integrationID"] },
|
||||||
query: { location: input["location"] },
|
query: { location: input["location"] },
|
||||||
payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
|
payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint9_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
|
type Endpoint9_5Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
|
||||||
type Endpoint9_4Input = {
|
type Endpoint9_5Input = {
|
||||||
readonly attemptID: Endpoint9_4Request["params"]["attemptID"]
|
readonly attemptID: Endpoint9_5Request["params"]["attemptID"]
|
||||||
readonly location?: Endpoint9_4Request["query"]["location"]
|
readonly location?: Endpoint9_5Request["query"]["location"]
|
||||||
}
|
}
|
||||||
const Endpoint9_4 = (raw: RawClient["server.integration"]) => (input: Endpoint9_4Input) =>
|
const Endpoint9_5 = (raw: RawClient["server.integration"]) => (input: Endpoint9_5Input) =>
|
||||||
raw["integration.attempt.status"]({
|
raw["integration.attempt.status"]({
|
||||||
params: { attemptID: input["attemptID"] },
|
params: { attemptID: input["attemptID"] },
|
||||||
query: { location: input["location"] },
|
query: { location: input["location"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint9_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
|
type Endpoint9_6Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
|
||||||
type Endpoint9_5Input = {
|
type Endpoint9_6Input = {
|
||||||
readonly attemptID: Endpoint9_5Request["params"]["attemptID"]
|
readonly attemptID: Endpoint9_6Request["params"]["attemptID"]
|
||||||
readonly location?: Endpoint9_5Request["query"]["location"]
|
readonly location?: Endpoint9_6Request["query"]["location"]
|
||||||
readonly code?: Endpoint9_5Request["payload"]["code"]
|
readonly code?: Endpoint9_6Request["payload"]["code"]
|
||||||
}
|
}
|
||||||
const Endpoint9_5 = (raw: RawClient["server.integration"]) => (input: Endpoint9_5Input) =>
|
const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_6Input) =>
|
||||||
raw["integration.attempt.complete"]({
|
raw["integration.attempt.complete"]({
|
||||||
params: { attemptID: input["attemptID"] },
|
params: { attemptID: input["attemptID"] },
|
||||||
query: { location: input["location"] },
|
query: { location: input["location"] },
|
||||||
payload: { code: input["code"] },
|
payload: { code: input["code"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint9_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
|
type Endpoint9_7Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
|
||||||
type Endpoint9_6Input = {
|
type Endpoint9_7Input = {
|
||||||
readonly attemptID: Endpoint9_6Request["params"]["attemptID"]
|
readonly attemptID: Endpoint9_7Request["params"]["attemptID"]
|
||||||
readonly location?: Endpoint9_6Request["query"]["location"]
|
readonly location?: Endpoint9_7Request["query"]["location"]
|
||||||
}
|
}
|
||||||
const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_6Input) =>
|
const Endpoint9_7 = (raw: RawClient["server.integration"]) => (input: Endpoint9_7Input) =>
|
||||||
raw["integration.attempt.cancel"]({
|
raw["integration.attempt.cancel"]({
|
||||||
params: { attemptID: input["attemptID"] },
|
params: { attemptID: input["attemptID"] },
|
||||||
query: { location: input["location"] },
|
query: { location: input["location"] },
|
||||||
@@ -517,11 +530,12 @@ const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_
|
|||||||
const adaptGroup9 = (raw: RawClient["server.integration"]) => ({
|
const adaptGroup9 = (raw: RawClient["server.integration"]) => ({
|
||||||
list: Endpoint9_0(raw),
|
list: Endpoint9_0(raw),
|
||||||
get: Endpoint9_1(raw),
|
get: Endpoint9_1(raw),
|
||||||
connectKey: Endpoint9_2(raw),
|
selectCapability: Endpoint9_2(raw),
|
||||||
connectOauth: Endpoint9_3(raw),
|
connectKey: Endpoint9_3(raw),
|
||||||
attemptStatus: Endpoint9_4(raw),
|
connectOauth: Endpoint9_4(raw),
|
||||||
attemptComplete: Endpoint9_5(raw),
|
attemptStatus: Endpoint9_5(raw),
|
||||||
attemptCancel: Endpoint9_6(raw),
|
attemptComplete: Endpoint9_6(raw),
|
||||||
|
attemptCancel: Endpoint9_7(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
type Endpoint10_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
|
type Endpoint10_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
|
||||||
@@ -1071,6 +1085,31 @@ const Endpoint25_0 = (raw: RawClient["server.debug"]) => () =>
|
|||||||
|
|
||||||
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ location: Endpoint25_0(raw) })
|
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ location: Endpoint25_0(raw) })
|
||||||
|
|
||||||
|
type Endpoint26_0Request = Parameters<RawClient["server.search"]["search.query"]>[0]
|
||||||
|
type Endpoint26_0Input = {
|
||||||
|
readonly location?: Endpoint26_0Request["query"]["location"]
|
||||||
|
readonly query: Endpoint26_0Request["payload"]["query"]
|
||||||
|
readonly providerID?: Endpoint26_0Request["payload"]["providerID"]
|
||||||
|
readonly numResults?: Endpoint26_0Request["payload"]["numResults"]
|
||||||
|
readonly livecrawl?: Endpoint26_0Request["payload"]["livecrawl"]
|
||||||
|
readonly type?: Endpoint26_0Request["payload"]["type"]
|
||||||
|
readonly contextMaxCharacters?: Endpoint26_0Request["payload"]["contextMaxCharacters"]
|
||||||
|
}
|
||||||
|
const Endpoint26_0 = (raw: RawClient["server.search"]) => (input: Endpoint26_0Input) =>
|
||||||
|
raw["search.query"]({
|
||||||
|
query: { location: input["location"] },
|
||||||
|
payload: {
|
||||||
|
query: input["query"],
|
||||||
|
providerID: input["providerID"],
|
||||||
|
numResults: input["numResults"],
|
||||||
|
livecrawl: input["livecrawl"],
|
||||||
|
type: input["type"],
|
||||||
|
contextMaxCharacters: input["contextMaxCharacters"],
|
||||||
|
},
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
const adaptGroup26 = (raw: RawClient["server.search"]) => ({ query: Endpoint26_0(raw) })
|
||||||
|
|
||||||
const adaptClient = (raw: RawClient) => ({
|
const adaptClient = (raw: RawClient) => ({
|
||||||
health: adaptGroup0(raw["server.health"]),
|
health: adaptGroup0(raw["server.health"]),
|
||||||
location: adaptGroup1(raw["server.location"]),
|
location: adaptGroup1(raw["server.location"]),
|
||||||
@@ -1098,6 +1137,7 @@ const adaptClient = (raw: RawClient) => ({
|
|||||||
projectCopy: adaptGroup23(raw["server.projectCopy"]),
|
projectCopy: adaptGroup23(raw["server.projectCopy"]),
|
||||||
vcs: adaptGroup24(raw["server.vcs"]),
|
vcs: adaptGroup24(raw["server.vcs"]),
|
||||||
debug: adaptGroup25(raw["server.debug"]),
|
debug: adaptGroup25(raw["server.debug"]),
|
||||||
|
search: adaptGroup26(raw["server.search"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type {
|
|||||||
PluginApi,
|
PluginApi,
|
||||||
ProviderApi,
|
ProviderApi,
|
||||||
ReferenceApi,
|
ReferenceApi,
|
||||||
|
SearchApi,
|
||||||
SessionApi,
|
SessionApi,
|
||||||
SkillApi,
|
SkillApi,
|
||||||
} from "./api.js"
|
} from "./api.js"
|
||||||
@@ -36,6 +37,7 @@ export { Provider } from "@opencode-ai/schema/provider"
|
|||||||
export { Pty } from "@opencode-ai/schema/pty"
|
export { Pty } from "@opencode-ai/schema/pty"
|
||||||
export { Question } from "@opencode-ai/schema/question"
|
export { Question } from "@opencode-ai/schema/question"
|
||||||
export { Reference } from "@opencode-ai/schema/reference"
|
export { Reference } from "@opencode-ai/schema/reference"
|
||||||
|
export { Search } from "@opencode-ai/schema/search"
|
||||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||||
export { Session } from "@opencode-ai/schema/session"
|
export { Session } from "@opencode-ai/schema/session"
|
||||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
export { SessionInput } from "@opencode-ai/schema/session-input"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
PluginApi as EffectPluginApi,
|
PluginApi as EffectPluginApi,
|
||||||
ProviderApi as EffectProviderApi,
|
ProviderApi as EffectProviderApi,
|
||||||
ReferenceApi as EffectReferenceApi,
|
ReferenceApi as EffectReferenceApi,
|
||||||
|
SearchApi as EffectSearchApi,
|
||||||
SessionApi as EffectSessionApi,
|
SessionApi as EffectSessionApi,
|
||||||
SkillApi as EffectSkillApi,
|
SkillApi as EffectSkillApi,
|
||||||
} from "../effect/api/api.js"
|
} from "../effect/api/api.js"
|
||||||
@@ -36,6 +37,7 @@ export type ModelApi = PromisifyApi<EffectModelApi<unknown>>
|
|||||||
export type PluginApi = PromisifyApi<EffectPluginApi<unknown>>
|
export type PluginApi = PromisifyApi<EffectPluginApi<unknown>>
|
||||||
export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>>
|
export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>>
|
||||||
export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>>
|
export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>>
|
||||||
|
export type SearchApi = PromisifyApi<EffectSearchApi<unknown>>
|
||||||
export type SessionApi = PromisifyApi<EffectSessionApi<unknown>>
|
export type SessionApi = PromisifyApi<EffectSessionApi<unknown>>
|
||||||
export type SkillApi = PromisifyApi<EffectSkillApi<unknown>>
|
export type SkillApi = PromisifyApi<EffectSkillApi<unknown>>
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ import type {
|
|||||||
IntegrationListOutput,
|
IntegrationListOutput,
|
||||||
IntegrationGetInput,
|
IntegrationGetInput,
|
||||||
IntegrationGetOutput,
|
IntegrationGetOutput,
|
||||||
|
IntegrationSelectCapabilityInput,
|
||||||
|
IntegrationSelectCapabilityOutput,
|
||||||
IntegrationConnectKeyInput,
|
IntegrationConnectKeyInput,
|
||||||
IntegrationConnectKeyOutput,
|
IntegrationConnectKeyOutput,
|
||||||
IntegrationConnectOauthInput,
|
IntegrationConnectOauthInput,
|
||||||
@@ -178,6 +180,8 @@ import type {
|
|||||||
VcsDiffInput,
|
VcsDiffInput,
|
||||||
VcsDiffOutput,
|
VcsDiffOutput,
|
||||||
DebugLocationOutput,
|
DebugLocationOutput,
|
||||||
|
SearchQueryInput,
|
||||||
|
SearchQueryOutput,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { ClientError } from "./client-error"
|
import { ClientError } from "./client-error"
|
||||||
|
|
||||||
@@ -813,6 +817,19 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
),
|
),
|
||||||
|
selectCapability: (input: IntegrationSelectCapabilityInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<IntegrationSelectCapabilityOutput>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/api/integration/${encodeURIComponent(input.integrationID)}/capability`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
body: { capability: input["capability"] },
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [400, 401],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
connectKey: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) =>
|
connectKey: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) =>
|
||||||
request<IntegrationConnectKeyOutput>(
|
request<IntegrationConnectKeyOutput>(
|
||||||
{
|
{
|
||||||
@@ -1495,6 +1512,28 @@ export function make(options: ClientOptions) {
|
|||||||
requestOptions,
|
requestOptions,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
search: {
|
||||||
|
query: (input: SearchQueryInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<SearchQueryOutput>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/api/search`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
body: {
|
||||||
|
query: input["query"],
|
||||||
|
providerID: input["providerID"],
|
||||||
|
numResults: input["numResults"],
|
||||||
|
livecrawl: input["livecrawl"],
|
||||||
|
type: input["type"],
|
||||||
|
contextMaxCharacters: input["contextMaxCharacters"],
|
||||||
|
},
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [400, 503, 401],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2279,6 +2279,11 @@ export type IntegrationListOutput = {
|
|||||||
| { readonly type: "key"; readonly label?: string }
|
| { readonly type: "key"; readonly label?: string }
|
||||||
| { readonly type: "env"; readonly names: ReadonlyArray<string> }
|
| { readonly type: "env"; readonly names: ReadonlyArray<string> }
|
||||||
>
|
>
|
||||||
|
readonly capabilities: ReadonlyArray<{
|
||||||
|
readonly type: "search"
|
||||||
|
readonly connection: "optional" | "required"
|
||||||
|
readonly selected: boolean
|
||||||
|
}>
|
||||||
readonly connections: ReadonlyArray<
|
readonly connections: ReadonlyArray<
|
||||||
| { readonly type: "credential"; readonly id: string; readonly label: string }
|
| { readonly type: "credential"; readonly id: string; readonly label: string }
|
||||||
| { readonly type: "env"; readonly name: string }
|
| { readonly type: "env"; readonly name: string }
|
||||||
@@ -2331,6 +2336,11 @@ export type IntegrationGetOutput = {
|
|||||||
| { readonly type: "key"; readonly label?: string }
|
| { readonly type: "key"; readonly label?: string }
|
||||||
| { readonly type: "env"; readonly names: ReadonlyArray<string> }
|
| { readonly type: "env"; readonly names: ReadonlyArray<string> }
|
||||||
>
|
>
|
||||||
|
readonly capabilities: ReadonlyArray<{
|
||||||
|
readonly type: "search"
|
||||||
|
readonly connection: "optional" | "required"
|
||||||
|
readonly selected: boolean
|
||||||
|
}>
|
||||||
readonly connections: ReadonlyArray<
|
readonly connections: ReadonlyArray<
|
||||||
| { readonly type: "credential"; readonly id: string; readonly label: string }
|
| { readonly type: "credential"; readonly id: string; readonly label: string }
|
||||||
| { readonly type: "env"; readonly name: string }
|
| { readonly type: "env"; readonly name: string }
|
||||||
@@ -2338,6 +2348,16 @@ export type IntegrationGetOutput = {
|
|||||||
} | null
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IntegrationSelectCapabilityInput = {
|
||||||
|
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
||||||
|
readonly location?: {
|
||||||
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
|
}["location"]
|
||||||
|
readonly capability: { readonly capability: "search" }["capability"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntegrationSelectCapabilityOutput = void
|
||||||
|
|
||||||
export type IntegrationConnectKeyInput = {
|
export type IntegrationConnectKeyInput = {
|
||||||
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
||||||
readonly location?: {
|
readonly location?: {
|
||||||
@@ -2643,6 +2663,14 @@ export type FormListRequestsOutput = {
|
|||||||
readonly mode: "url"
|
readonly mode: "url"
|
||||||
readonly url: string
|
readonly url: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly title?: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly mode: "integration"
|
||||||
|
readonly integrationID: string
|
||||||
|
}
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2755,6 +2783,14 @@ export type FormListOutput = {
|
|||||||
readonly mode: "url"
|
readonly mode: "url"
|
||||||
readonly url: string
|
readonly url: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly title?: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly mode: "integration"
|
||||||
|
readonly integrationID: string
|
||||||
|
}
|
||||||
>
|
>
|
||||||
}["data"]
|
}["data"]
|
||||||
|
|
||||||
@@ -3451,6 +3487,14 @@ export type FormCreateOutput = {
|
|||||||
readonly mode: "url"
|
readonly mode: "url"
|
||||||
readonly url: string
|
readonly url: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly title?: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly mode: "integration"
|
||||||
|
readonly integrationID: string
|
||||||
|
}
|
||||||
}["data"]
|
}["data"]
|
||||||
|
|
||||||
export type FormGetInput = {
|
export type FormGetInput = {
|
||||||
@@ -3565,6 +3609,14 @@ export type FormGetOutput = {
|
|||||||
readonly mode: "url"
|
readonly mode: "url"
|
||||||
readonly url: string
|
readonly url: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly title?: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly mode: "integration"
|
||||||
|
readonly integrationID: string
|
||||||
|
}
|
||||||
}["data"]
|
}["data"]
|
||||||
|
|
||||||
export type FormStateInput = {
|
export type FormStateInput = {
|
||||||
@@ -5351,6 +5403,14 @@ export type EventSubscribeOutput =
|
|||||||
readonly mode: "url"
|
readonly mode: "url"
|
||||||
readonly url: string
|
readonly url: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly title?: string
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
|
readonly mode: "integration"
|
||||||
|
readonly integrationID: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -6130,3 +6190,66 @@ export type VcsDiffOutput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }>
|
export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }>
|
||||||
|
|
||||||
|
export type SearchQueryInput = {
|
||||||
|
readonly location?: {
|
||||||
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
|
}["location"]
|
||||||
|
readonly query: {
|
||||||
|
readonly query: string
|
||||||
|
readonly providerID?: string
|
||||||
|
readonly numResults?: number
|
||||||
|
readonly livecrawl?: "fallback" | "preferred"
|
||||||
|
readonly type?: "auto" | "fast" | "deep"
|
||||||
|
readonly contextMaxCharacters?: number
|
||||||
|
}["query"]
|
||||||
|
readonly providerID?: {
|
||||||
|
readonly query: string
|
||||||
|
readonly providerID?: string
|
||||||
|
readonly numResults?: number
|
||||||
|
readonly livecrawl?: "fallback" | "preferred"
|
||||||
|
readonly type?: "auto" | "fast" | "deep"
|
||||||
|
readonly contextMaxCharacters?: number
|
||||||
|
}["providerID"]
|
||||||
|
readonly numResults?: {
|
||||||
|
readonly query: string
|
||||||
|
readonly providerID?: string
|
||||||
|
readonly numResults?: number
|
||||||
|
readonly livecrawl?: "fallback" | "preferred"
|
||||||
|
readonly type?: "auto" | "fast" | "deep"
|
||||||
|
readonly contextMaxCharacters?: number
|
||||||
|
}["numResults"]
|
||||||
|
readonly livecrawl?: {
|
||||||
|
readonly query: string
|
||||||
|
readonly providerID?: string
|
||||||
|
readonly numResults?: number
|
||||||
|
readonly livecrawl?: "fallback" | "preferred"
|
||||||
|
readonly type?: "auto" | "fast" | "deep"
|
||||||
|
readonly contextMaxCharacters?: number
|
||||||
|
}["livecrawl"]
|
||||||
|
readonly type?: {
|
||||||
|
readonly query: string
|
||||||
|
readonly providerID?: string
|
||||||
|
readonly numResults?: number
|
||||||
|
readonly livecrawl?: "fallback" | "preferred"
|
||||||
|
readonly type?: "auto" | "fast" | "deep"
|
||||||
|
readonly contextMaxCharacters?: number
|
||||||
|
}["type"]
|
||||||
|
readonly contextMaxCharacters?: {
|
||||||
|
readonly query: string
|
||||||
|
readonly providerID?: string
|
||||||
|
readonly numResults?: number
|
||||||
|
readonly livecrawl?: "fallback" | "preferred"
|
||||||
|
readonly type?: "auto" | "fast" | "deep"
|
||||||
|
readonly contextMaxCharacters?: number
|
||||||
|
}["contextMaxCharacters"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SearchQueryOutput = {
|
||||||
|
readonly location: {
|
||||||
|
readonly directory: string
|
||||||
|
readonly workspaceID?: string
|
||||||
|
readonly project: { readonly id: string; readonly directory: string }
|
||||||
|
}
|
||||||
|
readonly data: { readonly providerID: string; readonly text: string; readonly metadata?: JsonValue }
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type {
|
|||||||
PluginApi,
|
PluginApi,
|
||||||
ProviderApi,
|
ProviderApi,
|
||||||
ReferenceApi,
|
ReferenceApi,
|
||||||
|
SearchApi,
|
||||||
SessionApi,
|
SessionApi,
|
||||||
SkillApi,
|
SkillApi,
|
||||||
} from "./api.js"
|
} from "./api.js"
|
||||||
|
|||||||
@@ -31,18 +31,21 @@ test("exposes every standard HTTP API group", () => {
|
|||||||
"projectCopy",
|
"projectCopy",
|
||||||
"vcs",
|
"vcs",
|
||||||
"debug",
|
"debug",
|
||||||
|
"search",
|
||||||
])
|
])
|
||||||
expect(Object.keys(client.debug)).toEqual(["location"])
|
expect(Object.keys(client.debug)).toEqual(["location"])
|
||||||
expect(Object.keys(client.message)).toEqual(["list"])
|
expect(Object.keys(client.message)).toEqual(["list"])
|
||||||
expect(Object.keys(client.integration)).toEqual([
|
expect(Object.keys(client.integration)).toEqual([
|
||||||
"list",
|
"list",
|
||||||
"get",
|
"get",
|
||||||
|
"selectCapability",
|
||||||
"connectKey",
|
"connectKey",
|
||||||
"connectOauth",
|
"connectOauth",
|
||||||
"attemptStatus",
|
"attemptStatus",
|
||||||
"attemptComplete",
|
"attemptComplete",
|
||||||
"attemptCancel",
|
"attemptCancel",
|
||||||
])
|
])
|
||||||
|
expect(Object.keys(client.search)).toEqual(["query"])
|
||||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
||||||
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
|
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
|
||||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||||
@@ -50,6 +53,32 @@ test("exposes every standard HTTP API group", () => {
|
|||||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("search.query uses the public HTTP contract", async () => {
|
||||||
|
let request: Request | undefined
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async (input, init) => {
|
||||||
|
request = input instanceof Request ? input : new Request(input, init)
|
||||||
|
return Response.json({
|
||||||
|
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
|
||||||
|
data: { providerID: "exa", text: "result", metadata: { requestID: "req_test" } },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await client.search.query({
|
||||||
|
query: "opencode",
|
||||||
|
providerID: "exa",
|
||||||
|
numResults: 5,
|
||||||
|
location: { directory: "/tmp/project" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.data).toEqual({ providerID: "exa", text: "result", metadata: { requestID: "req_test" } })
|
||||||
|
expect(request?.method).toBe("POST")
|
||||||
|
expect(request?.url).toBe("http://localhost:3000/api/search?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||||
|
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa", numResults: 5 })
|
||||||
|
})
|
||||||
|
|
||||||
test("file.read returns binary content from the public HTTP contract", async () => {
|
test("file.read returns binary content from the public HTTP contract", async () => {
|
||||||
let request: Request | undefined
|
let request: Request | undefined
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
|
|||||||
@@ -36,6 +36,10 @@
|
|||||||
"name": "event",
|
"name": "event",
|
||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "integration_capability",
|
||||||
|
"entityType": "tables"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "permission",
|
"name": "permission",
|
||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
@@ -554,6 +558,46 @@
|
|||||||
"entityType": "columns",
|
"entityType": "columns",
|
||||||
"table": "event"
|
"table": "event"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "capability",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "integration_capability"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "integration_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "integration_capability"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "time_created",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "integration_capability"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "time_updated",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "integration_capability"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -1805,6 +1849,13 @@
|
|||||||
"table": "event",
|
"table": "event",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"columns": ["capability"],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "integration_capability_pk",
|
||||||
|
"table": "integration_capability",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": ["id"],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { ConfigModel } from "./config/model"
|
|||||||
import { ConfigPlugin } from "./config/plugin"
|
import { ConfigPlugin } from "./config/plugin"
|
||||||
import { ConfigProvider } from "./config/provider"
|
import { ConfigProvider } from "./config/provider"
|
||||||
import { ConfigReference } from "./config/reference"
|
import { ConfigReference } from "./config/reference"
|
||||||
|
import { ConfigSearch } from "./config/search"
|
||||||
import { ConfigToolOutput } from "./config/tool-output"
|
import { ConfigToolOutput } from "./config/tool-output"
|
||||||
import { ConfigVariable } from "./config/variable"
|
import { ConfigVariable } from "./config/variable"
|
||||||
import { ConfigWatcher } from "./config/watcher"
|
import { ConfigWatcher } from "./config/watcher"
|
||||||
@@ -102,6 +103,9 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
|||||||
references: ConfigReference.Info.pipe(Schema.optional).annotate({
|
references: ConfigReference.Info.pipe(Schema.optional).annotate({
|
||||||
description: "Named local directories or Git repositories available as external context",
|
description: "Named local directories or Git repositories available as external context",
|
||||||
}),
|
}),
|
||||||
|
search: ConfigSearch.Info.pipe(Schema.optional).annotate({
|
||||||
|
description: "Web search provider selection",
|
||||||
|
}),
|
||||||
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||||
description: "Ordered plugin enablement directives and external package declarations",
|
description: "Ordered plugin enablement directives and external package declarations",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export * as ConfigSearch from "./search"
|
||||||
|
|
||||||
|
import { Integration } from "@opencode-ai/schema/integration"
|
||||||
|
import { Schema } from "effect"
|
||||||
|
|
||||||
|
export class Info extends Schema.Class<Info>("ConfigSearch.Info")({
|
||||||
|
provider: Integration.ID,
|
||||||
|
}) {}
|
||||||
+1
@@ -46,6 +46,7 @@ export const migrations = (
|
|||||||
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
|
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
|
||||||
import("./migration/20260703200000_reset_v2_session_events"),
|
import("./migration/20260703200000_reset_v2_session_events"),
|
||||||
import("./migration/20260705180000_rename_instructions"),
|
import("./migration/20260705180000_rename_instructions"),
|
||||||
|
import("./migration/20260706133920_integration-search"),
|
||||||
import("./migration/20260706223930_add-session-fork"),
|
import("./migration/20260706223930_add-session-fork"),
|
||||||
import("./migration/20260707010146_durable_session_inbox"),
|
import("./migration/20260707010146_durable_session_inbox"),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Effect } from "effect"
|
||||||
|
import type { DatabaseMigration } from "../migration"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "20260706133920_integration-search",
|
||||||
|
up(tx) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
yield* tx.run(`
|
||||||
|
CREATE TABLE \`integration_capability\` (
|
||||||
|
\`capability\` text PRIMARY KEY,
|
||||||
|
\`integration_id\` text NOT NULL,
|
||||||
|
\`time_created\` integer NOT NULL,
|
||||||
|
\`time_updated\` integer NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
} satisfies DatabaseMigration.Migration
|
||||||
@@ -87,6 +87,14 @@ export default {
|
|||||||
CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE
|
CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
yield* tx.run(`
|
||||||
|
CREATE TABLE \`integration_capability\` (
|
||||||
|
\`capability\` text PRIMARY KEY,
|
||||||
|
\`integration_id\` text NOT NULL,
|
||||||
|
\`time_created\` integer NOT NULL,
|
||||||
|
\`time_updated\` integer NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
yield* tx.run(`
|
yield* tx.run(`
|
||||||
CREATE TABLE \`permission\` (
|
CREATE TABLE \`permission\` (
|
||||||
\`id\` text PRIMARY KEY,
|
\`id\` text PRIMARY KEY,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export class InvalidFormError extends Schema.TaggedErrorClass<InvalidFormError>(
|
|||||||
export type CreateInput =
|
export type CreateInput =
|
||||||
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
|
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
|
||||||
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
|
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
|
||||||
|
| (Omit<Form.IntegrationInfo, "id"> & { readonly id?: ID })
|
||||||
|
|
||||||
export interface ReplyInput {
|
export interface ReplyInput {
|
||||||
readonly id: ID
|
readonly id: ID
|
||||||
@@ -135,10 +136,7 @@ export const layer = Layer.effect(
|
|||||||
title: input.title,
|
title: input.title,
|
||||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||||
}
|
}
|
||||||
const form: Info =
|
const form = makeInfo(input, base)
|
||||||
input.mode === "form"
|
|
||||||
? { ...base, mode: "form", fields: input.fields }
|
|
||||||
: { ...base, mode: "url", url: input.url }
|
|
||||||
const entry: Entry = {
|
const entry: Entry = {
|
||||||
form,
|
form,
|
||||||
state: { status: "pending" },
|
state: { status: "pending" },
|
||||||
@@ -228,9 +226,9 @@ export const locationLayer = layer
|
|||||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
||||||
|
|
||||||
function validateAnswer(form: Info, answer: Answer) {
|
function validateAnswer(form: Info, answer: Answer) {
|
||||||
if (form.mode === "url") {
|
if (form.mode !== "form") {
|
||||||
if (Object.keys(answer).length === 0) return
|
if (Object.keys(answer).length === 0) return
|
||||||
return "URL forms must be answered with an empty answer"
|
return `${form.mode === "url" ? "URL" : "Integration"} forms must be answered with an empty answer`
|
||||||
}
|
}
|
||||||
const fields = new Map(form.fields.map((field) => [field.key, field]))
|
const fields = new Map(form.fields.map((field) => [field.key, field]))
|
||||||
for (const key of Object.keys(answer)) {
|
for (const key of Object.keys(answer)) {
|
||||||
@@ -249,6 +247,17 @@ function validateAnswer(form: Info, answer: Answer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeInfo(input: CreateInput, base: Omit<Info, "mode" | "fields" | "url" | "integrationID">): Info {
|
||||||
|
switch (input.mode) {
|
||||||
|
case "form":
|
||||||
|
return { ...base, mode: "form", fields: input.fields }
|
||||||
|
case "url":
|
||||||
|
return { ...base, mode: "url", url: input.url }
|
||||||
|
case "integration":
|
||||||
|
return { ...base, mode: "integration", integrationID: input.integrationID }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isActive(field: Form.Field, answer: Answer) {
|
function isActive(field: Form.Field, answer: Answer) {
|
||||||
if (!field.when) return true
|
if (!field.when) return true
|
||||||
return field.when.every((when) => matches(when, answer[when.key]))
|
return field.when.every((when) => matches(when, answer[when.key]))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * as Integration from "./integration"
|
export * as Integration from "./integration"
|
||||||
|
|
||||||
import { makeLocationNode } from "./effect/app-node"
|
import { makeLocationNode } from "./effect/app-node"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
import {
|
import {
|
||||||
Cause,
|
Cause,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -16,10 +17,13 @@ import {
|
|||||||
Types,
|
Types,
|
||||||
} from "effect"
|
} from "effect"
|
||||||
import { Integration } from "@opencode-ai/schema/integration"
|
import { Integration } from "@opencode-ai/schema/integration"
|
||||||
|
import { Search } from "@opencode-ai/schema/search"
|
||||||
import { Credential } from "./credential"
|
import { Credential } from "./credential"
|
||||||
|
import { Database } from "./database/database"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { IntegrationConnection } from "./integration/connection"
|
import { IntegrationConnection } from "./integration/connection"
|
||||||
|
import { IntegrationCapabilityTable } from "./integration/sql"
|
||||||
|
|
||||||
export const ID = Integration.ID
|
export const ID = Integration.ID
|
||||||
export type ID = Integration.ID
|
export type ID = Integration.ID
|
||||||
@@ -60,6 +64,12 @@ export type Info = Integration.Info
|
|||||||
export const Inputs = Integration.Inputs
|
export const Inputs = Integration.Inputs
|
||||||
export type Inputs = Integration.Inputs
|
export type Inputs = Integration.Inputs
|
||||||
|
|
||||||
|
export const SearchCapability = Integration.SearchCapability
|
||||||
|
export type SearchCapability = Omit<Integration.SearchCapability, "selected">
|
||||||
|
|
||||||
|
export const Capability = Integration.Capability
|
||||||
|
export type Capability = Integration.Capability
|
||||||
|
|
||||||
export type OAuthAuthorization = {
|
export type OAuthAuthorization = {
|
||||||
readonly url: string
|
readonly url: string
|
||||||
readonly instructions: string
|
readonly instructions: string
|
||||||
@@ -94,6 +104,15 @@ export interface EnvImplementation {
|
|||||||
|
|
||||||
export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
|
export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
|
||||||
|
|
||||||
|
export interface SearchImplementation {
|
||||||
|
readonly integrationID: ID
|
||||||
|
readonly capability: SearchCapability
|
||||||
|
readonly execute: (
|
||||||
|
input: Search.Input,
|
||||||
|
context: { readonly credential?: Credential.Value; readonly sessionID?: string },
|
||||||
|
) => Effect.Effect<Search.ProviderOutput, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
export const Attempt = Integration.Attempt
|
export const Attempt = Integration.Attempt
|
||||||
export type Attempt = Integration.Attempt
|
export type Attempt = Integration.Attempt
|
||||||
|
|
||||||
@@ -119,6 +138,7 @@ type Entry = {
|
|||||||
ref: Types.DeepMutable<Ref>
|
ref: Types.DeepMutable<Ref>
|
||||||
methods: Types.DeepMutable<Method>[]
|
methods: Types.DeepMutable<Method>[]
|
||||||
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
|
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
|
||||||
|
search?: Types.DeepMutable<SearchImplementation>
|
||||||
}
|
}
|
||||||
|
|
||||||
type Data = {
|
type Data = {
|
||||||
@@ -135,6 +155,13 @@ export type Draft = {
|
|||||||
update: (implementation: Implementation) => void
|
update: (implementation: Implementation) => void
|
||||||
remove: (integrationID: ID, method: Method) => void
|
remove: (integrationID: ID, method: Method) => void
|
||||||
}
|
}
|
||||||
|
capability: {
|
||||||
|
search: {
|
||||||
|
list: () => readonly SearchImplementation[]
|
||||||
|
update: (implementation: SearchImplementation) => void
|
||||||
|
remove: (integrationID: ID) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface extends State.Transformable<Draft> {
|
export interface Interface extends State.Transformable<Draft> {
|
||||||
@@ -191,6 +218,14 @@ export interface Interface extends State.Transformable<Draft> {
|
|||||||
/** Cancels an attempt and releases its resources. */
|
/** Cancels an attempt and releases its resources. */
|
||||||
readonly cancel: (attemptID: AttemptID) => Effect.Effect<void>
|
readonly cancel: (attemptID: AttemptID) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
readonly capability: {
|
||||||
|
readonly search: {
|
||||||
|
readonly list: () => Effect.Effect<readonly SearchImplementation[]>
|
||||||
|
readonly get: (integrationID: ID) => Effect.Effect<SearchImplementation | undefined>
|
||||||
|
readonly selected: () => Effect.Effect<ID | undefined>
|
||||||
|
readonly select: (integrationID: ID) => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
|
||||||
@@ -222,6 +257,7 @@ const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const credentials = yield* Credential.Service
|
const credentials = yield* Credential.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
|
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
|
||||||
@@ -281,6 +317,32 @@ const layer = Layer.effect(
|
|||||||
if (method.type === "oauth") current.implementations.delete(method.id)
|
if (method.type === "oauth") current.implementations.delete(method.id)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
capability: {
|
||||||
|
search: {
|
||||||
|
list: () =>
|
||||||
|
Array.from(draft.integrations.values()).flatMap((entry) =>
|
||||||
|
entry.search ? [entry.search as SearchImplementation] : [],
|
||||||
|
),
|
||||||
|
update: (implementation) => {
|
||||||
|
const current = draft.integrations.get(implementation.integrationID) ?? {
|
||||||
|
ref: {
|
||||||
|
id: implementation.integrationID,
|
||||||
|
name: implementation.integrationID,
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
|
||||||
|
}
|
||||||
|
if (!draft.integrations.has(implementation.integrationID)) {
|
||||||
|
draft.integrations.set(implementation.integrationID, current)
|
||||||
|
}
|
||||||
|
current.search = implementation as Types.DeepMutable<SearchImplementation>
|
||||||
|
},
|
||||||
|
remove: (integrationID) => {
|
||||||
|
const current = draft.integrations.get(integrationID)
|
||||||
|
if (current) delete current.search
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||||
})
|
})
|
||||||
@@ -300,14 +362,24 @@ const layer = Layer.effect(
|
|||||||
return [...credentials, ...env]
|
return [...credentials, ...env]
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
|
const project = (entry: Entry, connections: IntegrationConnection.Info[], selectedSearch: ID | undefined) =>
|
||||||
new Info({
|
new Info({
|
||||||
id: entry.ref.id,
|
id: entry.ref.id,
|
||||||
name: entry.ref.name,
|
name: entry.ref.name,
|
||||||
methods: entry.methods,
|
methods: entry.methods,
|
||||||
|
capabilities: entry.search ? [{ ...entry.search.capability, selected: entry.ref.id === selectedSearch }] : [],
|
||||||
connections,
|
connections,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const selectedSearch = Effect.fn("Integration.capability.search.selected")(function* () {
|
||||||
|
return (yield* db
|
||||||
|
.select({ integrationID: IntegrationCapabilityTable.integration_id })
|
||||||
|
.from(IntegrationCapabilityTable)
|
||||||
|
.where(eq(IntegrationCapabilityTable.capability, "search"))
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie))?.integrationID
|
||||||
|
})
|
||||||
|
|
||||||
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||||
effect.pipe(Effect.mapError((cause) => new AuthorizationError({ cause })))
|
effect.pipe(Effect.mapError((cause) => new AuthorizationError({ cause })))
|
||||||
|
|
||||||
@@ -369,12 +441,13 @@ const layer = Layer.effect(
|
|||||||
get: Effect.fn("Integration.get")(function* (id) {
|
get: Effect.fn("Integration.get")(function* (id) {
|
||||||
const entry = state.get().integrations.get(id)
|
const entry = state.get().integrations.get(id)
|
||||||
if (!entry) return undefined
|
if (!entry) return undefined
|
||||||
return project(entry, resolveConnections(entry, yield* credentials.list(id)))
|
return project(entry, resolveConnections(entry, yield* credentials.list(id)), yield* selectedSearch())
|
||||||
}),
|
}),
|
||||||
list: Effect.fn("Integration.list")(function* () {
|
list: Effect.fn("Integration.list")(function* () {
|
||||||
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
||||||
|
const selected = yield* selectedSearch()
|
||||||
return Array.from(state.get().integrations.values(), (entry) =>
|
return Array.from(state.get().integrations.values(), (entry) =>
|
||||||
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
|
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? []), selected),
|
||||||
).toSorted((a, b) => a.name.localeCompare(b.name))
|
).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
}),
|
}),
|
||||||
connection: {
|
connection: {
|
||||||
@@ -514,8 +587,36 @@ const layer = Layer.effect(
|
|||||||
if (attempt) yield* Scope.close(attempt.scope, Exit.void)
|
if (attempt) yield* Scope.close(attempt.scope, Exit.void)
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
capability: {
|
||||||
|
search: {
|
||||||
|
list: Effect.fn("Integration.capability.search.list")(function* () {
|
||||||
|
return Array.from(state.get().integrations.values()).flatMap((entry) =>
|
||||||
|
entry.search ? [entry.search as SearchImplementation] : [],
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
get: Effect.fn("Integration.capability.search.get")(function* (integrationID) {
|
||||||
|
return state.get().integrations.get(integrationID)?.search as SearchImplementation | undefined
|
||||||
|
}),
|
||||||
|
selected: selectedSearch,
|
||||||
|
select: Effect.fn("Integration.capability.search.select")(function* (integrationID) {
|
||||||
|
if (!state.get().integrations.get(integrationID)?.search) {
|
||||||
|
return yield* Effect.die(new Error(`Search capability not found: ${integrationID}`))
|
||||||
|
}
|
||||||
|
yield* db
|
||||||
|
.insert(IntegrationCapabilityTable)
|
||||||
|
.values({ capability: "search", integration_id: integrationID })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: IntegrationCapabilityTable.capability,
|
||||||
|
set: { integration_id: integrationID },
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
yield* events.publish(Event.Updated, {})
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [Credential.node, EventV2.node] })
|
export const node = makeLocationNode({ service: Service, layer, deps: [Credential.node, Database.node, EventV2.node] })
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||||
|
import { Timestamps } from "../database/schema.sql"
|
||||||
|
import type { Integration } from "../integration"
|
||||||
|
|
||||||
|
export const IntegrationCapabilityTable = sqliteTable("integration_capability", {
|
||||||
|
capability: text().$type<Integration.Capability["type"]>().primaryKey(),
|
||||||
|
integration_id: text().$type<Integration.ID>().notNull(),
|
||||||
|
...Timestamps,
|
||||||
|
})
|
||||||
@@ -34,6 +34,7 @@ import { Pty } from "./pty"
|
|||||||
import { QuestionV2 } from "./question"
|
import { QuestionV2 } from "./question"
|
||||||
import { Shell } from "./shell"
|
import { Shell } from "./shell"
|
||||||
import { Reference } from "./reference"
|
import { Reference } from "./reference"
|
||||||
|
import { Search } from "./search"
|
||||||
import { ReferenceGuidance } from "./reference/guidance"
|
import { ReferenceGuidance } from "./reference/guidance"
|
||||||
import { Ripgrep } from "./ripgrep"
|
import { Ripgrep } from "./ripgrep"
|
||||||
import { SessionRunnerLLM } from "./session/runner/llm"
|
import { SessionRunnerLLM } from "./session/runner/llm"
|
||||||
@@ -51,7 +52,6 @@ import { SessionInstructions } from "./session/instructions"
|
|||||||
import { McpTool } from "./tool/mcp"
|
import { McpTool } from "./tool/mcp"
|
||||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||||
import { ToolRegistry } from "./tool/registry"
|
import { ToolRegistry } from "./tool/registry"
|
||||||
import { WebSearchTool } from "./tool/websearch"
|
|
||||||
import { ToolOutputStore } from "./tool-output-store"
|
import { ToolOutputStore } from "./tool-output-store"
|
||||||
import { Vcs } from "./vcs"
|
import { Vcs } from "./vcs"
|
||||||
|
|
||||||
@@ -84,13 +84,13 @@ const pluginSupervisorNode = makeLocationNode({
|
|||||||
Form.node,
|
Form.node,
|
||||||
ReadToolFileSystem.node,
|
ReadToolFileSystem.node,
|
||||||
Reference.node,
|
Reference.node,
|
||||||
|
Search.node,
|
||||||
Ripgrep.node,
|
Ripgrep.node,
|
||||||
SessionInstructions.node,
|
SessionInstructions.node,
|
||||||
SessionTodo.node,
|
SessionTodo.node,
|
||||||
Shell.node,
|
Shell.node,
|
||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
WebSearchTool.configNode,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -100,6 +100,7 @@ const locationServiceNodes = [
|
|||||||
AgentV2.node,
|
AgentV2.node,
|
||||||
CommandV2.node,
|
CommandV2.node,
|
||||||
Reference.node,
|
Reference.node,
|
||||||
|
Search.node,
|
||||||
Integration.node,
|
Integration.node,
|
||||||
Catalog.node,
|
Catalog.node,
|
||||||
AISDK.node,
|
AISDK.node,
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
integration: {
|
integration: {
|
||||||
list: () => response(integration.list()),
|
list: () => response(integration.list()),
|
||||||
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
|
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
|
||||||
|
selectCapability: (input) => integration.capability.search.select(Integration.ID.make(input.integrationID)),
|
||||||
connectKey: (input) =>
|
connectKey: (input) =>
|
||||||
integration.connection.key({
|
integration.connection.key({
|
||||||
integrationID: Integration.ID.make(input.integrationID),
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
@@ -269,6 +270,29 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
remove: (id, method) =>
|
remove: (id, method) =>
|
||||||
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(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 { Npm } from "../npm"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { Reference } from "../reference"
|
import { Reference } from "../reference"
|
||||||
|
import { Search } from "../search"
|
||||||
import { Ripgrep } from "../ripgrep"
|
import { Ripgrep } from "../ripgrep"
|
||||||
import { SessionInstructions } from "../session/instructions"
|
import { SessionInstructions } from "../session/instructions"
|
||||||
import { SessionTodo } from "../session/todo"
|
import { SessionTodo } from "../session/todo"
|
||||||
@@ -50,6 +51,7 @@ import { AgentPlugin } from "./agent"
|
|||||||
import { CommandPlugin } from "./command"
|
import { CommandPlugin } from "./command"
|
||||||
import { ModelsDevPlugin } from "./models-dev"
|
import { ModelsDevPlugin } from "./models-dev"
|
||||||
import { ProviderPlugins } from "./provider"
|
import { ProviderPlugins } from "./provider"
|
||||||
|
import { SearchPlugins } from "./search"
|
||||||
import { PluginRuntime } from "./runtime"
|
import { PluginRuntime } from "./runtime"
|
||||||
import { SkillPlugin } from "./skill"
|
import { SkillPlugin } from "./skill"
|
||||||
import { VariantPlugin } from "./variant"
|
import { VariantPlugin } from "./variant"
|
||||||
@@ -76,13 +78,13 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||||||
const form = yield* Form.Service
|
const form = yield* Form.Service
|
||||||
const read = yield* ReadToolFileSystem.Service
|
const read = yield* ReadToolFileSystem.Service
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
|
const search = yield* Search.Service
|
||||||
const ripgrep = yield* Ripgrep.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
const instructions = yield* SessionInstructions.Service
|
const instructions = yield* SessionInstructions.Service
|
||||||
const todo = yield* SessionTodo.Service
|
const todo = yield* SessionTodo.Service
|
||||||
const shell = yield* Shell.Service
|
const shell = yield* Shell.Service
|
||||||
const skill = yield* SkillV2.Service
|
const skill = yield* SkillV2.Service
|
||||||
const tools = yield* Tools.Service
|
const tools = yield* Tools.Service
|
||||||
const websearch = yield* WebSearchTool.ConfigService
|
|
||||||
return Context.mergeAll(
|
return Context.mergeAll(
|
||||||
Context.make(AgentV2.Service, agent),
|
Context.make(AgentV2.Service, agent),
|
||||||
Context.make(Catalog.Service, catalog),
|
Context.make(Catalog.Service, catalog),
|
||||||
@@ -105,13 +107,13 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||||||
Context.make(Form.Service, form),
|
Context.make(Form.Service, form),
|
||||||
Context.make(ReadToolFileSystem.Service, read),
|
Context.make(ReadToolFileSystem.Service, read),
|
||||||
Context.make(Reference.Service, reference),
|
Context.make(Reference.Service, reference),
|
||||||
|
Context.make(Search.Service, search),
|
||||||
Context.make(Ripgrep.Service, ripgrep),
|
Context.make(Ripgrep.Service, ripgrep),
|
||||||
Context.make(SessionInstructions.Service, instructions),
|
Context.make(SessionInstructions.Service, instructions),
|
||||||
Context.make(SessionTodo.Service, todo),
|
Context.make(SessionTodo.Service, todo),
|
||||||
Context.make(Shell.Service, shell),
|
Context.make(Shell.Service, shell),
|
||||||
Context.make(SkillV2.Service, skill),
|
Context.make(SkillV2.Service, skill),
|
||||||
Context.make(Tools.Service, tools),
|
Context.make(Tools.Service, tools),
|
||||||
Context.make(WebSearchTool.ConfigService, websearch),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -127,6 +129,7 @@ const pre = [
|
|||||||
SkillPlugin.Plugin,
|
SkillPlugin.Plugin,
|
||||||
ModelsDevPlugin,
|
ModelsDevPlugin,
|
||||||
...ProviderPlugins,
|
...ProviderPlugins,
|
||||||
|
...SearchPlugins,
|
||||||
ApplyPatchTool.Plugin,
|
ApplyPatchTool.Plugin,
|
||||||
EditTool.Plugin,
|
EditTool.Plugin,
|
||||||
GlobTool.Plugin,
|
GlobTool.Plugin,
|
||||||
|
|||||||
@@ -79,12 +79,42 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
integration: {
|
integration: {
|
||||||
list: (input) => run(host.integration.list(input)),
|
list: (input) => run(host.integration.list(input)),
|
||||||
get: (input) => run(host.integration.get(input)),
|
get: (input) => run(host.integration.get(input)),
|
||||||
|
selectCapability: (input) => run(host.integration.selectCapability(input)),
|
||||||
connectKey: (input) => run(host.integration.connectKey(input)),
|
connectKey: (input) => run(host.integration.connectKey(input)),
|
||||||
connectOauth: (input) => run(host.integration.connectOauth(input)),
|
connectOauth: (input) => run(host.integration.connectOauth(input)),
|
||||||
attemptStatus: (input) => run(host.integration.attemptStatus(input)),
|
attemptStatus: (input) => run(host.integration.attemptStatus(input)),
|
||||||
attemptComplete: (input) => run(host.integration.attemptComplete(input)),
|
attemptComplete: (input) => run(host.integration.attemptComplete(input)),
|
||||||
attemptCancel: (input) => run(host.integration.attemptCancel(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()),
|
reload: () => run(host.integration.reload()),
|
||||||
connection: {
|
connection: {
|
||||||
active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)),
|
active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)),
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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),
|
||||||
|
})
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const url = new URL(endpoint)
|
||||||
|
if (context.credential?.type === "key") url.searchParams.set("exaApiKey", context.credential.key)
|
||||||
|
return SearchMcp.call(http, url.toString(), "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 ?? "" })))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
export * as SearchFirecrawl from "./firecrawl"
|
||||||
|
|
||||||
|
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||||
|
import type { Search } from "@opencode-ai/schema/search"
|
||||||
|
import { Duration, Effect, Schema, Scope } from "effect"
|
||||||
|
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { collectBoundedResponseBody } from "../../tool/http-body"
|
||||||
|
import { SearchMcp } from "./mcp"
|
||||||
|
|
||||||
|
export const endpoint = "https://api.firecrawl.dev/v2/search"
|
||||||
|
|
||||||
|
const FirecrawlRequest = Schema.Struct({
|
||||||
|
query: Schema.String,
|
||||||
|
limit: Schema.optional(Schema.Number),
|
||||||
|
})
|
||||||
|
|
||||||
|
const ResultBase = {
|
||||||
|
url: Schema.String,
|
||||||
|
title: Schema.optional(Schema.String),
|
||||||
|
position: Schema.optional(Schema.Number),
|
||||||
|
}
|
||||||
|
const PageResult = {
|
||||||
|
...ResultBase,
|
||||||
|
markdown: Schema.optional(Schema.NullOr(Schema.String)),
|
||||||
|
}
|
||||||
|
const WebResult = Schema.Struct({
|
||||||
|
...PageResult,
|
||||||
|
description: Schema.optional(Schema.String),
|
||||||
|
category: Schema.optional(Schema.String),
|
||||||
|
})
|
||||||
|
const NewsResult = Schema.Struct({
|
||||||
|
...PageResult,
|
||||||
|
snippet: Schema.optional(Schema.String),
|
||||||
|
date: Schema.optional(Schema.String),
|
||||||
|
})
|
||||||
|
const ImageResult = Schema.Struct({
|
||||||
|
...ResultBase,
|
||||||
|
imageUrl: Schema.String,
|
||||||
|
imageWidth: Schema.optional(Schema.Number),
|
||||||
|
imageHeight: Schema.optional(Schema.Number),
|
||||||
|
})
|
||||||
|
const FirecrawlResponse = Schema.Struct({
|
||||||
|
success: Schema.Literal(true),
|
||||||
|
data: Schema.Struct({
|
||||||
|
web: Schema.optional(Schema.Array(WebResult)),
|
||||||
|
news: Schema.optional(Schema.Array(NewsResult)),
|
||||||
|
images: Schema.optional(Schema.Array(ImageResult)),
|
||||||
|
}),
|
||||||
|
warning: Schema.optional(Schema.NullOr(Schema.String)),
|
||||||
|
id: Schema.optional(Schema.String),
|
||||||
|
creditsUsed: Schema.optional(Schema.Number),
|
||||||
|
})
|
||||||
|
const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Json))
|
||||||
|
const decodeResponse = Schema.decodeUnknownEffect(FirecrawlResponse)
|
||||||
|
|
||||||
|
const formatResults = (response: typeof FirecrawlResponse.Type) =>
|
||||||
|
[
|
||||||
|
...(response.data.web ?? []).map((result) =>
|
||||||
|
[`## ${result.title ?? result.url}`, `URL: ${result.url}`, result.description, result.markdown || undefined]
|
||||||
|
.filter((line) => line !== undefined)
|
||||||
|
.join("\n\n"),
|
||||||
|
),
|
||||||
|
...(response.data.news ?? []).map((result) =>
|
||||||
|
[
|
||||||
|
`## ${result.title ?? result.url}`,
|
||||||
|
`URL: ${result.url}`,
|
||||||
|
result.date ? `Date: ${result.date}` : undefined,
|
||||||
|
result.snippet,
|
||||||
|
result.markdown || undefined,
|
||||||
|
]
|
||||||
|
.filter((line) => line !== undefined)
|
||||||
|
.join("\n\n"),
|
||||||
|
),
|
||||||
|
...(response.data.images ?? []).map((result) =>
|
||||||
|
[`## ${result.title ?? result.url}`, `Source: ${result.url}`, `Image: ${result.imageUrl}`].join("\n\n"),
|
||||||
|
),
|
||||||
|
].join("\n\n")
|
||||||
|
|
||||||
|
const search = (
|
||||||
|
http: HttpClient.HttpClient,
|
||||||
|
input: Pick<Search.Input, "query" | "numResults" | "contextMaxCharacters">,
|
||||||
|
apiKey?: string,
|
||||||
|
) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const request = yield* HttpClientRequest.post(endpoint).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.setHeaders(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||||
|
HttpClientRequest.schemaBodyJson(FirecrawlRequest)({ query: input.query, limit: input.numResults }),
|
||||||
|
)
|
||||||
|
const response = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||||
|
const body = yield* collectBoundedResponseBody(
|
||||||
|
response,
|
||||||
|
SearchMcp.MAX_RESPONSE_BYTES,
|
||||||
|
() => new Error(`Firecrawl response exceeded ${SearchMcp.MAX_RESPONSE_BYTES} bytes`),
|
||||||
|
)
|
||||||
|
const metadata = yield* decodeJson(body.toString("utf8"))
|
||||||
|
const result = yield* decodeResponse(metadata)
|
||||||
|
const text = formatResults(result)
|
||||||
|
return {
|
||||||
|
text: input.contextMaxCharacters ? text.slice(0, input.contextMaxCharacters) : text,
|
||||||
|
metadata,
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
Effect.timeoutOrElse({
|
||||||
|
duration: Duration.seconds(25),
|
||||||
|
orElse: () => Effect.fail(new Error("Firecrawl search request timed out")),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||||
|
id: "opencode.search.firecrawl",
|
||||||
|
effect: Effect.fn("SearchFirecrawl.Plugin")(function* (ctx) {
|
||||||
|
const http = yield* HttpClient.HttpClient
|
||||||
|
yield* ctx.integration.transform((draft) => {
|
||||||
|
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
|
||||||
|
draft.method.update({ integrationID: "firecrawl", method: { type: "key", label: "API key (optional)" } })
|
||||||
|
draft.method.update({ integrationID: "firecrawl", method: { type: "env", names: ["FIRECRAWL_API_KEY"] } })
|
||||||
|
draft.capability.search.update({
|
||||||
|
integrationID: "firecrawl",
|
||||||
|
capability: { type: "search", connection: "optional" },
|
||||||
|
execute: (input, context) =>
|
||||||
|
search(http, input, context.credential?.type === "key" ? context.credential.key : undefined),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { SearchExa } from "./exa"
|
||||||
|
import { SearchFirecrawl } from "./firecrawl"
|
||||||
|
import { SearchParallel } from "./parallel"
|
||||||
|
|
||||||
|
export const SearchPlugins = [SearchExa.Plugin, SearchFirecrawl.Plugin, SearchParallel.Plugin] as const
|
||||||
@@ -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`)),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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 ?? "" }))),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
export * as Search from "./search"
|
||||||
|
|
||||||
|
import { Search } from "@opencode-ai/schema/search"
|
||||||
|
import { Context, Effect, Layer, Schema, Semaphore } from "effect"
|
||||||
|
import { Config } from "./config"
|
||||||
|
import { makeLocationNode } from "./effect/app-node"
|
||||||
|
import { Form } from "./form"
|
||||||
|
import { Integration } from "./integration"
|
||||||
|
import { truthy } from "./flag/flag"
|
||||||
|
|
||||||
|
export const Input = Search.Input
|
||||||
|
export type Input = Search.Input
|
||||||
|
|
||||||
|
export const ProviderOutput = Search.ProviderOutput
|
||||||
|
export type ProviderOutput = Search.ProviderOutput
|
||||||
|
|
||||||
|
export const Result = Search.Result
|
||||||
|
export type Result = Search.Result
|
||||||
|
|
||||||
|
export class ProviderRequiredError extends Schema.TaggedErrorClass<ProviderRequiredError>()(
|
||||||
|
"Search.ProviderRequired",
|
||||||
|
{},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()("Search.ProviderNotFound", {
|
||||||
|
providerID: Integration.ID,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class ConnectionRequiredError extends Schema.TaggedErrorClass<ConnectionRequiredError>()(
|
||||||
|
"Search.ConnectionRequired",
|
||||||
|
{ providerID: Integration.ID },
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("Search.Cancelled", {}) {}
|
||||||
|
|
||||||
|
export class RequestError extends Schema.TaggedErrorClass<RequestError>()("Search.Request", {
|
||||||
|
providerID: Integration.ID,
|
||||||
|
cause: Schema.Defect(),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export type Error =
|
||||||
|
| ProviderRequiredError
|
||||||
|
| ProviderNotFoundError
|
||||||
|
| ConnectionRequiredError
|
||||||
|
| CancelledError
|
||||||
|
| RequestError
|
||||||
|
|
||||||
|
export interface QueryInput extends Input {
|
||||||
|
readonly sessionID?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly query: (input: QueryInput) => Effect.Effect<Result, Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Search") {}
|
||||||
|
|
||||||
|
const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const forms = yield* Form.Service
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
const onboarding = Semaphore.makeUnsafe(1)
|
||||||
|
const decodeOutput = Schema.decodeUnknownEffect(ProviderOutput)
|
||||||
|
|
||||||
|
const requireProvider = (
|
||||||
|
providers: Map<Integration.ID, Integration.SearchImplementation>,
|
||||||
|
providerID: Integration.ID,
|
||||||
|
) => {
|
||||||
|
const provider = providers.get(providerID)
|
||||||
|
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredProvider = Effect.fn("Search.configuredProvider")(function* () {
|
||||||
|
const providerID = Config.latest(yield* config.entries(), "search")?.provider
|
||||||
|
if (providerID) return providerID
|
||||||
|
if (process.env.OPENCODE_WEBSEARCH_PROVIDER) {
|
||||||
|
return Integration.ID.make(process.env.OPENCODE_WEBSEARCH_PROVIDER)
|
||||||
|
}
|
||||||
|
if (truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL")) {
|
||||||
|
return Integration.ID.make("parallel")
|
||||||
|
}
|
||||||
|
if (truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA")) {
|
||||||
|
return Integration.ID.make("exa")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const ask = Effect.fn("Search.ask")(function* (
|
||||||
|
providers: Map<Integration.ID, Integration.SearchImplementation>,
|
||||||
|
sessionID: string,
|
||||||
|
) {
|
||||||
|
if (providers.size === 0) return yield* new ProviderRequiredError()
|
||||||
|
const infos = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||||
|
const state = yield* forms
|
||||||
|
.ask({
|
||||||
|
sessionID,
|
||||||
|
title: "Choose a web search provider",
|
||||||
|
metadata: { kind: "search.provider" },
|
||||||
|
mode: "form",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "provider",
|
||||||
|
title: "Provider",
|
||||||
|
description: "This becomes your default and can be changed later from Connect integration.",
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
custom: false,
|
||||||
|
options: Array.from(providers.values())
|
||||||
|
.flatMap((provider) => {
|
||||||
|
const info = infos.get(provider.integrationID)
|
||||||
|
if (!info) return []
|
||||||
|
const disconnected =
|
||||||
|
provider.capability.connection === "optional" ? "Keyless available" : "Connection required"
|
||||||
|
return [{ info, description: info.connections.length ? "Connected" : disconnected }]
|
||||||
|
})
|
||||||
|
.toSorted((a, b) => a.info.name.localeCompare(b.info.name))
|
||||||
|
.map(({ info, description }) => ({
|
||||||
|
value: info.id,
|
||||||
|
label: info.name,
|
||||||
|
description,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
if (state.status === "cancelled") return yield* new CancelledError()
|
||||||
|
const answer = state.answer.provider
|
||||||
|
if (typeof answer !== "string") return yield* new ProviderRequiredError()
|
||||||
|
return yield* requireProvider(providers, Integration.ID.make(answer))
|
||||||
|
})
|
||||||
|
|
||||||
|
const connect = Effect.fn("Search.connect")(function* (
|
||||||
|
provider: Integration.SearchImplementation,
|
||||||
|
sessionID?: string,
|
||||||
|
) {
|
||||||
|
const active = yield* integrations.connection.active(provider.integrationID)
|
||||||
|
if (active || provider.capability.connection === "optional") return active
|
||||||
|
if (!sessionID) return yield* new ConnectionRequiredError({ providerID: provider.integrationID })
|
||||||
|
const state = yield* forms
|
||||||
|
.ask({
|
||||||
|
sessionID,
|
||||||
|
title: `Connect ${provider.integrationID}`,
|
||||||
|
metadata: { kind: "integration.connection" },
|
||||||
|
mode: "integration",
|
||||||
|
integrationID: provider.integrationID,
|
||||||
|
})
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
if (state.status === "cancelled") return yield* new CancelledError()
|
||||||
|
const connected = yield* integrations.connection.active(provider.integrationID)
|
||||||
|
if (!connected) return yield* new ConnectionRequiredError({ providerID: provider.integrationID })
|
||||||
|
return connected
|
||||||
|
})
|
||||||
|
|
||||||
|
const select = Effect.fn("Search.select")(function* (input: QueryInput) {
|
||||||
|
const providers = new Map(
|
||||||
|
(yield* integrations.capability.search.list()).map((provider) => [provider.integrationID, provider]),
|
||||||
|
)
|
||||||
|
if (input.providerID) return yield* requireProvider(providers, input.providerID)
|
||||||
|
const override = yield* configuredProvider()
|
||||||
|
if (override) return yield* requireProvider(providers, override)
|
||||||
|
const selected = yield* integrations.capability.search.selected()
|
||||||
|
const provider = selected ? providers.get(selected) : undefined
|
||||||
|
if (provider) return provider
|
||||||
|
const sessionID = input.sessionID
|
||||||
|
if (!sessionID) return yield* new ProviderRequiredError()
|
||||||
|
return yield* onboarding.withPermit(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const current = yield* integrations.capability.search.selected()
|
||||||
|
const selected = current ? providers.get(current) : undefined
|
||||||
|
if (selected) return selected
|
||||||
|
const provider = yield* ask(providers, sessionID)
|
||||||
|
yield* connect(provider, sessionID)
|
||||||
|
yield* integrations.capability.search.select(provider.integrationID)
|
||||||
|
return provider
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
query: Effect.fn("Search.query")(function* (input) {
|
||||||
|
const provider = yield* select(input)
|
||||||
|
const connection = yield* connect(provider, input.sessionID)
|
||||||
|
const credential = connection
|
||||||
|
? yield* integrations.connection
|
||||||
|
.resolve(connection)
|
||||||
|
.pipe(Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })))
|
||||||
|
: undefined
|
||||||
|
const output = yield* provider.execute(input, { credential, sessionID: input.sessionID }).pipe(
|
||||||
|
Effect.flatMap(decodeOutput),
|
||||||
|
Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })),
|
||||||
|
)
|
||||||
|
return new Result({ providerID: provider.integrationID, ...output })
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node, Form.node, Integration.node] })
|
||||||
@@ -2,197 +2,58 @@ export * as WebSearchTool from "./websearch"
|
|||||||
|
|
||||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { ToolFailure } from "@opencode-ai/llm"
|
import { ToolFailure } from "@opencode-ai/llm"
|
||||||
import { Context, Duration, Effect, Layer, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
import { Integration } from "../integration"
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
|
||||||
import { truthy } from "../flag/flag"
|
|
||||||
import { InstallationVersion } from "../installation/version"
|
|
||||||
import { PositiveInt } from "../schema"
|
import { PositiveInt } from "../schema"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
|
import { Search } from "../search"
|
||||||
|
import { SearchExa } from "../plugin/search/exa"
|
||||||
|
import { SearchMcp } from "../plugin/search/mcp"
|
||||||
|
import { SearchParallel } from "../plugin/search/parallel"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { collectBoundedResponseBody } from "./http-body"
|
|
||||||
import { checksum } from "../util/encode"
|
|
||||||
|
|
||||||
export const name = "websearch"
|
export const name = "websearch"
|
||||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||||
export const EXA_URL = "https://mcp.exa.ai/mcp"
|
export const EXA_URL = SearchExa.endpoint
|
||||||
export const PARALLEL_URL = "https://search.parallel.ai/mcp"
|
export const PARALLEL_URL = SearchParallel.endpoint
|
||||||
export const MAX_NUM_RESULTS = 20
|
export const MAX_NUM_RESULTS = 20
|
||||||
export const MAX_CONTEXT_CHARACTERS = 50_000
|
export const MAX_CONTEXT_CHARACTERS = 50_000
|
||||||
export const MAX_RESPONSE_BYTES = 256 * 1024
|
export const MAX_RESPONSE_BYTES = SearchMcp.MAX_RESPONSE_BYTES
|
||||||
|
export const parseResponse = SearchMcp.parseResponse
|
||||||
|
|
||||||
/**
|
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
|
||||||
* Provider-independent local web search retained in V2 core for launch parity.
|
|
||||||
* This invokes the legacy Exa/Parallel product backends itself. It is distinct
|
|
||||||
* from provider-hosted web search tools, which remain route-owned and execute
|
|
||||||
* at the model provider. Ownership of this compromise can be revisited later.
|
|
||||||
*/
|
|
||||||
export const description = `Search the web using the session's local web search provider. Use this for current information beyond knowledge cutoff.
|
|
||||||
|
|
||||||
This is a provider-independent local tool backed by Exa or Parallel. Provider-hosted web search tools are separate and execute at the model provider.
|
Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters. Providers apply supported controls and otherwise use their defaults.
|
||||||
|
|
||||||
Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters.
|
|
||||||
|
|
||||||
The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
|
The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
|
||||||
|
|
||||||
export const Input = Schema.Struct({
|
export const Input = Schema.Struct({
|
||||||
query: Schema.String.annotate({ description: "Websearch query" }),
|
query: Schema.String.annotate({ description: "Websearch query" }),
|
||||||
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({
|
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({
|
||||||
description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`,
|
description: `Number of search results to return (maximum: ${MAX_NUM_RESULTS})`,
|
||||||
}),
|
}),
|
||||||
livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
|
livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
|
||||||
description:
|
description: "Live crawl preference when supported by the selected provider",
|
||||||
"Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
|
|
||||||
}),
|
}),
|
||||||
type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
|
type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
|
||||||
description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
|
description: "Search depth preference when supported by the selected provider",
|
||||||
}),
|
}),
|
||||||
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate(
|
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate(
|
||||||
{
|
{ description: `Maximum context characters (maximum: ${MAX_CONTEXT_CHARACTERS})` },
|
||||||
description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`,
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Provider = Schema.Literals(["exa", "parallel"])
|
|
||||||
export type Provider = typeof Provider.Type
|
|
||||||
|
|
||||||
export interface Config {
|
|
||||||
readonly provider?: Provider
|
|
||||||
readonly enableExa: boolean
|
|
||||||
readonly enableParallel: boolean
|
|
||||||
readonly exaApiKey?: string
|
|
||||||
readonly parallelApiKey?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ConfigService extends Context.Service<ConfigService, Config>()("@opencode/v2/WebSearchConfig") {}
|
|
||||||
|
|
||||||
/** Isolates the retained product environment contract from the generic tool implementation. */
|
|
||||||
export const defaultConfigLayer = Layer.sync(ConfigService, () =>
|
|
||||||
ConfigService.of({
|
|
||||||
provider:
|
|
||||||
process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel"
|
|
||||||
? process.env.OPENCODE_WEBSEARCH_PROVIDER
|
|
||||||
: undefined,
|
|
||||||
enableExa: truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA"),
|
|
||||||
enableParallel: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
|
|
||||||
exaApiKey: process.env.EXA_API_KEY,
|
|
||||||
parallelApiKey: process.env.PARALLEL_API_KEY,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] })
|
|
||||||
|
|
||||||
export function selectProvider(
|
|
||||||
sessionID: string,
|
|
||||||
flags: Pick<Config, "enableExa" | "enableParallel"> = { enableExa: false, enableParallel: false },
|
|
||||||
override?: Provider,
|
|
||||||
): Provider {
|
|
||||||
if (override) return override
|
|
||||||
if (flags.enableParallel) return "parallel"
|
|
||||||
if (flags.enableExa) return "exa"
|
|
||||||
return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel"
|
|
||||||
}
|
|
||||||
|
|
||||||
const McpResult = Schema.Struct({
|
|
||||||
result: Schema.Struct({
|
|
||||||
content: Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.String })),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const decodeMcpResult = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
|
|
||||||
|
|
||||||
const parsePayload = (payload: string) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const trimmed = payload.trim()
|
|
||||||
if (!trimmed.startsWith("{")) return undefined
|
|
||||||
return (yield* decodeMcpResult(trimmed)).result.content.find((item) => item.text)?.text
|
|
||||||
})
|
|
||||||
|
|
||||||
export const parseResponse = Effect.fn("WebSearchTool.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
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
const ExaArgs = Schema.Struct({
|
|
||||||
query: Schema.String,
|
|
||||||
type: Schema.String,
|
|
||||||
numResults: Schema.Number,
|
|
||||||
livecrawl: Schema.String,
|
|
||||||
contextMaxCharacters: Schema.optional(Schema.Number),
|
|
||||||
})
|
|
||||||
const ParallelArgs = Schema.Struct({
|
|
||||||
objective: Schema.String,
|
|
||||||
search_queries: Schema.Array(Schema.String),
|
|
||||||
session_id: Schema.String,
|
|
||||||
})
|
|
||||||
const McpRequest = <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 }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const exaUrl = (apiKey: string | undefined) => {
|
|
||||||
if (!apiKey) return EXA_URL
|
|
||||||
const url = new URL(EXA_URL)
|
|
||||||
url.searchParams.set("exaApiKey", apiKey)
|
|
||||||
return url.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const callMcp = <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(McpRequest(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`)),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const Output = Schema.Struct({
|
const Output = Schema.Struct({
|
||||||
provider: Provider,
|
provider: Integration.ID,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
|
metadata: Schema.optional(Schema.Json),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Plugin = {
|
export const Plugin = {
|
||||||
id: "opencode.tool.websearch",
|
id: "opencode.tool.websearch",
|
||||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const http = yield* HttpClient.HttpClient
|
|
||||||
const config = yield* ConfigService
|
|
||||||
const permission = yield* PermissionV2.Service
|
const permission = yield* PermissionV2.Service
|
||||||
|
const search = yield* Search.Service
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
@@ -203,54 +64,28 @@ export const Plugin = {
|
|||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||||
execute: (input, context) => {
|
execute: (input, context) =>
|
||||||
const provider = selectProvider(context.sessionID, config, config.provider)
|
Effect.gen(function* () {
|
||||||
return Effect.gen(function* () {
|
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: name,
|
action: name,
|
||||||
resources: [input.query],
|
resources: [input.query],
|
||||||
save: ["*"],
|
save: ["*"],
|
||||||
metadata: { ...input, provider },
|
metadata: input,
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||||
})
|
})
|
||||||
|
const result = yield* search.query({ ...input, sessionID: context.sessionID })
|
||||||
const text =
|
|
||||||
provider === "exa"
|
|
||||||
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
|
|
||||||
query: input.query,
|
|
||||||
type: input.type || "auto",
|
|
||||||
numResults: input.numResults || 8,
|
|
||||||
livecrawl: input.livecrawl || "fallback",
|
|
||||||
contextMaxCharacters: input.contextMaxCharacters,
|
|
||||||
})
|
|
||||||
: yield* callMcp(
|
|
||||||
http,
|
|
||||||
PARALLEL_URL,
|
|
||||||
"web_search",
|
|
||||||
ParallelArgs,
|
|
||||||
{
|
|
||||||
objective: input.query,
|
|
||||||
search_queries: [input.query],
|
|
||||||
session_id: context.sessionID,
|
|
||||||
// V2 invocation context does not safely expose the model yet.
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"User-Agent": `opencode/${InstallationVersion}`,
|
|
||||||
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
provider,
|
provider: result.providerID,
|
||||||
text: text ?? NO_RESULTS,
|
text: result.text || NO_RESULTS,
|
||||||
|
metadata: result.metadata,
|
||||||
}
|
}
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
|
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { Form } from "@opencode-ai/core/form"
|
import { Form } from "@opencode-ai/core/form"
|
||||||
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
@@ -59,6 +60,27 @@ describe("Form", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("uses an empty reply to complete an integration form", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const service = yield* Form.Service
|
||||||
|
const created = yield* service.create({
|
||||||
|
sessionID: "ses_test",
|
||||||
|
mode: "integration",
|
||||||
|
integrationID: Integration.ID.make("exa"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const invalid = yield* service.reply({ id: created.id, answer: { connected: true } }).pipe(Effect.flip)
|
||||||
|
expect(invalid).toEqual(
|
||||||
|
new Form.InvalidAnswerError({
|
||||||
|
id: created.id,
|
||||||
|
message: "Integration forms must be answered with an empty answer",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* service.reply({ id: created.id, answer: {} })
|
||||||
|
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: {} })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("gates required fields and rejects inactive answers via when", () =>
|
it.effect("gates required fields and rejects inactive answers via when", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* Form.Service
|
const service = yield* Form.Service
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ describe("Integration", () => {
|
|||||||
.transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI")))
|
.transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI")))
|
||||||
.pipe(Scope.provide(scope))
|
.pipe(Scope.provide(scope))
|
||||||
expect(yield* integrations.get(openai)).toEqual(
|
expect(yield* integrations.get(openai)).toEqual(
|
||||||
new Integration.Info({ id: openai, name: "OpenAI", methods: [], connections: [] }),
|
new Integration.Info({ id: openai, name: "OpenAI", methods: [], capabilities: [], connections: [] }),
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* Scope.close(scope, Exit.void)
|
yield* Scope.close(scope, Exit.void)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||||||
integration: overrides.integration ?? {
|
integration: overrides.integration ?? {
|
||||||
list: () => Effect.die("unused integration.list"),
|
list: () => Effect.die("unused integration.list"),
|
||||||
get: () => Effect.die("unused integration.get"),
|
get: () => Effect.die("unused integration.get"),
|
||||||
|
selectCapability: () => Effect.die("unused integration.selectCapability"),
|
||||||
connectKey: () => Effect.die("unused integration.connectKey"),
|
connectKey: () => Effect.die("unused integration.connectKey"),
|
||||||
connectOauth: () => Effect.die("unused integration.connectOauth"),
|
connectOauth: () => Effect.die("unused integration.connectOauth"),
|
||||||
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
|
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
|
||||||
@@ -188,6 +189,7 @@ export function integrationHost(integration: Integration.Interface): PluginConte
|
|||||||
return {
|
return {
|
||||||
list: () => Effect.die("unused integration.list"),
|
list: () => Effect.die("unused integration.list"),
|
||||||
get: () => Effect.die("unused integration.get"),
|
get: () => Effect.die("unused integration.get"),
|
||||||
|
selectCapability: () => Effect.die("unused integration.selectCapability"),
|
||||||
connectKey: () => Effect.die("unused integration.connectKey"),
|
connectKey: () => Effect.die("unused integration.connectKey"),
|
||||||
connectOauth: () => Effect.die("unused integration.connectOauth"),
|
connectOauth: () => Effect.die("unused integration.connectOauth"),
|
||||||
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
|
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
|
||||||
@@ -281,6 +283,18 @@ export function integrationHost(integration: Integration.Interface): PluginConte
|
|||||||
},
|
},
|
||||||
remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)),
|
remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)),
|
||||||
},
|
},
|
||||||
|
capability: {
|
||||||
|
search: {
|
||||||
|
list: () => [],
|
||||||
|
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)),
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ describe("ModelsDevPlugin", () => {
|
|||||||
names: ["ACME_API_KEY"],
|
names: ["ACME_API_KEY"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
capabilities: [],
|
||||||
connections: [],
|
connections: [],
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||||
@@ -93,4 +94,29 @@ describe("fromPromise", () => {
|
|||||||
expect(yield* agents.get(AgentV2.ID.make("temp"))).toBeUndefined()
|
expect(yield* agents.get(AgentV2.ID.make("temp"))).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("adapts promise search capability execution", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make(plugin)
|
||||||
|
const promisePlugin = define({
|
||||||
|
id: "promise-search",
|
||||||
|
setup: async (ctx) => {
|
||||||
|
await ctx.integration.transform((draft) => {
|
||||||
|
draft.capability.search.update({
|
||||||
|
integrationID: "promise-search",
|
||||||
|
capability: { type: "search", connection: "optional" },
|
||||||
|
execute: async (input) => ({ text: `promise: ${input.query}` }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
|
||||||
|
const provider = yield* integrations.capability.search.get(Integration.ID.make("promise-search"))
|
||||||
|
if (!provider) return yield* Effect.die("Expected promise search provider")
|
||||||
|
expect(yield* provider.execute({ query: "effect" }, {})).toEqual({ text: "promise: effect" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { beforeEach, describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
|
import { SearchFirecrawl } from "@opencode-ai/core/plugin/search/firecrawl"
|
||||||
|
import { host, integrationHost } from "./host"
|
||||||
|
import { requests, resetSearchFixture, searchIntegrationTest } from "./search-fixture"
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
web: [
|
||||||
|
{
|
||||||
|
url: "https://effect.website/",
|
||||||
|
title: "Effect",
|
||||||
|
description: "Build production TypeScript applications.",
|
||||||
|
position: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
id: "search_1",
|
||||||
|
creditsUsed: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetSearchFixture(JSON.stringify(metadata))
|
||||||
|
})
|
||||||
|
|
||||||
|
const it = searchIntegrationTest
|
||||||
|
|
||||||
|
describe("Firecrawl search integration", () => {
|
||||||
|
it.effect("registers Firecrawl and uses its keyless default response", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
yield* SearchFirecrawl.Plugin.effect(host({ integration: integrationHost(integrations) }))
|
||||||
|
const provider = yield* integrations.capability.search.get(Integration.ID.make("firecrawl"))
|
||||||
|
if (!provider) return yield* Effect.die("Expected Firecrawl search provider")
|
||||||
|
|
||||||
|
const output = yield* provider.execute({ query: "effect", numResults: 3 }, {})
|
||||||
|
expect(output).toEqual({
|
||||||
|
text: "## Effect\n\nURL: https://effect.website/\n\nBuild production TypeScript applications.",
|
||||||
|
metadata,
|
||||||
|
})
|
||||||
|
expect(requests).toEqual([
|
||||||
|
{
|
||||||
|
url: SearchFirecrawl.endpoint,
|
||||||
|
headers: expect.not.objectContaining({ authorization: expect.anything() }),
|
||||||
|
body: { query: "effect", limit: 3 },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("sends a configured Firecrawl key as a bearer credential", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
yield* SearchFirecrawl.Plugin.effect(host({ integration: integrationHost(integrations) }))
|
||||||
|
const provider = yield* integrations.capability.search.get(Integration.ID.make("firecrawl"))
|
||||||
|
if (!provider) return yield* Effect.die("Expected Firecrawl search provider")
|
||||||
|
|
||||||
|
const output = yield* provider.execute(
|
||||||
|
{ query: "effect" },
|
||||||
|
{ credential: Credential.Key.make({ type: "key", key: "firecrawl-secret" }) },
|
||||||
|
)
|
||||||
|
expect(requests[0]?.headers).toMatchObject({ authorization: "Bearer firecrawl-secret" })
|
||||||
|
expect(JSON.stringify(output)).not.toContain("firecrawl-secret")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
export interface SearchRequest {
|
||||||
|
readonly url: string
|
||||||
|
readonly headers: Record<string, string>
|
||||||
|
readonly body: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requests: SearchRequest[] = []
|
||||||
|
export const response = { body: "" }
|
||||||
|
|
||||||
|
export function resetSearchFixture(body: string) {
|
||||||
|
requests.length = 0
|
||||||
|
response.body = body
|
||||||
|
}
|
||||||
|
|
||||||
|
const http = Layer.succeed(
|
||||||
|
HttpClient.HttpClient,
|
||||||
|
HttpClient.make((request) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||||
|
requests.push({
|
||||||
|
url: request.url,
|
||||||
|
headers: request.headers,
|
||||||
|
body: JSON.parse(new TextDecoder().decode(request.body.body)),
|
||||||
|
})
|
||||||
|
return HttpClientResponse.fromWeb(request, new Response(response.body, { status: 200 }))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const searchIntegrationTest = testEffect(
|
||||||
|
Layer.merge(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node])), http),
|
||||||
|
)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { beforeEach, describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
|
import { SearchExa } from "@opencode-ai/core/plugin/search/exa"
|
||||||
|
import { SearchParallel } from "@opencode-ai/core/plugin/search/parallel"
|
||||||
|
import { host, integrationHost } from "./host"
|
||||||
|
import { requests, resetSearchFixture, searchIntegrationTest } from "./search-fixture"
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetSearchFixture(
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
result: { content: [{ type: "text", text: "search results" }] },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const it = searchIntegrationTest
|
||||||
|
|
||||||
|
describe("built-in search integrations", () => {
|
||||||
|
it.effect("registers Exa and maps search hints to its MCP tool", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
yield* SearchExa.Plugin.effect(host({ integration: integrationHost(integrations) }))
|
||||||
|
|
||||||
|
const info = yield* integrations.get(Integration.ID.make("exa"))
|
||||||
|
expect(info).toMatchObject({
|
||||||
|
id: "exa",
|
||||||
|
name: "Exa",
|
||||||
|
methods: [{ type: "key" }, { type: "env", names: ["EXA_API_KEY"] }],
|
||||||
|
capabilities: [{ type: "search", connection: "optional", selected: false }],
|
||||||
|
})
|
||||||
|
const provider = yield* integrations.capability.search.get(Integration.ID.make("exa"))
|
||||||
|
if (!provider) return yield* Effect.die("Expected Exa search provider")
|
||||||
|
expect(
|
||||||
|
yield* provider.execute(
|
||||||
|
{
|
||||||
|
query: "effect typescript",
|
||||||
|
numResults: 3,
|
||||||
|
livecrawl: "preferred",
|
||||||
|
type: "fast",
|
||||||
|
contextMaxCharacters: 2500,
|
||||||
|
},
|
||||||
|
{ credential: Credential.Key.make({ type: "key", key: "exa secret" }) },
|
||||||
|
),
|
||||||
|
).toEqual({ text: "search results" })
|
||||||
|
expect(requests).toEqual([
|
||||||
|
{
|
||||||
|
url: `${SearchExa.endpoint}?exaApiKey=exa+secret`,
|
||||||
|
headers: expect.any(Object),
|
||||||
|
body: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "tools/call",
|
||||||
|
params: {
|
||||||
|
name: "web_search_exa",
|
||||||
|
arguments: {
|
||||||
|
query: "effect typescript",
|
||||||
|
type: "fast",
|
||||||
|
numResults: 3,
|
||||||
|
livecrawl: "preferred",
|
||||||
|
contextMaxCharacters: 2500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("registers Parallel and keeps its credential in the authorization header", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
yield* SearchParallel.Plugin.effect(host({ integration: integrationHost(integrations) }))
|
||||||
|
const provider = yield* integrations.capability.search.get(Integration.ID.make("parallel"))
|
||||||
|
if (!provider) return yield* Effect.die("Expected Parallel search provider")
|
||||||
|
|
||||||
|
const output = yield* provider.execute(
|
||||||
|
{ query: "effect layers" },
|
||||||
|
{
|
||||||
|
sessionID: "ses_parallel",
|
||||||
|
credential: Credential.Key.make({ type: "key", key: "parallel-secret" }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(output).toEqual({ text: "search results" })
|
||||||
|
expect(requests[0]).toMatchObject({
|
||||||
|
url: SearchParallel.endpoint,
|
||||||
|
headers: { authorization: "Bearer parallel-secret" },
|
||||||
|
body: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "tools/call",
|
||||||
|
params: {
|
||||||
|
name: "web_search",
|
||||||
|
arguments: {
|
||||||
|
objective: "effect layers",
|
||||||
|
search_queries: ["effect layers"],
|
||||||
|
session_id: "ses_parallel",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(JSON.stringify(output)).not.toContain("parallel-secret")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
})
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { beforeEach, describe, expect } from "bun:test"
|
||||||
|
import { Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
import { Config } from "@opencode-ai/core/config"
|
||||||
|
import { ConfigSearch } from "@opencode-ai/core/config/search"
|
||||||
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
|
import { Form } from "@opencode-ai/core/form"
|
||||||
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
|
import { Search } from "@opencode-ai/core/search"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
let entries: Config.Entry[] = []
|
||||||
|
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(entries) }))
|
||||||
|
const it = testEffect(
|
||||||
|
AppNodeBuilder.build(LayerNode.group([Search.node, Integration.node, Credential.node, EventV2.node, Form.node]), [
|
||||||
|
[Config.node, config],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const register = (id: string, connection: "optional" | "required" = "optional") =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
const integrationID = Integration.ID.make(id)
|
||||||
|
const calls: { input: Search.Input; credential?: Credential.Value; sessionID?: string }[] = []
|
||||||
|
yield* integrations.transform((draft) => {
|
||||||
|
draft.update(integrationID, (integration) => (integration.name = id.toUpperCase()))
|
||||||
|
draft.capability.search.update({
|
||||||
|
integrationID,
|
||||||
|
capability: { type: "search", connection },
|
||||||
|
execute: (input, context) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
calls.push({ input, ...context })
|
||||||
|
return { text: `${id}: ${input.query}`, metadata: { id } }
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return { integrationID, calls }
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
entries = []
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Search", () => {
|
||||||
|
it.effect("executes an explicit provider without changing the default", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const provider = yield* register("exa")
|
||||||
|
const search = yield* Search.Service
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
|
||||||
|
expect(yield* search.query({ query: "effect", providerID: provider.integrationID })).toEqual(
|
||||||
|
new Search.Result({
|
||||||
|
providerID: provider.integrationID,
|
||||||
|
text: "exa: effect",
|
||||||
|
metadata: { id: "exa" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(yield* integrations.capability.search.selected()).toBeUndefined()
|
||||||
|
expect(provider.calls).toEqual([
|
||||||
|
{
|
||||||
|
input: { query: "effect", providerID: provider.integrationID },
|
||||||
|
credential: undefined,
|
||||||
|
sessionID: undefined,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("uses the persisted integration capability selection", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* register("exa")
|
||||||
|
const parallel = yield* register("parallel")
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
const search = yield* Search.Service
|
||||||
|
yield* integrations.capability.search.select(parallel.integrationID)
|
||||||
|
|
||||||
|
expect((yield* search.query({ query: "layers" })).providerID).toBe(parallel.integrationID)
|
||||||
|
expect((yield* integrations.get(parallel.integrationID))?.capabilities).toEqual([
|
||||||
|
{ type: "search", connection: "optional", selected: true },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("prefers the location config over the global selection", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const exa = yield* register("exa")
|
||||||
|
const parallel = yield* register("parallel")
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
const search = yield* Search.Service
|
||||||
|
yield* integrations.capability.search.select(exa.integrationID)
|
||||||
|
entries = [
|
||||||
|
new Config.Document({
|
||||||
|
type: "document",
|
||||||
|
info: new Config.Info({ search: new ConfigSearch.Info({ provider: parallel.integrationID }) }),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
expect((yield* search.query({ query: "configured" })).providerID).toBe(parallel.integrationID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("serializes concurrent first-use onboarding and persists the answer", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const provider = yield* register("exa")
|
||||||
|
const search = yield* Search.Service
|
||||||
|
const forms = yield* Form.Service
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
const first = yield* search.query({ query: "one", sessionID: "ses_search" }).pipe(Effect.forkChild)
|
||||||
|
const second = yield* search.query({ query: "two", sessionID: "ses_search" }).pipe(Effect.forkChild)
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
|
const pending = yield* forms.list({ sessionID: "ses_search" })
|
||||||
|
expect(pending).toHaveLength(1)
|
||||||
|
const form = pending[0]
|
||||||
|
if (!form) return yield* Effect.die("Expected an onboarding form")
|
||||||
|
yield* forms.reply({ id: form.id, answer: { provider: provider.integrationID } })
|
||||||
|
|
||||||
|
expect((yield* Fiber.join(first)).providerID).toBe(provider.integrationID)
|
||||||
|
expect((yield* Fiber.join(second)).providerID).toBe(provider.integrationID)
|
||||||
|
expect(yield* integrations.capability.search.selected()).toBe(provider.integrationID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("requires a connection before invoking a required provider", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const provider = yield* register("private", "required")
|
||||||
|
const search = yield* Search.Service
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* search.query({ query: "secret", providerID: provider.integrationID }).pipe(Effect.flip),
|
||||||
|
).toBeInstanceOf(Search.ConnectionRequiredError)
|
||||||
|
expect(provider.calls).toEqual([])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("removes scoped provider registrations", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
const scope = yield* Scope.fork(yield* Scope.Scope)
|
||||||
|
const provider = yield* register("temporary").pipe(Scope.provide(scope))
|
||||||
|
expect(yield* integrations.capability.search.get(provider.integrationID)).toBeDefined()
|
||||||
|
yield* Scope.close(scope, Exit.void)
|
||||||
|
expect(yield* integrations.capability.search.get(provider.integrationID)).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
import { beforeEach, describe, expect, test } from "bun:test"
|
import { beforeEach, describe, expect, test } from "bun:test"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
|
||||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||||
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
|
import { Search } from "@opencode-ai/core/search"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
|
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
|
||||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
import { executeTool, registerToolPlugin, settleTool, toolDefinitions, toolIdentity } from "./lib/tool"
|
||||||
|
|
||||||
const webSearchToolNode = makeLocationNode({
|
const webSearchToolNode = makeLocationNode({
|
||||||
name: "test/websearch-tool-plugin",
|
name: "test/websearch-tool-plugin",
|
||||||
layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)),
|
layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)),
|
||||||
deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode],
|
deps: [ToolRegistry.toolsNode, PermissionV2.node, Search.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionID = SessionV2.ID.make("ses_websearch_test")
|
const sessionID = SessionV2.ID.make("ses_websearch_test")
|
||||||
@@ -27,31 +27,13 @@ const payload = (text: string) =>
|
|||||||
result: { content: [{ type: "text", text }] },
|
result: { content: [{ type: "text", text }] },
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("WebSearchTool provider selection", () => {
|
describe("WebSearchTool input", () => {
|
||||||
test("rejects out-of-range numeric controls", () => {
|
test("rejects out-of-range numeric controls", () => {
|
||||||
const decode = Schema.decodeUnknownSync(WebSearchTool.Input)
|
const decode = Schema.decodeUnknownSync(WebSearchTool.Input)
|
||||||
expect(() => decode({ query: "x", numResults: 0 })).toThrow()
|
expect(() => decode({ query: "x", numResults: 0 })).toThrow()
|
||||||
expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow()
|
expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow()
|
||||||
expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow()
|
expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow()
|
||||||
})
|
})
|
||||||
test("selects a stable provider per session", () => {
|
|
||||||
expect(WebSearchTool.selectProvider(sessionID)).toBe(WebSearchTool.selectProvider(sessionID))
|
|
||||||
})
|
|
||||||
|
|
||||||
test("supports an explicit operational override", () => {
|
|
||||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "parallel")).toBe(
|
|
||||||
"parallel",
|
|
||||||
)
|
|
||||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "exa")).toBe("exa")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("prefers Parallel when both explicit flags are enabled", () => {
|
|
||||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: true })).toBe("parallel")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("prefers Exa when only its explicit flag is enabled", () => {
|
|
||||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: false })).toBe("exa")
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("WebSearchTool MCP response parser", () => {
|
describe("WebSearchTool MCP response parser", () => {
|
||||||
@@ -68,37 +50,16 @@ describe("WebSearchTool MCP response parser", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
interface Request {
|
|
||||||
readonly url: string
|
|
||||||
readonly headers: Record<string, string>
|
|
||||||
readonly body: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
const requests: Request[] = []
|
|
||||||
const assertions: PermissionV2.AssertInput[] = []
|
const assertions: PermissionV2.AssertInput[] = []
|
||||||
let responseBody = payload("search results")
|
const queries: Search.QueryInput[] = []
|
||||||
let makeResponse = () => new Response(responseBody, { status: 200 })
|
let result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
|
||||||
let config: WebSearchTool.Config = { enableExa: false, enableParallel: false }
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
responseBody = payload("search results")
|
assertions.length = 0
|
||||||
makeResponse = () => new Response(responseBody, { status: 200 })
|
queries.length = 0
|
||||||
|
result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
|
||||||
})
|
})
|
||||||
|
|
||||||
const http = Layer.succeed(
|
|
||||||
HttpClient.HttpClient,
|
|
||||||
HttpClient.make((request) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
|
||||||
requests.push({
|
|
||||||
url: request.url,
|
|
||||||
headers: request.headers,
|
|
||||||
body: JSON.parse(new TextDecoder().decode(request.body.body)),
|
|
||||||
})
|
|
||||||
return HttpClientResponse.fromWeb(request, makeResponse())
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const permission = Layer.succeed(
|
const permission = Layer.succeed(
|
||||||
PermissionV2.Service,
|
PermissionV2.Service,
|
||||||
PermissionV2.Service.of({
|
PermissionV2.Service.of({
|
||||||
@@ -110,45 +71,27 @@ const permission = Layer.succeed(
|
|||||||
list: () => Effect.die("unused"),
|
list: () => Effect.die("unused"),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const websearchConfig = Layer.succeed(
|
const search = Layer.succeed(
|
||||||
WebSearchTool.ConfigService,
|
Search.Service,
|
||||||
WebSearchTool.ConfigService.of({
|
Search.Service.of({
|
||||||
get provider() {
|
query: (input) =>
|
||||||
return config.provider
|
Effect.sync(() => {
|
||||||
},
|
queries.push(input)
|
||||||
get enableExa() {
|
return result
|
||||||
return config.enableExa
|
}),
|
||||||
},
|
|
||||||
get enableParallel() {
|
|
||||||
return config.enableParallel
|
|
||||||
},
|
|
||||||
get exaApiKey() {
|
|
||||||
return config.exaApiKey
|
|
||||||
},
|
|
||||||
get parallelApiKey() {
|
|
||||||
return config.parallelApiKey
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
AppNodeBuilder.build(
|
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, Search.node, webSearchToolNode]), [
|
||||||
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]),
|
[PermissionV2.node, permission],
|
||||||
[
|
[Search.node, search],
|
||||||
[PermissionV2.node, permission],
|
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||||
[LayerNodePlatform.httpClient, http],
|
]),
|
||||||
[WebSearchTool.configNode, websearchConfig],
|
|
||||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
describe("WebSearchTool registration", () => {
|
describe("WebSearchTool registration", () => {
|
||||||
it.effect("registers websearch, asserts query permission, and calls Exa", () =>
|
it.effect("asserts permission before delegating to Search", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
requests.length = 0
|
|
||||||
assertions.length = 0
|
|
||||||
responseBody = payload("exa results")
|
|
||||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
|
|
||||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch"])
|
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch"])
|
||||||
@@ -158,7 +101,7 @@ describe("WebSearchTool registration", () => {
|
|||||||
...toolIdentity,
|
...toolIdentity,
|
||||||
call: {
|
call: {
|
||||||
type: "tool-call",
|
type: "tool-call",
|
||||||
id: "call-exa",
|
id: "call-search",
|
||||||
name: "websearch",
|
name: "websearch",
|
||||||
input: {
|
input: {
|
||||||
query: "effect typescript",
|
query: "effect typescript",
|
||||||
@@ -169,7 +112,7 @@ describe("WebSearchTool registration", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toEqual({ type: "text", value: "exa results" })
|
).toEqual({ type: "text", value: "search results" })
|
||||||
expect(assertions).toMatchObject([
|
expect(assertions).toMatchObject([
|
||||||
{
|
{
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -182,98 +125,50 @@ describe("WebSearchTool registration", () => {
|
|||||||
livecrawl: "preferred",
|
livecrawl: "preferred",
|
||||||
type: "fast",
|
type: "fast",
|
||||||
contextMaxCharacters: 2500,
|
contextMaxCharacters: 2500,
|
||||||
provider: "exa",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
expect(requests).toEqual([
|
expect(queries).toEqual([
|
||||||
{
|
{
|
||||||
url: WebSearchTool.EXA_URL,
|
sessionID,
|
||||||
headers: expect.any(Object),
|
query: "effect typescript",
|
||||||
body: {
|
numResults: 3,
|
||||||
jsonrpc: "2.0",
|
livecrawl: "preferred",
|
||||||
id: 1,
|
type: "fast",
|
||||||
method: "tools/call",
|
contextMaxCharacters: 2500,
|
||||||
params: {
|
|
||||||
name: "web_search_exa",
|
|
||||||
arguments: {
|
|
||||||
query: "effect typescript",
|
|
||||||
type: "fast",
|
|
||||||
numResults: 3,
|
|
||||||
livecrawl: "preferred",
|
|
||||||
contextMaxCharacters: 2500,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("calls Parallel with session ID and keeps bearer credentials out of output", () =>
|
it.effect("keeps provider metadata in structured output", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
requests.length = 0
|
result = new Search.Result({
|
||||||
assertions.length = 0
|
providerID: Integration.ID.make("parallel"),
|
||||||
responseBody = payload("parallel results")
|
text: "parallel results",
|
||||||
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
|
metadata: { requestID: "req_1" },
|
||||||
|
})
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
|
|
||||||
const settled = yield* settleTool(registry, {
|
expect(
|
||||||
sessionID,
|
yield* settleTool(registry, {
|
||||||
...toolIdentity,
|
sessionID,
|
||||||
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
|
...toolIdentity,
|
||||||
})
|
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
|
||||||
|
}),
|
||||||
expect(requests[0]).toMatchObject({
|
).toEqual({
|
||||||
url: WebSearchTool.PARALLEL_URL,
|
|
||||||
headers: { authorization: "Bearer parallel-secret" },
|
|
||||||
body: {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
method: "tools/call",
|
|
||||||
params: {
|
|
||||||
name: "web_search",
|
|
||||||
arguments: { objective: "effect layers", search_queries: ["effect layers"], session_id: sessionID },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name")
|
|
||||||
expect(settled).toEqual({
|
|
||||||
result: { type: "text", value: "parallel results" },
|
result: { type: "text", value: "parallel results" },
|
||||||
output: {
|
output: {
|
||||||
structured: { provider: "parallel", text: "parallel results" },
|
structured: { provider: "parallel", text: "parallel results", metadata: { requestID: "req_1" } },
|
||||||
content: [{ type: "text", text: "parallel results" }],
|
content: [{ type: "text", text: "parallel results" }],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(JSON.stringify(settled)).not.toContain("parallel-secret")
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("keeps an Exa credential in the transport URL and out of model output", () =>
|
it.effect("uses the concise no-results fallback", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
requests.length = 0
|
result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "" })
|
||||||
assertions.length = 0
|
|
||||||
responseBody = payload("credentialed exa results")
|
|
||||||
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
|
|
||||||
const registry = yield* ToolRegistry.Service
|
|
||||||
|
|
||||||
const settled = yield* settleTool(registry, {
|
|
||||||
sessionID,
|
|
||||||
...toolIdentity,
|
|
||||||
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(requests[0]?.url).toBe(`${WebSearchTool.EXA_URL}?exaApiKey=exa+secret`)
|
|
||||||
expect(JSON.stringify(settled)).not.toContain("exa secret")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("returns the legacy no-results fallback as concise model text", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
requests.length = 0
|
|
||||||
assertions.length = 0
|
|
||||||
responseBody = ""
|
|
||||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -285,39 +180,4 @@ describe("WebSearchTool registration", () => {
|
|||||||
).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS })
|
).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rejects oversized MCP response bodies", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
requests.length = 0
|
|
||||||
assertions.length = 0
|
|
||||||
let chunksRead = 0
|
|
||||||
let cancelled = false
|
|
||||||
makeResponse = () =>
|
|
||||||
new Response(
|
|
||||||
new ReadableStream({
|
|
||||||
pull(controller) {
|
|
||||||
chunksRead++
|
|
||||||
if (chunksRead === 10) throw new Error("response was not stopped at the byte limit")
|
|
||||||
controller.enqueue(new Uint8Array(64 * 1024))
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
cancelled = true
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{ status: 200 },
|
|
||||||
)
|
|
||||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
|
||||||
const registry = yield* ToolRegistry.Service
|
|
||||||
|
|
||||||
expect(
|
|
||||||
yield* executeTool(registry, {
|
|
||||||
sessionID,
|
|
||||||
...toolIdentity,
|
|
||||||
call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } },
|
|
||||||
}),
|
|
||||||
).toEqual({ type: "error", value: "Unable to search the web for too much" })
|
|
||||||
expect(chunksRead).toBeLessThan(10)
|
|
||||||
expect(cancelled).toBe(true)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ export type { AISDKHooks } from "./aisdk.js"
|
|||||||
export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js"
|
export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js"
|
||||||
export type { CommandDraft, CommandHooks } from "./command.js"
|
export type { CommandDraft, CommandHooks } from "./command.js"
|
||||||
export type { EventHooks } from "./event.js"
|
export type { EventHooks } from "./event.js"
|
||||||
export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js"
|
export type {
|
||||||
|
IntegrationDraft,
|
||||||
|
IntegrationHooks,
|
||||||
|
IntegrationMethodRegistration,
|
||||||
|
IntegrationSearchCapabilityRegistration,
|
||||||
|
} from "./integration.js"
|
||||||
export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
|
export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
|
||||||
export type { SkillDraft, SkillHooks } from "./skill.js"
|
export type { SkillDraft, SkillHooks } from "./skill.js"
|
||||||
export * as Tool from "./tool.js"
|
export * as Tool from "./tool.js"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
IntegrationRef,
|
IntegrationRef,
|
||||||
} from "@opencode-ai/sdk/v2/types"
|
} from "@opencode-ai/sdk/v2/types"
|
||||||
import type { IntegrationApi } from "@opencode-ai/client/effect/api"
|
import type { IntegrationApi } from "@opencode-ai/client/effect/api"
|
||||||
|
import type { Search } from "@opencode-ai/schema/search"
|
||||||
import type { Effect, Scope } from "effect"
|
import type { Effect, Scope } from "effect"
|
||||||
import type { TransformHook } from "./registration.js"
|
import type { TransformHook } from "./registration.js"
|
||||||
|
|
||||||
@@ -44,6 +45,18 @@ export type IntegrationMethodRegistration =
|
|||||||
readonly method: IntegrationEnvMethod
|
readonly method: IntegrationEnvMethod
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IntegrationSearchCapabilityRegistration {
|
||||||
|
readonly integrationID: string
|
||||||
|
readonly capability: {
|
||||||
|
readonly type: "search"
|
||||||
|
readonly connection: "optional" | "required"
|
||||||
|
}
|
||||||
|
readonly execute: (
|
||||||
|
input: Search.Input,
|
||||||
|
context: { readonly credential?: CredentialValue; readonly sessionID?: string },
|
||||||
|
) => Effect.Effect<Search.ProviderOutput, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
export interface IntegrationDraft {
|
export interface IntegrationDraft {
|
||||||
list(): readonly IntegrationRef[]
|
list(): readonly IntegrationRef[]
|
||||||
get(id: string): IntegrationRef | undefined
|
get(id: string): IntegrationRef | undefined
|
||||||
@@ -54,6 +67,13 @@ export interface IntegrationDraft {
|
|||||||
update(input: IntegrationMethodRegistration): void
|
update(input: IntegrationMethodRegistration): void
|
||||||
remove(integrationID: string, method: IntegrationMethod): void
|
remove(integrationID: string, method: IntegrationMethod): void
|
||||||
}
|
}
|
||||||
|
readonly capability: {
|
||||||
|
readonly search: {
|
||||||
|
list(): readonly IntegrationSearchCapabilityRegistration[]
|
||||||
|
update(input: IntegrationSearchCapabilityRegistration): void
|
||||||
|
remove(integrationID: string): void
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntegrationHooks extends IntegrationApi<unknown> {
|
export interface IntegrationHooks extends IntegrationApi<unknown> {
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ export type { AISDKHooks } from "./aisdk.js"
|
|||||||
export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js"
|
export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js"
|
||||||
export type { CommandDraft, CommandHooks } from "./command.js"
|
export type { CommandDraft, CommandHooks } from "./command.js"
|
||||||
export type { EventHooks } from "./event.js"
|
export type { EventHooks } from "./event.js"
|
||||||
export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js"
|
export type {
|
||||||
|
IntegrationDraft,
|
||||||
|
IntegrationHooks,
|
||||||
|
IntegrationMethodRegistration,
|
||||||
|
IntegrationSearchCapabilityRegistration,
|
||||||
|
} from "./integration.js"
|
||||||
export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
|
export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
|
||||||
export type { SessionHooks } from "./runtime.js"
|
export type { SessionHooks } from "./runtime.js"
|
||||||
export type { SkillDraft, SkillHooks } from "./skill.js"
|
export type { SkillDraft, SkillHooks } from "./skill.js"
|
||||||
|
|||||||
@@ -1,9 +1,32 @@
|
|||||||
import type { IntegrationApi } from "@opencode-ai/client/promise/api"
|
import type { IntegrationApi } from "@opencode-ai/client/promise/api"
|
||||||
import type { IntegrationDraft, IntegrationMethodRegistration } from "../effect/integration.js"
|
import type { IntegrationMethodRegistration } from "../effect/integration.js"
|
||||||
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
|
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
|
||||||
|
import type { Search } from "@opencode-ai/schema/search"
|
||||||
import type { TransformHook } from "./registration.js"
|
import type { TransformHook } from "./registration.js"
|
||||||
|
|
||||||
export type { IntegrationDraft, IntegrationMethodRegistration }
|
export type { IntegrationMethodRegistration }
|
||||||
|
|
||||||
|
export interface IntegrationSearchCapabilityRegistration {
|
||||||
|
readonly integrationID: string
|
||||||
|
readonly capability: {
|
||||||
|
readonly type: "search"
|
||||||
|
readonly connection: "optional" | "required"
|
||||||
|
}
|
||||||
|
readonly execute: (
|
||||||
|
input: Search.Input,
|
||||||
|
context: { readonly credential?: CredentialValue; readonly sessionID?: string; readonly signal: AbortSignal },
|
||||||
|
) => Promise<Search.ProviderOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntegrationDraft extends Omit<import("../effect/integration.js").IntegrationDraft, "capability"> {
|
||||||
|
readonly capability: {
|
||||||
|
readonly search: {
|
||||||
|
list(): readonly IntegrationSearchCapabilityRegistration[]
|
||||||
|
update(input: IntegrationSearchCapabilityRegistration): void
|
||||||
|
remove(integrationID: string): void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface IntegrationHooks extends IntegrationApi {
|
export interface IntegrationHooks extends IntegrationApi {
|
||||||
readonly transform: TransformHook<IntegrationDraft>
|
readonly transform: TransformHook<IntegrationDraft>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { ReferenceGroup } from "./groups/reference.js"
|
|||||||
import { Authorization } from "./middleware/authorization.js"
|
import { Authorization } from "./middleware/authorization.js"
|
||||||
import { LocationGroup } from "./groups/location.js"
|
import { LocationGroup } from "./groups/location.js"
|
||||||
import { IntegrationGroup } from "./groups/integration.js"
|
import { IntegrationGroup } from "./groups/integration.js"
|
||||||
|
import { SearchGroup } from "./groups/search.js"
|
||||||
import { McpGroup } from "./groups/mcp.js"
|
import { McpGroup } from "./groups/mcp.js"
|
||||||
import { CredentialGroup } from "./groups/credential.js"
|
import { CredentialGroup } from "./groups/credential.js"
|
||||||
import { ProjectGroup } from "./groups/project.js"
|
import { ProjectGroup } from "./groups/project.js"
|
||||||
@@ -38,6 +39,7 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
|||||||
| HttpApiGroup.AddMiddleware<typeof GenerateGroup, LocationId>
|
| HttpApiGroup.AddMiddleware<typeof GenerateGroup, LocationId>
|
||||||
| HttpApiGroup.AddMiddleware<typeof ProviderGroup, LocationId>
|
| HttpApiGroup.AddMiddleware<typeof ProviderGroup, LocationId>
|
||||||
| HttpApiGroup.AddMiddleware<typeof IntegrationGroup, LocationId>
|
| HttpApiGroup.AddMiddleware<typeof IntegrationGroup, LocationId>
|
||||||
|
| HttpApiGroup.AddMiddleware<typeof SearchGroup, LocationId>
|
||||||
| HttpApiGroup.AddMiddleware<typeof McpGroup, LocationId>
|
| HttpApiGroup.AddMiddleware<typeof McpGroup, LocationId>
|
||||||
| HttpApiGroup.AddMiddleware<typeof CredentialGroup, LocationId>
|
| HttpApiGroup.AddMiddleware<typeof CredentialGroup, LocationId>
|
||||||
| HttpApiGroup.AddMiddleware<typeof ProjectGroup, LocationId>
|
| HttpApiGroup.AddMiddleware<typeof ProjectGroup, LocationId>
|
||||||
@@ -168,6 +170,7 @@ const makeApiFromGroup = <
|
|||||||
.add(ProjectCopyGroup.middleware(locationMiddleware))
|
.add(ProjectCopyGroup.middleware(locationMiddleware))
|
||||||
.add(VcsGroup.middleware(locationMiddleware))
|
.add(VcsGroup.middleware(locationMiddleware))
|
||||||
.add(DebugGroup)
|
.add(DebugGroup)
|
||||||
|
.add(SearchGroup.middleware(locationMiddleware))
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
title: "opencode HttpApi",
|
title: "opencode HttpApi",
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const groupNames = {
|
|||||||
"server.generate": "generate",
|
"server.generate": "generate",
|
||||||
"server.provider": "provider",
|
"server.provider": "provider",
|
||||||
"server.integration": "integration",
|
"server.integration": "integration",
|
||||||
|
"server.search": "search",
|
||||||
"server.credential": "credential",
|
"server.credential": "credential",
|
||||||
"server.form": "form",
|
"server.form": "form",
|
||||||
"server.permission": "permission",
|
"server.permission": "permission",
|
||||||
@@ -63,6 +64,7 @@ export const groupNames = {
|
|||||||
export const endpointNames = {
|
export const endpointNames = {
|
||||||
"session.messages": "list",
|
"session.messages": "list",
|
||||||
"integration.connect.key": "connectKey",
|
"integration.connect.key": "connectKey",
|
||||||
|
"integration.capability.select": "selectCapability",
|
||||||
"integration.connect.oauth": "connectOauth",
|
"integration.connect.oauth": "connectOauth",
|
||||||
"integration.attempt.status": "attemptStatus",
|
"integration.attempt.status": "attemptStatus",
|
||||||
"integration.attempt.complete": "attemptComplete",
|
"integration.attempt.complete": "attemptComplete",
|
||||||
|
|||||||
@@ -37,6 +37,23 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.post("integration.capability.select", "/api/integration/:integrationID/capability", {
|
||||||
|
params: { integrationID: Integration.ID },
|
||||||
|
query: LocationQuery,
|
||||||
|
payload: Schema.Struct({ capability: Schema.Literal("search") }),
|
||||||
|
success: HttpApiSchema.NoContent,
|
||||||
|
error: InvalidRequestError,
|
||||||
|
})
|
||||||
|
.annotateMerge(locationQueryOpenApi)
|
||||||
|
.annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "v2.integration.capability.select",
|
||||||
|
summary: "Select integration capability",
|
||||||
|
description: "Set the default integration for a capability.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.post("integration.connect.key", "/api/integration/:integrationID/connect/key", {
|
HttpApiEndpoint.post("integration.connect.key", "/api/integration/:integrationID/connect/key", {
|
||||||
params: { integrationID: Integration.ID },
|
params: { integrationID: Integration.ID },
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Location } from "@opencode-ai/schema/location"
|
||||||
|
import { Search } from "@opencode-ai/schema/search"
|
||||||
|
import { Schema } from "effect"
|
||||||
|
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
import { InvalidRequestError, ServiceUnavailableError } from "../errors.js"
|
||||||
|
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||||
|
|
||||||
|
export const SearchGroup = HttpApiGroup.make("server.search")
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.post("search.query", "/api/search", {
|
||||||
|
query: LocationQuery,
|
||||||
|
payload: Schema.Struct(Search.Input.fields),
|
||||||
|
success: Location.response(Search.Result),
|
||||||
|
error: [InvalidRequestError, ServiceUnavailableError],
|
||||||
|
})
|
||||||
|
.annotateMerge(locationQueryOpenApi)
|
||||||
|
.annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "v2.search.query",
|
||||||
|
summary: "Search the web",
|
||||||
|
description:
|
||||||
|
"Run one web search through the selected integration. Specify a provider to override the configured default.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
title: "search",
|
||||||
|
description: "Location-scoped web search routes.",
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -3,6 +3,7 @@ export * as Form from "./form.js"
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { ephemeral, inventory } from "./event.js"
|
import { ephemeral, inventory } from "./event.js"
|
||||||
import { ascending } from "./identifier.js"
|
import { ascending } from "./identifier.js"
|
||||||
|
import { IntegrationID } from "./integration-id.js"
|
||||||
import { NonNegativeInt, optional, statics } from "./schema.js"
|
import { NonNegativeInt, optional, statics } from "./schema.js"
|
||||||
|
|
||||||
const IDSchema = Schema.String.check(Schema.isStartsWith("frm_")).pipe(Schema.brand("Form.ID"))
|
const IDSchema = Schema.String.check(Schema.isStartsWith("frm_")).pipe(Schema.brand("Form.ID"))
|
||||||
@@ -124,8 +125,15 @@ export const UrlInfo = Schema.Struct({
|
|||||||
}).annotate({ identifier: "Form.UrlInfo" })
|
}).annotate({ identifier: "Form.UrlInfo" })
|
||||||
export interface UrlInfo extends Schema.Schema.Type<typeof UrlInfo> {}
|
export interface UrlInfo extends Schema.Schema.Type<typeof UrlInfo> {}
|
||||||
|
|
||||||
export const Info = Schema.Union([FormInfo, UrlInfo]).pipe(Schema.toTaggedUnion("mode"))
|
export const IntegrationInfo = Schema.Struct({
|
||||||
export type Info = FormInfo | UrlInfo
|
...InfoBase,
|
||||||
|
mode: Schema.Literal("integration"),
|
||||||
|
integrationID: IntegrationID,
|
||||||
|
}).annotate({ identifier: "Form.IntegrationInfo" })
|
||||||
|
export interface IntegrationInfo extends Schema.Schema.Type<typeof IntegrationInfo> {}
|
||||||
|
|
||||||
|
export const Info = Schema.Union([FormInfo, UrlInfo, IntegrationInfo]).pipe(Schema.toTaggedUnion("mode"))
|
||||||
|
export type Info = FormInfo | UrlInfo | IntegrationInfo
|
||||||
|
|
||||||
export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate(
|
export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export { Project } from "./project.js"
|
|||||||
export { ProjectCopy } from "./project-copy.js"
|
export { ProjectCopy } from "./project-copy.js"
|
||||||
export { Provider } from "./provider.js"
|
export { Provider } from "./provider.js"
|
||||||
export { Reference } from "./reference.js"
|
export { Reference } from "./reference.js"
|
||||||
|
export { Search } from "./search.js"
|
||||||
export { Revert } from "./revert.js"
|
export { Revert } from "./revert.js"
|
||||||
export { Session } from "./session.js"
|
export { Session } from "./session.js"
|
||||||
export { Vcs } from "./vcs.js"
|
export { Vcs } from "./vcs.js"
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ export type Method = typeof Method.Type
|
|||||||
export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" })
|
export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" })
|
||||||
export type Inputs = typeof Inputs.Type
|
export type Inputs = typeof Inputs.Type
|
||||||
|
|
||||||
|
export interface SearchCapability extends Schema.Schema.Type<typeof SearchCapability> {}
|
||||||
|
export const SearchCapability = Schema.Struct({
|
||||||
|
type: Schema.Literal("search"),
|
||||||
|
connection: Schema.Literals(["optional", "required"]),
|
||||||
|
selected: Schema.Boolean,
|
||||||
|
}).annotate({ identifier: "Integration.SearchCapability" })
|
||||||
|
|
||||||
|
export const Capability = SearchCapability
|
||||||
|
export type Capability = SearchCapability
|
||||||
|
|
||||||
const Updated = ephemeral({
|
const Updated = ephemeral({
|
||||||
type: "integration.updated",
|
type: "integration.updated",
|
||||||
schema: {},
|
schema: {},
|
||||||
@@ -96,6 +106,7 @@ export class Info extends Schema.Class<Info>("Integration.Info")({
|
|||||||
id: ID,
|
id: ID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
methods: Schema.Array(Method),
|
methods: Schema.Array(Method),
|
||||||
|
capabilities: Schema.Array(Capability),
|
||||||
connections: Schema.Array(Connection.Info),
|
connections: Schema.Array(Connection.Info),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export * as Search from "./search.js"
|
||||||
|
|
||||||
|
import { Schema } from "effect"
|
||||||
|
import { IntegrationID } from "./integration-id.js"
|
||||||
|
import { optional, PositiveInt } from "./schema.js"
|
||||||
|
|
||||||
|
export interface Input extends Schema.Schema.Type<typeof Input> {}
|
||||||
|
export const Input = Schema.Struct({
|
||||||
|
query: Schema.String,
|
||||||
|
providerID: IntegrationID.pipe(optional),
|
||||||
|
numResults: PositiveInt.check(Schema.isLessThanOrEqualTo(20)).pipe(optional),
|
||||||
|
livecrawl: Schema.Literals(["fallback", "preferred"]).pipe(optional),
|
||||||
|
type: Schema.Literals(["auto", "fast", "deep"]).pipe(optional),
|
||||||
|
contextMaxCharacters: PositiveInt.check(Schema.isLessThanOrEqualTo(50_000)).pipe(optional),
|
||||||
|
}).annotate({ identifier: "Search.Input" })
|
||||||
|
|
||||||
|
export interface ProviderOutput extends Schema.Schema.Type<typeof ProviderOutput> {}
|
||||||
|
export const ProviderOutput = Schema.Struct({
|
||||||
|
text: Schema.String,
|
||||||
|
metadata: Schema.Json.pipe(optional),
|
||||||
|
}).annotate({ identifier: "Search.ProviderOutput" })
|
||||||
|
|
||||||
|
export class Result extends Schema.Class<Result>("Search.Result")({
|
||||||
|
providerID: IntegrationID,
|
||||||
|
...ProviderOutput.fields,
|
||||||
|
}) {}
|
||||||
@@ -20,6 +20,7 @@ export { Provider } from "@opencode-ai/schema/provider"
|
|||||||
export { Pty } from "@opencode-ai/schema/pty"
|
export { Pty } from "@opencode-ai/schema/pty"
|
||||||
export { Question } from "@opencode-ai/schema/question"
|
export { Question } from "@opencode-ai/schema/question"
|
||||||
export { Reference } from "@opencode-ai/schema/reference"
|
export { Reference } from "@opencode-ai/schema/reference"
|
||||||
|
export { Search } from "@opencode-ai/schema/search"
|
||||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||||
export { Session } from "@opencode-ai/schema/session"
|
export { Session } from "@opencode-ai/schema/session"
|
||||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
export { SessionInput } from "@opencode-ai/schema/session-input"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { Model } from "@opencode-ai/schema/model"
|
import { Model } from "@opencode-ai/schema/model"
|
||||||
|
import { Search } from "@opencode-ai/schema/search"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
|
|
||||||
const SDK = await import("../src/index")
|
const SDK = await import("../src/index")
|
||||||
@@ -8,6 +9,7 @@ const SDK = await import("../src/index")
|
|||||||
test("re-exports canonical contracts directly from Schema", () => {
|
test("re-exports canonical contracts directly from Schema", () => {
|
||||||
expect(SDK.Agent).toBe(Agent)
|
expect(SDK.Agent).toBe(Agent)
|
||||||
expect(SDK.Model).toBe(Model)
|
expect(SDK.Model).toBe(Model)
|
||||||
|
expect(SDK.Search).toBe(Search)
|
||||||
expect(SDK.Session).toBe(Session)
|
expect(SDK.Session).toBe(Session)
|
||||||
expect(Object.keys(SDK).sort()).toEqual([
|
expect(Object.keys(SDK).sort()).toEqual([
|
||||||
"AbsolutePath",
|
"AbsolutePath",
|
||||||
@@ -31,6 +33,7 @@ test("re-exports canonical contracts directly from Schema", () => {
|
|||||||
"Question",
|
"Question",
|
||||||
"Reference",
|
"Reference",
|
||||||
"RelativePath",
|
"RelativePath",
|
||||||
|
"Search",
|
||||||
"Session",
|
"Session",
|
||||||
"SessionInput",
|
"SessionInput",
|
||||||
"SessionMessage",
|
"SessionMessage",
|
||||||
|
|||||||
@@ -258,6 +258,40 @@ it.live(
|
|||||||
10_000,
|
10_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("embedded client exposes integration-backed search", () =>
|
||||||
|
withEmbedded("opencode-embedded-search-", (fixture) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const opencode = yield* fixture.sdk.OpenCode.create()
|
||||||
|
const providerID = fixture.sdk.Integration.ID.make("embedded-search")
|
||||||
|
yield* opencode.plugin({
|
||||||
|
id: `embedded-search-${crypto.randomUUID()}`,
|
||||||
|
effect: (ctx) =>
|
||||||
|
ctx.integration.transform((draft) => {
|
||||||
|
draft.update(providerID, (integration) => (integration.name = "Embedded search"))
|
||||||
|
draft.capability.search.update({
|
||||||
|
integrationID: providerID,
|
||||||
|
capability: { type: "search", connection: "optional" },
|
||||||
|
execute: (input) =>
|
||||||
|
Effect.succeed({ text: `Found ${input.query}`, metadata: { source: "embedded" } }),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = yield* opencode.search.query({
|
||||||
|
query: "opencode",
|
||||||
|
providerID,
|
||||||
|
location: location(fixture),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.data).toEqual({
|
||||||
|
providerID,
|
||||||
|
text: "Found opencode",
|
||||||
|
metadata: { source: "embedded" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live(
|
it.live(
|
||||||
"Location-owned runner events reach the ready global client",
|
"Location-owned runner events reach the ready global client",
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -298,6 +298,8 @@ import type {
|
|||||||
V2IntegrationAttemptCompleteResponses,
|
V2IntegrationAttemptCompleteResponses,
|
||||||
V2IntegrationAttemptStatusErrors,
|
V2IntegrationAttemptStatusErrors,
|
||||||
V2IntegrationAttemptStatusResponses,
|
V2IntegrationAttemptStatusResponses,
|
||||||
|
V2IntegrationCapabilitySelectErrors,
|
||||||
|
V2IntegrationCapabilitySelectResponses,
|
||||||
V2IntegrationConnectKeyErrors,
|
V2IntegrationConnectKeyErrors,
|
||||||
V2IntegrationConnectKeyResponses,
|
V2IntegrationConnectKeyResponses,
|
||||||
V2IntegrationConnectOauthErrors,
|
V2IntegrationConnectOauthErrors,
|
||||||
@@ -356,6 +358,8 @@ import type {
|
|||||||
V2QuestionRequestListResponses,
|
V2QuestionRequestListResponses,
|
||||||
V2ReferenceListErrors,
|
V2ReferenceListErrors,
|
||||||
V2ReferenceListResponses,
|
V2ReferenceListResponses,
|
||||||
|
V2SearchQueryErrors,
|
||||||
|
V2SearchQueryResponses,
|
||||||
V2SessionActiveErrors,
|
V2SessionActiveErrors,
|
||||||
V2SessionActiveResponses,
|
V2SessionActiveResponses,
|
||||||
V2SessionBackgroundErrors,
|
V2SessionBackgroundErrors,
|
||||||
@@ -6684,6 +6688,52 @@ export class Provider2 extends HeyApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class Capability extends HeyApiClient {
|
||||||
|
/**
|
||||||
|
* Select integration capability
|
||||||
|
*
|
||||||
|
* Set the default integration for a capability.
|
||||||
|
*/
|
||||||
|
public select<ThrowOnError extends boolean = false>(
|
||||||
|
parameters: {
|
||||||
|
integrationID: string
|
||||||
|
location?: {
|
||||||
|
directory?: string | null
|
||||||
|
workspace?: string | null
|
||||||
|
} | null
|
||||||
|
capability?: "search"
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "path", key: "integrationID" },
|
||||||
|
{ in: "query", key: "location" },
|
||||||
|
{ in: "body", key: "capability" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (options?.client ?? this.client).post<
|
||||||
|
V2IntegrationCapabilitySelectResponses,
|
||||||
|
V2IntegrationCapabilitySelectErrors,
|
||||||
|
ThrowOnError
|
||||||
|
>({
|
||||||
|
url: "/api/integration/{integrationID}/capability",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
...params.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Connect extends HeyApiClient {
|
export class Connect extends HeyApiClient {
|
||||||
/**
|
/**
|
||||||
* Connect with key
|
* Connect with key
|
||||||
@@ -6958,6 +7008,11 @@ export class Integration extends HeyApiClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _capability?: Capability
|
||||||
|
get capability(): Capability {
|
||||||
|
return (this._capability ??= new Capability({ client: this.client }))
|
||||||
|
}
|
||||||
|
|
||||||
private _connect?: Connect
|
private _connect?: Connect
|
||||||
get connect(): Connect {
|
get connect(): Connect {
|
||||||
return (this._connect ??= new Connect({ client: this.client }))
|
return (this._connect ??= new Connect({ client: this.client }))
|
||||||
@@ -8131,6 +8186,56 @@ export class Debug extends HeyApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class Search extends HeyApiClient {
|
||||||
|
/**
|
||||||
|
* Search the web
|
||||||
|
*
|
||||||
|
* Run one web search through the selected integration. Specify a provider to override the configured default.
|
||||||
|
*/
|
||||||
|
public query<ThrowOnError extends boolean = false>(
|
||||||
|
parameters?: {
|
||||||
|
location?: {
|
||||||
|
directory?: string | null
|
||||||
|
workspace?: string | null
|
||||||
|
} | null
|
||||||
|
query?: string
|
||||||
|
providerID?: string
|
||||||
|
numResults?: number
|
||||||
|
livecrawl?: "fallback" | "preferred"
|
||||||
|
type?: "auto" | "fast" | "deep"
|
||||||
|
contextMaxCharacters?: number
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "query", key: "location" },
|
||||||
|
{ in: "body", key: "query" },
|
||||||
|
{ in: "body", key: "providerID" },
|
||||||
|
{ in: "body", key: "numResults" },
|
||||||
|
{ in: "body", key: "livecrawl" },
|
||||||
|
{ in: "body", key: "type" },
|
||||||
|
{ in: "body", key: "contextMaxCharacters" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (options?.client ?? this.client).post<V2SearchQueryResponses, V2SearchQueryErrors, ThrowOnError>({
|
||||||
|
url: "/api/search",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
...params.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class V2 extends HeyApiClient {
|
export class V2 extends HeyApiClient {
|
||||||
private _health?: Health
|
private _health?: Health
|
||||||
get health(): Health {
|
get health(): Health {
|
||||||
@@ -8256,6 +8361,11 @@ export class V2 extends HeyApiClient {
|
|||||||
get debug(): Debug {
|
get debug(): Debug {
|
||||||
return (this._debug ??= new Debug({ client: this.client }))
|
return (this._debug ??= new Debug({ client: this.client }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _search?: Search
|
||||||
|
get search(): Search {
|
||||||
|
return (this._search ??= new Search({ client: this.client }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OpencodeClient extends HeyApiClient {
|
export class OpencodeClient extends HeyApiClient {
|
||||||
|
|||||||
@@ -1482,7 +1482,7 @@ export type GlobalEvent = {
|
|||||||
id: string
|
id: string
|
||||||
type: "form.created"
|
type: "form.created"
|
||||||
properties: {
|
properties: {
|
||||||
form: FormFormInfo | FormUrlInfo
|
form: FormFormInfo | FormUrlInfo | FormIntegrationInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -3520,6 +3520,15 @@ export type FormUrlInfo = {
|
|||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FormIntegrationInfo = {
|
||||||
|
id: string
|
||||||
|
sessionID: string
|
||||||
|
title?: string
|
||||||
|
metadata?: FormMetadata
|
||||||
|
mode: "integration"
|
||||||
|
integrationID: string
|
||||||
|
}
|
||||||
|
|
||||||
export type FormValue =
|
export type FormValue =
|
||||||
| string
|
| string
|
||||||
| number
|
| number
|
||||||
@@ -5602,6 +5611,12 @@ export type IntegrationEnvMethod = {
|
|||||||
names: Array<string>
|
names: Array<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IntegrationSearchCapability = {
|
||||||
|
type: "search"
|
||||||
|
connection: "optional" | "required"
|
||||||
|
selected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type ConnectionCredentialInfo = {
|
export type ConnectionCredentialInfo = {
|
||||||
type: "credential"
|
type: "credential"
|
||||||
id: string
|
id: string
|
||||||
@@ -5619,6 +5634,7 @@ export type IntegrationInfo = {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
methods: Array<IntegrationMethod>
|
methods: Array<IntegrationMethod>
|
||||||
|
capabilities: Array<IntegrationSearchCapability>
|
||||||
connections: Array<ConnectionInfo>
|
connections: Array<ConnectionInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6418,7 +6434,7 @@ export type FormCreated = {
|
|||||||
type: "form.created"
|
type: "form.created"
|
||||||
location?: LocationRef
|
location?: LocationRef
|
||||||
data: {
|
data: {
|
||||||
form: FormFormInfo | FormUrlInfo
|
form: FormFormInfo | FormUrlInfo | FormIntegrationInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6869,6 +6885,12 @@ export type ProjectCopyCopy = {
|
|||||||
|
|
||||||
export type VcsMode = "working" | "branch"
|
export type VcsMode = "working" | "branch"
|
||||||
|
|
||||||
|
export type SearchResult = {
|
||||||
|
providerID: string
|
||||||
|
text: string
|
||||||
|
metadata?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
export type EventModelsDevRefreshed = {
|
export type EventModelsDevRefreshed = {
|
||||||
id: string
|
id: string
|
||||||
type: "models-dev.refreshed"
|
type: "models-dev.refreshed"
|
||||||
@@ -7683,7 +7705,7 @@ export type EventFormCreated = {
|
|||||||
id: string
|
id: string
|
||||||
type: "form.created"
|
type: "form.created"
|
||||||
properties: {
|
properties: {
|
||||||
form: FormFormInfo | FormUrlInfo
|
form: FormFormInfo | FormUrlInfo | FormIntegrationInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9747,6 +9769,15 @@ export type FormUrlInfoV2 = {
|
|||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FormIntegrationInfoV2 = {
|
||||||
|
id: string
|
||||||
|
sessionID: string
|
||||||
|
title?: string
|
||||||
|
metadata?: FormMetadata
|
||||||
|
mode: "integration"
|
||||||
|
integrationID: string
|
||||||
|
}
|
||||||
|
|
||||||
export type FormCreatePayloadV2 = {
|
export type FormCreatePayloadV2 = {
|
||||||
id?: string | null
|
id?: string | null
|
||||||
title?: string
|
title?: string
|
||||||
@@ -10414,6 +10445,15 @@ export type FormUrlInfo1 = {
|
|||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FormIntegrationInfo1 = {
|
||||||
|
id: string
|
||||||
|
sessionID: string
|
||||||
|
title?: string
|
||||||
|
metadata?: FormMetadata1
|
||||||
|
mode: "integration"
|
||||||
|
integrationID: string
|
||||||
|
}
|
||||||
|
|
||||||
export type FormCreatedV2 = {
|
export type FormCreatedV2 = {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
created: number
|
||||||
@@ -10423,7 +10463,7 @@ export type FormCreatedV2 = {
|
|||||||
type: "form.created"
|
type: "form.created"
|
||||||
location?: LocationRefV2
|
location?: LocationRefV2
|
||||||
data: {
|
data: {
|
||||||
form: FormFormInfo1 | FormUrlInfo1
|
form: FormFormInfo1 | FormUrlInfo1 | FormIntegrationInfo1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16446,6 +16486,46 @@ export type V2IntegrationGetResponses = {
|
|||||||
|
|
||||||
export type V2IntegrationGetResponse = V2IntegrationGetResponses[keyof V2IntegrationGetResponses]
|
export type V2IntegrationGetResponse = V2IntegrationGetResponses[keyof V2IntegrationGetResponses]
|
||||||
|
|
||||||
|
export type V2IntegrationCapabilitySelectData = {
|
||||||
|
body: {
|
||||||
|
capability: "search"
|
||||||
|
}
|
||||||
|
path: {
|
||||||
|
integrationID: string
|
||||||
|
}
|
||||||
|
query?: {
|
||||||
|
location?: {
|
||||||
|
directory?: string | null
|
||||||
|
workspace?: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
url: "/api/integration/{integrationID}/capability"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2IntegrationCapabilitySelectErrors = {
|
||||||
|
/**
|
||||||
|
* InvalidRequestError
|
||||||
|
*/
|
||||||
|
400: InvalidRequestError1 | InvalidRequestErrorV2
|
||||||
|
/**
|
||||||
|
* UnauthorizedError
|
||||||
|
*/
|
||||||
|
401: UnauthorizedError
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2IntegrationCapabilitySelectError =
|
||||||
|
V2IntegrationCapabilitySelectErrors[keyof V2IntegrationCapabilitySelectErrors]
|
||||||
|
|
||||||
|
export type V2IntegrationCapabilitySelectResponses = {
|
||||||
|
/**
|
||||||
|
* <No Content>
|
||||||
|
*/
|
||||||
|
204: void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2IntegrationCapabilitySelectResponse =
|
||||||
|
V2IntegrationCapabilitySelectResponses[keyof V2IntegrationCapabilitySelectResponses]
|
||||||
|
|
||||||
export type V2IntegrationConnectKeyData = {
|
export type V2IntegrationConnectKeyData = {
|
||||||
body: {
|
body: {
|
||||||
key: string
|
key: string
|
||||||
@@ -16889,7 +16969,7 @@ export type V2FormRequestListResponses = {
|
|||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
location: LocationInfoV2
|
location: LocationInfoV2
|
||||||
data: Array<FormFormInfoV2 | FormUrlInfoV2>
|
data: Array<FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16926,7 +17006,7 @@ export type V2SessionFormListResponses = {
|
|||||||
* Success
|
* Success
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
data: Array<FormFormInfoV2 | FormUrlInfoV2>
|
data: Array<FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16967,7 +17047,7 @@ export type V2SessionFormCreateResponses = {
|
|||||||
* Success
|
* Success
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
data: FormFormInfoV2 | FormUrlInfoV2
|
data: FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17005,7 +17085,7 @@ export type V2SessionFormGetResponses = {
|
|||||||
* Success
|
* Success
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
data: FormFormInfoV2 | FormUrlInfoV2
|
data: FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18578,6 +18658,54 @@ export type V2DebugLocationResponses = {
|
|||||||
|
|
||||||
export type V2DebugLocationResponse = V2DebugLocationResponses[keyof V2DebugLocationResponses]
|
export type V2DebugLocationResponse = V2DebugLocationResponses[keyof V2DebugLocationResponses]
|
||||||
|
|
||||||
|
export type V2SearchQueryData = {
|
||||||
|
body: {
|
||||||
|
query: string
|
||||||
|
providerID?: string
|
||||||
|
numResults?: number
|
||||||
|
livecrawl?: "fallback" | "preferred"
|
||||||
|
type?: "auto" | "fast" | "deep"
|
||||||
|
contextMaxCharacters?: number
|
||||||
|
}
|
||||||
|
path?: never
|
||||||
|
query?: {
|
||||||
|
location?: {
|
||||||
|
directory?: string | null
|
||||||
|
workspace?: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
url: "/api/search"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2SearchQueryErrors = {
|
||||||
|
/**
|
||||||
|
* InvalidRequestError
|
||||||
|
*/
|
||||||
|
400: InvalidRequestError1 | InvalidRequestErrorV2
|
||||||
|
/**
|
||||||
|
* UnauthorizedError
|
||||||
|
*/
|
||||||
|
401: UnauthorizedError
|
||||||
|
/**
|
||||||
|
* ServiceUnavailableError
|
||||||
|
*/
|
||||||
|
503: ServiceUnavailableErrorV2
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2SearchQueryError = V2SearchQueryErrors[keyof V2SearchQueryErrors]
|
||||||
|
|
||||||
|
export type V2SearchQueryResponses = {
|
||||||
|
/**
|
||||||
|
* Success
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
location: LocationInfoV2
|
||||||
|
data: SearchResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2SearchQueryResponse = V2SearchQueryResponses[keyof V2SearchQueryResponses]
|
||||||
|
|
||||||
export type PtyConnectData = {
|
export type PtyConnectData = {
|
||||||
body?: never
|
body?: never
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { QuestionHandler } from "./handlers/question"
|
|||||||
import { ReferenceHandler } from "./handlers/reference"
|
import { ReferenceHandler } from "./handlers/reference"
|
||||||
import { LocationHandler } from "./handlers/location"
|
import { LocationHandler } from "./handlers/location"
|
||||||
import { IntegrationHandler } from "./handlers/integration"
|
import { IntegrationHandler } from "./handlers/integration"
|
||||||
|
import { SearchHandler } from "./handlers/search"
|
||||||
import { McpHandler } from "./handlers/mcp"
|
import { McpHandler } from "./handlers/mcp"
|
||||||
import { CredentialHandler } from "./handlers/credential"
|
import { CredentialHandler } from "./handlers/credential"
|
||||||
import { ProjectHandler } from "./handlers/project"
|
import { ProjectHandler } from "./handlers/project"
|
||||||
@@ -38,6 +39,7 @@ export const handlers = Layer.mergeAll(
|
|||||||
GenerateHandler,
|
GenerateHandler,
|
||||||
ProviderHandler,
|
ProviderHandler,
|
||||||
IntegrationHandler,
|
IntegrationHandler,
|
||||||
|
SearchHandler,
|
||||||
McpHandler,
|
McpHandler,
|
||||||
CredentialHandler,
|
CredentialHandler,
|
||||||
ProjectHandler,
|
ProjectHandler,
|
||||||
|
|||||||
@@ -33,6 +33,21 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
|
|||||||
return yield* response(service.get(ctx.params.integrationID))
|
return yield* response(service.get(ctx.params.integrationID))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.handle(
|
||||||
|
"integration.capability.select",
|
||||||
|
Effect.fn(function* (ctx) {
|
||||||
|
const service = yield* Integration.Service
|
||||||
|
const integration = yield* service.get(ctx.params.integrationID)
|
||||||
|
if (!integration?.capabilities.some((capability) => capability.type === ctx.payload.capability)) {
|
||||||
|
return yield* new InvalidRequestError({
|
||||||
|
message: `Capability not found: ${ctx.payload.capability}`,
|
||||||
|
kind: "integration_capability_not_found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
yield* service.capability.search.select(ctx.params.integrationID)
|
||||||
|
return HttpApiSchema.NoContent.make()
|
||||||
|
}),
|
||||||
|
)
|
||||||
.handle(
|
.handle(
|
||||||
"integration.connect.key",
|
"integration.connect.key",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||||
|
import { Search } from "@opencode-ai/core/search"
|
||||||
|
import { InvalidRequestError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
|
import { Api } from "../api"
|
||||||
|
import { response } from "../location"
|
||||||
|
|
||||||
|
export const SearchHandler = HttpApiBuilder.group(Api, "server.search", (handlers) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
return handlers.handle(
|
||||||
|
"search.query",
|
||||||
|
Effect.fn("server.search.query")(function* (request) {
|
||||||
|
const plugins = yield* PluginSupervisor.Service
|
||||||
|
yield* plugins.ready.pipe(
|
||||||
|
Effect.timeoutOrElse({
|
||||||
|
duration: "5 seconds",
|
||||||
|
orElse: () =>
|
||||||
|
Effect.fail(
|
||||||
|
new ServiceUnavailableError({
|
||||||
|
message: "Search integration initialization timed out",
|
||||||
|
service: "search",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const search = yield* Search.Service
|
||||||
|
return yield* response(
|
||||||
|
search.query(request.payload).pipe(
|
||||||
|
Effect.catchTags({
|
||||||
|
"Search.ProviderRequired": () =>
|
||||||
|
new InvalidRequestError({
|
||||||
|
message: "Search provider is required",
|
||||||
|
kind: "search_provider_required",
|
||||||
|
field: "providerID",
|
||||||
|
}),
|
||||||
|
"Search.ProviderNotFound": (error) =>
|
||||||
|
new InvalidRequestError({
|
||||||
|
message: `Search provider not found: ${error.providerID}`,
|
||||||
|
kind: "search_provider_not_found",
|
||||||
|
field: "providerID",
|
||||||
|
}),
|
||||||
|
"Search.ConnectionRequired": (error) =>
|
||||||
|
new InvalidRequestError({
|
||||||
|
message: `Search provider requires a connection: ${error.providerID}`,
|
||||||
|
kind: "search_connection_required",
|
||||||
|
field: "providerID",
|
||||||
|
}),
|
||||||
|
"Search.Cancelled": () =>
|
||||||
|
new InvalidRequestError({ message: "Search cancelled", kind: "search_cancelled" }),
|
||||||
|
"Search.Request": (error) =>
|
||||||
|
new ServiceUnavailableError({
|
||||||
|
message: `Search request failed: ${error.providerID}`,
|
||||||
|
service: error.providerID,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -53,28 +53,46 @@ export function connectionSummary(integration: IntegrationInfo) {
|
|||||||
.join(", ")
|
.join(", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DialogIntegration(props: { onConnected?: OnIntegrationConnected } = {}) {
|
export function DialogIntegration(
|
||||||
|
props: { onConnected?: OnIntegrationConnected; integrationID?: string; connectionOnly?: boolean } = {},
|
||||||
|
) {
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const options = createMemo(() =>
|
const options = createMemo(() =>
|
||||||
integrationOptions(data.location.integration.list() ?? []).map((integration) => {
|
integrationOptions(data.location.integration.list() ?? [])
|
||||||
const methods = connectMethods(integration)
|
.filter((integration) => props.integrationID === undefined || integration.id === props.integrationID)
|
||||||
const connected = integration.connections.length > 0
|
.map((integration) => {
|
||||||
return {
|
const methods = connectMethods(integration)
|
||||||
title: integration.name,
|
const connected = integration.connections.length > 0
|
||||||
value: integration.id,
|
const search = integration.capabilities.find((capability) => capability.type === "search")
|
||||||
description: methods.length ? undefined : "Environment only",
|
const credentials = credentialConnections(integration)
|
||||||
footer: connectionSummary(integration) || undefined,
|
const description = search?.connection === "optional" ? "API key optional" : undefined
|
||||||
category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services",
|
const category = search ? "Web search" : undefined
|
||||||
disabled: methods.length === 0,
|
return {
|
||||||
gutter: connected ? () => <text fg={theme.success}>✓</text> : undefined,
|
title: integration.name,
|
||||||
onSelect: () =>
|
value: integration.id,
|
||||||
credentialConnections(integration).length
|
description: description ?? (methods.length === 0 ? "Environment only" : undefined),
|
||||||
? manageConnections(integration, methods, dialog, props.onConnected)
|
footer:
|
||||||
: selectMethod(integration, methods, dialog, props.onConnected),
|
[connectionSummary(integration), search?.selected ? "Web search default" : undefined]
|
||||||
}
|
.filter((value) => value !== undefined && value.length > 0)
|
||||||
}),
|
.join(" · ") || undefined,
|
||||||
|
category: category ?? (integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services"),
|
||||||
|
disabled: methods.length === 0 && !search,
|
||||||
|
gutter: connected ? () => <text fg={theme.success}>✓</text> : undefined,
|
||||||
|
onSelect: () => {
|
||||||
|
if (props.connectionOnly) {
|
||||||
|
return credentials.length
|
||||||
|
? manageConnections(integration, methods, dialog, props.onConnected)
|
||||||
|
: selectMethod(integration, methods, dialog, props.onConnected)
|
||||||
|
}
|
||||||
|
if (search) return manageIntegration(integration, methods, search, dialog)
|
||||||
|
return credentials.length
|
||||||
|
? manageConnections(integration, methods, dialog, props.onConnected)
|
||||||
|
: selectMethod(integration, methods, dialog, props.onConnected)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -90,6 +108,64 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function manageIntegration(
|
||||||
|
integration: IntegrationInfo,
|
||||||
|
methods: ConnectMethod[],
|
||||||
|
search: IntegrationInfo["capabilities"][number],
|
||||||
|
dialog: ReturnType<typeof useDialog>,
|
||||||
|
) {
|
||||||
|
const connected = integration.connections.length > 0
|
||||||
|
dialog.replace(() => {
|
||||||
|
const data = useData()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const toast = useToast()
|
||||||
|
const credentials = credentialConnections(integration)
|
||||||
|
const selectSearch = () => {
|
||||||
|
void sdk.api.integration
|
||||||
|
.selectCapability({
|
||||||
|
integrationID: integration.id,
|
||||||
|
capability: "search",
|
||||||
|
location: location(data),
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
await data.location.integration.refresh()
|
||||||
|
toast.show({ variant: "success", message: `${integration.name} is now the web search default` })
|
||||||
|
dialog.clear()
|
||||||
|
})
|
||||||
|
.catch(toast.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogSelect
|
||||||
|
title={integration.name}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
title: search.selected ? "Web search default" : "Use for web search",
|
||||||
|
value: "search",
|
||||||
|
disabled: search.selected,
|
||||||
|
onSelect:
|
||||||
|
search.connection === "required" && !connected
|
||||||
|
? () => selectMethod(integration, methods, dialog, selectSearch)
|
||||||
|
: selectSearch,
|
||||||
|
},
|
||||||
|
...(methods.length
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: credentials.length ? "Manage connections" : "Connect",
|
||||||
|
value: "connect",
|
||||||
|
onSelect: () =>
|
||||||
|
credentials.length
|
||||||
|
? manageConnections(integration, methods, dialog)
|
||||||
|
: selectMethod(integration, methods, dialog),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function manageConnections(
|
function manageConnections(
|
||||||
integration: IntegrationInfo,
|
integration: IntegrationInfo,
|
||||||
methods: ConnectMethod[],
|
methods: ConnectMethod[],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
AgentV2Info,
|
AgentV2Info,
|
||||||
CommandV2Info,
|
CommandV2Info,
|
||||||
FormFormInfo,
|
FormFormInfo,
|
||||||
|
FormIntegrationInfo,
|
||||||
FormUrlInfo,
|
FormUrlInfo,
|
||||||
IntegrationInfo,
|
IntegrationInfo,
|
||||||
LocationRef,
|
LocationRef,
|
||||||
@@ -30,7 +31,7 @@ export type DataSessionStatus = "idle" | "running"
|
|||||||
|
|
||||||
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
||||||
|
|
||||||
export type FormInfo = FormFormInfo | FormUrlInfo
|
export type FormInfo = FormFormInfo | FormUrlInfo | FormIntegrationInfo
|
||||||
|
|
||||||
type LocationData = {
|
type LocationData = {
|
||||||
agent?: AgentV2Info[]
|
agent?: AgentV2Info[]
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { useSDK } from "../../context/sdk"
|
|||||||
import { SplitBorder } from "../../ui/border"
|
import { SplitBorder } from "../../ui/border"
|
||||||
import { useTuiConfig } from "../../config"
|
import { useTuiConfig } from "../../config"
|
||||||
import { useBindings, useOpencodeModeStack } from "../../keymap"
|
import { useBindings, useOpencodeModeStack } from "../../keymap"
|
||||||
|
import { DialogIntegration } from "../../component/dialog-integration"
|
||||||
|
import { useDialog } from "../../ui/dialog"
|
||||||
|
|
||||||
const FORM_MODE = "form"
|
const FORM_MODE = "form"
|
||||||
|
|
||||||
@@ -131,7 +133,50 @@ function display(field: Field, value: FormValue | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FormPrompt(props: { form: FormInfo }) {
|
export function FormPrompt(props: { form: FormInfo }) {
|
||||||
return props.form.mode === "url" ? <UrlPrompt form={props.form} /> : <FieldsPrompt form={props.form} />
|
if (props.form.mode === "url") return <UrlPrompt form={props.form} />
|
||||||
|
if (props.form.mode === "integration") return <IntegrationPrompt form={props.form} />
|
||||||
|
return <FieldsPrompt form={props.form} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntegrationPrompt(props: { form: FormInfo & { mode: "integration" } }) {
|
||||||
|
const sdk = useSDK()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
let settled = false
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
dialog.replace(
|
||||||
|
() => (
|
||||||
|
<DialogIntegration
|
||||||
|
integrationID={props.form.integrationID}
|
||||||
|
connectionOnly
|
||||||
|
onConnected={() => {
|
||||||
|
settled = true
|
||||||
|
dialog.clear()
|
||||||
|
void sdk.api.form.reply({ sessionID: props.form.sessionID, formID: props.form.id, answer: {} })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
() => {
|
||||||
|
if (settled) return
|
||||||
|
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
backgroundColor={theme.backgroundPanel}
|
||||||
|
border={["left"]}
|
||||||
|
borderColor={theme.accent}
|
||||||
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
|
>
|
||||||
|
<box gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||||
|
<text fg={theme.text}>{props.form.title ?? "Connect integration"}</text>
|
||||||
|
<text fg={theme.textMuted}>Complete the connection dialog to continue.</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) {
|
function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
|
|
||||||
const integration = (value: Partial<IntegrationInfo> & Pick<IntegrationInfo, "id" | "name">): IntegrationInfo => ({
|
const integration = (value: Partial<IntegrationInfo> & Pick<IntegrationInfo, "id" | "name">): IntegrationInfo => ({
|
||||||
methods: [],
|
methods: [],
|
||||||
|
capabilities: [],
|
||||||
connections: [],
|
connections: [],
|
||||||
...value,
|
...value,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user