feat: load user CRUSH.md and AGENTS.md from config

Amolith created

Issue: charmbracelet/crush#1050

Change summary

README.md                             | 25 +++++++++++++++++++++++++
internal/agent/prompt/prompt.go       | 25 ++++++++++++++++++++-----
internal/agent/templates/coder.md.tpl | 18 ++++++++++++++++--
internal/config/config.go             |  1 +
internal/config/load.go               | 10 ++++++++++
schema.json                           | 11 +++++++++++
6 files changed, 83 insertions(+), 7 deletions(-)

Detailed changes

README.md 🔗

@@ -303,6 +303,31 @@ using `$(echo $VAR)` syntax.
 }
 ```
 
+### Memory
+
+Crush automatically includes two memory files for cross-project instructions.
+
+- `~/.config/crush/CRUSH.md`: Crush-specific rules that would confuse other
+  agentic coding tools. If you only use Crush, this is the only one you need to
+  edit.
+- `~/.config/AGENTS.md`: generic instructions that other coding tools might
+  read. Avoid referring to Crush-specific tools or workflows here.
+
+You can customize these paths using the `memory_paths` option in your
+configuration:
+
+```json
+{
+  "$schema": "https://charm.land/crush.json",
+  "options": {
+    "memory_paths": [
+      "/path/to/custom/memory/file.md",
+      "/path/to/folder/of/files/" // recursively load all .md files in folder
+    ]
+  }
+}
+```
+
 ### Ignoring Files
 
 Crush respects `.gitignore` files by default, but you can also create a

internal/agent/prompt/prompt.go 🔗

@@ -35,6 +35,7 @@ type PromptDat struct {
 	Date         string
 	GitStatus    string
 	ContextFiles []ContextFile
+	MemoryFiles  []ContextFile
 }
 
 type ContextFile struct {
@@ -150,16 +151,27 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con
 	workingDir := cmp.Or(p.workingDir, cfg.WorkingDir())
 	platform := cmp.Or(p.platform, runtime.GOOS)
 
-	files := map[string][]ContextFile{}
+	contextFiles := map[string][]ContextFile{}
+	memoryFiles := map[string][]ContextFile{}
 
 	for _, pth := range cfg.Options.ContextPaths {
 		expanded := expandPath(pth, cfg)
 		pathKey := strings.ToLower(expanded)
-		if _, ok := files[pathKey]; ok {
+		if _, ok := contextFiles[pathKey]; ok {
 			continue
 		}
 		content := processContextPath(expanded, cfg)
-		files[pathKey] = content
+		contextFiles[pathKey] = content
+	}
+
+	for _, pth := range cfg.Options.MemoryPaths {
+		expanded := expandPath(pth, cfg)
+		pathKey := strings.ToLower(expanded)
+		if _, ok := memoryFiles[pathKey]; ok {
+			continue
+		}
+		content := processContextPath(expanded, cfg)
+		memoryFiles[pathKey] = content
 	}
 
 	isGit := isGitRepo(cfg.WorkingDir())
@@ -180,8 +192,11 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con
 		}
 	}
 
-	for _, contextFiles := range files {
-		data.ContextFiles = append(data.ContextFiles, contextFiles...)
+	for _, files := range contextFiles {
+		data.ContextFiles = append(data.ContextFiles, files...)
+	}
+	for _, files := range memoryFiles {
+		data.MemoryFiles = append(data.MemoryFiles, files...)
 	}
 	return data, nil
 }

internal/agent/templates/coder.md.tpl 🔗

@@ -362,11 +362,25 @@ Diagnostics (lint/typecheck) included in tool output.
 {{end}}
 
 {{if .ContextFiles}}
-<memory>
+# Project-Specific Context
+Make sure to follow the instructions in the context below.
+<project_context>
 {{range .ContextFiles}}
 <file path="{{.Path}}">
 {{.Content}}
 </file>
 {{end}}
-</memory>
+</project_context>
+{{end}}
+
+{{if .MemoryFiles}}
+# User context
+The following is personal content added by the user that they'd like you to follow no matter what project you're working in.
+<user_preferences>
+{{range .MemoryFiles}}
+<file path="{{.Path}}">
+{{.Content}}
+</file>
+{{end}}
+</user_preferences>
 {{end}}

internal/config/config.go 🔗

@@ -217,6 +217,7 @@ func (Attribution) JSONSchemaExtend(schema *jsonschema.Schema) {
 
 type Options struct {
 	ContextPaths              []string     `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"`
+	MemoryPaths               []string     `json:"memory_paths,omitempty" jsonschema:"description=Paths to files containing memory information for the AI,default=~/.config/crush/CRUSH.md,default=~/.config/AGENTS.md"`
 	TUI                       *TUIOptions  `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"`
 	Debug                     bool         `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"`
 	DebugLSP                  bool         `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"`

internal/config/load.go 🔗

@@ -322,6 +322,16 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 	if c.Options.ContextPaths == nil {
 		c.Options.ContextPaths = []string{}
 	}
+	if c.Options.MemoryPaths == nil {
+		crushConfigDir := filepath.Dir(GlobalConfig())
+		c.Options.MemoryPaths = []string{
+			filepath.Join(crushConfigDir, "CRUSH.md"),
+			filepath.Join(filepath.Dir(crushConfigDir), "AGENTS.md"),
+		}
+	}
+	slices.Sort(c.Options.MemoryPaths)
+	c.Options.MemoryPaths = slices.Compact(c.Options.MemoryPaths)
+
 	if dataDir != "" {
 		c.Options.DataDirectory = dataDir
 	} else if c.Options.DataDirectory == "" {

schema.json 🔗

@@ -367,6 +367,17 @@
           "type": "array",
           "description": "Paths to files containing context information for the AI"
         },
+        "memory_paths": {
+          "items": {
+            "type": "string"
+          },
+          "type": "array",
+          "description": "Paths to files containing memory information for the AI",
+          "default": [
+            "~/.config/crush/CRUSH.md",
+            "~/.config/AGENTS.md"
+          ]
+        },
         "tui": {
           "$ref": "#/$defs/TUIOptions",
           "description": "Terminal user interface options"