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
198 lines
4.8 KiB
Go
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
|
|
}
|