230 lines
5.4 KiB
Go
230 lines
5.4 KiB
Go
// Package skills provides skill discovery and matching.
|
|
// Skills are markdown knowledge modules with YAML frontmatter.
|
|
package skills
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"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
|
|
index *embedding.Index
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// MatchResult holds a matched skill and its similarity score.
|
|
type MatchResult struct {
|
|
Skill Skill
|
|
Score float32
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 idx.index == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
results, err := idx.index.Match(query, threshold, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Convert embedding.MatchResult to MatchResult with full Skill
|
|
out := make([]MatchResult, len(results))
|
|
for i, r := range results {
|
|
// Find the skill by name
|
|
for _, skill := range idx.skills {
|
|
if skill.Name == r.Item.Name {
|
|
out[i] = MatchResult{Skill: skill, Score: r.Score}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Convert to embedding.Item for the generic index
|
|
items := make([]embedding.Item, len(skills))
|
|
for i, skill := range skills {
|
|
items[i] = embedding.Item{
|
|
Name: skill.Name,
|
|
Description: skill.Description,
|
|
}
|
|
}
|
|
|
|
// Build embedding index
|
|
embIdx, err := embedding.NewIndex(idx.model, items)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
idx.skills = skills
|
|
idx.index = embIdx
|
|
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")
|
|
}
|