orca.ai/pkg/skill/parser.go
大森 6b94476347 Initial commit: Orca Agent Framework
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
2026-05-08 00:55:48 +08:00

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)
}