169 lines
3.8 KiB
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)
|
|
}
|