add skills registry and skill_list/skill_load/skill_active built-in dispatchers

This commit is contained in:
ollie 2026-07-29 00:16:54 +02:00
parent 852ce1e41b
commit e3a9ff4bf3
4 changed files with 344 additions and 1 deletions

189
pkg/skills/registry.go Normal file
View File

@ -0,0 +1,189 @@
// Package skills parses skill metadata from SKILL.md front-matter and
// provides an in-memory registry for skill discovery and session-level loading.
package skills
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// SkillsPath returns the directory to search for skill modules.
// Resolved from OLLIE_SKILLS_PATH, then ~/.config/ollie/skills.
func SkillsPath() string {
if p := os.Getenv("OLLIE_SKILLS_PATH"); p != "" {
return p
}
home, _ := os.UserHomeDir()
return home + "/.config/ollie/skills"
}
// SkillInfo describes an available skill module.
type SkillInfo struct {
Name string
Description string
Dir string // directory containing SKILL.md
}
// Registry discovers and loads skills from the filesystem.
// Tracks which skills are loaded per session.
type Registry struct {
mu sync.RWMutex
global map[string]SkillInfo
sessions map[string]map[string]bool // sessionID → skill name → loaded
}
func NewRegistry() *Registry {
return &Registry{
global: make(map[string]SkillInfo),
sessions: make(map[string]map[string]bool),
}
}
// Discover scans the skills directory and registers all found skills.
func (r *Registry) Discover() error {
dir := SkillsPath()
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("read skills dir %s: %w", dir, err)
}
global := make(map[string]SkillInfo)
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
skillDir := filepath.Join(dir, name)
skillFile := filepath.Join(skillDir, "SKILL.md")
f, err := os.Open(skillFile)
if err != nil {
continue
}
meta, err := ParseFrontMatter(f, name, skillDir)
f.Close()
if err != nil {
continue
}
global[name] = SkillInfo{
Name: meta.Name,
Description: meta.Description,
Dir: skillDir,
}
}
r.mu.Lock()
r.global = global
r.mu.Unlock()
return nil
}
// Summaries returns all discovered skills.
func (r *Registry) Summaries() []SkillInfo {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]SkillInfo, 0, len(r.global))
for _, info := range r.global {
out = append(out, info)
}
return out
}
// Load marks a skill as loaded for the given session.
func (r *Registry) Load(sessionID, name string) error {
r.mu.RLock()
_, exists := r.global[name]
r.mu.RUnlock()
if !exists {
return fmt.Errorf("skill not found: %s", name)
}
r.mu.Lock()
defer r.mu.Unlock()
if r.sessions[sessionID] == nil {
r.sessions[sessionID] = make(map[string]bool)
}
r.sessions[sessionID][name] = true
return nil
}
// Unload removes a skill from the session's loaded set.
func (r *Registry) Unload(sessionID, name string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.sessions[sessionID]; ok {
delete(r.sessions[sessionID], name)
if len(r.sessions[sessionID]) == 0 {
delete(r.sessions, sessionID)
}
}
return nil
}
// Loaded returns skills currently loaded for a session.
func (r *Registry) Loaded(sessionID string) []SkillInfo {
r.mu.RLock()
defer r.mu.RUnlock()
names, ok := r.sessions[sessionID]
if !ok {
return nil
}
out := make([]SkillInfo, 0, len(names))
for name := range names {
if info, exists := r.global[name]; exists {
out = append(out, info)
}
}
return out
}
// SkillContent reads and returns the full content of a skill's SKILL.md file,
// with front-matter stripped. Returns (content, error).
func (r *Registry) SkillContent(name string) (string, error) {
r.mu.RLock()
info, exists := r.global[name]
r.mu.RUnlock()
if !exists {
return "", fmt.Errorf("skill not found: %s", name)
}
data, err := os.ReadFile(filepath.Join(info.Dir, "SKILL.md"))
if err != nil {
return "", fmt.Errorf("read skill %s: %w", name, err)
}
return StripFrontMatter(string(data)), nil
}
// IsLoaded checks if a skill is loaded for the given session.
func (r *Registry) IsLoaded(sessionID, name string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
names, ok := r.sessions[sessionID]
if !ok {
return false
}
return names[name]
}
// StripFrontMatter removes YAML front-matter from a markdown string,
// returning everything after the closing ---.
func StripFrontMatter(content string) string {
lines := strings.Split(content, "\n")
if len(lines) < 2 || strings.TrimSpace(lines[0]) != "---" {
return content
}
inFM := false
bodyStart := 0
for i, line := range lines {
if strings.TrimSpace(line) == "---" {
if !inFM {
inFM = true
} else {
bodyStart = i + 1
break
}
}
}
if bodyStart == 0 {
return content
}
return strings.Join(lines[bodyStart:], "\n")
}

View File

@ -58,6 +58,12 @@ func (e *Server) Dispatch(ctx context.Context, name string, args json.RawMessage
return dispatchToolLoad(ctx, e, args)
case "tool_active":
return dispatchToolActive(ctx, e, args)
case "skill_list":
return dispatchSkillList(ctx, e, args)
case "skill_load":
return dispatchSkillLoad(ctx, e, args)
case "skill_active":
return dispatchSkillActive(ctx, e, args)
default:
return "", fmt.Errorf("unknown execute tool: %s", name)
}

View File

@ -20,6 +20,7 @@ import (
"ollie/internal/sandbox"
"ollie/pkg/paths"
"ollie/pkg/tools"
"ollie/pkg/skills"
)
const (
@ -55,9 +56,13 @@ type Server struct {
allowTools map[string]bool
toolRegistry *Registry
skillsRegistry *skills.Registry
sessionID string
// OnInjection is called when a skill is loaded and its content
// should be injected into the agent's context.
OnInjection func(content string)
// rate limiting state (per-Server)
rateLimitMu sync.Mutex
validationFailures int
@ -193,6 +198,9 @@ Returns tools with descriptions, one per line.`,
if e.toolRegistry != nil && e.sessionID != "" {
all = append(all, e.toolRegistry.Loaded(e.sessionID)...)
}
// Append skill tools if skills registry is available
skillTools := ListSkillsTools(e.skillsRegistry, e.sessionID)
all = append(all, skillTools...)
return all, nil
}

140
pkg/tools/execute/skills.go Normal file
View File

@ -0,0 +1,140 @@
package execute
import (
"context"
"encoding/json"
"fmt"
"strings"
"ollie/pkg/skills"
"ollie/pkg/tools"
)
// SetSkillsRegistry attaches a skills registry to the execute server.
// The server uses it to serve skill_list, skill_load, and skill_active.
func (e *Server) SetSkillsRegistry(r *skills.Registry) {
e.skillsRegistry = r
}
// ListSkillsTools returns ToolInfo entries for the skill_* built-ins.
// These are included alongside the standard tool_* tools.
func ListSkillsTools(skillsReg *skills.Registry, sessionID string) []tools.ToolInfo {
if skillsReg == nil {
return nil
}
tools := []tools.ToolInfo{
{
Name: "skill_list",
Description: `List available skill modules with name and description.
Usage: {"name": "skillname"} — if name is provided, loads that skill.
Otherwise lists all skills with descriptions.`,
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {}
}`),
},
{
Name: "skill_load",
Description: `Load a skill module into the current session's context.
Usage: {"name": "skillname"}
The skill content (SKILL.md body) is injected into the agent's context
and persists across turns.`,
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["name"],
"properties": {
"name": {"type": "string", "description": "Skill name to load."}
}
}`),
},
{
Name: "skill_active",
Description: `List skills currently loaded in this session.
Usage: (no arguments)
Returns loaded skill names, one per line.`,
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {}
}`),
},
}
return tools
}
// dispatchSkillList lists all available skills from the global registry.
func dispatchSkillList(ctx context.Context, e *Server, args json.RawMessage) (string, error) {
if e.skillsRegistry == nil {
return "", fmt.Errorf("skill_list: no skills registry available")
}
summaries := e.skillsRegistry.Summaries()
if len(summaries) == 0 {
return "(no skills found)", nil
}
var out strings.Builder
for _, s := range summaries {
out.WriteString(s.Name)
if s.Description != "" {
out.WriteString(" — ")
out.WriteString(s.Description)
}
out.WriteString("\n")
}
return strings.TrimRight(out.String(), "\n"), nil
}
// dispatchSkillLoad loads a skill into the current session.
func dispatchSkillLoad(ctx context.Context, e *Server, args json.RawMessage) (string, error) {
var a struct {
Name string `json:"name"`
}
if err := json.Unmarshal(args, &a); err != nil {
return "", fmt.Errorf("skill_load: bad args: %w", err)
}
if a.Name == "" {
return "", fmt.Errorf("skill_load: name is required")
}
if e.skillsRegistry == nil || e.sessionID == "" {
return "", fmt.Errorf("skill_load: no session registry")
}
if err := e.skillsRegistry.Load(e.sessionID, a.Name); err != nil {
return "", fmt.Errorf("skill_load: %w", err)
}
content, err := e.skillsRegistry.SkillContent(a.Name)
if err != nil {
return "", fmt.Errorf("skill_load: %w", err)
}
// Inject skill content into the agent context.
// Skill content is sent as a system message so it persists across turns.
injectMsg := fmt.Sprintf("[system: loaded skill %s]\n\n%s", a.Name, content)
if e.OnInjection != nil {
e.OnInjection(injectMsg)
}
return fmt.Sprintf("loaded: %s (%d bytes)", a.Name, len(content)), nil
}
// dispatchSkillActive lists skills currently loaded in this session.
func dispatchSkillActive(ctx context.Context, e *Server, args json.RawMessage) (string, error) {
if e.skillsRegistry == nil || e.sessionID == "" {
return "(no skills loaded)", nil
}
loaded := e.skillsRegistry.Loaded(e.sessionID)
if len(loaded) == 0 {
return "(no skills loaded)", nil
}
var out strings.Builder
for _, s := range loaded {
out.WriteString(s.Name)
if s.Description != "" {
out.WriteString(" — ")
out.WriteString(s.Description)
}
out.WriteString("\n")
}
return strings.TrimRight(out.String(), "\n"), nil
}