package skills import ( "fmt" "io" "log/slog" "os" "path/filepath" "sort" "strings" "gopkg.in/yaml.v3" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // 技能定义的磁盘文件名(目录式)。目录式优先于单文件式(design/09 §6.1)。 const skillDefinitionFile = "skill.yaml" // 目录式技能除 skill.yaml 之外的文件(支持文件)不由加载器读入 —— 它们只作 // 只读引用素材,由调用方按 Loaded.Dir 解析(design/09 §6.5)。 // Loader 从 YAML 加载技能定义:单流(Load)、单文件(LoadFile)、或 // configs/skills 目录(LoadDir / LoadDirDetailed)。它只读盘、不落库:需要 // 持久化走 Store,需要发布走 Register。 type Loader struct { log *slog.Logger } // NewLoader 构造 Loader。log 可以为 nil。 func NewLoader(log *slog.Logger) *Loader { return &Loader{log: sourcedLog(log)} } // skillYAML 是技能定义的磁盘 YAML 形状(design/09 §6.2 定稿字段)。 type skillYAML struct { Name string `yaml:"name"` Description string `yaml:"description"` Prompt string `yaml:"prompt"` Steps []yamlStep `yaml:"steps"` Refs []yamlRef `yaml:"refs"` Guardrails []yamlGuardrail `yaml:"guardrails"` // 以下为 design/09 §6.2 新增字段。字符串键一律小写连字符,对齐 Agent Skills // 的既有惯例。枚举先按字符串收下,再经 sdk.Parse* 校验(非法值直接拒)。 Version int `yaml:"version"` WhenToUse string `yaml:"when-to-use"` Invocation string `yaml:"invocation"` Triggers []string `yaml:"triggers"` Paths []string `yaml:"paths"` AllowedTools []string `yaml:"allowed-tools"` DisallowedTools []string `yaml:"disallowed-tools"` Context string `yaml:"context"` Agent string `yaml:"agent"` Model string `yaml:"model"` Effort string `yaml:"effort"` Metadata map[string]string `yaml:"metadata"` } type yamlStep struct { Name string `yaml:"name"` Prompt string `yaml:"prompt"` Depends []string `yaml:"depends"` } type yamlRef struct { Name string `yaml:"name"` Type string `yaml:"type"` Value string `yaml:"value"` Optional bool `yaml:"optional"` } type yamlGuardrail struct { ID string `yaml:"id"` Message string `yaml:"message"` Enforced bool `yaml:"enforced"` } // Loaded 是一份技能定义及其磁盘来源。Dir 让调用方能够解析目录式技能的**支持 // 文件**(只读引用素材)。 type Loaded struct { // ID 是磁盘上的技能 id(目录名,或单文件去掉扩展名的文件名)。 ID string // Dir 是技能目录(目录式)或定义文件所在目录(单文件式)。 Dir string // File 是实际读到的定义文件路径。 File string // Skill 是解析并校验后的技能。 Skill *Skill } // Load 从 r 读一份技能定义。名字为空返回 ErrInvalidSkill;YAML 解码失败返回 // 解码错误;结构约束不满足返回 Validate 的可分类错误(ErrStepDependsMissing / // ErrStepCycle / ErrToolConflict / ...)。 func (l *Loader) Load(r io.Reader) (*Skill, error) { if r == nil { return nil, fmt.Errorf("skills: loader: load: %w", ErrInvalidSkill) } var doc skillYAML if err := yaml.NewDecoder(r).Decode(&doc); err != nil { return nil, fmt.Errorf("skills: loader: decode: %w", err) } skill, err := doc.toSkill() if err != nil { return nil, err } if err := Validate(skill); err != nil { return nil, fmt.Errorf("skills: loader: %w", err) } l.log.Info("skill loaded", "skill", skill.Name(), "steps", len(skill.Steps()), "invocation", string(skill.Invocation())) return skill, nil } // LoadFile 加载 path 指向的单份技能定义文件。 func (l *Loader) LoadFile(path string) (*Skill, error) { f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("skills: loader: open %q: %w", path, err) } defer f.Close() skill, err := l.Load(f) if err != nil { return nil, fmt.Errorf("skills: loader: file %q: %w", path, err) } return skill, nil } // LoadDir 加载 dir 下的全部技能定义,返回解析并校验后的技能(按技能 id 升序)。 // 任一文件解码/校验失败即整体失败(fail fast),绝不半加载。 func (l *Loader) LoadDir(dir string) ([]*Skill, error) { loaded, err := l.LoadDirDetailed(dir) if err != nil { return nil, err } out := make([]*Skill, 0, len(loaded)) for _, item := range loaded { out = append(out, item.Skill) } return out, nil } // LoadDirDetailed 与 LoadDir 相同,但额外返回每份定义的磁盘来源(id / 目录 / // 文件),供调用方解析目录式技能的支持文件。 // // 解析顺序(design/09 §6.1「先目录、后文件」): // // - 子目录 / 含 skill.yaml(或 skill.yml)⇒ 目录式,id = 目录名; // - .yaml / .yml ⇒ 单文件式,id = 去扩展名的文件名; // - 同一 id 两者都在 ⇒ **目录式胜出**,并记一条 WARN(不静默覆盖); // - 子目录里没有 skill.yaml ⇒ 跳过(它可能是支持文件目录); // - 其它文件(含 . 开头的隐藏文件)⇒ 跳过。 func (l *Loader) LoadDirDetailed(dir string) ([]Loaded, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("skills: loader: read dir %q: %w", dir, err) } byDir, byFile := scanSkillEntries(dir, entries) ids := make([]string, 0, len(byDir)+len(byFile)) for id := range byDir { ids = append(ids, id) } for id := range byFile { if _, dup := byDir[id]; !dup { ids = append(ids, id) } } sort.Strings(ids) out := make([]Loaded, 0, len(ids)) for _, id := range ids { path, fromDir := byDir[id] if !fromDir { path = byFile[id] } else if filePath, both := byFile[id]; both { l.log.Warn("skill defined as both directory and file; directory wins", "skill", id, "dir_file", path, "file", filePath) } skill, err := l.LoadFile(path) if err != nil { return nil, fmt.Errorf("skills: loader: dir %q: %w", dir, err) } out = append(out, Loaded{ID: id, Dir: filepath.Dir(path), File: path, Skill: skill}) } return out, nil } // scanSkillEntries 把目录扫成两张表:目录式技能的 id→skill.yaml 路径,单文件式 // 技能的 id→文件路径。 func scanSkillEntries(dir string, entries []os.DirEntry) (byDir, byFile map[string]string) { byDir = make(map[string]string) byFile = make(map[string]string) for _, e := range entries { name := e.Name() if e.IsDir() { if path := definitionInDir(filepath.Join(dir, name)); path != "" { byDir[name] = path } continue } if e.Type()&os.ModeSymlink != 0 { // 符号链接不追(避免把目录外的定义悄悄引进来);如目标是普通文件, // ReadDir 的 Type 会带 ModeSymlink,这里一律跳过并留给调用方显式加载。 continue } if strings.HasPrefix(name, ".") || !isYAML(name) { continue } byFile[strings.TrimSuffix(name, filepath.Ext(name))] = filepath.Join(dir, name) } return byDir, byFile } // definitionInDir 返回子目录里的定义文件路径(skill.yaml 优先,其次 skill.yml); // 都没有则返回空串。 func definitionInDir(dir string) string { primary := filepath.Join(dir, skillDefinitionFile) if info, err := os.Stat(primary); err == nil && info.Mode().IsRegular() { return primary } alt := filepath.Join(dir, strings.TrimSuffix(skillDefinitionFile, ".yaml")+".yml") if info, err := os.Stat(alt); err == nil && info.Mode().IsRegular() { return alt } return "" } // toSkill 把 YAML 文档转成 Skill:枚举经 sdk.Parse* 校验(非法值直接拒), // 其余字段原样搬运;结构约束由 Validate 负责。 func (d skillYAML) toSkill() (*Skill, error) { invocation, err := sdk.ParseInvocation(d.Invocation) if err != nil { return nil, fmt.Errorf("skills: loader: %q: %w", d.Name, err) } contextMode, err := sdk.ParseContextMode(d.Context) if err != nil { return nil, fmt.Errorf("skills: loader: %q: %w", d.Name, err) } steps := make([]sdk.Step, 0, len(d.Steps)) for _, st := range d.Steps { steps = append(steps, sdk.Step{Name: st.Name, Prompt: st.Prompt, Depends: st.Depends}) } refs := make([]sdk.Ref, 0, len(d.Refs)) for _, rf := range d.Refs { refs = append(refs, sdk.Ref{Name: rf.Name, Type: rf.Type, Value: rf.Value, Optional: rf.Optional}) } guardrails := make([]sdk.Guardrail, 0, len(d.Guardrails)) for _, g := range d.Guardrails { guardrails = append(guardrails, sdk.Guardrail{ID: g.ID, Message: g.Message, Enforced: g.Enforced}) } skill, err := New(d.Name, d.Description, d.Prompt, steps, refs, guardrails, WithSpec(Spec{ Version: d.Version, WhenToUse: d.WhenToUse, Invocation: invocation, Triggers: d.Triggers, Paths: d.Paths, AllowedTools: d.AllowedTools, DisallowedTools: d.DisallowedTools, Context: contextMode, Agent: d.Agent, Model: d.Model, Effort: d.Effort, Metadata: d.Metadata, })) if err != nil { return nil, fmt.Errorf("skills: loader: invalid skill %q: %w", d.Name, err) } return skill, nil } // isYAML 报告文件名是否是 YAML(.yaml/.yml,大小写不敏感)。 func isYAML(name string) bool { lower := strings.ToLower(name) return strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".yml") }