system_prompt.go

  1package server
  2
  3import (
  4	_ "embed"
  5	"fmt"
  6	"os"
  7	"os/exec"
  8	"path/filepath"
  9	"strings"
 10	"text/template"
 11
 12	"shelley.exe.dev/skills"
 13)
 14
 15//go:embed system_prompt.txt
 16var systemPromptTemplate string
 17
 18//go:embed subagent_system_prompt.txt
 19var subagentSystemPromptTemplate string
 20
 21// SystemPromptData contains all the data needed to render the system prompt template
 22type SystemPromptData struct {
 23	WorkingDirectory string
 24	GitInfo          *GitInfo
 25	Codebase         *CodebaseInfo
 26	IsExeDev         bool
 27	IsSudoAvailable  bool
 28	Hostname         string // For exe.dev, the public hostname (e.g., "vmname.exe.xyz")
 29	ShelleyDBPath    string // Path to the shelley database
 30	SkillsXML        string // XML block for available skills
 31}
 32
 33// DBPath is the path to the shelley database, set at startup
 34var DBPath string
 35
 36type GitInfo struct {
 37	Root string
 38}
 39
 40type CodebaseInfo struct {
 41	InjectFiles        []string
 42	InjectFileContents map[string]string
 43	GuidanceFiles      []string
 44}
 45
 46// GenerateSystemPrompt generates the system prompt using the embedded template.
 47// If workingDir is empty, it uses the current working directory.
 48func GenerateSystemPrompt(workingDir string) (string, error) {
 49	data, err := collectSystemData(workingDir)
 50	if err != nil {
 51		return "", fmt.Errorf("failed to collect system data: %w", err)
 52	}
 53
 54	tmpl, err := template.New("system_prompt").Parse(systemPromptTemplate)
 55	if err != nil {
 56		return "", fmt.Errorf("failed to parse template: %w", err)
 57	}
 58
 59	var buf strings.Builder
 60	err = tmpl.Execute(&buf, data)
 61	if err != nil {
 62		return "", fmt.Errorf("failed to execute template: %w", err)
 63	}
 64
 65	return buf.String(), nil
 66}
 67
 68func collectSystemData(workingDir string) (*SystemPromptData, error) {
 69	wd := workingDir
 70	if wd == "" {
 71		var err error
 72		wd, err = os.Getwd()
 73		if err != nil {
 74			return nil, fmt.Errorf("failed to get working directory: %w", err)
 75		}
 76	}
 77
 78	data := &SystemPromptData{
 79		WorkingDirectory: wd,
 80	}
 81
 82	// Try to collect git info
 83	gitInfo, err := collectGitInfo(wd)
 84	if err == nil {
 85		data.GitInfo = gitInfo
 86	}
 87
 88	// Collect codebase info
 89	codebaseInfo, err := collectCodebaseInfo(wd, gitInfo)
 90	if err == nil {
 91		data.Codebase = codebaseInfo
 92	}
 93
 94	// Check if running on exe.dev
 95	data.IsExeDev = isExeDev()
 96
 97	// Check sudo availability
 98	data.IsSudoAvailable = isSudoAvailable()
 99
100	// Get hostname for exe.dev
101	if data.IsExeDev {
102		if hostname, err := os.Hostname(); err == nil {
103			// If hostname doesn't contain dots, add .exe.xyz suffix
104			if !strings.Contains(hostname, ".") {
105				hostname = hostname + ".exe.xyz"
106			}
107			data.Hostname = hostname
108		}
109	}
110
111	// Set shelley database path if it was configured
112	if DBPath != "" {
113		// Convert to absolute path if relative
114		if !filepath.IsAbs(DBPath) {
115			if absPath, err := filepath.Abs(DBPath); err == nil {
116				data.ShelleyDBPath = absPath
117			} else {
118				data.ShelleyDBPath = DBPath
119			}
120		} else {
121			data.ShelleyDBPath = DBPath
122		}
123	}
124
125	// Discover and load skills
126	var gitRoot string
127	if gitInfo != nil {
128		gitRoot = gitInfo.Root
129	}
130	data.SkillsXML = collectSkills(wd, gitRoot)
131
132	return data, nil
133}
134
135func collectGitInfo(dir string) (*GitInfo, error) {
136	// Find git root
137	rootCmd := exec.Command("git", "rev-parse", "--show-toplevel")
138	if dir != "" {
139		rootCmd.Dir = dir
140	}
141	rootOutput, err := rootCmd.Output()
142	if err != nil {
143		return nil, err
144	}
145	root := strings.TrimSpace(string(rootOutput))
146
147	return &GitInfo{
148		Root: root,
149	}, nil
150}
151
152func collectCodebaseInfo(wd string, gitInfo *GitInfo) (*CodebaseInfo, error) {
153	info := &CodebaseInfo{
154		InjectFiles:        []string{},
155		InjectFileContents: make(map[string]string),
156		GuidanceFiles:      []string{},
157	}
158
159	// Track seen files to avoid duplicates on case-insensitive file systems
160	seenFiles := make(map[string]bool)
161
162	// Check for user-level agent instructions in ~/.config/shelley/AGENTS.md and ~/.shelley/AGENTS.md
163	if home, err := os.UserHomeDir(); err == nil {
164		// Prefer ~/.config/shelley/AGENTS.md (XDG convention)
165		configAgentsFile := filepath.Join(home, ".config", "shelley", "AGENTS.md")
166		if content, err := os.ReadFile(configAgentsFile); err == nil && len(content) > 0 {
167			info.InjectFiles = append(info.InjectFiles, configAgentsFile)
168			info.InjectFileContents[configAgentsFile] = string(content)
169			seenFiles[strings.ToLower(configAgentsFile)] = true
170		}
171		// Also check legacy ~/.shelley/AGENTS.md location
172		shelleyAgentsFile := filepath.Join(home, ".shelley", "AGENTS.md")
173		if content, err := os.ReadFile(shelleyAgentsFile); err == nil && len(content) > 0 {
174			lowerPath := strings.ToLower(shelleyAgentsFile)
175			if !seenFiles[lowerPath] {
176				info.InjectFiles = append(info.InjectFiles, shelleyAgentsFile)
177				info.InjectFileContents[shelleyAgentsFile] = string(content)
178				seenFiles[lowerPath] = true
179			}
180		}
181	}
182
183	// Determine the root directory to search
184	searchRoot := wd
185	if gitInfo != nil {
186		searchRoot = gitInfo.Root
187	}
188
189	// Find root-level guidance files (case-insensitive)
190	rootGuidanceFiles := findGuidanceFilesInDir(searchRoot)
191	for _, file := range rootGuidanceFiles {
192		lowerPath := strings.ToLower(file)
193		if seenFiles[lowerPath] {
194			continue
195		}
196		seenFiles[lowerPath] = true
197
198		content, err := os.ReadFile(file)
199		if err == nil && len(content) > 0 {
200			info.InjectFiles = append(info.InjectFiles, file)
201			info.InjectFileContents[file] = string(content)
202		}
203	}
204
205	// If working directory is different from root, also check working directory
206	if wd != searchRoot {
207		wdGuidanceFiles := findGuidanceFilesInDir(wd)
208		for _, file := range wdGuidanceFiles {
209			lowerPath := strings.ToLower(file)
210			if seenFiles[lowerPath] {
211				continue
212			}
213			seenFiles[lowerPath] = true
214
215			content, err := os.ReadFile(file)
216			if err == nil && len(content) > 0 {
217				info.InjectFiles = append(info.InjectFiles, file)
218				info.InjectFileContents[file] = string(content)
219			}
220		}
221	}
222
223	// Find all guidance files recursively for the directory listing
224	allGuidanceFiles := findAllGuidanceFiles(searchRoot)
225	info.GuidanceFiles = allGuidanceFiles
226
227	return info, nil
228}
229
230func findGuidanceFilesInDir(dir string) []string {
231	// Read directory entries to handle case-insensitive file systems
232	entries, err := os.ReadDir(dir)
233	if err != nil {
234		return nil
235	}
236
237	guidanceNames := map[string]bool{
238		"agent.md":    true,
239		"agents.md":   true,
240		"claude.md":   true,
241		"dear_llm.md": true,
242		"readme.md":   true,
243	}
244
245	var found []string
246	seen := make(map[string]bool)
247
248	for _, entry := range entries {
249		if entry.IsDir() {
250			continue
251		}
252		lowerName := strings.ToLower(entry.Name())
253		if guidanceNames[lowerName] && !seen[lowerName] {
254			seen[lowerName] = true
255			found = append(found, filepath.Join(dir, entry.Name()))
256		}
257	}
258	return found
259}
260
261func findAllGuidanceFiles(root string) []string {
262	guidanceNames := map[string]bool{
263		"agent.md":    true,
264		"agents.md":   true,
265		"claude.md":   true,
266		"dear_llm.md": true,
267	}
268
269	var found []string
270	seen := make(map[string]bool)
271
272	filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
273		if err != nil {
274			return nil // Continue on errors
275		}
276		if info.IsDir() {
277			// Skip hidden directories and common ignore patterns
278			if strings.HasPrefix(info.Name(), ".") || info.Name() == "node_modules" || info.Name() == "vendor" {
279				return filepath.SkipDir
280			}
281			return nil
282		}
283		lowerName := strings.ToLower(info.Name())
284		if guidanceNames[lowerName] {
285			lowerPath := strings.ToLower(path)
286			if !seen[lowerPath] {
287				seen[lowerPath] = true
288				found = append(found, path)
289			}
290		}
291		return nil
292	})
293	return found
294}
295
296func isExeDev() bool {
297	_, err := os.Stat("/exe.dev")
298	return err == nil
299}
300
301// collectSkills discovers skills from default directories, project .skills dirs,
302// and the project tree.
303func collectSkills(workingDir, gitRoot string) string {
304	// Start with default directories (user-level skills)
305	dirs := skills.DefaultDirs()
306
307	// Add .skills directories found in the project tree
308	dirs = append(dirs, skills.ProjectSkillsDirs(workingDir, gitRoot)...)
309
310	// Discover skills from all directories
311	foundSkills := skills.Discover(dirs)
312
313	// Also discover skills anywhere in the project tree
314	treeSkills := skills.DiscoverInTree(workingDir, gitRoot)
315
316	// Merge, avoiding duplicates by path
317	seen := make(map[string]bool)
318	for _, s := range foundSkills {
319		seen[s.Path] = true
320	}
321	for _, s := range treeSkills {
322		if !seen[s.Path] {
323			foundSkills = append(foundSkills, s)
324			seen[s.Path] = true
325		}
326	}
327
328	// Generate XML
329	return skills.ToPromptXML(foundSkills)
330}
331
332func isSudoAvailable() bool {
333	cmd := exec.Command("sudo", "-n", "id")
334	_, err := cmd.CombinedOutput()
335	return err == nil
336}
337
338// SubagentSystemPromptData contains data for subagent system prompts (minimal subset)
339type SubagentSystemPromptData struct {
340	WorkingDirectory string
341	GitInfo          *GitInfo
342}
343
344// GenerateSubagentSystemPrompt generates a minimal system prompt for subagent conversations.
345func GenerateSubagentSystemPrompt(workingDir string) (string, error) {
346	wd := workingDir
347	if wd == "" {
348		var err error
349		wd, err = os.Getwd()
350		if err != nil {
351			return "", fmt.Errorf("failed to get working directory: %w", err)
352		}
353	}
354
355	data := &SubagentSystemPromptData{
356		WorkingDirectory: wd,
357	}
358
359	// Try to collect git info
360	gitInfo, err := collectGitInfo(wd)
361	if err == nil {
362		data.GitInfo = gitInfo
363	}
364
365	tmpl, err := template.New("subagent_system_prompt").Parse(subagentSystemPromptTemplate)
366	if err != nil {
367		return "", fmt.Errorf("failed to parse subagent template: %w", err)
368	}
369
370	var buf strings.Builder
371	err = tmpl.Execute(&buf, data)
372	if err != nil {
373		return "", fmt.Errorf("failed to execute subagent template: %w", err)
374	}
375
376	return buf.String(), nil
377}