skill.ts 4.6 KB

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