Files
Augustin 4523bbd42c
All checks were successful
Stable Release / stable (push) Successful in 1m34s
feat: RAG, memory, plugins, lessons, file editor, split panes, Markdown rendering, PWA + UI overhaul
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>
2026-04-27 21:04:11 +02:00

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