- 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
202 lines
5.0 KiB
Go
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
|
|
}
|