This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-core/skills/registry.go

193 lines
4.6 KiB
Go

// Package skills parses skill metadata from SKILL.md front-matter and
// provides an in-memory registry for skill discovery and session-level loading.
package skills
import (
"fmt"
"sort"
"os"
"path/filepath"
"strings"
"sync"
)
// SkillsPath returns the directory to search for skill modules.
// Resolved from OLLIE_SKILLS_PATH, then ~/.config/ollie/skills.
func SkillsPath() string {
if p := os.Getenv("OLLIE_SKILLS_PATH"); p != "" {
return p
}
home, _ := os.UserHomeDir()
return home + "/.config/ollie/skills"
}
// SkillInfo describes an available skill module.
type SkillInfo struct {
Name string
Description string
Dir string // directory containing SKILL.md
}
// Registry discovers and loads skills from the filesystem.
// Tracks which skills are loaded per session.
type Registry struct {
mu sync.RWMutex
global map[string]SkillInfo
sessions map[string]map[string]bool // sessionID → skill name → loaded
}
func NewRegistry() *Registry {
return &Registry{
global: make(map[string]SkillInfo),
sessions: make(map[string]map[string]bool),
}
}
// Discover scans the skills directory and registers all found skills.
func (r *Registry) Discover() error {
dir := SkillsPath()
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("read skills dir %s: %w", dir, err)
}
global := make(map[string]SkillInfo)
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
skillDir := filepath.Join(dir, name)
skillFile := filepath.Join(skillDir, "SKILL.md")
f, err := os.Open(skillFile)
if err != nil {
continue
}
meta, err := ParseFrontMatter(f, name, skillDir)
f.Close()
if err != nil {
continue
}
global[name] = SkillInfo{
Name: meta.Name,
Description: meta.Description,
Dir: skillDir,
}
}
r.mu.Lock()
r.global = global
r.mu.Unlock()
return nil
}
// Summaries returns all discovered skills.
func (r *Registry) Summaries() []SkillInfo {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]SkillInfo, 0, len(r.global))
for _, info := range r.global {
out = append(out, info)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Load marks a skill as loaded for the given session.
func (r *Registry) Load(sessionID, name string) error {
r.mu.RLock()
_, exists := r.global[name]
r.mu.RUnlock()
if !exists {
return fmt.Errorf("skill not found: %s", name)
}
r.mu.Lock()
defer r.mu.Unlock()
if r.sessions[sessionID] == nil {
r.sessions[sessionID] = make(map[string]bool)
}
r.sessions[sessionID][name] = true
return nil
}
// Unload removes a skill from the session's loaded set.
func (r *Registry) Unload(sessionID, name string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.sessions[sessionID]; ok {
delete(r.sessions[sessionID], name)
if len(r.sessions[sessionID]) == 0 {
delete(r.sessions, sessionID)
}
}
return nil
}
// Loaded returns skills currently loaded for a session.
func (r *Registry) Loaded(sessionID string) []SkillInfo {
r.mu.RLock()
defer r.mu.RUnlock()
names, ok := r.sessions[sessionID]
if !ok {
return nil
}
out := make([]SkillInfo, 0, len(names))
for name := range names {
if info, exists := r.global[name]; exists {
out = append(out, info)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// SkillContent reads and returns the full content of a skill's SKILL.md file,
// with front-matter stripped. Returns (content, error).
func (r *Registry) SkillContent(name string) (string, error) {
r.mu.RLock()
info, exists := r.global[name]
r.mu.RUnlock()
if !exists {
return "", fmt.Errorf("skill not found: %s", name)
}
data, err := os.ReadFile(filepath.Join(info.Dir, "SKILL.md"))
if err != nil {
return "", fmt.Errorf("read skill %s: %w", name, err)
}
return StripFrontMatter(string(data)), nil
}
// IsLoaded checks if a skill is loaded for the given session.
func (r *Registry) IsLoaded(sessionID, name string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
names, ok := r.sessions[sessionID]
if !ok {
return false
}
return names[name]
}
// StripFrontMatter removes YAML front-matter from a markdown string,
// returning everything after the closing ---.
func StripFrontMatter(content string) string {
lines := strings.Split(content, "\n")
if len(lines) < 2 || strings.TrimSpace(lines[0]) != "---" {
return content
}
inFM := false
bodyStart := 0
for i, line := range lines {
if strings.TrimSpace(line) == "---" {
if !inFM {
inFM = true
} else {
bodyStart = i + 1
break
}
}
}
if bodyStart == 0 {
return content
}
return strings.Join(lines[bodyStart:], "\n")
}