404 lines
15 KiB
TypeScript
404 lines
15 KiB
TypeScript
export * as PluginHost from "./host"
|
|
|
|
import { Plugin } from "@opencode-ai/plugin/effect"
|
|
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
|
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
|
|
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
|
import { App } from "../app"
|
|
import { Effect, Schema, Stream } from "effect"
|
|
import { Agent } from "../agent"
|
|
import { AISDK } from "../aisdk"
|
|
import { Catalog } from "../catalog"
|
|
import { Command } from "../command"
|
|
import { Credential } from "../credential"
|
|
import { Bus } from "../bus"
|
|
import { Integration } from "../integration"
|
|
import { Location } from "../location"
|
|
import { Model } from "../model"
|
|
import { PluginRuntime } from "./runtime"
|
|
import { Provider } from "../provider"
|
|
import { Reference } from "../reference"
|
|
import { AbsolutePath, type DeepMutable } from "../schema"
|
|
import { Skill } from "../skill"
|
|
import { Tool } from "../tool"
|
|
import { Workspace } from "../workspace"
|
|
import { WebSearch } from "../websearch"
|
|
import { PluginHooks } from "./hooks"
|
|
|
|
const mutable = <T>(value: T) => value as DeepMutable<T>
|
|
export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../plugin").Interface) {
|
|
const app = yield* App.Metadata
|
|
const agents = yield* Agent.Service
|
|
const aisdk = yield* AISDK.Service
|
|
const catalog = yield* Catalog.Service
|
|
const commands = yield* Command.Service
|
|
const bus = yield* Bus.Service
|
|
const integration = yield* Integration.Service
|
|
const location = yield* Location.Service
|
|
const reference = yield* Reference.Service
|
|
const skill = yield* Skill.Service
|
|
const tools = yield* Tool.Service
|
|
const websearch = yield* WebSearch.Service
|
|
const hooks = yield* PluginHooks.Service
|
|
const runtime = yield* PluginRuntime.Service
|
|
const locationInfo = () =>
|
|
new Location.Info({
|
|
directory: location.directory,
|
|
workspaceID: location.workspaceID,
|
|
project: location.project,
|
|
})
|
|
const locationRef = (input?: {
|
|
readonly location?: { readonly directory?: string; readonly workspace?: string }
|
|
}) =>
|
|
input?.location === undefined
|
|
? undefined
|
|
: Location.Ref.make({
|
|
directory: AbsolutePath.make(input.location.directory ?? location.directory),
|
|
workspaceID:
|
|
input.location.workspace === undefined
|
|
? location.workspaceID
|
|
: Workspace.ID.make(input.location.workspace),
|
|
})
|
|
const isCurrentLocation = (ref: Location.Ref) =>
|
|
ref.directory === location.directory && ref.workspaceID === location.workspaceID
|
|
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
|
|
|
return {
|
|
app,
|
|
options: {},
|
|
agent: {
|
|
get: (input) => {
|
|
const ref = locationRef(input)
|
|
const output =
|
|
ref && !isCurrentLocation(ref)
|
|
? runtime.location.agent
|
|
.list(ref)
|
|
.pipe(Effect.map((result) => ({ ...result, data: result.data.find((agent) => agent.id === input.agentID) })))
|
|
: response(agents.get(input.agentID))
|
|
return output.pipe(
|
|
Effect.flatMap((result) =>
|
|
result.data
|
|
? Effect.succeed({ ...result, data: result.data })
|
|
: Effect.fail(new Error(`Agent not found: ${input.agentID}`)),
|
|
),
|
|
)
|
|
},
|
|
list: (input) => {
|
|
const ref = locationRef(input)
|
|
if (ref && !isCurrentLocation(ref)) return runtime.location.agent.list(ref)
|
|
return agents.list().pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
|
},
|
|
reload: agents.reload,
|
|
transform: (callback) =>
|
|
agents.transform((draft) => {
|
|
callback({
|
|
list: () => mutable(draft.list()),
|
|
get: (id) => mutable(draft.get(Agent.ID.make(id))),
|
|
default: (id) => draft.default(id === undefined ? undefined : Agent.ID.make(id)),
|
|
update: (id, update) => draft.update(Agent.ID.make(id), update),
|
|
remove: (id) => draft.remove(Agent.ID.make(id)),
|
|
})
|
|
}),
|
|
},
|
|
aisdk: {
|
|
hook: (name, callback) => {
|
|
if (name === "sdk") {
|
|
return aisdk.hook.sdk((event) => {
|
|
const output = {
|
|
model: mutable(event.model),
|
|
package: event.package,
|
|
options: event.options,
|
|
sdk: event.sdk,
|
|
}
|
|
return Reflect.apply(callback, undefined, [output]).pipe(
|
|
Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))),
|
|
)
|
|
})
|
|
}
|
|
return aisdk.hook.language((event) => {
|
|
const output = {
|
|
model: mutable(event.model),
|
|
options: event.options,
|
|
sdk: event.sdk,
|
|
language: event.language,
|
|
}
|
|
return Reflect.apply(callback, undefined, [output]).pipe(
|
|
Effect.tap(() => Effect.sync(() => (event.language = output.language))),
|
|
)
|
|
})
|
|
},
|
|
},
|
|
catalog: {
|
|
provider: {
|
|
list: () => response(catalog.provider.available()),
|
|
get: (input) =>
|
|
catalog.provider
|
|
.get(Provider.ID.make(input.providerID))
|
|
.pipe(
|
|
Effect.flatMap((provider) =>
|
|
provider === undefined
|
|
? Effect.fail(new Error(`Provider not found: ${input.providerID}`))
|
|
: response(Effect.succeed(provider)),
|
|
),
|
|
),
|
|
},
|
|
model: {
|
|
list: () => response(catalog.model.available()),
|
|
default: () => response(catalog.model.default()),
|
|
},
|
|
reload: catalog.reload,
|
|
transform: (callback) =>
|
|
catalog.transform((draft) => {
|
|
callback({
|
|
provider: {
|
|
list: () => mutable(draft.provider.list()),
|
|
get: (id) => mutable(draft.provider.get(Provider.ID.make(id))),
|
|
update: (id, update) => draft.provider.update(Provider.ID.make(id), update),
|
|
remove: (id) => draft.provider.remove(Provider.ID.make(id)),
|
|
},
|
|
model: {
|
|
get: (providerID, modelID) =>
|
|
mutable(draft.model.get(Provider.ID.make(providerID), Model.ID.make(modelID))),
|
|
update: (providerID, modelID, update) =>
|
|
draft.model.update(Provider.ID.make(providerID), Model.ID.make(modelID), update),
|
|
remove: (providerID, modelID) =>
|
|
draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)),
|
|
default: {
|
|
get: draft.model.default.get,
|
|
set: (providerID, modelID) =>
|
|
draft.model.default.set(Provider.ID.make(providerID), Model.ID.make(modelID)),
|
|
},
|
|
},
|
|
})
|
|
}),
|
|
},
|
|
command: {
|
|
list: () => response(commands.list()),
|
|
reload: commands.reload,
|
|
transform: (callback) =>
|
|
commands.transform((draft) => {
|
|
callback(draft)
|
|
}),
|
|
},
|
|
event: {
|
|
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
|
|
},
|
|
integration: {
|
|
list: () => response(integration.list()),
|
|
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
|
|
connect: {
|
|
key: (input) =>
|
|
integration.connection.key({
|
|
integrationID: Integration.ID.make(input.integrationID),
|
|
key: input.key,
|
|
label: input.label,
|
|
}),
|
|
},
|
|
oauth: {
|
|
connect: (input) =>
|
|
response(
|
|
integration.oauth.connect({
|
|
integrationID: Integration.ID.make(input.integrationID),
|
|
methodID: Integration.MethodID.make(input.methodID),
|
|
inputs: input.inputs,
|
|
label: input.label,
|
|
}),
|
|
),
|
|
status: (input) =>
|
|
response(
|
|
integration.oauth.status({
|
|
integrationID: Integration.ID.make(input.integrationID),
|
|
attemptID: Integration.AttemptID.make(input.attemptID),
|
|
}),
|
|
),
|
|
complete: (input) =>
|
|
integration.oauth.complete({
|
|
integrationID: Integration.ID.make(input.integrationID),
|
|
attemptID: Integration.AttemptID.make(input.attemptID),
|
|
code: input.code,
|
|
}),
|
|
cancel: (input) =>
|
|
integration.oauth.cancel({
|
|
integrationID: Integration.ID.make(input.integrationID),
|
|
attemptID: Integration.AttemptID.make(input.attemptID),
|
|
}),
|
|
},
|
|
command: {
|
|
connect: (input) =>
|
|
response(
|
|
integration.command.connect({
|
|
integrationID: Integration.ID.make(input.integrationID),
|
|
methodID: Integration.MethodID.make(input.methodID),
|
|
label: input.label,
|
|
}),
|
|
),
|
|
status: (input) =>
|
|
response(
|
|
integration.command.status({
|
|
integrationID: Integration.ID.make(input.integrationID),
|
|
attemptID: Integration.AttemptID.make(input.attemptID),
|
|
}),
|
|
),
|
|
cancel: (input) =>
|
|
integration.command.cancel({
|
|
integrationID: Integration.ID.make(input.integrationID),
|
|
attemptID: Integration.AttemptID.make(input.attemptID),
|
|
}),
|
|
},
|
|
reload: integration.reload,
|
|
connection: {
|
|
active: (id) => integration.connection.active(Integration.ID.make(id)),
|
|
resolve: (connection) =>
|
|
integration.connection.resolve(
|
|
connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
|
|
),
|
|
},
|
|
transform: (callback) =>
|
|
integration.transform((draft) => {
|
|
callback({
|
|
list: () => mutable(draft.list()),
|
|
get: (id) => mutable(draft.get(Integration.ID.make(id))),
|
|
update: (id, update) => draft.update(Integration.ID.make(id), update),
|
|
remove: (id) => draft.remove(Integration.ID.make(id)),
|
|
method: {
|
|
list: (id) => mutable(draft.method.list(Integration.ID.make(id))),
|
|
update: (input) => draft.method.update(methodImplementation(input)),
|
|
remove: (id, method) =>
|
|
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
|
|
},
|
|
})
|
|
}),
|
|
},
|
|
plugin: {
|
|
list: () => response(plugin.list()),
|
|
},
|
|
reference: {
|
|
list: () => response(reference.list()),
|
|
reload: reference.reload,
|
|
transform: (callback) =>
|
|
reference.transform((draft) => {
|
|
callback({
|
|
add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
|
|
remove: draft.remove,
|
|
list: draft.list,
|
|
})
|
|
}),
|
|
},
|
|
skill: {
|
|
list: () => response(skill.list()),
|
|
reload: skill.reload,
|
|
transform: (callback) =>
|
|
skill.transform((draft) => {
|
|
callback({
|
|
source: (source) => draft.source(Schema.decodeUnknownSync(Skill.Source)(source)),
|
|
list: draft.list,
|
|
})
|
|
}),
|
|
},
|
|
shell: {
|
|
hook: (name, callback) => hooks.register("shell", name, callback),
|
|
},
|
|
tool: {
|
|
transform: (callback) =>
|
|
tools
|
|
.transform((draft) =>
|
|
callback({
|
|
add: (tool) => draft.add(tool),
|
|
}),
|
|
)
|
|
.pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
|
|
hook: (name, callback) => hooks.register("tool", name, callback),
|
|
},
|
|
websearch: {
|
|
providers: () => response(websearch.providers()),
|
|
query: (input) =>
|
|
response(
|
|
websearch.query({
|
|
query: input.query,
|
|
providerID: input.providerID === undefined ? undefined : WebSearch.ID.make(input.providerID),
|
|
}),
|
|
),
|
|
reload: websearch.reload,
|
|
transform: (callback) =>
|
|
websearch.transform((draft) => {
|
|
callback({
|
|
add: (definition) =>
|
|
draft.add({
|
|
id: WebSearch.ID.make(definition.id),
|
|
name: definition.name,
|
|
execute: definition.execute,
|
|
}),
|
|
default: {
|
|
get: draft.default.get,
|
|
set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)),
|
|
},
|
|
})
|
|
}),
|
|
},
|
|
session: {
|
|
hook: (name, callback) => hooks.register("session", name, callback),
|
|
create: (input) =>
|
|
runtime.session.create({
|
|
id: input?.id,
|
|
agent: input?.agent,
|
|
model: input?.model,
|
|
location:
|
|
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
|
}),
|
|
get: (input) => runtime.session.get(input.sessionID),
|
|
prompt: runtime.session.prompt,
|
|
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
|
|
command: runtime.session.command,
|
|
synthetic: runtime.session.synthetic,
|
|
interrupt: (input) => runtime.session.interrupt(input.sessionID),
|
|
},
|
|
} satisfies Plugin.Context
|
|
})
|
|
|
|
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {
|
|
if ("authorize" in input) {
|
|
const refresh = input.refresh
|
|
return {
|
|
integrationID: Integration.ID.make(input.integrationID),
|
|
method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
|
|
authorize: (inputs) =>
|
|
input.authorize(inputs).pipe(
|
|
Effect.map((authorization) => {
|
|
if (authorization.mode === "auto") {
|
|
return {
|
|
...authorization,
|
|
callback: authorization.callback.pipe(Effect.map(credential)),
|
|
}
|
|
}
|
|
return {
|
|
...authorization,
|
|
callback: (code: string) => authorization.callback(code).pipe(Effect.map(credential)),
|
|
}
|
|
}),
|
|
),
|
|
...(refresh ? { refresh: (value: Credential.OAuth) => refresh(value).pipe(Effect.map(credential)) } : {}),
|
|
...(input.label ? { label: input.label } : {}),
|
|
}
|
|
}
|
|
if (input.method.type === "env") {
|
|
return {
|
|
integrationID: Integration.ID.make(input.integrationID),
|
|
method: { type: "env", names: input.method.names },
|
|
}
|
|
}
|
|
if (input.method.type === "command") {
|
|
return {
|
|
integrationID: Integration.ID.make(input.integrationID),
|
|
method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method),
|
|
}
|
|
}
|
|
return {
|
|
integrationID: Integration.ID.make(input.integrationID),
|
|
method: { type: "key", label: input.method.label },
|
|
}
|
|
}
|
|
|
|
function credential(value: CredentialOAuth) {
|
|
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
|
|
}
|