diff --git a/pkg/skills/registry.go b/pkg/skills/registry.go new file mode 100644 index 0000000..6907d6e --- /dev/null +++ b/pkg/skills/registry.go @@ -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") +} diff --git a/pkg/tools/execute/code.go b/pkg/tools/execute/code.go index 189b699..5695b55 100644 --- a/pkg/tools/execute/code.go +++ b/pkg/tools/execute/code.go @@ -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) } diff --git a/pkg/tools/execute/server.go b/pkg/tools/execute/server.go index 365e473..bf66994 100644 --- a/pkg/tools/execute/server.go +++ b/pkg/tools/execute/server.go @@ -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 } diff --git a/pkg/tools/execute/skills.go b/pkg/tools/execute/skills.go new file mode 100644 index 0000000..075e280 --- /dev/null +++ b/pkg/tools/execute/skills.go @@ -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 +}