diff --git a/cmd/olliesrv/internal/agent/skill_match.go b/cmd/olliesrv/internal/agent/skill_match.go
index 0d23cf3..301e5aa 100644
--- a/cmd/olliesrv/internal/agent/skill_match.go
+++ b/cmd/olliesrv/internal/agent/skill_match.go
@@ -1,39 +1,22 @@
package agent
import (
- "encoding/json"
- "os"
- "path/filepath"
- "sort"
"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
+ skillMatchLimit = 3
)
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
@@ -69,7 +52,6 @@ func matchSkills(input string) string {
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")
@@ -80,113 +62,10 @@ func matchSkills(input string) string {
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 ""
- }
-
- workflowText := normalizeWorkflow(input).SearchText()
- workflow := normalizeWorkflow(input)
- searchInput := input
- if workflowText != "" {
- searchInput += " Workflow: " + workflowText
- }
- results, err := toolIndex.Match(searchInput, toolMatchThreshold, toolMatchLimit)
- if err != nil {
- return ""
- }
- for i := range results {
- results[i].Score += workflowToolAdjustment(results[i].Skill.Name, workflow)
- }
- sort.SliceStable(results, func(i, j int) bool {
- return results[i].Score > results[j].Score
- })
- if len(results) == 0 {
- return ""
- }
-
- var sb strings.Builder
- sb.WriteString("\n")
- sb.WriteString("Relevant tools for this request:\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("\nTo make one callable, use client_9p to load it through the agent ctl file:\n")
- sb.WriteString("client_9p(op=\"rdwr\", path=\"session/$OLLIE_SESSION_ID/agent/$OLLIE_UNAME/ctl\", data=\"tool_load \")\n")
- sb.WriteString("Then call the loaded tool.\n")
- sb.WriteString("\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"`
- Prompt string `json:"prompt"`
- Keywords []string `json:"keywords"`
- }
- if json.Unmarshal(data, &meta) != nil || meta.Description == "" {
- continue
- }
- toolName := strings.TrimSuffix(entry.Name(), ".meta")
- searchText := meta.Description
- if meta.Prompt != "" {
- searchText += " Usage: " + meta.Prompt
- }
- if len(meta.Keywords) > 0 {
- searchText += " Keywords: " + strings.Join(meta.Keywords, ", ")
- }
- toolSkills = append(toolSkills, skills.Skill{
- Name: toolName,
- Description: searchText,
- })
- }
-
- return skills.NewIndexFromSkills(model, toolSkills)
-}
diff --git a/cmd/olliesrv/internal/agent/tool_match.go b/cmd/olliesrv/internal/agent/tool_match.go
new file mode 100644
index 0000000..63ecb90
--- /dev/null
+++ b/cmd/olliesrv/internal/agent/tool_match.go
@@ -0,0 +1,107 @@
+package agent
+
+import (
+ "sort"
+ "strings"
+ "sync"
+
+ "ollie/embedding"
+ "ollie/skills"
+ "ollie/toolsrv/protocol"
+)
+
+// Tool matching configuration.
+const (
+ toolMatchThreshold = 0.35
+ toolMatchLimit = 5
+)
+
+var (
+ // Shared embedding model for tool matching.
+ toolEmbedModel *embedding.Model
+ toolEmbedErr error
+ toolEmbedOnce sync.Once
+)
+
+// getToolEmbedModel returns the shared embedding model, loading it on first call.
+func getToolEmbedModel() *embedding.Model {
+ toolEmbedOnce.Do(func() {
+ toolEmbedModel, toolEmbedErr = embedding.LoadModel(skills.DefaultModelDir())
+ })
+ return toolEmbedModel
+}
+
+// matchTools finds tools relevant to the user's input from the loaded tools
+// and returns a hint block telling the model to call them.
+func matchTools(input string, tools []protocol.ToolInfo) string {
+ if len(tools) == 0 {
+ return ""
+ }
+
+ model := getToolEmbedModel()
+ if model == nil {
+ return ""
+ }
+
+ // Convert tools to embedding items
+ items := make([]embedding.Item, 0, len(tools))
+ for _, ti := range tools {
+ if ti.Description == "" {
+ continue
+ }
+ searchText := ti.Description
+ if ti.Prompt != "" {
+ searchText += " Usage: " + ti.Prompt
+ }
+ items = append(items, embedding.Item{
+ Name: ti.Name,
+ Description: searchText,
+ })
+ }
+ if len(items) == 0 {
+ return ""
+ }
+
+ // Build index and match
+ idx, err := embedding.NewIndex(model, items)
+ if err != nil {
+ return ""
+ }
+
+ workflowText := normalizeWorkflow(input).SearchText()
+ workflow := normalizeWorkflow(input)
+ searchInput := input
+ if workflowText != "" {
+ searchInput += " Workflow: " + workflowText
+ }
+
+ results, err := idx.Match(searchInput, toolMatchThreshold, toolMatchLimit)
+ if err != nil {
+ return ""
+ }
+
+ // Apply workflow adjustments
+ for i := range results {
+ results[i].Score += workflowToolAdjustment(results[i].Item.Name, workflow)
+ }
+ sort.SliceStable(results, func(i, j int) bool {
+ return results[i].Score > results[j].Score
+ })
+
+ if len(results) == 0 {
+ return ""
+ }
+
+ var sb strings.Builder
+ sb.WriteString("\n")
+ sb.WriteString("Relevant tools for this request:\n\n")
+ for _, r := range results {
+ sb.WriteString("→ `")
+ sb.WriteString(r.Item.Name)
+ sb.WriteString("({...})` — ")
+ sb.WriteString(r.Item.Description)
+ sb.WriteString("\n")
+ }
+ sb.WriteString("\n")
+ return sb.String()
+}
diff --git a/cmd/olliesrv/internal/agent/turn.go b/cmd/olliesrv/internal/agent/turn.go
index d6994a4..9b5eeca 100644
--- a/cmd/olliesrv/internal/agent/turn.go
+++ b/cmd/olliesrv/internal/agent/turn.go
@@ -131,8 +131,13 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
if skillContent := matchSkills(input); skillContent != "" {
contextParts = append(contextParts, skillContent)
}
- if ag.runtime.ToolServer != nil {
- if toolHints := matchTools(input); toolHints != "" {
+ if len(ag.runtime.ToolMeta) > 0 {
+ // Convert map to slice for matching
+ tools := make([]protocol.ToolInfo, 0, len(ag.runtime.ToolMeta))
+ for _, ti := range ag.runtime.ToolMeta {
+ tools = append(tools, ti)
+ }
+ if toolHints := matchTools(input, tools); toolHints != "" {
contextParts = append(contextParts, toolHints)
}
}
diff --git a/cmd/toolsrv/internal/server/proc.go b/cmd/toolsrv/internal/server/proc.go
index eda1ac8..041b91b 100644
--- a/cmd/toolsrv/internal/server/proc.go
+++ b/cmd/toolsrv/internal/server/proc.go
@@ -290,14 +290,7 @@ func (st *State) NewProc(ctx context.Context, payload string, background bool) (
info, ok := reg.Lookup(aid, toolName)
if !ok {
- // Auto-load if tool exists
- if err := reg.Load(aid, toolName); err != nil {
- return "", 0, err // "tool not found: X"
- }
- info, _ = reg.Lookup(aid, toolName)
- if st.OnToolsChanged != nil {
- st.OnToolsChanged()
- }
+ return "", 0, fmt.Errorf("tool not loaded: %s (add to autoLoad in agent config)", toolName)
}
// Reject shell calls that invoke a native tool.
diff --git a/embedding/index.go b/embedding/index.go
new file mode 100644
index 0000000..af84a89
--- /dev/null
+++ b/embedding/index.go
@@ -0,0 +1,87 @@
+package embedding
+
+import (
+ "fmt"
+ "sort"
+ "sync"
+)
+
+// Item represents something that can be embedded and matched.
+type Item struct {
+ Name string
+ Description string // text used for embedding
+}
+
+// Index holds precomputed embeddings for semantic matching.
+type Index struct {
+ model *Model
+ items []Item
+ vecs []Vector
+ mu sync.RWMutex
+}
+
+// NewIndex creates an index from items using the given embedding model.
+// The model is borrowed, not owned — caller is responsible for its lifecycle.
+func NewIndex(model *Model, items []Item) (*Index, error) {
+ if model == nil {
+ return nil, fmt.Errorf("model is nil")
+ }
+
+ vecs := make([]Vector, len(items))
+ for i, item := range items {
+ vec, err := model.Embed(item.Description)
+ if err != nil {
+ return nil, fmt.Errorf("embed %q: %w", item.Name, err)
+ }
+ vecs[i] = vec
+ }
+
+ return &Index{
+ model: model,
+ items: items,
+ vecs: vecs,
+ }, nil
+}
+
+// Match returns items matching the query, sorted by relevance.
+// threshold is the minimum cosine similarity (0-1) to include.
+// limit is the maximum number of results (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.items) == 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, item := range idx.items {
+ sim := CosineSimilarity(qvec, idx.vecs[i])
+ if sim >= threshold {
+ results = append(results, MatchResult{
+ Item: item,
+ Score: sim,
+ })
+ }
+ }
+
+ 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 item and its similarity score.
+type MatchResult struct {
+ Item Item
+ Score float32
+}
diff --git a/skills/skills.go b/skills/skills.go
index 333280e..f6b849d 100644
--- a/skills/skills.go
+++ b/skills/skills.go
@@ -1,4 +1,5 @@
-// Package skills provides skill discovery, embedding, and matching.
+// Package skills provides skill discovery and matching.
+// Skills are markdown knowledge modules with YAML frontmatter.
package skills
import (
@@ -7,7 +8,6 @@ import (
"fmt"
"os"
"path/filepath"
- "sort"
"strings"
"sync"
@@ -27,10 +27,16 @@ type Skill struct {
type Index struct {
model *embedding.Model
skills []Skill
- vecs []embedding.Vector
+ 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.
@@ -48,25 +54,6 @@ func NewIndex(modelDir string, skillDirs []string) (*Index, error) {
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 {
@@ -82,41 +69,27 @@ func (idx *Index) Match(query string, threshold float32, limit int) ([]MatchResu
idx.mu.RLock()
defer idx.mu.RUnlock()
- if len(idx.skills) == 0 {
+ if idx.index == nil {
return nil, nil
}
- qvec, err := idx.model.Embed(query)
+ results, err := idx.index.Match(query, threshold, limit)
if err != nil {
- return nil, fmt.Errorf("embed query: %w", err)
+ return nil, 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,
- })
+ // 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
+ }
}
}
-
- // 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
+ return out, nil
}
// All returns all indexed skills.
@@ -167,18 +140,23 @@ func (idx *Index) loadSkillsLocked(dirs []string) error {
}
}
- // Compute embeddings
- vecs := make([]embedding.Vector, len(skills))
+ // Convert to embedding.Item for the generic index
+ items := make([]embedding.Item, 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)
+ items[i] = embedding.Item{
+ Name: skill.Name,
+ Description: skill.Description,
}
- vecs[i] = vec
+ }
+
+ // Build embedding index
+ embIdx, err := embedding.NewIndex(idx.model, items)
+ if err != nil {
+ return err
}
idx.skills = skills
- idx.vecs = vecs
+ idx.index = embIdx
return nil
}