// Package skills 实现 SKILL.md 技能加载(借鉴 ccb docs/extensibility/skills 与 // picoclaw pkg/skills):skill = 目录下的 SKILL.md(YAML frontmatter + 正文指令), // 不是代码。系统提示只注入 name+description 列表(带预算截断),正文由模型 // 通过 load_skill 工具按需拉取,节省上下文。 package skills import ( "fmt" "os" "path/filepath" "strings" "git.gopher.ink/mirrors2/AutoZero/textx" ) // Skill 是一个已解析的技能。 type Skill struct { Name string // frontmatter name,缺省用目录名 Description string // 何时该用这个技能 AllowedTools []string // 可选:激活后允许使用的工具白名单 Body string // SKILL.md 正文(Markdown 指令) Dir string // 技能目录绝对路径 } // Load 依次扫描多个技能根目录(每个目录下是 /SKILL.md)。 // 同名技能先到先得——调用方把高优先级目录放前面(项目级覆盖用户级)。 // 单个坏文件只跳过,不影响其余。 func Load(dirs ...string) []Skill { var out []Skill seen := map[string]bool{} for _, dir := range dirs { entries, err := os.ReadDir(dir) if err != nil { continue } for _, e := range entries { if !e.IsDir() { continue } path := filepath.Join(dir, e.Name(), "SKILL.md") data, err := os.ReadFile(path) if err != nil { continue } meta, body := parseFrontmatter(string(data)) name := meta["name"] if name == "" { name = e.Name() } if seen[name] { continue } seen[name] = true s := Skill{ Name: name, Description: meta["description"], Body: strings.TrimSpace(body), Dir: filepath.Join(dir, e.Name()), } if v := meta["allowed-tools"]; v != "" { for _, t := range strings.Split(v, ",") { if t = strings.TrimSpace(t); t != "" { s.AllowedTools = append(s.AllowedTools, t) } } } out = append(out, s) } } return out } // maxPromptChars 是技能列表注入系统提示的预算(借鉴 ccb 的 1% 上下文预算思路)。 const maxPromptChars = 4000 // PromptSection 生成注入系统提示的技能列表段;空列表返回空串。 func PromptSection(sk []Skill) string { if len(sk) == 0 { return "" } var b strings.Builder b.WriteString("\n# Skills\nThe following skills are available. Before doing a task that matches a skill, call the load_skill tool with its name and follow the returned instructions.\n") for _, s := range sk { line := "- " + s.Name if s.Description != "" { line += ": " + s.Description } if len(s.AllowedTools) > 0 { line += fmt.Sprintf(" (allowed-tools: %s)", strings.Join(s.AllowedTools, ", ")) } b.WriteString(line + "\n") } out := b.String() if len(out) > maxPromptChars { // 技能描述大量是中文:按字节切会在尾部留下半个字,而这段是 system prompt。 out = textx.ClipBytes(out, maxPromptChars) + "\n...[skill list truncated]\n" } return out } // parseFrontmatter 解析开头的 `---` YAML 块,只支持顶层 `key: value` 行 // (足够覆盖 name/description/allowed-tools,避免引入 YAML 依赖)。 // 返回 (meta, 正文)。 func parseFrontmatter(raw string) (map[string]string, string) { s := strings.TrimSpace(raw) if !strings.HasPrefix(s, "---") { return map[string]string{}, raw } rel := strings.Index(s[3:], "\n---") if rel < 0 { return map[string]string{}, raw } meta := map[string]string{} for _, line := range strings.Split(s[3:3+rel], "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } k, v, ok := strings.Cut(line, ":") if !ok { continue } meta[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), `"'`) } body := s[3+rel+4:] // 跳过 "\n---" return meta, strings.TrimLeft(body, "\n") }