ollie/cmd/olliesrv/internal/agent/skill_match.go

169 lines
3.8 KiB
Go

package agent
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"ollie/embedding"
"ollie/skills"
"ollie/util"
)
// Skill matching configuration.
const (
// skillMatchThreshold is the minimum cosine similarity to include a skill.
skillMatchThreshold = 0.35
// skillMatchLimit is the maximum number of skills to inject per turn.
skillMatchLimit = 3
// toolMatchThreshold is the minimum cosine similarity to include a tool hint.
toolMatchThreshold = 0.35
// toolMatchLimit is the maximum number of tool hints to inject per turn.
toolMatchLimit = 5
)
var (
skillIndex *skills.Index
skillInitErr error
skillOnce sync.Once
toolIndex *skills.Index
toolInitErr error
toolOnce sync.Once
)
// matchSkills finds skills relevant to the user's input and returns
// their content formatted for injection. Initializes the skill index
// on first call.
func matchSkills(input string) string {
skillOnce.Do(func() {
idx, err := skills.NewIndex(skills.DefaultModelDir(), skills.DefaultSkillDirs())
if err != nil {
skillInitErr = err
return
}
skillIndex = idx
})
if skillIndex == nil {
return ""
}
results, err := skillIndex.Match(input, skillMatchThreshold, skillMatchLimit)
if err != nil {
return ""
}
if len(results) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("<skills>\n")
for _, r := range results {
sb.WriteString("<skill name=\"")
sb.WriteString(r.Skill.Name)
sb.WriteString("\" relevance=\"")
sb.WriteString(formatScore(r.Score))
sb.WriteString("\">\n")
// Include full skill content (already includes frontmatter)
sb.WriteString(r.Skill.Content)
if !strings.HasSuffix(r.Skill.Content, "\n") {
sb.WriteString("\n")
}
sb.WriteString("</skill>\n")
}
sb.WriteString("</skills>\n")
return sb.String()
}
// matchTools finds tools relevant to the user's input and returns
// a hint block telling the model to call them.
func matchTools(input string) string {
toolOnce.Do(func() {
idx, err := newToolIndex()
if err != nil {
toolInitErr = err
return
}
toolIndex = idx
})
if toolIndex == nil {
return ""
}
results, err := toolIndex.Match(input, toolMatchThreshold, toolMatchLimit)
if err != nil {
return ""
}
if len(results) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("<tool-hints>\n")
sb.WriteString("Relevant tools for this request (auto-load on first call):\n\n")
for _, r := range results {
sb.WriteString("→ `")
sb.WriteString(r.Skill.Name)
sb.WriteString("({...})` — ")
sb.WriteString(r.Skill.Description)
sb.WriteString("\n")
}
sb.WriteString("\nDo NOT say \"I don't have this tool\" — just CALL IT.\n")
sb.WriteString("</tool-hints>\n")
return sb.String()
}
func formatScore(s float32) string {
// Format as percentage
pct := int(s * 100)
if pct > 99 {
pct = 99
}
return string([]byte{'0' + byte(pct/10), '0' + byte(pct%10), '%'})
}
// newToolIndex creates a skill-compatible index from tool .meta files.
func newToolIndex() (*skills.Index, error) {
model, err := embedding.LoadModel(skills.DefaultModelDir())
if err != nil {
return nil, err
}
toolsDir := filepath.Join(util.CfgDir(), "tools")
entries, err := os.ReadDir(toolsDir)
if err != nil {
model.Close()
return nil, err
}
var toolSkills []skills.Skill
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta") {
continue
}
metaPath := filepath.Join(toolsDir, entry.Name())
data, err := os.ReadFile(metaPath)
if err != nil {
continue
}
var meta struct {
Description string `json:"description"`
}
if json.Unmarshal(data, &meta) != nil || meta.Description == "" {
continue
}
toolName := strings.TrimSuffix(entry.Name(), ".meta")
toolSkills = append(toolSkills, skills.Skill{
Name: toolName,
Description: meta.Description,
})
}
return skills.NewIndexFromSkills(model, toolSkills)
}