plugin.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. export * as PluginV2 from "./plugin"
  2. import { createDraft, finishDraft, type Draft } from "immer"
  3. import type { LanguageModelV3 } from "@ai-sdk/provider"
  4. import { Context, Effect, Exit, Layer, Schema, Scope } from "effect"
  5. import type { ModelV2 } from "./model"
  6. import type { Catalog } from "./catalog"
  7. import { EventV2 } from "./event"
  8. import { KeyedMutex } from "./effect/keyed-mutex"
  9. import { State } from "./state"
  10. export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
  11. export type ID = typeof ID.Type
  12. export const Event = {
  13. Added: EventV2.define({
  14. type: "plugin.added",
  15. schema: {
  16. id: ID,
  17. },
  18. }),
  19. }
  20. type HookSpec = {
  21. "catalog.transform": {
  22. input: Catalog.Draft
  23. output: {}
  24. }
  25. "aisdk.language": {
  26. input: {
  27. model: ModelV2.Info
  28. sdk: any
  29. options: Record<string, any>
  30. }
  31. output: {
  32. language?: LanguageModelV3
  33. }
  34. }
  35. "aisdk.sdk": {
  36. input: {
  37. model: ModelV2.Info
  38. package: string
  39. options: Record<string, any>
  40. }
  41. output: {
  42. sdk?: any
  43. }
  44. }
  45. }
  46. export type Hooks = {
  47. [Name in keyof HookSpec]: Readonly<HookSpec[Name]["input"]> & {
  48. -readonly [Field in keyof HookSpec[Name]["output"]]: HookSpec[Name]["output"][Field] extends object
  49. ? Draft<HookSpec[Name]["output"][Field]>
  50. : HookSpec[Name]["output"][Field]
  51. }
  52. }
  53. export type HookFunctions = {
  54. [key in keyof Hooks]?: (input: Hooks[key]) => Effect.Effect<void>
  55. }
  56. export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"]
  57. export type HookOutput<Name extends keyof Hooks> = HookSpec[Name]["output"]
  58. export interface Interface {
  59. readonly add: (input: {
  60. id: string
  61. effect: Effect.Effect<void | HookFunctions, never, Scope.Scope>
  62. }) => Effect.Effect<void, never, never>
  63. readonly remove: (id: ID) => Effect.Effect<void>
  64. readonly hook: <Name extends keyof Hooks>(
  65. name: Name,
  66. callback: (input: Hooks[Name]) => Effect.Effect<void> | void,
  67. ) => Effect.Effect<State.Registration, never, Scope.Scope>
  68. readonly triggerFor: <Name extends keyof Hooks>(
  69. id: ID,
  70. name: Name,
  71. input: HookInput<Name>,
  72. output: HookOutput<Name>,
  73. ) => Effect.Effect<HookInput<Name> & HookOutput<Name>>
  74. readonly trigger: <Name extends keyof Hooks>(
  75. name: Name,
  76. input: HookInput<Name>,
  77. output: HookOutput<Name>,
  78. ) => Effect.Effect<HookInput<Name> & HookOutput<Name>>
  79. }
  80. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {}
  81. export const layer = Layer.effect(
  82. Service,
  83. Effect.gen(function* () {
  84. let hooks: {
  85. id: ID
  86. hooks: HookFunctions
  87. scope: Scope.Closeable
  88. }[] = []
  89. let registrations: {
  90. [Name in keyof Hooks]: {
  91. name: Name
  92. callback: (input: Hooks[Name]) => Effect.Effect<void> | void
  93. }
  94. }[keyof Hooks][] = []
  95. const events = yield* EventV2.Service
  96. const locks = KeyedMutex.makeUnsafe<ID>()
  97. const scope = yield* Scope.make()
  98. // One registry-owned scope lets shutdown remove every plugin transform in one batch.
  99. yield* Effect.addFinalizer((exit) =>
  100. Effect.gen(function* () {
  101. hooks = []
  102. yield* State.batch(Scope.close(scope, exit))
  103. }),
  104. )
  105. const svc = Service.of({
  106. add: Effect.fn("Plugin.add")(function* (input) {
  107. const id = ID.make(input.id)
  108. yield* locks.withLock(id)(
  109. Effect.gen(function* () {
  110. const existing = hooks.find((item) => item.id === id)
  111. if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore)
  112. const childScope = yield* Scope.fork(scope)
  113. const result = yield* input.effect.pipe(
  114. Scope.provide(childScope),
  115. Effect.withSpan("Plugin.load", {
  116. attributes: {
  117. "plugin.id": id,
  118. },
  119. }),
  120. Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)),
  121. )
  122. const next = {
  123. id,
  124. hooks: result ?? {},
  125. scope: childScope,
  126. }
  127. hooks = existing ? hooks.with(hooks.indexOf(existing), next) : [...hooks, next]
  128. yield* events.publish(Event.Added, { id })
  129. }),
  130. )
  131. }),
  132. trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) {
  133. return yield* svc.triggerFor(ID.make("*"), name, input, output)
  134. }),
  135. triggerFor: Effect.fn("Plugin.triggerFor")(function* (id, name, input, output) {
  136. const draftEntries = new Map<string, ReturnType<typeof createDraft>>()
  137. const event = {
  138. ...input,
  139. ...output,
  140. } as Record<string, unknown>
  141. for (const [field, value] of Object.entries(output)) {
  142. if (value && typeof value === "object") {
  143. draftEntries.set(field, createDraft(value))
  144. event[field] = draftEntries.get(field)
  145. }
  146. }
  147. for (const item of hooks) {
  148. if (id !== ID.make("*") && item.id !== id) continue
  149. const match = item.hooks[name]
  150. if (!match) continue
  151. yield* match(event as any).pipe(
  152. Effect.withSpan(`Plugin.hook.${name}`, {
  153. attributes: {
  154. plugin: item.id,
  155. hook: name,
  156. },
  157. }),
  158. )
  159. }
  160. for (const item of registrations) {
  161. if (item.name !== name) continue
  162. const result = item.callback(event as never)
  163. if (Effect.isEffect(result)) yield* result
  164. }
  165. for (const [field, draft] of draftEntries) {
  166. event[field] = finishDraft(draft)
  167. }
  168. return event as any
  169. }),
  170. remove: Effect.fn("Plugin.remove")(function* (id) {
  171. yield* locks.withLock(id)(
  172. Effect.gen(function* () {
  173. const existing = hooks.find((item) => item.id === id)
  174. hooks = hooks.filter((item) => item.id !== id)
  175. if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore)
  176. }),
  177. )
  178. }),
  179. hook: Effect.fn("Plugin.hook")(function* (name, callback) {
  180. const scope = yield* Scope.Scope
  181. const registration = { name, callback } as (typeof registrations)[number]
  182. let active = true
  183. registrations = [...registrations, registration]
  184. const dispose = Effect.sync(() => {
  185. if (!active) return
  186. active = false
  187. registrations = registrations.filter((item) => item !== registration)
  188. })
  189. yield* Scope.addFinalizer(scope, dispose)
  190. return { dispose }
  191. }),
  192. })
  193. return svc
  194. }),
  195. )
  196. export const locationLayer = layer
  197. // opencode
  198. // sdcok