skill.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. export * as SkillV2 from "./skill"
  2. import path from "path"
  3. import { Context, Effect, Layer, Schema, Types } from "effect"
  4. import { Skill } from "@opencode-ai/schema/skill"
  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 } from "./schema"
  10. import { SkillDiscovery } from "./skill/discovery"
  11. import { State } from "./state"
  12. export const DirectorySource = Skill.DirectorySource
  13. export type DirectorySource = Skill.DirectorySource
  14. export const UrlSource = Skill.UrlSource
  15. export type UrlSource = Skill.UrlSource
  16. export const EmbeddedSource = Skill.EmbeddedSource
  17. export type EmbeddedSource = Skill.EmbeddedSource
  18. export const Source = Skill.Source
  19. export type Source = typeof Source.Type
  20. export const Info = Skill.Info
  21. export type Info = Skill.Info
  22. export const available = (skills: ReadonlyArray<Info>, agent: AgentV2.Info) =>
  23. skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny")
  24. const Frontmatter = Schema.Struct({
  25. name: Schema.String.pipe(Schema.optional),
  26. description: Schema.String.pipe(Schema.optional),
  27. slash: Schema.Boolean.pipe(Schema.optional),
  28. })
  29. const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter)
  30. export type Data = {
  31. sources: Types.DeepMutable<Source>[]
  32. }
  33. export type Draft = {
  34. source: (source: Source) => void
  35. list: () => readonly Source[]
  36. }
  37. export interface Interface extends State.Transformable<Draft> {
  38. readonly sources: () => Effect.Effect<Source[]>
  39. readonly list: () => Effect.Effect<Info[]>
  40. }
  41. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Skill") {}
  42. export const layer = Layer.effect(
  43. Service,
  44. Effect.gen(function* () {
  45. const discovery = yield* SkillDiscovery.Service
  46. const fs = yield* FSUtil.Service
  47. const state = State.create<Data, Draft>({
  48. initial: () => ({ sources: [] }),
  49. draft: (draft) => ({
  50. source: (source) => {
  51. if (draft.sources.some((item) => Source.equals(item, source))) return
  52. draft.sources.push(source as Types.DeepMutable<Source>)
  53. },
  54. list: () => draft.sources as Source[],
  55. }),
  56. })
  57. const load = Effect.fn("SkillV2.load")(function* (source: Source) {
  58. const skills: Info[] = []
  59. if (source.type === "embedded") return [source.skill]
  60. const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
  61. for (const directory of directories) {
  62. const files = yield* fs
  63. .glob("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
  64. .pipe(Effect.catch(() => Effect.succeed([] as string[])))
  65. for (const filepath of files.toSorted()) {
  66. const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
  67. if (!content) continue
  68. const markdown = ConfigMarkdown.parseOption(content)
  69. if (!markdown) continue
  70. const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined
  71. if (!frontmatter) continue
  72. const name =
  73. frontmatter.name !== undefined
  74. ? frontmatter.name
  75. : path.dirname(filepath) === directory
  76. ? path.basename(filepath, ".md")
  77. : undefined
  78. if (!name) continue
  79. skills.push({
  80. name,
  81. description: frontmatter.description,
  82. slash: frontmatter.slash,
  83. location: AbsolutePath.make(filepath),
  84. content: markdown.content,
  85. })
  86. }
  87. }
  88. return skills
  89. })
  90. // QUESTION(Dax): Should local skill sources invalidate on filesystem watch
  91. // events, following the reload policy chosen for other context sources?
  92. const cache = new Map<string, Info[]>()
  93. const list = Effect.fn("SkillV2.list")(function* () {
  94. const skills = new Map<string, Info>()
  95. for (const source of state.get().sources) {
  96. const key = Source.key(source)
  97. const loaded = cache.get(key) ?? (yield* load(source))
  98. cache.set(key, loaded)
  99. for (const skill of loaded) skills.set(skill.name, skill)
  100. }
  101. return Array.from(skills.values())
  102. })
  103. return Service.of({
  104. transform: state.transform,
  105. reload: state.reload,
  106. sources: Effect.fn("SkillV2.sources")(function* () {
  107. return state.get().sources
  108. }),
  109. list,
  110. })
  111. }),
  112. )
  113. export const locationLayer = layer.pipe(Layer.provide(SkillDiscovery.defaultLayer))