- Add bubbletea/lipgloss/glamour dependencies for TUI - Create internal/tui package with EventWriter, styles, and bubbletea Model - Support streaming output display in conversation window - Add right panel with statistics and active agent status - Implement multi-agent collaboration with sub-agents - Add AgentCallTool for delegating tasks to sub-agents - Support parallel tool execution with goroutines - Auto-discover sub-agents from ~/.orca/prompts/ directory - Fix orchestrator routing based on msg.To field - Add non-blocking event writer with timeout to prevent blocking
248 lines
5.9 KiB
Go
248 lines
5.9 KiB
Go
// Package skill 提供 Skill 定义和管理系统。
|
|
package skill
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// frontmatterDelim 是 SKILL.md 文件中 YAML 前置元数据的分隔符。
|
|
const (
|
|
frontmatterDelim = "---"
|
|
)
|
|
|
|
// ParseSkillFile 解析 SKILL.md 文件并返回填充好的 Skill 结构体。
|
|
//
|
|
// 预期格式为:
|
|
//
|
|
// ---
|
|
// 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 从原始字节解析 SKILL.md 内容。
|
|
// path 参数用于定位 scripts/ 目录。
|
|
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 提取由 "---" 行分隔的 YAML 前置元数据
|
|
// 并填充 Skill 结构体字段。
|
|
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 解析技能前置元数据的简化 YAML 格式。
|
|
// 支持:字符串值、带引号的字符串和数组值。
|
|
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 解析 YAML 数组,如 '["a", "b", "c"]' 或 '[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 分割逗号分隔的字符串,尊重引号部分。
|
|
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 移除字符串值周围的引号。
|
|
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 列出 scripts 目录中所有可执行/可读的文件。
|
|
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 从包含 SKILL.md 文件的目录加载技能。
|
|
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)
|
|
}
|