orca.ai/pkg/skill/manager.go
大森 e18dde7c15 feat: implement TUI with bubbletea and multi-agent collaboration
- Add bubbletea/lipgloss/glamour dependencies for TUI
- Create internal/tui package with EventWriter, styles, and bubbletea Model
- Support streaming output display in conversation window
- Add right panel with statistics and active agent status
- Implement multi-agent collaboration with sub-agents
- Add AgentCallTool for delegating tasks to sub-agents
- Support parallel tool execution with goroutines
- Auto-discover sub-agents from ~/.orca/prompts/ directory
- Fix orchestrator routing based on msg.To field
- Add non-blocking event writer with timeout to prevent blocking
2026-05-10 14:28:17 +08:00

202 lines
5.0 KiB
Go

// Package skill 提供 Skill 定义和管理系统。
//
// 技能是从 ~/.agents/skills/ 加载的可组合能力。
// 每个技能都有一个带 YAML 前置元数据的 SKILL.md 清单文件,
// 以及可选的 scripts/ 子目录中的脚本。
// 技能可以通过触发关键词被发现和调用。
package skill
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
const (
// DefaultSkillsDir 是用户安装技能的默认目录。
DefaultSkillsDir = "~/.agents/skills"
// SkillManifestFile 是技能清单文件的名称。
SkillManifestFile = "SKILL.md"
)
// Manager 是一个用于加载、存储和查询技能的线程安全注册表。
//
// 技能从目录树加载,每个包含 SKILL.md 文件的子目录都被视为一个技能。
// 管理器在初始化时自动发现技能,并提供按触发关键词或名称查找技能的方法。
type Manager struct {
mu sync.RWMutex
skillsDir string
skills map[string]*Skill
}
// NewManager 创建一个新的技能管理器,扫描给定目录中的技能。
// 如果 skillsDir 为空,则使用 DefaultSkillsDir。
func NewManager(skillsDir string) *Manager {
if skillsDir == "" {
skillsDir = DefaultSkillsDir
}
// Expand ~ to home directory
skillsDir = expandHome(skillsDir)
return &Manager{
skillsDir: skillsDir,
skills: make(map[string]*Skill),
}
}
// LoadAll 扫描技能目录并加载所有找到的技能。
// 返回加载的技能数量和遇到的任何错误。
func (m *Manager) LoadAll() (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
// Clear existing skills
m.skills = make(map[string]*Skill)
// Check if skills directory exists
info, err := os.Stat(m.skillsDir)
if err != nil {
if os.IsNotExist(err) {
return 0, nil // No skills directory yet — not an error
}
return 0, fmt.Errorf("skill: cannot access skills directory %q: %w", m.skillsDir, err)
}
if !info.IsDir() {
return 0, fmt.Errorf("skill: %q is not a directory", m.skillsDir)
}
// Read all entries in the skills directory
entries, err := os.ReadDir(m.skillsDir)
if err != nil {
return 0, fmt.Errorf("skill: failed to read skills directory %q: %w", m.skillsDir, err)
}
var loadErrors []string
loaded := 0
for _, entry := range entries {
if !entry.IsDir() {
continue
}
skillDir := filepath.Join(m.skillsDir, entry.Name())
skillPath := filepath.Join(skillDir, SkillManifestFile)
if _, err := os.Stat(skillPath); os.IsNotExist(err) {
continue // No SKILL.md in this directory — skip
}
skill, err := ParseSkillFile(skillPath)
if err != nil {
loadErrors = append(loadErrors, fmt.Sprintf("%s: %v", entry.Name(), err))
continue
}
m.skills[skill.Name] = skill
loaded++
}
if len(loadErrors) > 0 {
return loaded, fmt.Errorf("skill: loaded %d skills with %d errors: %s",
loaded, len(loadErrors), joinStrings(loadErrors, "; "))
}
return loaded, nil
}
// GetSkill 按名称检索技能。如果未找到则返回 false。
func (m *Manager) GetSkill(name string) (*Skill, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
skill, ok := m.skills[name]
return skill, ok
}
// ListSkills 返回按名称排序的所有已加载技能。
func (m *Manager) ListSkills() []*Skill {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]*Skill, 0, len(m.skills))
for _, skill := range m.skills {
result = append(result, skill)
}
sort.Slice(result, func(i, j int) bool {
return result[i].Name < result[j].Name
})
return result
}
// FindSkill 查找触发器与给定查询字符串匹配的技能。
// 返回所有匹配的技能,按相关性排序(触发器匹配多的优先)。
func (m *Manager) FindSkill(query string) []*Skill {
m.mu.RLock()
defer m.mu.RUnlock()
var matches []*Skill
for _, skill := range m.skills {
if skill.MatchTrigger(query) {
matches = append(matches, skill)
}
}
// Sort by number of matching triggers (descending)
sort.Slice(matches, func(i, j int) bool {
return countMatches(matches[i], query) > countMatches(matches[j], query)
})
return matches
}
// SkillsDir 返回正在扫描技能的目录。
func (m *Manager) SkillsDir() string {
return m.skillsDir
}
// Reload 从磁盘刷新所有技能。
func (m *Manager) Reload() (int, error) {
return m.LoadAll()
}
// countMatches 计算技能的触发器中有多少个与查询匹配。
func countMatches(skill *Skill, query string) int {
count := 0
queryLower := strings.ToLower(query)
for _, trigger := range skill.Triggers {
if strings.Contains(queryLower, strings.ToLower(trigger)) {
count++
}
}
return count
}
// expandHome replaces "~" with the user's home directory.
func expandHome(path string) string {
if len(path) > 0 && path[0] == '~' {
home, err := os.UserHomeDir()
if err != nil {
return path
}
return filepath.Join(home, path[1:])
}
return path
}
// joinStrings joins a slice of strings with a separator.
func joinStrings(parts []string, sep string) string {
if len(parts) == 0 {
return ""
}
result := parts[0]
for _, p := range parts[1:] {
result += sep + p
}
return result
}