llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
128 lines
3.8 KiB
Go
128 lines
3.8 KiB
Go
// 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-name>/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")
|
||
}
|