266 lines
6.2 KiB
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")
|
|
}
|