feat(core): add skill registry and file agent loading (#30617)
This commit is contained in:
parent
9991a33e3f
commit
889e0f9545
22 changed files with 881 additions and 189 deletions
155
packages/core/src/skill.ts
Normal file
155
packages/core/src/skill.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
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 const Source = Schema.Union([DirectorySource, UrlSource]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
withStatics(() => ({
|
||||
equals: (a: DirectorySource | UrlSource, b: DirectorySource | UrlSource) => {
|
||||
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
|
||||
return false
|
||||
},
|
||||
key: (source: DirectorySource | UrlSource) =>
|
||||
source.type === "directory" ? `directory:${source.path}` : `url:${source.url}`,
|
||||
})),
|
||||
)
|
||||
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[] = []
|
||||
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
|
||||
})
|
||||
|
||||
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),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue