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()
 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() (*GitInfo, error) {
136	// Find git root
137	rootCmd := exec.Command("git", "rev-parse", "--show-toplevel")
138	rootOutput, err := rootCmd.Output()
139	if err != nil {
140		return nil, err
141	}
142	root := strings.TrimSpace(string(rootOutput))
143
144	return &GitInfo{
145		Root: root,
146	}, nil
147}
148
149func collectCodebaseInfo(wd string, gitInfo *GitInfo) (*CodebaseInfo, error) {
150	info := &CodebaseInfo{
151		InjectFiles:        []string{},
152		InjectFileContents: make(map[string]string),
153		GuidanceFiles:      []string{},
154	}
155
156	// Track seen files to avoid duplicates on case-insensitive file systems
157	seenFiles := make(map[string]bool)
158
159	// Check for user-level agent instructions in ~/.config/shelley/AGENTS.md and ~/.shelley/AGENTS.md
160	if home, err := os.UserHomeDir(); err == nil {
161		// Prefer ~/.config/shelley/AGENTS.md (XDG convention)
162		configAgentsFile := filepath.Join(home, ".config", "shelley", "AGENTS.md")
163		if content, err := os.ReadFile(configAgentsFile); err == nil && len(content) > 0 {
164			info.InjectFiles = append(info.InjectFiles, configAgentsFile)
165			info.InjectFileContents[configAgentsFile] = string(content)
166			seenFiles[strings.ToLower(configAgentsFile)] = true
167		}
168		// Also check legacy ~/.shelley/AGENTS.md location
169		shelleyAgentsFile := filepath.Join(home, ".shelley", "AGENTS.md")
170		if content, err := os.ReadFile(shelleyAgentsFile); err == nil && len(content) > 0 {
171			lowerPath := strings.ToLower(shelleyAgentsFile)
172			if !seenFiles[lowerPath] {
173				info.InjectFiles = append(info.InjectFiles, shelleyAgentsFile)
174				info.InjectFileContents[shelleyAgentsFile] = string(content)
175				seenFiles[lowerPath] = true
176			}
177		}
178	}
179
180	// Determine the root directory to search
181	searchRoot := wd
182	if gitInfo != nil {
183		searchRoot = gitInfo.Root
184	}
185
186	// Find root-level guidance files (case-insensitive)
187	rootGuidanceFiles := findGuidanceFilesInDir(searchRoot)
188	for _, file := range rootGuidanceFiles {
189		lowerPath := strings.ToLower(file)
190		if seenFiles[lowerPath] {
191			continue
192		}
193		seenFiles[lowerPath] = true
194
195		content, err := os.ReadFile(file)
196		if err == nil && len(content) > 0 {
197			info.InjectFiles = append(info.InjectFiles, file)
198			info.InjectFileContents[file] = string(content)
199		}
200	}
201
202	// If working directory is different from root, also check working directory
203	if wd != searchRoot {
204		wdGuidanceFiles := findGuidanceFilesInDir(wd)
205		for _, file := range wdGuidanceFiles {
206			lowerPath := strings.ToLower(file)
207			if seenFiles[lowerPath] {
208				continue
209			}
210			seenFiles[lowerPath] = true
211
212			content, err := os.ReadFile(file)
213			if err == nil && len(content) > 0 {
214				info.InjectFiles = append(info.InjectFiles, file)
215				info.InjectFileContents[file] = string(content)
216			}
217		}
218	}
219
220	// Find all guidance files recursively for the directory listing
221	allGuidanceFiles := findAllGuidanceFiles(searchRoot)
222	info.GuidanceFiles = allGuidanceFiles
223
224	return info, nil
225}
226
227func findGuidanceFilesInDir(dir string) []string {
228	// Read directory entries to handle case-insensitive file systems
229	entries, err := os.ReadDir(dir)
230	if err != nil {
231		return nil
232	}
233
234	guidanceNames := map[string]bool{
235		"agent.md":    true,
236		"agents.md":   true,
237		"claude.md":   true,
238		"dear_llm.md": true,
239		"readme.md":   true,
240	}
241
242	var found []string
243	seen := make(map[string]bool)
244
245	for _, entry := range entries {
246		if entry.IsDir() {
247			continue
248		}
249		lowerName := strings.ToLower(entry.Name())
250		if guidanceNames[lowerName] && !seen[lowerName] {
251			seen[lowerName] = true
252			found = append(found, filepath.Join(dir, entry.Name()))
253		}
254	}
255	return found
256}
257
258func findAllGuidanceFiles(root string) []string {
259	guidanceNames := map[string]bool{
260		"agent.md":    true,
261		"agents.md":   true,
262		"claude.md":   true,
263		"dear_llm.md": true,
264	}
265
266	var found []string
267	seen := make(map[string]bool)
268
269	filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
270		if err != nil {
271			return nil // Continue on errors
272		}
273		if info.IsDir() {
274			// Skip hidden directories and common ignore patterns
275			if strings.HasPrefix(info.Name(), ".") || info.Name() == "node_modules" || info.Name() == "vendor" {
276				return filepath.SkipDir
277			}
278			return nil
279		}
280		lowerName := strings.ToLower(info.Name())
281		if guidanceNames[lowerName] {
282			lowerPath := strings.ToLower(path)
283			if !seen[lowerPath] {
284				seen[lowerPath] = true
285				found = append(found, path)
286			}
287		}
288		return nil
289	})
290	return found
291}
292
293func isExeDev() bool {
294	_, err := os.Stat("/exe.dev")
295	return err == nil
296}
297
298// collectSkills discovers skills from default directories and project tree.
299func collectSkills(workingDir, gitRoot string) string {
300	// Start with default directories (user-level skills)
301	dirs := skills.DefaultDirs()
302
303	// Discover user-level skills from configured directories
304	foundSkills := skills.Discover(dirs)
305
306	// Also discover skills anywhere in the project tree
307	treeSkills := skills.DiscoverInTree(workingDir, gitRoot)
308
309	// Merge, avoiding duplicates by path
310	seen := make(map[string]bool)
311	for _, s := range foundSkills {
312		seen[s.Path] = true
313	}
314	for _, s := range treeSkills {
315		if !seen[s.Path] {
316			foundSkills = append(foundSkills, s)
317			seen[s.Path] = true
318		}
319	}
320
321	// Generate XML
322	return skills.ToPromptXML(foundSkills)
323}
324
325func isSudoAvailable() bool {
326	cmd := exec.Command("sudo", "-n", "id")
327	_, err := cmd.CombinedOutput()
328	return err == nil
329}
330
331// SubagentSystemPromptData contains data for subagent system prompts (minimal subset)
332type SubagentSystemPromptData struct {
333	WorkingDirectory string
334	GitInfo          *GitInfo
335}
336
337// GenerateSubagentSystemPrompt generates a minimal system prompt for subagent conversations.
338func GenerateSubagentSystemPrompt(workingDir string) (string, error) {
339	wd := workingDir
340	if wd == "" {
341		var err error
342		wd, err = os.Getwd()
343		if err != nil {
344			return "", fmt.Errorf("failed to get working directory: %w", err)
345		}
346	}
347
348	data := &SubagentSystemPromptData{
349		WorkingDirectory: wd,
350	}
351
352	// Try to collect git info
353	gitInfo, err := collectGitInfo()
354	if err == nil {
355		data.GitInfo = gitInfo
356	}
357
358	tmpl, err := template.New("subagent_system_prompt").Parse(subagentSystemPromptTemplate)
359	if err != nil {
360		return "", fmt.Errorf("failed to parse subagent template: %w", err)
361	}
362
363	var buf strings.Builder
364	err = tmpl.Execute(&buf, data)
365	if err != nil {
366		return "", fmt.Errorf("failed to execute subagent template: %w", err)
367	}
368
369	return buf.String(), nil
370}