Core features: - Microkernel architecture with Actor model - Session management with JSONL persistence - Tool system (5 built-in tools) - Skill system with SKILL.md parsing - Sandbox security execution - Ollama integration with gemma4:e4b - Prompt-based tool calling (compatible with native function calling) - REPL interface 11 packages, all tests passing
247 lines
5.8 KiB
Go
247 lines
5.8 KiB
Go
package skill
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// FrontmatterDelimiters for YAML frontmatter in SKILL.md files.
|
|
const (
|
|
frontmatterDelim = "---"
|
|
)
|
|
|
|
// ParseSkillFile parses a SKILL.md file and returns a populated Skill struct.
|
|
//
|
|
// The expected format is:
|
|
//
|
|
// ---
|
|
// name: my-skill
|
|
// description: Does something useful
|
|
// triggers: ["keyword1", "keyword2"]
|
|
// ---
|
|
//
|
|
// # My Skill
|
|
//
|
|
// Detailed description...
|
|
func ParseSkillFile(path string) (*Skill, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("skill: cannot read %q: %w", path, err)
|
|
}
|
|
|
|
return ParseSkillData(path, data)
|
|
}
|
|
|
|
// ParseSkillData parses SKILL.md content from raw bytes.
|
|
// The path parameter is used to locate the scripts/ directory.
|
|
func ParseSkillData(path string, data []byte) (*Skill, error) {
|
|
content := string(data)
|
|
|
|
skill := &Skill{
|
|
Path: path,
|
|
Body: content,
|
|
Triggers: []string{},
|
|
}
|
|
|
|
// Parse YAML frontmatter
|
|
rest, err := parseFrontmatter(content, skill)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
skill.Body = strings.TrimSpace(rest)
|
|
|
|
// Validate required fields
|
|
if skill.Name == "" {
|
|
return nil, fmt.Errorf("skill: %q is missing 'name' in frontmatter", path)
|
|
}
|
|
|
|
// Discover scripts directory
|
|
skillDir := filepath.Dir(path)
|
|
scriptsDir := filepath.Join(skillDir, "scripts")
|
|
skill.ScriptsDir = scriptsDir
|
|
|
|
if info, err := os.Stat(scriptsDir); err == nil && info.IsDir() {
|
|
scripts, err := discoverScripts(scriptsDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("skill: failed to discover scripts in %q: %w", scriptsDir, err)
|
|
}
|
|
skill.Scripts = scripts
|
|
}
|
|
|
|
return skill, nil
|
|
}
|
|
|
|
// parseFrontmatter extracts YAML frontmatter delimited by "---" lines
|
|
// and populates the Skill struct fields.
|
|
func parseFrontmatter(content string, skill *Skill) (string, error) {
|
|
content = strings.TrimSpace(content)
|
|
|
|
if !strings.HasPrefix(content, frontmatterDelim) {
|
|
// No frontmatter — treat entire content as body
|
|
return content, nil
|
|
}
|
|
|
|
// Find the closing delimiter
|
|
rest := content[len(frontmatterDelim):]
|
|
rest = strings.TrimLeft(rest, "\n\r")
|
|
|
|
endIdx := strings.Index(rest, "\n"+frontmatterDelim)
|
|
if endIdx < 0 {
|
|
// Also check for end-of-file style
|
|
endIdx = strings.Index(rest, frontmatterDelim)
|
|
if endIdx < 0 {
|
|
return "", fmt.Errorf("skill: unclosed frontmatter in skill file")
|
|
}
|
|
}
|
|
|
|
frontmatter := rest[:endIdx]
|
|
body := rest[endIdx+len(frontmatterDelim)+1:]
|
|
|
|
// Parse the YAML frontmatter (simple key-value parser)
|
|
if err := parseSimpleYAML(frontmatter, skill); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return body, nil
|
|
}
|
|
|
|
// parseSimpleYAML parses a simplified YAML format for skill frontmatter.
|
|
// Supports: string values, quoted strings, and array values.
|
|
func parseSimpleYAML(yaml string, skill *Skill) error {
|
|
lines := strings.Split(yaml, "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
colonIdx := strings.Index(line, ":")
|
|
if colonIdx < 0 {
|
|
continue
|
|
}
|
|
|
|
key := strings.TrimSpace(line[:colonIdx])
|
|
value := strings.TrimSpace(line[colonIdx+1:])
|
|
|
|
switch key {
|
|
case "name":
|
|
skill.Name = trimQuotes(value)
|
|
|
|
case "description":
|
|
skill.Description = trimQuotes(value)
|
|
|
|
case "triggers":
|
|
triggers, err := parseYAMLArray(value)
|
|
if err != nil {
|
|
return fmt.Errorf("skill: invalid triggers format: %w", err)
|
|
}
|
|
skill.Triggers = triggers
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseYAMLArray parses a YAML array like '["a", "b", "c"]' or '[a, b, c]'.
|
|
func parseYAMLArray(value string) ([]string, error) {
|
|
value = strings.TrimSpace(value)
|
|
|
|
// Handle YAML list format: ["a", "b"] or [a, b]
|
|
if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
|
|
inner := value[1 : len(value)-1]
|
|
if strings.TrimSpace(inner) == "" {
|
|
return []string{}, nil
|
|
}
|
|
parts := splitCommas(inner)
|
|
result := make([]string, len(parts))
|
|
for i, p := range parts {
|
|
result[i] = trimQuotes(strings.TrimSpace(p))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Handle YAML list format with dashes:
|
|
// triggers:
|
|
// - browser
|
|
// - navigate
|
|
// (This would be handled line-by-line in a different flow)
|
|
// For now, treat single value as a one-element list
|
|
if value != "" && value != "[]" {
|
|
return []string{trimQuotes(value)}, nil
|
|
}
|
|
|
|
return []string{}, nil
|
|
}
|
|
|
|
// splitCommas splits a comma-separated string respecting quoted sections.
|
|
func splitCommas(s string) []string {
|
|
var parts []string
|
|
var current strings.Builder
|
|
inQuote := false
|
|
quoteChar := byte(0)
|
|
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if inQuote {
|
|
current.WriteByte(c)
|
|
if c == quoteChar {
|
|
inQuote = false
|
|
}
|
|
} else if c == '"' || c == '\'' {
|
|
current.WriteByte(c)
|
|
inQuote = true
|
|
quoteChar = c
|
|
} else if c == ',' {
|
|
parts = append(parts, current.String())
|
|
current.Reset()
|
|
} else {
|
|
current.WriteByte(c)
|
|
}
|
|
}
|
|
if current.Len() > 0 {
|
|
parts = append(parts, current.String())
|
|
}
|
|
return parts
|
|
}
|
|
|
|
// trimQuotes removes surrounding quotes from a string value.
|
|
func trimQuotes(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if len(s) >= 2 {
|
|
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
|
|
return s[1 : len(s)-1]
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// discoverScripts lists all executable/readable files in a scripts directory.
|
|
func discoverScripts(scriptsDir string) ([]string, error) {
|
|
entries, err := os.ReadDir(scriptsDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var scripts []string
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
scripts = append(scripts, entry.Name())
|
|
}
|
|
|
|
sort.Strings(scripts)
|
|
return scripts, nil
|
|
}
|
|
|
|
// LoadSkillFromDir loads a skill from a directory containing a SKILL.md file.
|
|
func LoadSkillFromDir(dir string) (*Skill, error) {
|
|
skillPath := filepath.Join(dir, "SKILL.md")
|
|
if _, err := os.Stat(skillPath); os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("skill: no SKILL.md found in %q", dir)
|
|
}
|
|
return ParseSkillFile(skillPath)
|
|
}
|