| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170 |
- export * as SkillV2 from "./skill"
- import path from "path"
- import { Context, Effect, Layer, Schema } from "effect"
- import { castDraft } from "immer"
- import { AgentV2 } from "./agent"
- import { ConfigMarkdown } from "./config/markdown"
- import { FSUtil } from "./fs-util"
- import { PermissionV2 } from "./permission"
- import { AbsolutePath, withStatics } from "./schema"
- import { SkillDiscovery } from "./skill/discovery"
- import { State } from "./state"
- export class DirectorySource extends Schema.Class<DirectorySource>("SkillV2.DirectorySource")({
- type: Schema.Literal("directory"),
- path: AbsolutePath,
- }) {}
- export class UrlSource extends Schema.Class<UrlSource>("SkillV2.UrlSource")({
- type: Schema.Literal("url"),
- url: Schema.String,
- }) {}
- export class EmbeddedSource extends Schema.Class<EmbeddedSource>("SkillV2.EmbeddedSource")({
- type: Schema.Literal("embedded"),
- skill: Schema.suspend(() => Info),
- }) {}
- export const Source = Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe(
- Schema.toTaggedUnion("type"),
- withStatics(() => ({
- equals: (a: DirectorySource | UrlSource | EmbeddedSource, b: DirectorySource | UrlSource | EmbeddedSource) => {
- if (a.type !== b.type) return false
- if (a.type === "directory" && b.type === "directory") return a.path === b.path
- if (a.type === "url" && b.type === "url") return a.url === b.url
- if (a.type === "embedded" && b.type === "embedded") return a.skill.name === b.skill.name
- return false
- },
- key: (source: DirectorySource | UrlSource | EmbeddedSource) =>
- source.type === "directory"
- ? `directory:${source.path}`
- : source.type === "url"
- ? `url:${source.url}`
- : `embedded:${source.skill.name}`,
- })),
- )
- export type Source = typeof Source.Type
- export class Info extends Schema.Class<Info>("SkillV2.Info")({
- name: Schema.String,
- description: Schema.String.pipe(Schema.optional),
- slash: Schema.Boolean.pipe(Schema.optional),
- location: AbsolutePath,
- content: Schema.String,
- }) {}
- const Frontmatter = Schema.Struct({
- name: Schema.String.pipe(Schema.optional),
- description: Schema.String.pipe(Schema.optional),
- slash: Schema.Boolean.pipe(Schema.optional),
- })
- const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter)
- export type Data = {
- sources: Source[]
- }
- export type Editor = {
- source: (source: Source) => void
- list: () => readonly Source[]
- }
- export interface Interface {
- readonly transform: State.Interface<Data, Editor>["transform"]
- readonly sources: () => Effect.Effect<Source[]>
- readonly list: () => Effect.Effect<Info[]>
- readonly forAgent: (agent: AgentV2.ID) => Effect.Effect<Info[]>
- }
- export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Skill") {}
- export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const agent = yield* AgentV2.Service
- const discovery = yield* SkillDiscovery.Service
- const fs = yield* FSUtil.Service
- const state = State.create<Data, Editor>({
- initial: () => ({ sources: [] }),
- editor: (draft) => ({
- source: (source) => {
- if (draft.sources.some((item) => Source.equals(item, source))) return
- draft.sources.push(castDraft(source))
- },
- list: () => draft.sources as Source[],
- }),
- })
- const load = Effect.fn("SkillV2.load")(function* (source: Source) {
- const skills: Info[] = []
- if (source.type === "embedded") return [source.skill]
- const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
- for (const directory of directories) {
- const files = yield* fs
- .glob("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
- .pipe(Effect.catch(() => Effect.succeed([] as string[])))
- for (const filepath of files.toSorted()) {
- const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
- if (!content) continue
- const markdown = ConfigMarkdown.parseOption(content)
- if (!markdown) continue
- const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined
- if (!frontmatter) continue
- const name =
- frontmatter.name !== undefined
- ? frontmatter.name
- : path.dirname(filepath) === directory
- ? path.basename(filepath, ".md")
- : undefined
- if (!name) continue
- skills.push(
- new Info({
- name,
- description: frontmatter.description,
- slash: frontmatter.slash,
- location: AbsolutePath.make(filepath),
- content: markdown.content,
- }),
- )
- }
- }
- return skills
- })
- // QUESTION(Dax): Should local skill sources invalidate on filesystem watch
- // events, following the reload policy chosen for other context sources?
- const cache = new Map<string, Info[]>()
- const list = Effect.fn("SkillV2.list")(function* () {
- const skills = new Map<string, Info>()
- for (const source of state.get().sources) {
- const key = Source.key(source)
- const loaded = cache.get(key) ?? (yield* load(source))
- cache.set(key, loaded)
- for (const skill of loaded) skills.set(skill.name, skill)
- }
- return Array.from(skills.values())
- })
- return Service.of({
- transform: state.transform,
- sources: Effect.fn("SkillV2.sources")(function* () {
- return state.get().sources
- }),
- list,
- forAgent: Effect.fn("SkillV2.forAgent")(function* (id) {
- const current = yield* agent.get(id)
- if (!current) return []
- return (yield* list()).filter(
- (skill) => PermissionV2.evaluate("skill", skill.name, current.permissions).effect !== "deny",
- )
- }),
- })
- }),
- )
- export const locationLayer = layer.pipe(
- Layer.provide(SkillDiscovery.defaultLayer),
- Layer.provideMerge(AgentV2.locationLayer),
- )
|