feat(skillmgr): 原生技能管理器插件 + OpenClaw 兼容层职责分离

新增 internal/plugins/skillmgr(native skill 全生命周期 owner):
- skill_list/info/load/unload/enable/disable/create/export/install
- skill_create 两步式:先生成骨架模板,LLM 补全后传 content 覆盖写入
  (plugin.ValidateSKILLContent 校验)并自动加载生效
- .skm 分发包(tar.gz):packSkill/unpackSkill 含 TarSlip 防护
  (拒绝绝对路径/../逃逸、强制单根目录、校验包内 SKILL.md)
- skills 目录扫描:纯 SKILL.md/skill.json 条目归本插件;
  sidecar(main.js/main.py)/OC plugin(openclaw.plugin.json) 留给兼容层

clawhubadapter 职责分离(OpenClaw 兼容层不再持有 native skill):
- 删除 p.skills 字段与 default 分支 LoadSKILL 逻辑
- 发现纯 SKILL 条目改为发布 events.EventSkillDetected 移交事件,
  由 skillmgr 订阅注册;启动时序 c<s 下全扫兜底,事件用于热新增
- claw_list/plugin_info 不再输出 SKILL 段,统一走 skill_list

方案B prompt 注入:
- agentCore 新增 SkillIndexProvider 接口 + SetSkillIndexProvider
- buildSystemPrompt 注入【可用技能】轻量索引(名称+版本+描述),
  LLM 匹配场景时主动 skill_info 拉全文按文档执行
- main.go 在插件加载后将 skillmgr 实例接线到 agent

内核小修:
- extractDescription 跳过 YAML frontmatter 块(此前所有带 frontmatter
  的 SKILL.md 描述都被误判为 '---')
- extractField 剥离 YAML 成对引号(version: "1.0" 不再带尾引号)
- plugin.ValidateSKILLContent 导出供生成侧校验
This commit is contained in:
JianFeeeee
2026-08-25 20:25:57 +08:00
parent 51eb98e0ae
commit 5f126d4d10
12 changed files with 1454 additions and 299 deletions

View File

@ -120,12 +120,44 @@ func (p *SKILLPlugin) SetEnabled(v bool) { p.mu.Lock(); defer p.mu.Unlock()
func (p *SKILLPlugin) Tools() []ToolDef { return p.toolDefs }
func (p *SKILLPlugin) IOConfig() *IOConfig { return p.ioConfig }
func (p *SKILLPlugin) RawContent() string { return p.rawContent }
func (p *SKILLPlugin) SourceDir() string { return p.sourceDir }
// ValidateSKILLContent 校验 SKILL.md 内容是否可被 LoadSKILL 正确解析:
// 必须含非空正文(description 来源),且提取出的工具定义名称合法。
// 供 skillmgr 的 skill_create 在落盘前校验生成结果。
func ValidateSKILLContent(content string) error {
if strings.TrimSpace(content) == "" {
return fmt.Errorf("SKILL content is empty")
}
if extractDescription(content) == "" {
return fmt.Errorf("SKILL content has no description (first non-empty non-heading line required)")
}
for _, td := range extractToolDefs(content) {
if strings.TrimSpace(td.Name) == "" || strings.ContainsAny(td.Name, " \t\n/") {
return fmt.Errorf("invalid tool name in SKILL content: %q", td.Name)
}
}
return nil
}
func extractDescription(content string) string {
for _, line := range splitLines(content) {
line = trimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
return line
inFrontmatter := false
for i, line := range splitLines(content) {
trimmed := trimSpace(line)
// 跳过 YAML frontmatter 块(首行 --- 至闭合 ---),
// 否则分隔符会被误认为描述(所有带 frontmatter 的 SKILL.md 描述都变成 "---")
if i == 0 && trimmed == "---" {
inFrontmatter = true
continue
}
if inFrontmatter {
if trimmed == "---" {
inFrontmatter = false
}
continue
}
if trimmed != "" && !strings.HasPrefix(trimmed, "#") {
return trimmed
}
}
return ""
@ -139,7 +171,12 @@ func extractField(content string, field string) string {
if strings.HasPrefix(strings.ToLower(trimmed), lowerPrefix) {
for i := 0; i < len(trimmed); i++ {
if trimmed[i] == ':' {
return strings.TrimSpace(trimmed[i+1:])
v := strings.TrimSpace(trimmed[i+1:])
// YAML 风格引号值:剥掉成对的首尾引号
if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] {
v = v[1 : len(v)-1]
}
return v
}
}
}