skill.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. export * as SkillV2 from "./skill"
  2. import path from "path"
  3. import { Context, Effect, Layer, Schema } from "effect"
  4. import { castDraft } from "immer"
  5. import { AgentV2 } from "./agent"
  6. import { ConfigMarkdown } from "./config/markdown"
  7. import { FSUtil } from "./fs-util"
  8. import { PermissionV2 } from "./permission"
  9. import { AbsolutePath, withStatics } from "./schema"
  10. import { SkillDiscovery } from "./skill/discovery"
  11. import { State } from "./state"
  12. export class DirectorySource extends Schema.Class<DirectorySource>("SkillV2.DirectorySource")({
  13. type: Schema.Literal("directory"),
  14. path: AbsolutePath,
  15. }) {}
  16. export class UrlSource extends Schema.Class<UrlSource>("SkillV2.UrlSource")({
  17. type: Schema.Literal("url"),
  18. url: Schema.String,
  19. }) {}
  20. export class EmbeddedSource extends Schema.Class<EmbeddedSource>("SkillV2.EmbeddedSource")({
  21. type: Schema.Literal("embedded"),
  22. skill: Schema.suspend(() => Info),
  23. }) {}
  24. export const Source = Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe(
  25. Schema.toTaggedUnion("type"),
  26. withStatics(() => ({
  27. equals: (a: DirectorySource | UrlSource | EmbeddedSource, b: DirectorySource | UrlSource | EmbeddedSource) => {
  28. if (a.type !== b.type) return false
  29. if (a.type === "directory" && b.type === "directory") return a.path === b.path
  30. if (a.type === "url" && b.type === "url") return a.url === b.url
  31. if (a.type === "embedded" && b.type === "embedded") return a.skill.name === b.skill.name
  32. return false
  33. },
  34. key: (source: DirectorySource | UrlSource | EmbeddedSource) =>
  35. source.type === "directory"
  36. ? `directory:${source.path}`
  37. : source.type === "url"
  38. ? `url:${source.url}`
  39. : `embedded:${source.skill.name}`,
  40. })),
  41. )
  42. export type Source = typeof Source.Type
  43. export class Info extends Schema.Class<Info>("SkillV2.Info")({
  44. name: Schema.String,
  45. description: Schema.String.pipe(Schema.optional),
  46. slash: Schema.Boolean.pipe(Schema.optional),
  47. location: AbsolutePath,
  48. content: Schema.String,
  49. }) {}
  50. const Frontmatter = Schema.Struct({
  51. name: Schema.String.pipe(Schema.optional),
  52. description: Schema.String.pipe(Schema.optional),
  53. slash: Schema.Boolean.pipe(Schema.optional),
  54. })
  55. const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter)
  56. export type Data = {
  57. sources: Source[]
  58. }
  59. export type Editor = {
  60. source: (source: Source) => void
  61. list: () => readonly Source[]
  62. }
  63. export interface Interface {
  64. readonly transform: State.Interface<Data, Editor>["transform"]
  65. readonly sources: () => Effect.Effect<Source[]>
  66. readonly list: () => Effect.Effect<Info[]>
  67. readonly forAgent: (agent: AgentV2.ID) => Effect.Effect<Info[]>
  68. }
  69. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Skill") {}
  70. export const layer = Layer.effect(
  71. Service,
  72. Effect.gen(function* () {
  73. const agent = yield* AgentV2.Service
  74. const discovery = yield* SkillDiscovery.Service
  75. const fs = yield* FSUtil.Service
  76. const state = State.create<Data, Editor>({
  77. initial: () => ({ sources: [] }),
  78. editor: (draft) => ({
  79. source: (source) => {
  80. if (draft.sources.some((item) => Source.equals(item, source))) return
  81. draft.sources.push(castDraft(source))
  82. },
  83. list: () => draft.sources as Source[],
  84. }),
  85. })
  86. const load = Effect.fn("SkillV2.load")(function* (source: Source) {
  87. const skills: Info[] = []
  88. if (source.type === "embedded") return [source.skill]
  89. const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
  90. for (const directory of directories) {
  91. const files = yield* fs
  92. .glob("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
  93. .pipe(Effect.catch(() => Effect.succeed([] as string[])))
  94. for (const filepath of files.toSorted()) {
  95. const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
  96. if (!content) continue
  97. const markdown = ConfigMarkdown.parseOption(content)
  98. if (!markdown) continue
  99. const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined
  100. if (!frontmatter) continue
  101. const name =
  102. frontmatter.name !== undefined
  103. ? frontmatter.name
  104. : path.dirname(filepath) === directory
  105. ? path.basename(filepath, ".md")
  106. : undefined
  107. if (!name) continue
  108. skills.push(
  109. new Info({
  110. name,
  111. description: frontmatter.description,
  112. slash: frontmatter.slash,
  113. location: AbsolutePath.make(filepath),
  114. content: markdown.content,
  115. }),
  116. )
  117. }
  118. }
  119. return skills
  120. })
  121. // QUESTION(Dax): Should local skill sources invalidate on filesystem watch
  122. // events, following the reload policy chosen for other context sources?
  123. const cache = new Map<string, Info[]>()
  124. const list = Effect.fn("SkillV2.list")(function* () {
  125. const skills = new Map<string, Info>()
  126. for (const source of state.get().sources) {
  127. const key = Source.key(source)
  128. const loaded = cache.get(key) ?? (yield* load(source))
  129. cache.set(key, loaded)
  130. for (const skill of loaded) skills.set(skill.name, skill)
  131. }
  132. return Array.from(skills.values())
  133. })
  134. return Service.of({
  135. transform: state.transform,
  136. sources: Effect.fn("SkillV2.sources")(function* () {
  137. return state.get().sources
  138. }),
  139. list,
  140. forAgent: Effect.fn("SkillV2.forAgent")(function* (id) {
  141. const current = yield* agent.get(id)
  142. if (!current) return []
  143. return (yield* list()).filter(
  144. (skill) => PermissionV2.evaluate("skill", skill.name, current.permissions).effect !== "deny",
  145. )
  146. }),
  147. })
  148. }),
  149. )
  150. export const locationLayer = layer.pipe(
  151. Layer.provide(SkillDiscovery.defaultLayer),
  152. Layer.provideMerge(AgentV2.locationLayer),
  153. )