All checks were successful
Stable Release / stable (push) Successful in 1m34s
Major additions: - RAG pipeline (indexing, chunking, search) with sidebar upload button - Memory system with CRUD API - Plugins and lessons modules - MCP discovery and MCP server - Advanced skills (auto-create, conditional, improver) - Agent browser/image support, delegate, sessions - File editor with CodeMirror in split panes - Markdown rendering via react-markdown + KaTeX + highlight.js - Raw markdown toggle - PWA manifest + service worker - Extension UI redesign with new design tokens and studio-style chat - Pipeline API for chat streaming - Mobile responsive layout 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
514 lines
12 KiB
Go
514 lines
12 KiB
Go
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]
|
|
}
|
|
}
|
|
}
|
|
}
|