package lessons import ( "fmt" "os" "path/filepath" "regexp" "strings" "sync" "time" "gopkg.in/yaml.v3" ) type LessonMode string const ( ModeInteractive LessonMode = "interactive" ModeAutonomous LessonMode = "autonomous" ModeBoth LessonMode = "both" ) type Lesson struct { Name string `yaml:"name" json:"name"` Title string `yaml:"title" json:"title"` Description string `yaml:"description" json:"description"` Category string `yaml:"category" json:"category"` Triggers Triggers `yaml:"triggers" json:"triggers"` Content string `yaml:"content" json:"content"` Mode LessonMode `yaml:"mode" json:"mode"` Priority int `yaml:"priority" json:"priority"` Enabled bool `yaml:"enabled" json:"enabled"` Path string `yaml:"-" json:"path,omitempty"` } type Triggers struct { Keywords []string `yaml:"keywords" json:"keywords"` Tools []string `yaml:"tools" json:"tools"` Patterns []string `yaml:"patterns" json:"patterns"` } type MatchContext struct { Message string `json:"message"` ToolsUsed []string `json:"tools_used,omitempty"` Mode string `json:"mode,omitempty"` } type MatchResult struct { Lesson *Lesson `json:"lesson"` Score float64 `json:"score"` } type LessonFrontmatter struct { Name string `yaml:"name"` Title string `yaml:"title"` Description string `yaml:"description"` Category string `yaml:"category"` Mode LessonMode `yaml:"mode"` Priority int `yaml:"priority"` Enabled *bool `yaml:"enabled"` Triggers Triggers `yaml:"triggers"` } type LessonIndex struct { mu sync.RWMutex lessons []*Lesson paths []string cache map[string]time.Time } var ( globalIndex *LessonIndex globalIndexOnce sync.Once ) func GetIndex() *LessonIndex { globalIndexOnce.Do(func() { globalIndex = &LessonIndex{ lessons: make([]*Lesson, 0), cache: make(map[string]time.Time), } globalIndex.paths = DefaultLessonDirs() globalIndex.Reload() }) return globalIndex } func DefaultLessonDirs() []string { var dirs []string home, _ := os.UserHomeDir() if home != "" { dirs = append(dirs, filepath.Join(home, ".muyue", "lessons"), ) } configDir, err := os.UserConfigDir() if err == nil { dirs = append(dirs, filepath.Join(configDir, "muyue", "lessons")) } if extra := os.Getenv("MUYUE_LESSONS_EXTRA_DIRS"); extra != "" { for _, d := range strings.Split(extra, ":") { d = strings.TrimSpace(d) if d != "" { dirs = append(dirs, d) } } } return dirs } func (idx *LessonIndex) Reload() { idx.mu.Lock() defer idx.mu.Unlock() var all []*Lesson seen := make(map[string]bool) for _, dir := range idx.paths { files, err := filepath.Glob(filepath.Join(dir, "*.md")) if err != nil { continue } for _, f := range files { realPath, _ := filepath.EvalSymlinks(f) if realPath == "" { realPath = f } if seen[realPath] { continue } seen[realPath] = true lesson, err := ParseLessonFile(f) if err != nil { continue } lesson.Path = f if lesson.Category == "" { lesson.Category = filepath.Base(filepath.Dir(f)) } all = append(all, lesson) } subDirs, _ := filepath.Glob(filepath.Join(dir, "*")) for _, subDir := range subDirs { info, err := os.Stat(subDir) if err != nil || !info.IsDir() { continue } category := filepath.Base(subDir) subFiles, _ := filepath.Glob(filepath.Join(subDir, "*.md")) for _, f := range subFiles { realPath, _ := filepath.EvalSymlinks(f) if realPath == "" { realPath = f } if seen[realPath] { continue } seen[realPath] = true lesson, err := ParseLessonFile(f) if err != nil { continue } lesson.Path = f if lesson.Category == "" { lesson.Category = category } all = append(all, lesson) } } } idx.lessons = all } func (idx *LessonIndex) All() []*Lesson { idx.mu.RLock() defer idx.mu.RUnlock() result := make([]*Lesson, 0, len(idx.lessons)) for _, l := range idx.lessons { if l.Enabled { result = append(result, l) } } return result } func (idx *LessonIndex) Get(name string) *Lesson { idx.mu.RLock() defer idx.mu.RUnlock() for _, l := range idx.lessons { if l.Name == name { return l } } return nil } func (idx *LessonIndex) Count() int { idx.mu.RLock() defer idx.mu.RUnlock() return len(idx.lessons) } func ParseLessonFile(path string) (*Lesson, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read lesson: %w", err) } content := string(data) var frontmatter LessonFrontmatter var body string if strings.HasPrefix(content, "---") { end := strings.Index(content[3:], "---") if end != -1 { fm := content[3 : end+3] body = strings.TrimSpace(content[end+6:]) if err := yaml.Unmarshal([]byte(fm), &frontmatter); err != nil { body = content } } else { body = content } } else { body = content } enabled := true if frontmatter.Enabled != nil { enabled = *frontmatter.Enabled } if frontmatter.Mode == "" { frontmatter.Mode = ModeBoth } name := frontmatter.Name if name == "" { name = strings.TrimSuffix(filepath.Base(path), ".md") name = strings.ReplaceAll(name, "-", "_") } return &Lesson{ Name: name, Title: frontmatter.Title, Description: frontmatter.Description, Category: frontmatter.Category, Triggers: frontmatter.Triggers, Content: body, Mode: frontmatter.Mode, Priority: frontmatter.Priority, Enabled: enabled, }, nil } func Match(lessons []*Lesson, ctx MatchContext) []*MatchResult { var results []*MatchResult msgLower := strings.ToLower(ctx.Message) for _, l := range lessons { if !l.Enabled { continue } score := 0.0 for _, kw := range l.Triggers.Keywords { if containsKeyword(msgLower, strings.ToLower(kw)) { score += 1.0 } } for _, pattern := range l.Triggers.Patterns { re, err := regexp.Compile("(?i)" + pattern) if err == nil && re.MatchString(ctx.Message) { score += 1.5 } } if len(ctx.ToolsUsed) > 0 && len(l.Triggers.Tools) > 0 { for _, usedTool := range ctx.ToolsUsed { for _, triggerTool := range l.Triggers.Tools { if usedTool == triggerTool { score += 2.0 break } } } } if l.Name != "" { nameLower := strings.ToLower(l.Name) if strings.Contains(msgLower, nameLower) { score += 1.5 } } if score > 0 { results = append(results, &MatchResult{ Lesson: l, Score: score, }) } } sortResults(results) return results } func AutoInclude(systemPrompt string, lessons []*Lesson, ctx MatchContext, maxLessons int) string { if maxLessons <= 0 { maxLessons = 5 } results := Match(lessons, ctx) if len(results) == 0 { return systemPrompt } if len(results) > maxLessons { results = results[:maxLessons] } var lessonBlock strings.Builder lessonBlock.WriteString("\n\n--- Active Lessons ---\n\n") for _, r := range results { lessonBlock.WriteString(fmt.Sprintf("## %s", r.Lesson.Name)) if r.Lesson.Title != "" { lessonBlock.WriteString(fmt.Sprintf(" (%s)", r.Lesson.Title)) } lessonBlock.WriteString("\n") lessonBlock.WriteString(r.Lesson.Content) lessonBlock.WriteString("\n\n") } return systemPrompt + lessonBlock.String() } func EnsureBuiltinLessons() error { home, _ := os.UserHomeDir() if home == "" { return nil } lessonsDir := filepath.Join(home, ".muyue", "lessons") if err := os.MkdirAll(lessonsDir, 0755); err != nil { return err } for _, lesson := range BuiltinLessons() { path := filepath.Join(lessonsDir, lesson.Name+".md") if _, err := os.Stat(path); err == nil { continue } if err := WriteLesson(path, lesson); err != nil { _ = err } } return nil } func WriteLesson(path string, lesson *Lesson) error { var sb strings.Builder sb.WriteString("---\n") data, err := yaml.Marshal(&LessonFrontmatter{ Name: lesson.Name, Title: lesson.Title, Description: lesson.Description, Category: lesson.Category, Mode: lesson.Mode, Priority: lesson.Priority, Enabled: &lesson.Enabled, Triggers: lesson.Triggers, }) if err != nil { return err } sb.WriteString(string(data)) sb.WriteString("---\n\n") sb.WriteString(lesson.Content) return os.WriteFile(path, []byte(sb.String()), 0644) } func BuiltinLessons() []*Lesson { return []*Lesson{ { Name: "code_style", Title: "Code Style Guidelines", Description: "Enforce consistent code style and formatting", Category: "development", Triggers: Triggers{ Keywords: []string{"code style", "formatting", "lint", "format", "indentation", "naming convention"}, Tools: []string{"terminal"}, }, Content: `- Follow the existing code style in each file - Use consistent indentation (match surrounding code) - Prefer descriptive variable names over abbreviations - Keep functions focused and small - Add error handling for all external calls`, Mode: ModeBoth, Priority: 5, Enabled: true, }, { Name: "git_workflow", Title: "Git Workflow Best Practices", Description: "Guidelines for git operations and commit practices", Category: "development", Triggers: Triggers{ Keywords: []string{"git", "commit", "branch", "merge", "pull request", "rebase"}, Tools: []string{"terminal"}, }, Content: `- Write clear, descriptive commit messages - Use conventional commits format when applicable - Keep commits atomic and focused - Don't commit sensitive data or secrets - Test before committing`, Mode: ModeBoth, Priority: 5, Enabled: true, }, { Name: "error_handling", Title: "Error Handling Patterns", Description: "Robust error handling guidelines", Category: "development", Triggers: Triggers{ Keywords: []string{"error", "panic", "exception", "crash", "fail", "nil pointer"}, Tools: []string{"terminal", "read_file"}, Patterns: []string{`err\s*!=\s*nil`, `panic\(`, `log\.Fatal`}, }, Content: `- Always check errors from external calls - Provide context when wrapping errors - Use sentinel errors for expected conditions - Log errors with enough context for debugging - Don't silently ignore errors`, Mode: ModeBoth, Priority: 6, Enabled: true, }, { Name: "testing", Title: "Testing Best Practices", Description: "Guidelines for writing effective tests", Category: "development", Triggers: Triggers{ Keywords: []string{"test", "testing", "unit test", "integration test", "coverage"}, Tools: []string{"terminal"}, }, Content: `- Write tests for critical paths first - Use table-driven tests for multiple cases - Keep tests independent and deterministic - Test error paths, not just happy paths - Aim for meaningful coverage, not just percentage`, Mode: ModeBoth, Priority: 5, Enabled: true, }, { Name: "security", Title: "Security Guidelines", Description: "Security best practices for development", Category: "development", Triggers: Triggers{ Keywords: []string{"security", "vulnerability", "inject", "sanitize", "auth", "secret", "password", "token"}, Tools: []string{"terminal", "read_file", "web_fetch"}, Patterns: []string{`SELECT\s.*\+`, `exec\.Command.*\+`, `os\.Getenv.*KEY`}, }, Content: `- Never log or expose secrets, API keys, or tokens - Validate and sanitize all user input - Use parameterized queries for database operations - Keep dependencies updated - Don't hardcode credentials`, Mode: ModeBoth, Priority: 8, Enabled: true, }, } } func containsKeyword(text, keyword string) bool { if keyword == "*" { return true } return strings.Contains(text, keyword) } func sortResults(results []*MatchResult) { for i := 0; i < len(results)-1; i++ { for j := i + 1; j < len(results); j++ { if results[j].Score > results[i].Score { results[i], results[j] = results[j], results[i] } } } }