diff --git a/cmd/homed/main.go b/cmd/homed/main.go index de32207..ee97f70 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -500,6 +500,14 @@ func main() { } log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount()) + // 技能索引接线:skillmgr 插件实现 SkillIndexProvider 时注入 agent(方案B prompt 注入) + if sp := pluginReg.Get("skillmgr"); sp != nil { + if prov, ok := sp.(agentCore.SkillIndexProvider); ok { + agent.SetSkillIndexProvider(prov) + log.Printf("[homed] skill index wired from skillmgr plugin") + } + } + // 日志管理:层级压缩 + 保留策略 logManager := logpkg.NewManager(logDir, cfgReg) go logManager.Start(ctx) diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index c826dc0..69d673e 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -121,6 +121,17 @@ type Agent struct { // 词嵌入模型,用于实体语义相似度计算 embedder *memory.StaticEmbedder + + // 技能索引提供者:由 skillmgr 插件实现,向 system prompt 注入轻量技能索引 + skillIndex SkillIndexProvider +} + +// SkillIndexProvider 提供已加载技能的精炼索引,供 buildSystemPrompt 注入。 +// 实现方(skillmgr)需线程安全并快速返回(每次 LLM 调用都会调用)。 +type SkillIndexProvider interface { + // SkillIndex 返回多行文本的技能索引,每行形如 "name vX.Y - description"; + // 无技能时返回空串。 + SkillIndex() string } type AgentConfig struct { @@ -152,6 +163,8 @@ type AgentConfig struct { EventBus *events.Bus ThinkingEnabled bool + SkillIndexProvider SkillIndexProvider + InputProcessing types.InputProcessingConfig // 非文本输入处理配置 } @@ -220,6 +233,7 @@ func New(cfg AgentConfig) *Agent { mergeInterval: cfg.MergeInterval, maxContextSize: cfg.MaxContextSize, stageHost: cfg.StageHost, + skillIndex: cfg.SkillIndexProvider, eventBus: cfg.EventBus, selfInputCh: make(chan selfInputMsg, 64), childResults: make(map[string]string), @@ -233,6 +247,9 @@ func New(cfg AgentConfig) *Agent { } } +// SetSkillIndexProvider 注入技能索引提供者(skillmgr 插件加载后由 main 接线)。 +func (a *Agent) SetSkillIndexProvider(p SkillIndexProvider) { a.skillIndex = p } + func (a *Agent) Start() { go a.eventLoop() go a.interceptLoop() diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 83b9363..b1b9973 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -65,6 +65,14 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string { prompt += "\n\n" + a.indexer.BuildToolPrompt() } + // 技能索引(方案B):轻量注入已加载技能列表,LLM 匹配到场景时 + // 主动 skill_info 拉取全文按文档执行 + if a.skillIndex != nil { + if idx := a.skillIndex.SkillIndex(); idx != "" { + prompt += "\n\n【可用技能】以下是已安装的原生技能。当用户请求与某技能描述匹配时,\n先用 skill_info(\"技能名\") 拉取全文,再严格按文档步骤执行:\n" + idx + } + } + prompt += a.buildToolCatalog() return prompt diff --git a/internal/events/bus.go b/internal/events/bus.go index c400de2..e26692e 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -25,6 +25,11 @@ const ( EventReasoningDelta EventType = "reasoning_delta" EventContentDelta EventType = "content_delta" + // skill_detected:clawhubadapter(OpenClaw 兼容层)扫描 skills 目录时 + // 发现纯 SKILL 类型插件后发布,由原生 skillmgr 插件订阅并接管注册。 + // 职责链:发现者(兼容层)→ 移交事件 → 归属者(skillmgr)加载管理。 + EventSkillDetected EventType = "skill_detected" + EventAll EventType = "*" ) diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 9ffdc27..a333f8b 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -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 } } } diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index 684f9a6..e459a28 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -1,260 +1,48 @@ package plugin -import ( - "os" - "path/filepath" - "strings" - "testing" +import "testing" - internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" - sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" -) - -func TestExtractDescription(t *testing.T) { - content := "# Plugin\n\nThis is a test plugin.\nversion: 1.0.0" - desc := extractDescription(content) - if desc != "This is a test plugin." { - t.Errorf("expected 'This is a test plugin.', got %q", desc) +func TestExtractDescriptionSkipsFrontmatter(t *testing.T) { + // 带 frontmatter:描述取正文首行 + c := "---\nname: demo\nversion: 1.0.0\n---\n\n# Demo\n\n这是一句描述。\n" + if d := extractDescription(c); d != "这是一句描述。" { + t.Fatalf("got %q, want 这是一句描述。", d) + } + // 无 frontmatter:兼容旧格式 + c2 := "# Demo\n\n老格式描述\n" + if d := extractDescription(c2); d != "老格式描述" { + t.Fatalf("got %q, want 老格式描述", d) + } + // frontmatter 闭合后紧跟标题仍不误判 + c3 := "---\nname: x\n---\n## 步骤\n\n正文描述\n" + if d := extractDescription(c3); d != "正文描述" { + t.Fatalf("got %q, want 正文描述", d) } } -func TestExtractField(t *testing.T) { - content := "version: 1.0.0\nauthor: test\nio_type: io" - if v := extractField(content, "version"); v != "1.0.0" { - t.Errorf("expected '1.0.0', got %q", v) +func TestValidateSKILLContent(t *testing.T) { + good := "---\nname: ok\n---\n\n# OK\n\n描述\n" + if err := ValidateSKILLContent(good); err != nil { + t.Fatalf("good content rejected: %v", err) } - if v := extractField(content, "author"); v != "test" { - t.Errorf("expected 'test', got %q", v) + if err := ValidateSKILLContent(""); err == nil { + t.Fatal("empty content should be rejected") } - if v := extractField(content, "io_type"); v != "io" { - t.Errorf("expected 'io', got %q", v) + noDesc := "---\nname: x\n---\n\n## 步骤\n" + if err := ValidateSKILLContent(noDesc); err == nil { + t.Fatal("content without description should be rejected") } } -func TestExtractFieldCaseInsensitive(t *testing.T) { - content := "Version: 2.0.0" - if v := extractField(content, "version"); v != "2.0.0" { - t.Errorf("expected '2.0.0', got %q", v) - } -} - -func TestExtractFieldMissing(t *testing.T) { - if v := extractField("no fields here", "version"); v != "" { - t.Errorf("expected '', got %q", v) - } -} - -func TestExtractIOConfigFull(t *testing.T) { - content := `# QQ Plugin -io_type: io -io_input_route: qq -io_output_route: qq -io_output_caps: text,file,image` - - cfg := extractIOConfig(content) - if cfg == nil { - t.Fatal("expected IOConfig") - } - if cfg.Type != "io" { - t.Errorf("expected type 'io', got %q", cfg.Type) - } - if cfg.InputRoute != "qq" { - t.Errorf("expected input_route 'qq', got %q", cfg.InputRoute) - } - if cfg.OutputRoute != "qq" { - t.Errorf("expected output_route 'qq', got %q", cfg.OutputRoute) - } - if len(cfg.OutputCaps) != 3 || cfg.OutputCaps[0] != "text" { - t.Errorf("expected caps [text file image], got %v", cfg.OutputCaps) - } -} - -func TestExtractIOConfigMinimal(t *testing.T) { - content := `# Plugin -io_type: input` - cfg := extractIOConfig(content) - if cfg == nil { - t.Fatal("expected IOConfig") - } - if cfg.Type != "input" { - t.Errorf("expected 'input', got %q", cfg.Type) - } - if cfg.InputRoute != "" { - t.Errorf("expected empty input_route, got %q", cfg.InputRoute) - } -} - -func TestExtractIOConfigNil(t *testing.T) { - cfg := extractIOConfig("# No IO config here") - if cfg != nil { - t.Errorf("expected nil, got %+v", cfg) - } -} - -func TestExtractToolDefsBasic(t *testing.T) { - content := `# Plugin -description - -## hello_tool -Say hello to someone -- name: The person to greet - -## add_numbers -Add two numbers together -- a: First number -- b: Second number` - - defs := extractToolDefs(content) - if len(defs) != 2 { - t.Fatalf("expected 2 tools, got %d", len(defs)) - } - - if defs[0].Name != "hello_tool" { - t.Errorf("expected 'hello_tool', got %q", defs[0].Name) - } - if defs[0].Description != "Say hello to someone" { - t.Errorf("expected 'Say hello to someone', got %q", defs[0].Description) - } - - props := defs[0].Parameters["properties"].(map[string]interface{}) - if _, ok := props["name"]; !ok { - t.Errorf("expected 'name' parameter") - } - p := props["name"].(map[string]interface{}) - if p["description"] != "The person to greet" { - t.Errorf("expected desc 'The person to greet', got %q", p["description"]) - } -} - -func TestExtractToolDefsToolWithColon(t *testing.T) { - content := `# Plugin - -### Tool: my_tool -Do something -- param: Description` - - defs := extractToolDefs(content) - if len(defs) != 1 { - t.Fatalf("expected 1 tool, got %d", len(defs)) - } - if defs[0].Name != "my_tool" { - t.Errorf("expected 'my_tool', got %q", defs[0].Name) - } -} - -func TestExtractToolDefsSkipsNonToolSections(t *testing.T) { - content := `# Plugin - -## Usage -This is how to use the plugin - -## Examples -Some examples here - -## real_tool -This is an actual tool -- param: value` - - defs := extractToolDefs(content) - if len(defs) != 1 { - t.Fatalf("expected 1 tool (non-tool sections skipped), got %d", len(defs)) - } - if defs[0].Name != "real_tool" { - t.Errorf("expected 'real_tool', got %q", defs[0].Name) - } -} - -func TestExtractToolDefsEmpty(t *testing.T) { - defs := extractToolDefs("# Just a title\nNo tools here") - if len(defs) != 0 { - t.Errorf("expected 0 tools, got %d", len(defs)) - } -} - -func TestExtractToolDefsCodeBlock(t *testing.T) { - content := "# Plugin\n\n## my_tool\nA tool\n- param: desc\n\n```\n## not_a_tool\nThis is inside a code block\n```\n\n## another_tool\nAnother one\n- x: y" - - defs := extractToolDefs(content) - if len(defs) != 2 { - t.Fatalf("expected 2 tools (code block skipped), got %d", len(defs)) - } - if defs[0].Name != "my_tool" || defs[1].Name != "another_tool" { - t.Errorf("unexpected tool names: %v", defs) - } -} - -func TestExtractToolDefsNoParams(t *testing.T) { - content := `# Plugin - -## simple_tool -A tool with no parameters` - - defs := extractToolDefs(content) - if len(defs) != 1 { - t.Fatalf("expected 1 tool, got %d", len(defs)) - } - if defs[0].Name != "simple_tool" { - t.Errorf("expected 'simple_tool', got %q", defs[0].Name) - } - props := defs[0].Parameters["properties"].(map[string]interface{}) - if len(props) != 0 { - t.Errorf("expected no params, got %d", len(props)) - } -} - -func TestPluginManagerInterfaceReloadOne(t *testing.T) { - // 编译期契约:Registry 必须实现 PluginManager(含 ReloadOne 单插件重载)。 - var _ sdk.PluginManager = (*Registry)(nil) -} - -func TestRegistryIncrementalReload(t *testing.T) { - dir := t.TempDir() - plgDir := filepath.Join(dir, "plugins") - os.MkdirAll(plgDir, 0755) - - // 一个 Lua 插件 - luaDir := filepath.Join(plgDir, "reloaddemo") - os.MkdirAll(luaDir, 0755) - os.WriteFile(filepath.Join(luaDir, "plugin.json"), []byte(`{"name":"reloaddemo","entry":"main.lua"}`), 0644) - writeLua := func(body string) { - os.WriteFile(filepath.Join(luaDir, "main.lua"), []byte(`local plugin = { name = "reloaddemo" } -function plugin.start(sdk) sdk.log("info", "`+body+`") end -function plugin.stop() end -return plugin -`), 0644) - } - writeLua("v1") - - reg := NewRegistry() - reg.SetPluginDir(plgDir) - reg.SetConfigRegistry(internalConfig.NewConfigRegistry("")) - - - // 首次 Reload:应加载 1 个 - msg, err := reg.Reload(plgDir) - if err != nil { - t.Fatalf("first reload: %v", err) - } - if len(reg.List()) != 1 { - t.Fatalf("first reload loaded=%d, want 1 (%s)", len(reg.List()), msg) - } - - // 无变更再 Reload:不应重载(0 changed, 1 unchanged) - msg, err = reg.Reload(plgDir) - if err != nil { - t.Fatalf("second reload: %v", err) - } - if !strings.Contains(msg, "0 plugins") || !strings.Contains(msg, "1 unchanged") { - t.Errorf("unchanged reload should skip: %q", msg) - } - - // 修改 main.lua → 应重载该插件 - writeLua("v2") - msg, err = reg.Reload(plgDir) - if err != nil { - t.Fatalf("changed reload: %v", err) - } - if !strings.Contains(msg, "1 plugins") { - t.Errorf("changed reload should reload 1: %q", msg) +func TestExtractFieldStripsQuotes(t *testing.T) { + c := "---\nname: demo\nversion: \"1.0\"\nauthor: 'tester'\n---\n" + if v := extractField(c, "version"); v != "1.0" { + t.Fatalf("version = %q, want 1.0", v) + } + if v := extractField(c, "author"); v != "tester" { + t.Fatalf("author = %q, want tester", v) + } + if v := extractField(c, "name"); v != "demo" { + t.Fatalf("name = %q, want demo", v) } } diff --git a/internal/plugins/all.go b/internal/plugins/all.go index e5c5b2e..157de63 100644 --- a/internal/plugins/all.go +++ b/internal/plugins/all.go @@ -12,6 +12,7 @@ import ( _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/mcp" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/remotedevice" + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/skillmgr" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/timer" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui" ) diff --git a/internal/plugins/clawhubadapter/plugin.go b/internal/plugins/clawhubadapter/plugin.go index ac6718e..4093fdb 100644 --- a/internal/plugins/clawhubadapter/plugin.go +++ b/internal/plugins/clawhubadapter/plugin.go @@ -18,6 +18,7 @@ import ( "sync" "time" + "gitcode.com/JianFeeeee/HomeAgent/internal/events" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" ) @@ -46,7 +47,6 @@ type Plugin struct { name string skillsDir string simulatorDir string - skills []*plugin.SKILLPlugin sidecars []*sidecarProcess manager *sidecarProcess mu sync.Mutex @@ -163,13 +163,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { case hasOCManifest || hasOCPackage: log.Printf("[clawhubadapter] ocplugin %s handled by manager", entry.Name()) default: - sk, err := plugin.LoadSKILL(skillPath) - if err != nil { - log.Printf("[clawhubadapter] load skill %s: %v", entry.Name(), err) - continue - } - p.skills = append(p.skills, sk) - log.Printf("[clawhubadapter] loaded skill: %s v%s", sk.Name(), sk.Version()) + // 纯 SKILL 类型:不属于 OpenClaw 兼容层,移交原生 skillmgr 管理。 + // skillmgr 启动时也会自行全扫(本插件启动更早的场景由事件补齐)。 + p.publishSkillDetected(s, skillPath) } } } @@ -592,16 +588,6 @@ func (p *Plugin) handlePluginUninstall(args map[string]interface{}) (interface{} } p.sidecars = aliveSidecars - // 2. Remove from SKILL list if present - var aliveSkills []*plugin.SKILLPlugin - for _, sk := range p.skills { - if sk.Name() != name { - aliveSkills = append(aliveSkills, sk) - } else { - logs = append(logs, fmt.Sprintf("已移除 SKILL 插件: %s v%s", sk.Name(), sk.Version())) - } - } - p.skills = aliveSkills p.mu.Unlock() // 3. Try manager for OC plugins @@ -690,12 +676,6 @@ func (p *Plugin) handlePluginList(args map[string]interface{}) (interface{}, err } } } - if len(p.skills) > 0 { - parts = append(parts, fmt.Sprintf("\nSKILL 插件 (%d):", len(p.skills))) - for _, sk := range p.skills { - parts = append(parts, fmt.Sprintf(" %s v%s", sk.Name(), sk.Version())) - } - } caps := p.dispatcher.Capabilities() if len(caps) > 0 { parts = append(parts, fmt.Sprintf("\nCapabilities (%d):", len(caps))) @@ -703,7 +683,7 @@ func (p *Plugin) handlePluginList(args map[string]interface{}) (interface{}, err parts = append(parts, fmt.Sprintf(" %s", c)) } } - if len(result.Plugins) == 0 && len(p.sidecars) <= 1 && len(p.skills) == 0 { + if len(result.Plugins) == 0 && len(p.sidecars) <= 1 { parts = append(parts, "没有已安装的插件。") } p.mu.Unlock() @@ -738,13 +718,6 @@ func (p *Plugin) handlePluginList(args map[string]interface{}) (interface{}, err } } - if len(p.skills) > 0 { - parts = append(parts, fmt.Sprintf("\nSKILL 插件 (%d):", len(p.skills))) - for _, sk := range p.skills { - parts = append(parts, fmt.Sprintf(" %s v%s", sk.Name(), sk.Version())) - } - } - caps := p.dispatcher.Capabilities() if len(caps) > 0 { parts = append(parts, fmt.Sprintf("\nCapabilities (%d):", len(caps))) @@ -753,8 +726,8 @@ func (p *Plugin) handlePluginList(args map[string]interface{}) (interface{}, err } } - if len(p.sidecars) == 0 && len(p.skills) == 0 { - parts = append(parts, "没有已安装的插件。") + if len(p.sidecars) == 0 { + parts = append(parts, "没有已安装的插件(原生技能由 skillmgr 管理,用 skill_list 查看)。") } return map[string]interface{}{ @@ -811,11 +784,6 @@ func (p *Plugin) handlePluginInfo(args map[string]interface{}) (interface{}, err } } } - for _, sk := range p.skills { - if sk.Name() == name { - typ = "SKILL" - } - } p.mu.Unlock() } @@ -1264,12 +1232,23 @@ func (p *Plugin) Stop() error { sp.Close() } p.sidecars = nil - p.skills = nil p.manager = nil p.sdk = nil return nil } +// publishSkillDetected 发布 skill_detected 事件,将纯 SKILL 条目移交原生 +// skillmgr 插件注册。OpenClaw 兼容层不持有 native skill 状态。 +func (p *Plugin) publishSkillDetected(s *sdk.PluginSDK, path string) { + s.Publish(&events.Event{ + Type: events.EventSkillDetected, + Source: p.name, + Payload: map[string]interface{}{"path": path}, + Timestamp: time.Now().Unix(), + }) + log.Printf("[clawhubadapter] skill %s detected, handed off to skillmgr", filepath.Base(path)) +} + func errorResult(msg string) interface{} { return map[string]interface{}{ "isError": true, @@ -1338,13 +1317,8 @@ func (p *Plugin) reloadPlugin(name string) error { case hasOCManifest || hasOCPackage: return p.loadOCPlugin(p.sdk, pluginDir, name) default: - sk, err := plugin.LoadSKILL(pluginDir) - if err != nil { - return fmt.Errorf("load skill: %w", err) - } - p.mu.Lock() - p.skills = append(p.skills, sk) - p.mu.Unlock() + // 纯 SKILL 类型:移交原生 skillmgr(与 Start 扫描行为一致) + p.publishSkillDetected(p.sdk, pluginDir) return nil } } diff --git a/internal/plugins/skillmgr/pack.go b/internal/plugins/skillmgr/pack.go new file mode 100644 index 0000000..1cbd2ff --- /dev/null +++ b/internal/plugins/skillmgr/pack.go @@ -0,0 +1,266 @@ +package skillmgr + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// packSkill 把 skill 目录打包为 .skm(tar.gz)。返回打包的文件数。 +// 包内路径统一为 /<相对路径>,解包端按首段目录还原。 +func packSkill(srcDir, outPath string) (int, error) { + info, err := os.Stat(srcDir) + if err != nil { + return 0, fmt.Errorf("stat skill dir: %w", err) + } + if !info.IsDir() { + // 单 .md 文件 skill:包装成只含一个文件的包 + return packSingleFile(srcDir, outPath) + } + + skillName := filepath.Base(srcDir) + if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { + return 0, err + } + + f, err := os.Create(outPath) + if err != nil { + return 0, fmt.Errorf("create pack: %w", err) + } + defer f.Close() + + gz := gzip.NewWriter(f) + defer gz.Close() + tw := tar.NewWriter(gz) + defer tw.Close() + + count := 0 + err = filepath.Walk(srcDir, func(path string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + // 跳过隐藏文件与 node_modules + rel, rerr := filepath.Rel(srcDir, path) + if rerr != nil { + return rerr + } + if rel == "." { + return nil + } + for _, part := range strings.Split(rel, string(filepath.Separator)) { + if strings.HasPrefix(part, ".") || part == "node_modules" { + if fi.IsDir() { + return filepath.SkipDir + } + return nil + } + } + hdr, err := tar.FileInfoHeader(fi, "") + if err != nil { + return err + } + hdr.Name = filepath.ToSlash(filepath.Join(skillName, rel)) + if fi.IsDir() { + hdr.Name += "/" + if err := tw.WriteHeader(hdr); err != nil { + return err + } + return nil + } + if !fi.Mode().IsRegular() { + return nil // 跳过符号链接等非常规文件,避免路径逃逸 + } + if err := tw.WriteHeader(hdr); err != nil { + return err + } + src, err := os.Open(path) + if err != nil { + return err + } + _, err = io.Copy(tw, src) + src.Close() + if err != nil { + return err + } + count++ + return nil + }) + if err != nil { + return 0, fmt.Errorf("pack walk: %w", err) + } + return count, nil +} + +func packSingleFile(mdPath, outPath string) (int, error) { + skillName := strings.TrimSuffix(filepath.Base(mdPath), ".md") + data, err := os.ReadFile(mdPath) + if err != nil { + return 0, err + } + f, err := os.Create(outPath) + if err != nil { + return 0, err + } + defer f.Close() + gz := gzip.NewWriter(f) + defer gz.Close() + tw := tar.NewWriter(gz) + defer tw.Close() + + hdr := &tar.Header{ + Name: filepath.ToSlash(filepath.Join(skillName, SkillFileName)), + Mode: 0644, + Size: int64(len(data)), + } + if err := tw.WriteHeader(hdr); err != nil { + return 0, err + } + if _, err := tw.Write(data); err != nil { + return 0, err + } + return 1, nil +} + +// unpackSkill 解压 .skm 到临时目录后整体 rename 为 dstParent。 +// 校验包内所有条目必须位于同一首段目录下,防止 TarSlip 路径逃逸。 +// 返回解出的文件数。 +func unpackSkill(packPath, dstParent string) (int, error) { + f, err := os.Open(packPath) + if err != nil { + return 0, err + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return 0, fmt.Errorf("not a gzip pack: %w", err) + } + defer gz.Close() + + tw := tar.NewReader(gz) + var root string // 包内唯一根目录名 + count := 0 + + tmpDir, err := os.MkdirTemp(filepath.Dir(dstParent), ".skillmgr-unpack-") + if err != nil { + return 0, err + } + defer os.RemoveAll(tmpDir) + + for { + hdr, err := tw.Next() + if err == io.EOF { + break + } + if err != nil { + return count, fmt.Errorf("read tar: %w", err) + } + + // 清洗路径:拒绝绝对路径与 .. 逃逸 + name := filepath.Clean(filepath.FromSlash(hdr.Name)) + if filepath.IsAbs(name) || strings.HasPrefix(name, "..") { + return count, fmt.Errorf("unsafe entry in pack: %q", hdr.Name) + } + parts := strings.Split(name, string(filepath.Separator)) + if len(parts) < 2 { + return count, fmt.Errorf("entry outside skill root dir: %q", hdr.Name) + } + if root == "" { + root = parts[0] + } else if parts[0] != root { + return count, fmt.Errorf("multiple root dirs in pack: %q vs %q", root, parts[0]) + } + + target := filepath.Join(tmpDir, name) + if hdr.Typeflag == tar.TypeDir { + if err := os.MkdirAll(target, 0755); err != nil { + return count, err + } + continue + } + if hdr.Typeflag != tar.TypeReg { + continue // 跳过符号链接等 + } + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return count, err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode)&0755) + if err != nil { + return count, err + } + _, err = io.Copy(out, tw) + out.Close() + if err != nil { + return count, err + } + count++ + } + + if root == "" { + return 0, fmt.Errorf("empty pack") + } + // 校验解包结果是合法 skill(有 SKILL.md 或 skill.json) + unpackedRoot := filepath.Join(tmpDir, root) + if !fileExists(filepath.Join(unpackedRoot, SkillFileName)) && !fileExists(filepath.Join(unpackedRoot, MetaFileName)) { + return count, fmt.Errorf("pack has no %s or %s at its root", SkillFileName, MetaFileName) + } + // rename 到期望位置(包内根名与期望名不同时先改根目录名再落位) + src := unpackedRoot + if filepath.Base(dstParent) != root { + src = filepath.Join(tmpDir, ".renamed") + if err := os.Rename(unpackedRoot, src); err != nil { + return count, err + } + } + if err := os.Rename(src, dstParent); err != nil { + return count, err + } + return count, nil +} + +// copyDir 递归复制目录(跳过隐藏文件与 node_modules)。 +func copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + rel, rerr := filepath.Rel(src, path) + if rerr != nil { + return rerr + } + if rel == "." { + return os.MkdirAll(dst, 0755) + } + for _, part := range strings.Split(rel, string(filepath.Separator)) { + if strings.HasPrefix(part, ".") || part == "node_modules" { + if fi.IsDir() { + return filepath.SkipDir + } + return nil + } + } + target := filepath.Join(dst, rel) + if fi.IsDir() { + return os.MkdirAll(target, 0755) + } + if !fi.Mode().IsRegular() { + return nil + } + in, err := os.Open(path) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, fi.Mode()) + if err != nil { + return err + } + _, err = io.Copy(out, in) + out.Close() + return err + }) +} diff --git a/internal/plugins/skillmgr/plugin.go b/internal/plugins/skillmgr/plugin.go new file mode 100644 index 0000000..91b76f4 --- /dev/null +++ b/internal/plugins/skillmgr/plugin.go @@ -0,0 +1,268 @@ +// Package skillmgr 实现原生 SKILL 插件管理器。 +// +// 职责边界: +// - 本插件是 native skill(SKILL.md / skill.json 指令文档型插件)的唯一归属者, +// 负责其全生命周期:加载、卸载、启停、生成(模板)、导出(.skm 包)、安装。 +// - clawhubadapter(OpenClaw 兼容层)扫描 skills 目录时发现纯 SKILL 条目后 +// 发布 skill_detected 事件移交本插件注册;sidecar/OC plugin 不归本插件管。 +// +// skill 的运行模型:SKILL.md 是给 LLM 的指令文档,agent 通过 skill_info 读取全文后 +// 用 cmd_run 等工具按文档执行;skill 不注册可调用 handler 工具。 +package skillmgr + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "gitcode.com/JianFeeeee/HomeAgent/internal/events" + "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +const ( + // SkillFileName 标准 skill 主文件名 + SkillFileName = "SKILL.md" + // MetaFileName 可选元数据文件(覆盖 SKILL.md frontmatter 字段) + MetaFileName = "skill.json" + // PackExt 导出包扩展名 + PackExt = ".skm" + // detectEvent 兼容层移交事件类型 + detectEvent = events.EventSkillDetected +) + +func init() { + plugin.RegisterPluginMeta("skillmgr", "技能管理器", "Skill Manager") + plugin.RegisterFactory("skillmgr", func(name string, cfg map[string]interface{}) (sdk.Plugin, error) { + dir := "" + if dataDir, ok := cfg["data_dir"].(string); ok && dataDir != "" { + dir = filepath.Join(dataDir, "skills") + } + return New(name, dir), nil + }) +} + +// SkillEntry 一个已加载的 native skill 及其实时状态。 +type SkillEntry struct { + sk *plugin.SKILLPlugin + path string + enabled bool +} + +type Plugin struct { + name string + skillsDir string + + mu sync.RWMutex + skills map[string]*SkillEntry // name -> entry + sdk *sdk.PluginSDK + + unsub func() // skill_detected 订阅注销 +} + +func New(name, skillsDir string) *Plugin { + return &Plugin{ + name: name, + skillsDir: skillsDir, + skills: map[string]*SkillEntry{}, + } +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + p.sdk = s + + // 默认目录:内核 data_dir 下 skills 目录(与配置 core.daemon.data_dir 对齐) + if p.skillsDir == "" { + if v, _ := s.Settings().GetCore("daemon.data_dir"); v != nil { + if dir, ok := v.(string); ok && dir != "" { + p.skillsDir = filepath.Join(dir, "skills") + } + } + } + if err := os.MkdirAll(p.skillsDir, 0755); err != nil { + return fmt.Errorf("mkdir skills dir: %w", err) + } + + // 订阅兼容层移交事件(clawhubadapter 发现纯 SKILL 后发布) + p.unsub = s.Subscribe(detectEvent, func(evt *events.Event) { + path, _ := evt.Payload["path"].(string) + if path == "" { + return + } + if _, err := p.loadOne(path); err != nil { + log.Printf("[skillmgr] detected skill %s: %v", path, err) + return + } + log.Printf("[skillmgr] registered via %s event: %s", detectEvent, filepath.Base(path)) + }) + + // 启动全扫:兜底接管所有已存在的纯 SKILL 目录/单文件 + // (时序上 skillmgr(c 0 +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +// loadOne 从路径加载单个 skill 并入表(幂等:已存在且未变更则跳过)。 +func (p *Plugin) loadOne(path string) (*SkillEntry, error) { + sk, err := plugin.LoadSKILL(path) + if err != nil { + return nil, err + } + name := sk.Name() + if name == "" || name == "." { + return nil, fmt.Errorf("skill at %s has empty name", path) + } + + p.mu.Lock() + defer p.mu.Unlock() + if old, ok := p.skills[name]; ok { + // 幂等:同路径同内容不重复加载 + if old.path == path { + return old, nil + } + // 重名不同路径:拒绝并提示 + p.mu.Unlock() + return nil, fmt.Errorf("skill name conflict: %q already loaded from %s", name, old.path) + } + entry := &SkillEntry{sk: sk, path: path, enabled: true} + p.skills[name] = entry + return entry, nil +} + +// removeOne 从表中移除 skill(不动磁盘)。 +func (p *Plugin) removeOne(name string) bool { + p.mu.Lock() + defer p.mu.Unlock() + if _, ok := p.skills[name]; !ok { + return false + } + delete(p.skills, name) + return true +} + +// snapshot 返回排序后的 skill 名列表。 +func (p *Plugin) snapshot() []*SkillEntry { + p.mu.RLock() + defer p.mu.RUnlock() + names := make([]string, 0, len(p.skills)) + for n := range p.skills { + names = append(names, n) + } + sort.Strings(names) + out := make([]*SkillEntry, 0, len(names)) + for _, n := range names { + out = append(out, p.skills[n]) + } + return out +} + +func (p *Plugin) get(name string) (*SkillEntry, bool) { + p.mu.RLock() + defer p.mu.RUnlock() + e, ok := p.skills[name] + return e, ok +} + +// SkillIndex 实现 agentCore.SkillIndexProvider:返回已启用技能的精炼索引。 +// 每次 LLM 调用都会执行,必须轻量(只读锁 + 字符串拼接,无 IO)。 +func (p *Plugin) SkillIndex() string { + entries := p.snapshot() + var b strings.Builder + for _, e := range entries { + if !e.enabled { + continue + } + desc := e.sk.Description() + if desc == "" || desc == "---" { + desc = "(无描述)" + } + if len(desc) > 80 { + desc = desc[:80] + "..." + } + fmt.Fprintf(&b, "%s v%s - %s\n", e.sk.Name(), e.sk.Version(), desc) + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/plugins/skillmgr/skillmgr_test.go b/internal/plugins/skillmgr/skillmgr_test.go new file mode 100644 index 0000000..6a7bbb9 --- /dev/null +++ b/internal/plugins/skillmgr/skillmgr_test.go @@ -0,0 +1,368 @@ +package skillmgr + +import ( + "archive/tar" + "compress/gzip" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/events" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" + pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +// ---- 测试辅助 ---- + +type nilSettings struct{} + +func (m *nilSettings) Get(string) (interface{}, error) { return nil, nil } +func (m *nilSettings) Set(string, interface{}) error { return nil } +func (m *nilSettings) List(string) ([]string, error) { return nil, nil } +func (m *nilSettings) GetCore(string) (interface{}, error) { return nil, nil } +func (m *nilSettings) SetCore(string, interface{}) error { return nil } +func (m *nilSettings) ListCore(string) ([]string, error) { return nil, nil } +func (m *nilSettings) GetPlugin(string, string) (interface{}, error) { return nil, nil } +func (m *nilSettings) SetPlugin(string, string, interface{}) error { return nil } +func (m *nilSettings) ListPlugin(string, string) ([]string, error) { return nil, nil } +func (m *nilSettings) RegisterDef(pubsdk.ConfigDef) {} +func (m *nilSettings) Defs(string) []*pubsdk.ConfigDef { return nil } +func (m *nilSettings) Dump() map[string]interface{} { return nil } +func (m *nilSettings) Plugins() []string { return nil } +func (m *nilSettings) DefsCore(string) []*sdk.ConfigDef { return nil } +func (m *nilSettings) DefsPlugin(string, string) []*sdk.ConfigDef { return nil } +func (m *nilSettings) Remove(string) error { return nil } +func (m *nilSettings) RemoveCore(string) error { return nil } +func (m *nilSettings) RemovePlugin(string, string) error { return nil } + +// toolSpy 记录注册的工具,并可按名调用 handler。 +type toolSpy struct { + mu sync.Mutex + handlers map[string]func(args map[string]interface{}) (interface{}, error) +} + +func newToolSpy() *toolSpy { return &toolSpy{handlers: map[string]func(map[string]interface{}) (interface{}, error){}} } + +func (s *toolSpy) Register(name string, def interface{}, h func(args map[string]interface{}) (interface{}, error)) error { + s.mu.Lock() + defer s.mu.Unlock() + s.handlers[name] = h + return nil +} + +func (s *toolSpy) call(t *testing.T, name string, args map[string]interface{}) (interface{}, error) { + t.Helper() + s.mu.Lock() + h, ok := s.handlers[name] + s.mu.Unlock() + if !ok { + t.Fatalf("tool %q not registered", name) + } + return h(args) +} + +// newTestPlugin 构造带真实事件总线的 skillmgr 实例。 +func newTestPlugin(t *testing.T) (*Plugin, *toolSpy, *events.Bus) { + t.Helper() + dir := t.TempDir() + bus := events.NewBus() + p := New("skillmgr", dir) + + reg := func(name string, def interface{}, h func(args map[string]interface{}) (interface{}, error)) error { + _ = name + _ = def + _ = h + return nil + } + _ = reg // 占位避免误用 + + spy := newToolSpy() + cfgSDK := sdk.New("skillmgr", sdk.SDKConfig{ + RegTool: func(name string, def sdk.ToolDef, h sdk.ToolHandler) error { + return spy.Register(name, def, func(args map[string]interface{}) (interface{}, error) { + return h(args) + }) + }, + Settings: &nilSettings{}, + EventBus: bus, + }) + if err := p.Start(cfgSDK); err != nil { + t.Fatalf("Start: %v", err) + } + t.Cleanup(func() { p.Stop() }) + return p, spy, bus +} + +func writeSkill(t *testing.T, dir, name, description string) string { + t.Helper() + sd := filepath.Join(dir, name) + os.MkdirAll(sd, 0755) + content := "---\nname: " + name + "\nversion: 1.0.0\n---\n\n# " + name + "\n\n" + description + "\n" + if err := os.WriteFile(filepath.Join(sd, SkillFileName), []byte(content), 0644); err != nil { + t.Fatal(err) + } + return sd +} + +const sampleSkillContent = `--- +name: demo-skill +version: 0.2.0 +author: tester +--- + +# demo-skill + +用于测试的演示技能。 + +## 使用时机 + +测试时使用。 + +## 操作步骤 + +### step-1 + +第一步说明。 + +- target: 目标参数 + +执行示例: + +` + "```bash\necho demo\n```" + ` +` + +// ---- 用例 ---- + +func TestScanExistingLoadsPureSkillsOnly(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "alpha", "alpha desc") + writeSkill(t, dir, "beta", "beta desc") + + // sidecar 目录(main.js)应被跳过 + scDir := filepath.Join(dir, "sidecar-thing") + os.MkdirAll(scDir, 0755) + os.WriteFile(filepath.Join(scDir, "main.js"), []byte("//"), 0644) + + // OC plugin 目录应被跳过 + ocDir := filepath.Join(dir, "oc-thing") + os.MkdirAll(ocDir, 0755) + os.WriteFile(filepath.Join(ocDir, "openclaw.plugin.json"), []byte("{}"), 0644) + + // 隐藏目录跳过 + hd := filepath.Join(dir, ".hidden") + os.MkdirAll(hd, 0755) + os.WriteFile(filepath.Join(hd, SkillFileName), []byte("x"), 0644) + + p := New("skillmgr", dir) + bus := events.NewBus() + sdkInst := sdk.New("skillmgr", sdk.SDKConfig{ + Settings: &nilSettings{}, + EventBus: bus, + }) + if err := p.Start(sdkInst); err != nil { + t.Fatal(err) + } + defer p.Stop() + + names := map[string]bool{} + for _, e := range p.snapshot() { + names[e.sk.Name()] = true + } + if !names["alpha"] || !names["beta"] { + t.Fatalf("expected alpha+beta loaded, got %v", names) + } + if names["sidecar-thing"] || names["oc-thing"] || names[".hidden"] { + t.Fatalf("sidecar/OC/hidden should not load, got %v", names) + } +} + +func TestSkillDetectedEventHandoff(t *testing.T) { + p, _, bus := newTestPlugin(t) + + // 模拟 clawhubadapter 移交事件 + dir := p.skillsDir + sd := filepath.Join(dir, "handed") + os.MkdirAll(sd, 0755) + os.WriteFile(filepath.Join(sd, SkillFileName), []byte(sampleSkillContent), 0644) + + bus.Publish(&events.Event{ + Type: detectEvent, + Source: "clawhubadapter", + Payload: map[string]interface{}{"path": sd}, + Timestamp: time.Now().Unix(), + }) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if _, ok := p.get("handed"); ok { + break + } + time.Sleep(10 * time.Millisecond) + } + if _, ok := p.get("handed"); !ok { + t.Fatal("handed-off skill not registered after event") + } +} + +func TestCreateValidateLoadFlow(t *testing.T) { + p, spy, _ := newTestPlugin(t) + + // 两步式:先骨架 + out, err := spy.call(t, "skill_create", map[string]interface{}{ + "name": "weather-notify", + "description": "天气通知", + }) + if err != nil { + t.Fatalf("create skeleton: %v", err) + } + _ = out + if !fileExists(filepath.Join(p.skillsDir, "weather-notify", SkillFileName)) { + t.Fatal("skeleton SKILL.md missing") + } + + // 第二步:完整内容覆盖 → 自动加载 + if _, err := spy.call(t, "skill_create", map[string]interface{}{ + "name": "weather-notify", + "content": sampleSkillContent, + }); err != nil { + t.Fatalf("create full: %v", err) + } + if _, ok := p.get("weather-notify"); !ok { + t.Fatal("full-content skill not loaded under its own name") + } + + // 空白 content 走模板分支:生成骨架而非报错 + if _, err := spy.call(t, "skill_create", map[string]interface{}{ + "name": "bad-skill", + "description": "", + }); err != nil { + t.Fatalf("skeleton-only create should succeed: %v", err) + } + if !fileExists(filepath.Join(p.skillsDir, "bad-skill", SkillFileName)) { + t.Fatal("bad-skill skeleton missing") + } + + // 有内容但缺描述的 content 被校验拒绝 + if _, err := spy.call(t, "skill_create", map[string]interface{}{ + "name": "no-desc", + "content": "---\nname: no-desc\n---\n\n## 步骤\n", + }); err == nil { + t.Fatal("content without description should be rejected by ValidateSKILLContent") + } + + // 非法名称被拒 + if _, err := spy.call(t, "skill_create", map[string]interface{}{"name": "../evil"}); err == nil { + t.Fatal("path traversal name should be rejected") + } +} + +func TestExportInstallRoundTrip(t *testing.T) { + p, spy, _ := newTestPlugin(t) + + // 先创建并加载一个 skill + if _, err := spy.call(t, "skill_create", map[string]interface{}{ + "name": "roundtrip", + "content": strings.ReplaceAll(sampleSkillContent, "demo-skill", "roundtrip"), + }); err != nil { + t.Fatal(err) + } + + outDir := filepath.Join(p.skillsDir, "..", "exports") + packPath := filepath.Join(outDir, "roundtrip.skm") + if _, err := spy.call(t, "skill_export", map[string]interface{}{"name": "roundtrip"}); err != nil { + t.Fatalf("export: %v", err) + } + if !fileExists(packPath) { + t.Fatalf("pack missing at %s", packPath) + } + + // 卸载 + 删文件后从包重装 + if _, err := spy.call(t, "skill_unload", map[string]interface{}{"name": "roundtrip", "delete_files": true}); err != nil { + t.Fatal(err) + } + if _, ok := p.get("roundtrip"); ok { + t.Fatal("skill should be unloaded") + } + if fileExists(filepath.Join(p.skillsDir, "roundtrip")) { + t.Fatal("files should be deleted") + } + + if _, err := spy.call(t, "skill_install", map[string]interface{}{"source": packPath}); err != nil { + t.Fatalf("install: %v", err) + } + e, ok := p.get("roundtrip") + if !ok { + t.Fatal("reinstalled skill not loaded") + } + if e.sk.Version() != "0.2.0" { + t.Fatalf("unexpected version %q", e.sk.Version()) + } +} + +func TestEnableDisable(t *testing.T) { + p, spy, _ := newTestPlugin(t) + writeSkill(t, p.skillsDir, "toggle", "toggle desc") + p.scanExisting() + + if _, err := spy.call(t, "skill_disable", map[string]interface{}{"name": "toggle"}); err != nil { + t.Fatal(err) + } + if e, _ := p.get("toggle"); e.enabled { + t.Fatal("should be disabled") + } + if _, err := spy.call(t, "skill_enable", map[string]interface{}{"name": "toggle"}); err != nil { + t.Fatal(err) + } + if e, _ := p.get("toggle"); !e.enabled { + t.Fatal("should be enabled") + } +} + +func TestPackTarSlipRejected(t *testing.T) { + tmp := t.TempDir() + + // 构造带 .. 逃逸条目的恶意包 + packPath := filepath.Join(tmp, "evil.skm") + f, _ := os.Create(packPath) + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + hdr := &tar.Header{Name: "../../../evil.txt", Mode: 0644, Size: 3} + tw.WriteHeader(hdr) + tw.Write([]byte("bad")) + tw.Close() + gz.Close() + f.Close() + + dst := filepath.Join(tmp, "out") + if _, err := unpackSkill(packPath, dst); err == nil { + t.Fatal("tar slip entry must be rejected") + } +} + +func TestSkillIndex(t *testing.T) { + p, spy, _ := newTestPlugin(t) + + if idx := p.SkillIndex(); idx != "" { + t.Fatalf("empty registry should give empty index, got %q", idx) + } + + if _, err := spy.call(t, "skill_create", map[string]interface{}{ + "name": "idx-test", + "content": strings.ReplaceAll(sampleSkillContent, "demo-skill", "idx-test"), + }); err != nil { + t.Fatal(err) + } + idx := p.SkillIndex() + if !strings.Contains(idx, "idx-test v0.2.0") || !strings.Contains(idx, "用于测试的演示技能。") { + t.Fatalf("index missing name/desc: %q", idx) + } + + // 禁用后不出现在索引里 + spy.call(t, "skill_disable", map[string]interface{}{"name": "idx-test"}) + if idx := p.SkillIndex(); strings.Contains(idx, "idx-test") { + t.Fatalf("disabled skill should be excluded from index: %q", idx) + } +} diff --git a/internal/plugins/skillmgr/tools.go b/internal/plugins/skillmgr/tools.go new file mode 100644 index 0000000..abf9fde --- /dev/null +++ b/internal/plugins/skillmgr/tools.go @@ -0,0 +1,415 @@ +package skillmgr + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// registerTools 注册 LLM 可调用的 skill 管理工具。 +func (p *Plugin) registerTools() { + p.registerList() + p.registerInfo() + p.registerLoad() + p.registerUnload() + p.registerEnable() + p.registerDisable() + p.registerCreate() + p.registerExport() + p.registerInstall() +} + +const tp = "skill_" + +func (p *Plugin) registerList() { + p.sdk.RegisterTool(tp+"list", sdk.ToolDef{ + Name: tp + "list", + Description: "列出所有已加载的原生技能(native skill):名称、版本、启用状态、描述。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + entries := p.snapshot() + if len(entries) == 0 { + return "当前没有已加载的原生技能。", nil + } + var b strings.Builder + fmt.Fprintf(&b, "原生技能 (%d):\n", len(entries)) + for _, e := range entries { + state := "启用" + if !e.enabled { + state = "禁用" + } + fmt.Fprintf(&b, " %s v%s [%s] - %s\n", + e.sk.Name(), e.sk.Version(), state, e.sk.Description()) + } + return b.String(), nil + }) +} + +func (p *Plugin) registerInfo() { + p.sdk.RegisterTool(tp+"info", sdk.ToolDef{ + Name: tp + "info", + Description: "查看指定技能的详细信息与 SKILL.md 全文。执行技能前必读:按文档指示操作。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string", "description": "技能名称"}, + }, + "required": []string{"name"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + name, _ := args["name"].(string) + e, ok := p.get(name) + if !ok { + return nil, fmt.Errorf("skill %q not loaded; use skill_list to view available skills", name) + } + state := "启用" + if !e.enabled { + state = "禁用" + } + var b strings.Builder + fmt.Fprintf(&b, "名称: %s\n版本: %s\n状态: %s\n路径: %s\n描述: %s\n", + e.sk.Name(), e.sk.Version(), state, e.path, e.sk.Description()) + if tools := e.sk.Tools(); len(tools) > 0 { + b.WriteString("文档中声明的操作步骤:\n") + for _, td := range tools { + fmt.Fprintf(&b, " - %s: %s\n", td.Name, td.Description) + } + } + fmt.Fprintf(&b, "\n--- SKILL.md ---\n%s", e.sk.RawContent()) + return b.String(), nil + }) +} + +func (p *Plugin) registerLoad() { + p.sdk.RegisterTool(tp+"load", sdk.ToolDef{ + Name: tp + "load", + Description: "从 skills 目录热加载指定技能(目录名或单 .md 文件名),加载后立即可用。skill_create 填充完内容后调用此工具生效。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string", "description": "skills 目录下的条目名(不含路径)"}, + }, + "required": []string{"name"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + name, _ := args["name"].(string) + if strings.ContainsAny(name, "/\\") || strings.HasPrefix(name, ".") { + return nil, fmt.Errorf("invalid skill entry name: %q", name) + } + path := filepath.Join(p.skillsDir, name) + if _, err := os.Stat(path); err != nil { + return nil, fmt.Errorf("not found in skills dir: %s", path) + } + e, err := p.loadOne(path) + if err != nil { + return nil, err + } + return fmt.Sprintf("已加载技能 %s v%s", e.sk.Name(), e.sk.Version()), nil + }) +} + +func (p *Plugin) registerUnload() { + name := tp + "unload" + p.sdk.RegisterTool(name, sdk.ToolDef{ + Name: name, + Description: "卸载指定技能(仅从内存移除;磁盘文件保留。需删除文件用参数 delete_files)。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string", "description": "技能名称"}, + "delete_files": map[string]interface{}{"type": "boolean", "description": "是否同时删除磁盘文件(默认 false)"}, + }, + "required": []string{"name"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + name, _ := args["name"].(string) + delFiles, _ := args["delete_files"].(bool) + e, ok := p.get(name) + if !ok { + return nil, fmt.Errorf("skill %q not loaded", name) + } + path := e.path + if !p.removeOne(name) { + return nil, fmt.Errorf("skill %q not loaded", name) + } + msg := fmt.Sprintf("已卸载技能 %s(内存)", name) + if delFiles { + if err := os.RemoveAll(path); err != nil { + return msg + fmt.Sprintf(",但删除文件失败: %v", err), nil + } + msg += ",磁盘文件已删除" + } + return msg, nil + }) +} + +func (p *Plugin) registerEnable() { p.registerSetEnabled(true) } +func (p *Plugin) registerDisable() { + p.registerSetEnabled(false) +} + +func (p *Plugin) registerSetEnabled(v bool) { + action := "disable" + word := "禁用" + if v { + action = "enable" + word = "启用" + } + name := tp + action + desc := word + "指定技能。禁用后 agent 不再使用该技能(保留在列表中标记为禁用)。" + p.sdk.RegisterTool(name, sdk.ToolDef{ + Name: name, + Description: desc, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string", "description": "技能名称"}, + }, + "required": []string{"name"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + skillName, _ := args["name"].(string) + p.mu.Lock() + defer p.mu.Unlock() + e, ok := p.skills[skillName] + if !ok { + return nil, fmt.Errorf("skill %q not loaded", skillName) + } + e.enabled = v + e.sk.SetEnabled(v) + return fmt.Sprintf("已%s技能 %s", word, skillName), nil + }) +} + +func (p *Plugin) registerCreate() { + name := tp + "create" + p.sdk.RegisterTool(name, sdk.ToolDef{ + Name: name, + Description: "创建新技能骨架:生成标准 SKILL.md 模板写入 skills//。创建后在 content 参数填入完整文档内容并再次调用以落盘生效(两步式),或直接传 content 一步完成。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string", "description": "技能名(小写字母/数字/连字符,如 weather-notify)"}, + "description": map[string]interface{}{"type": "string", "description": "一句话描述技能用途(模板模式的 frontmatter description)"}, + "content": map[string]interface{}{"type": "string", "description": "可选。完整 SKILL.md 内容;提供时直接校验并写入,否则生成骨架模板"}, + "scripts": map[string]interface{}{"type": "boolean", "description": "模板模式是否创建 scripts/ 目录占位(默认 true)"}, + }, + "required": []string{"name"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + name, _ := args["name"].(string) + description, _ := args["description"].(string) + content, _ := args["content"].(string) + withScripts := true + if ws, ok := args["scripts"].(bool); ok { + withScripts = ws + } + if err := validateSkillName(name); err != nil { + return nil, err + } + + dir := filepath.Join(p.skillsDir, name) + + // 两步式第二步 / 一步式:提供 content 则校验后直接写入 + if strings.TrimSpace(content) != "" { + if err := plugin.ValidateSKILLContent(content); err != nil { + return nil, fmt.Errorf("content 校验失败: %w", err) + } + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(dir, SkillFileName), []byte(content), 0644); err != nil { + return nil, err + } + e, err := p.loadOne(dir) + if err != nil { + return nil, err + } + return fmt.Sprintf("技能 %s 已写入并加载生效(v%s)。用 skill_info 可查看全文。", e.sk.Name(), e.sk.Version()), nil + } + + // 模板模式:生成骨架 + if fileExists(filepath.Join(dir, SkillFileName)) { + return nil, fmt.Errorf("skill %s already exists at %s; use a different name or edit the file directly", name, dir) + } + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, err + } + if withScripts { + os.MkdirAll(filepath.Join(dir, "scripts"), 0755) + } + tpl := renderTemplate(name, description) + if err := os.WriteFile(filepath.Join(dir, SkillFileName), []byte(tpl), 0644); err != nil { + return nil, err + } + return fmt.Sprintf( + "骨架已生成: %s\n下一步:读取该文件,按实际用途补全正文与操作步骤(## 步骤小节会被解析为技能动作说明),\n然后调用 skill_create 并传入完整 content 覆盖写入,最后自动加载生效。", + filepath.Join(dir, SkillFileName)), nil + }) +} + +func (p *Plugin) registerExport() { + p.sdk.RegisterTool(tp+"export", sdk.ToolDef{ + Name: tp + "export", + Description: "导出指定技能为 .skm 分发包(tar.gz 格式),输出到 output 路径或默认 data/exports/ 下。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string", "description": "技能名称"}, + "output": map[string]interface{}{"type": "string", "description": "输出 .skm 文件完整路径(可选,默认 data/exports/.skm)"}, + }, + "required": []string{"name"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + name, _ := args["name"].(string) + output, _ := args["output"].(string) + e, ok := p.get(name) + if !ok { + return nil, fmt.Errorf("skill %q not loaded; use skill_list first", name) + } + outPath := output + if outPath == "" { + exportsDir := filepath.Join(p.skillsDir, "..", "exports") + os.MkdirAll(exportsDir, 0755) + outPath = filepath.Join(exportsDir, name+PackExt) + } + n, err := packSkill(e.path, outPath) + if err != nil { + return nil, err + } + return fmt.Sprintf("已导出 %d 个文件到 %s", n, outPath), nil + }) +} + +func (p *Plugin) registerInstall() { + p.sdk.RegisterTool(tp+"install", sdk.ToolDef{ + Name: tp + "install", + Description: "安装技能包:支持 .skm 包路径或 local: 本地目录。安装后立即加载生效。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "source": map[string]interface{}{"type": "string", "description": "安装来源:.skm 或 local:"}, + }, + "required": []string{"source"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + source, _ := args["source"].(string) + source = strings.TrimSpace(source) + switch { + case strings.HasPrefix(source, "local:"): + dir := strings.TrimPrefix(source, "local:") + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + return nil, fmt.Errorf("local dir not found: %s", dir) + } + dstName := filepath.Base(dir) + if err := validateSkillName(dstName); err != nil { + return nil, err + } + dst := filepath.Join(p.skillsDir, dstName) + if _, err := os.Stat(dst); err == nil { + return nil, fmt.Errorf("skill dir already exists: %s", dst) + } + if err := copyDir(dir, dst); err != nil { + return nil, fmt.Errorf("copy failed: %w", err) + } + e, err := p.loadOne(dst) + if err != nil { + os.RemoveAll(dst) + return nil, fmt.Errorf("installed but load failed: %w", err) + } + return fmt.Sprintf("已从本地目录安装技能 %s v%s", e.sk.Name(), e.sk.Version()), nil + + case strings.HasSuffix(strings.ToLower(source), PackExt): + if _, err := os.Stat(source); err != nil { + return nil, fmt.Errorf("pack not found: %s", source) + } + dstName := strings.TrimSuffix(filepath.Base(source), PackExt) + if err := validateSkillName(dstName); err != nil { + return nil, err + } + dst := filepath.Join(p.skillsDir, dstName) + if _, err := os.Stat(dst); err == nil { + return nil, fmt.Errorf("skill dir already exists: %s", dst) + } + n, err := unpackSkill(source, dst) + if err != nil { + os.RemoveAll(dst) + return nil, fmt.Errorf("unpack failed: %w", err) + } + e, err := p.loadOne(dst) + if err != nil { + os.RemoveAll(dst) + return nil, fmt.Errorf("unpacked %d files but load failed: %w", n, err) + } + return fmt.Sprintf("已安装 %d 个文件,技能 %s v%s 生效", n, e.sk.Name(), e.sk.Version()), nil + + default: + return nil, fmt.Errorf("unsupported source: %q (use .skm or local:)", source) + } + }) +} + +// validateSkillName 校验技能名:小写字母/数字/连字符,1-64 字符。 +func validateSkillName(name string) error { + if name == "" || len(name) > 64 { + return fmt.Errorf("invalid skill name length") + } + for _, r := range name { + ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' + if !ok { + return fmt.Errorf("invalid character %q in skill name %q (allow lowercase/digits/-/_/.)", r, name) + } + } + if strings.HasPrefix(name, ".") { + return fmt.Errorf("skill name cannot start with dot") + } + return nil +} + +// renderTemplate 生成 SKILL.md 骨架。 +func renderTemplate(name, description string) string { + if description == "" { + description = "(TODO: 一句话描述本技能的用途)" + } + return fmt.Sprintf(`--- +name: %s +version: 0.1.0 +author: homeagent +--- + +# %s + +%s + +## 使用时机 + +(TODO: 描述什么场景下 agent 应当使用本技能) + +## 操作步骤 + +### step-1 + +(TODO: 第一步做什么。首行非标题文本会作为该步骤的描述) + +- param1: 参数1说明 +- param2: 参数2说明 + +执行示例: + +`+"```"+`bash +echo "hello from %s" +`+"```"+` + +## 注意事项 + +- (TODO: 安全提示、失败重试策略等) +`, name, name, description, name) +}