ollie/skills/skills.go

266 lines
6.2 KiB
Go

// Package skills provides skill discovery, embedding, and matching.
package skills
import (
"bufio"
"bytes"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"ollie/embedding"
"ollie/util"
)
// Skill represents a discovered skill with its metadata and content.
type Skill struct {
Name string
Description string
Path string // path to SKILL.md
Content string // full content (including frontmatter)
}
// Index holds precomputed skill embeddings for fast matching.
type Index struct {
model *embedding.Model
skills []Skill
vecs []embedding.Vector
mu sync.RWMutex
}
// NewIndex creates a new skill index with precomputed embeddings.
// modelDir is the path to the directory containing the ONNX model.
// skillDirs is a list of directories to scan for skills.
func NewIndex(modelDir string, skillDirs []string) (*Index, error) {
model, err := embedding.LoadModel(modelDir)
if err != nil {
return nil, fmt.Errorf("load embedding model: %w", err)
}
idx := &Index{model: model}
if err := idx.loadSkills(skillDirs); err != nil {
model.Close()
return nil, err
}
return idx, nil
}
// NewIndexFromSkills creates an index from pre-loaded skills.
// Takes ownership of the model.
func NewIndexFromSkills(model *embedding.Model, skills []Skill) (*Index, error) {
idx := &Index{model: model, skills: skills}
// Compute embeddings
vecs := make([]embedding.Vector, len(skills))
for i, skill := range skills {
vec, err := model.Embed(skill.Description)
if err != nil {
return nil, fmt.Errorf("embed skill %q: %w", skill.Name, err)
}
vecs[i] = vec
}
idx.vecs = vecs
return idx, nil
}
// Close releases resources.
func (idx *Index) Close() error {
if idx.model != nil {
return idx.model.Close()
}
return nil
}
// Match returns skills matching the query, sorted by relevance.
// threshold is the minimum cosine similarity (0-1) to include.
// limit is the maximum number of skills to return (0 for no limit).
func (idx *Index) Match(query string, threshold float32, limit int) ([]MatchResult, error) {
idx.mu.RLock()
defer idx.mu.RUnlock()
if len(idx.skills) == 0 {
return nil, nil
}
qvec, err := idx.model.Embed(query)
if err != nil {
return nil, fmt.Errorf("embed query: %w", err)
}
var results []MatchResult
for i, skill := range idx.skills {
sim := embedding.CosineSimilarity(qvec, idx.vecs[i])
if sim >= threshold {
results = append(results, MatchResult{
Skill: skill,
Score: sim,
})
}
}
// Sort by score descending
sort.Slice(results, func(i, j int) bool {
return results[i].Score > results[j].Score
})
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// MatchResult holds a matched skill and its similarity score.
type MatchResult struct {
Skill Skill
Score float32
}
// All returns all indexed skills.
func (idx *Index) All() []Skill {
idx.mu.RLock()
defer idx.mu.RUnlock()
return append([]Skill(nil), idx.skills...)
}
// Reload rescans skill directories and updates embeddings.
func (idx *Index) Reload(skillDirs []string) error {
idx.mu.Lock()
defer idx.mu.Unlock()
return idx.loadSkillsLocked(skillDirs)
}
func (idx *Index) loadSkills(dirs []string) error {
idx.mu.Lock()
defer idx.mu.Unlock()
return idx.loadSkillsLocked(dirs)
}
func (idx *Index) loadSkillsLocked(dirs []string) error {
var skills []Skill
seen := make(map[string]bool)
for _, dir := range dirs {
dir = util.ExpandHome(dir)
entries, err := os.ReadDir(dir)
if err != nil {
continue // skip missing directories
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
if seen[name] {
continue // first dir wins
}
skillPath := filepath.Join(dir, name, "SKILL.md")
skill, err := loadSkill(skillPath)
if err != nil {
continue // skip invalid skills
}
seen[name] = true
skills = append(skills, skill)
}
}
// Compute embeddings
vecs := make([]embedding.Vector, len(skills))
for i, skill := range skills {
vec, err := idx.model.Embed(skill.Description)
if err != nil {
return fmt.Errorf("embed skill %q: %w", skill.Name, err)
}
vecs[i] = vec
}
idx.skills = skills
idx.vecs = vecs
return nil
}
func loadSkill(path string) (Skill, error) {
data, err := os.ReadFile(path)
if err != nil {
return Skill{}, err
}
name, desc, err := parseFrontmatter(data)
if err != nil {
return Skill{}, err
}
return Skill{
Name: name,
Description: desc,
Path: path,
Content: string(data),
}, nil
}
// parseFrontmatter extracts name and description from YAML frontmatter.
func parseFrontmatter(data []byte) (name, description string, err error) {
scanner := bufio.NewScanner(bytes.NewReader(data))
// First line must be ---
if !scanner.Scan() || strings.TrimSpace(scanner.Text()) != "---" {
return "", "", fmt.Errorf("missing frontmatter")
}
// Read until closing ---
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "---" {
break
}
if key, val, ok := strings.Cut(line, ":"); ok {
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
switch key {
case "name":
name = val
case "description":
description = val
}
}
}
if name == "" {
return "", "", fmt.Errorf("missing name in frontmatter")
}
if description == "" {
return "", "", fmt.Errorf("missing description in frontmatter")
}
return name, description, nil
}
// DefaultSkillDirs returns the skill directories to search.
// Reads from ~/.config/ollie/embedding.conf if it exists (one path per line).
// Supports ~ and $VAR expansion. Otherwise, defaults to ~/.config/ollie/skills.
func DefaultSkillDirs() []string {
confPath := filepath.Join(util.CfgDir(), "embedding.conf")
data, err := os.ReadFile(confPath)
if err == nil {
var dirs []string
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
line = os.ExpandEnv(line)
dirs = append(dirs, util.ExpandHome(line))
}
}
if len(dirs) > 0 {
return dirs
}
}
return []string{filepath.Join(util.CfgDir(), "skills")}
}
// DefaultModelDir returns the default model directory.
func DefaultModelDir() string {
return filepath.Join(util.DataDir(), "models")
}