feat(prompt): distinguish user/project context

Amolith created

Separate user-defined memory paths from project-specific context paths.
Previously, memory paths were appended to context paths. Now, they are
passed distinctly to the prompt generator.

The CoderPrompt now formats project context within <project_context>
tags and user memory within <user_context> tags, each with a
specific explanatory header for the LLM. This allows for clearer
separation and potential prioritization by the model.

Issue: charmbracelet/crush#1050

Change summary

internal/agent/prompt/prompt.go       | 25 ++++++++++++++++++++-----
internal/agent/templates/coder.md.tpl | 18 +++++++++++++++++-
internal/config/load.go               |  6 ++----
3 files changed, 39 insertions(+), 10 deletions(-)

Detailed changes

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 🔗

@@ -339,10 +339,26 @@ Diagnostics (lint/typecheck) included in tool output.
 
 {{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>
+
+{{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, though project preferences might conflict with their personal preferences. In cases of conflict, prioritise project context over user context.
+<user_context>
+{{range .MemoryFiles}}
+<file path="{{.Path}}">
+{{.Content}}
+</file>
+{{end}}
+</user_context>
+{{end}}
+<memory>
 {{end}}

internal/config/load.go 🔗

@@ -321,7 +321,8 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 			filepath.Join(filepath.Dir(crushConfigDir), "AGENTS.md"),
 		}
 	}
-	c.Options.ContextPaths = append(c.Options.ContextPaths, c.Options.MemoryPaths...)
+	slices.Sort(c.Options.MemoryPaths)
+	c.Options.MemoryPaths = slices.Compact(c.Options.MemoryPaths)
 
 	if dataDir != "" {
 		c.Options.DataDirectory = dataDir
@@ -350,9 +351,6 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 
 	// Add the default context paths if they are not already present
 	c.Options.ContextPaths = append(defaultContextPaths, c.Options.ContextPaths...)
-
-	// The ordering of contexts can be important; maybe the user's stuff should
-	// go at the bottom? Or top?
 	slices.Sort(c.Options.ContextPaths)
 	c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)