返回 DeepSeek-Reasonix
outputstyle.go
根目录 / internal / outputstyle / outputstyle.go
1 // Package outputstyle adds a selectable "output style" — a block of persona /
2 // tone instructions appended to (or replacing) the system prompt — so the user
3 // can shift how the agent communicates without rewriting the system prompt. It
4 // mirrors the skill/command loaders: built-in styles plus markdown files with
5 // frontmatter discovered under the project and home convention dirs.
6 package outputstyle
7
8 import (
9 "fmt"
10 "os"
11 "path/filepath"
12 "sort"
13 "strings"
14
15 fileencoding "reasonix/internal/fileutil/encoding"
16 "reasonix/internal/frontmatter"
17 )
18
19 // OutputStyle is one selectable persona. Body is appended to the system prompt
20 // (KeepCoding true) or used as the whole prompt (false). Name is the selector
21 // (case-insensitive); Builtin marks the baked-in ones for listing.
22 type OutputStyle struct {
23 Name string
24 Description string
25 Body string
26 KeepCoding bool // true: append to the coding system prompt; false: replace it
27 Builtin bool
28 Path string // file it loaded from ("" for built-ins)
29 }
30
31 // builtins are the always-available styles. Default ("" / "default") is absent
32 // on purpose — no style means the unmodified system prompt.
33 var builtins = []OutputStyle{
34 {
35 Name: "explanatory",
36 Description: "Explain non-obvious implementation choices as you go",
37 KeepCoding: true,
38 Builtin: true,
39 Body: "Communication style — Explanatory: as you work, surface the reasoning behind " +
40 "non-obvious choices. After a substantive change, add a short \"## Insight\" note " +
41 "covering the key trade-off or why an alternative was rejected. Teach the why, not just the what; keep it brief.",
42 },
43 {
44 Name: "learning",
45 Description: "Collaborate and leave TODO(human) stubs for the user to complete",
46 KeepCoding: true,
47 Builtin: true,
48 Body: "Communication style — Learning: work collaboratively rather than doing everything. " +
49 "When a meaningful implementation decision comes up, pause and ask the user to make the call. " +
50 "For the most instructive pieces, write the surrounding code but leave a small, clearly-marked " +
51 "`TODO(human)` stub with a one-line description for the user to implement themselves.",
52 },
53 {
54 Name: "concise",
55 Description: "Terse replies: minimal prose, code and bullets only",
56 KeepCoding: true,
57 Builtin: true,
58 Body: "Communication style — Concise: keep replies terse. No preamble or postamble, no restating " +
59 "the request. Prefer code and short bullet points over paragraphs; answer in the fewest words that are still clear.",
60 },
61 }
62
63 // Dirs returns the output-style search directories in load order (later wins),
64 // mirroring command/skill discovery: home convention dirs, then project ones.
65 // Home convention dirs are skipped when REASONIX_HOME is set (isolated runtime).
66 func Dirs() []string {
67 var dirs []string
68 if os.Getenv("REASONIX_HOME") == "" {
69 if home, err := os.UserHomeDir(); err == nil {
70 for i := len(conventionDirs) - 1; i >= 0; i-- {
71 dirs = append(dirs, filepath.Join(home, conventionDirs[i], "output-styles"))
72 }
73 }
74 }
75 for i := len(conventionDirs) - 1; i >= 0; i-- {
76 dirs = append(dirs, filepath.Join(".", conventionDirs[i], "output-styles"))
77 }
78 return dirs
79 }
80
81 // conventionDirs mirrors config.ConventionDirs (kept local to avoid an import
82 // cycle; config imports nothing from here, but this package stays dependency-light).
83 var conventionDirs = []string{".reasonix", ".agents", ".agent", ".claude"}
84
85 // List returns every available style — built-ins plus the markdown files under
86 // dirs — deduped by lowercased name, with custom files overriding built-ins.
87 // Sorted by name. Malformed files are skipped.
88 func List(dirs []string) []OutputStyle {
89 byName := map[string]OutputStyle{}
90 for _, b := range builtins {
91 byName[strings.ToLower(b.Name)] = b
92 }
93 for _, dir := range dirs {
94 entries, err := os.ReadDir(dir)
95 if err != nil {
96 continue
97 }
98 for _, e := range entries {
99 if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
100 continue
101 }
102 st, ok := parseFile(filepath.Join(dir, e.Name()))
103 if !ok {
104 continue
105 }
106 byName[strings.ToLower(st.Name)] = st
107 }
108 }
109 out := make([]OutputStyle, 0, len(byName))
110 for _, st := range byName {
111 out = append(out, st)
112 }
113 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
114 return out
115 }
116
117 // Resolve finds the style named name (case-insensitive) among dirs + built-ins.
118 // An empty or "default" name returns ok=false (no style — leave the prompt as-is).
119 func Resolve(name string, dirs []string) (OutputStyle, bool) {
120 n := strings.ToLower(strings.TrimSpace(name))
121 if n == "" || n == "default" {
122 return OutputStyle{}, false
123 }
124 for _, st := range List(dirs) {
125 if strings.ToLower(st.Name) == n {
126 return st, true
127 }
128 }
129 return OutputStyle{}, false
130 }
131
132 // Apply folds a style into a base system prompt: appended when KeepCoding is set,
133 // otherwise the style replaces the prompt (a pure persona). A style with an empty
134 // body leaves the base untouched.
135 func Apply(base string, st OutputStyle) string {
136 if strings.TrimSpace(st.Body) == "" {
137 return base
138 }
139 if !st.KeepCoding {
140 return st.Body
141 }
142 if strings.TrimSpace(base) == "" {
143 return st.Body
144 }
145 return base + "\n\n" + st.Body
146 }
147
148 // parseFile loads one <name>.md output-style file. The name is the filename
149 // stem; frontmatter supplies description and keep-coding-instructions; the body
150 // is the prompt text.
151 func parseFile(path string) (OutputStyle, bool) {
152 b, err := fileencoding.ReadFileUTF8(path)
153 if err != nil {
154 return OutputStyle{}, false
155 }
156 meta, body := frontmatter.Split(string(b))
157 name := meta["name"]
158 if name == "" {
159 name = strings.TrimSuffix(filepath.Base(path), ".md")
160 }
161 body = strings.TrimSpace(body)
162 if body == "" {
163 return OutputStyle{}, false
164 }
165 keep := true // default: augment the coding prompt rather than replace it
166 if v, ok := meta["keep-coding-instructions"]; ok {
167 keep = !isFalse(v)
168 }
169 return OutputStyle{
170 Name: name,
171 Description: meta["description"],
172 Body: body,
173 KeepCoding: keep,
174 Path: path,
175 }, true
176 }
177
178 func isFalse(s string) bool {
179 switch strings.ToLower(strings.TrimSpace(s)) {
180 case "false", "no", "0", "off":
181 return true
182 }
183 return false
184 }
185
186 // DescribeList renders the available styles as a short listing for /output-style.
187 func DescribeList(styles []OutputStyle, active string) string {
188 var b strings.Builder
189 for _, st := range styles {
190 marker := " "
191 if strings.EqualFold(st.Name, active) {
192 marker = "* "
193 }
194 scope := "builtin"
195 if !st.Builtin {
196 scope = "custom"
197 }
198 fmt.Fprintf(&b, "%s%s (%s) — %s\n", marker, st.Name, scope, st.Description)
199 }
200 return strings.TrimRight(b.String(), "\n")
201 }
202
202 lines GO