orca.ai/pkg/skill/manager.go
大森 6b94476347 Initial commit: Orca Agent Framework
Core features:
- Microkernel architecture with Actor model
- Session management with JSONL persistence
- Tool system (5 built-in tools)
- Skill system with SKILL.md parsing
- Sandbox security execution
- Ollama integration with gemma4:e4b
- Prompt-based tool calling (compatible with native function calling)
- REPL interface

11 packages, all tests passing
2026-05-08 00:55:48 +08:00

198 lines
4.8 KiB
Go

package skill
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
const (
// DefaultSkillsDir is the default directory for user-installed skills.
DefaultSkillsDir = "~/.agents/skills"
// SkillManifestFile is the name of the skill manifest file.
SkillManifestFile = "SKILL.md"
)
// Manager is a thread-safe registry for loading, storing, and querying Skills.
//
// Skills are loaded from a directory tree where each subdirectory containing
// a SKILL.md file is treated as a skill. The Manager automatically discovers
// skills on initialization and provides methods for finding skills by trigger
// keywords or by name.
type Manager struct {
mu sync.RWMutex
skillsDir string
skills map[string]*Skill
}
// NewManager creates a new Skill manager that scans the given directory for skills.
// If skillsDir is empty, DefaultSkillsDir is used.
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 scans the skills directory and loads all skills found.
// It returns the number of skills loaded and any errors encountered.
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 retrieves a skill by its name. Returns false if not found.
func (m *Manager) GetSkill(name string) (*Skill, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
skill, ok := m.skills[name]
return skill, ok
}
// ListSkills returns all loaded skills sorted by name.
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 finds skills whose triggers match the given query string.
// Returns all matching skills sorted by relevance (more trigger matches first).
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 returns the directory being scanned for skills.
func (m *Manager) SkillsDir() string {
return m.skillsDir
}
// Reload refreshes all skills from disk.
func (m *Manager) Reload() (int, error) {
return m.LoadAll()
}
// countMatches counts how many of the skill's triggers match the query.
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
}