development
rs 270 lines 11.4 KB
Raw
1 //! SplitFire's system prompt and slash commands. Converting prompt content is `ed-acp`'s job.
2
3 use std::path::{Path, PathBuf};
4
5 use agent_client_protocol::schema::v1::{
6 AvailableCommand, AvailableCommandInput, UnstructuredCommandInput,
7 };
8
9 /// The sentence the "no stems" paragraph starts and ends with. A host that embeds the agent
10 /// swaps this whole paragraph for its own stems guidance by finding these two markers
11 /// ([`crate::embed::trim::replace_no_stems`]), so the prompt below and the replacement must
12 /// stay in step — they share these constants to make that impossible to get wrong.
13 pub const NO_STEMS_START: &str = "Stem separation is not available in this session.";
14 pub const NO_STEMS_END: &str = "file or shell tools.";
15
16 pub fn system_prompt(
17 surface: &str,
18 cwd: &Path,
19 roots: &[PathBuf],
20 stems_available: bool,
21 ) -> String {
22 let mut extra: Vec<&PathBuf> = roots.iter().filter(|r| *r != cwd).collect();
23 extra.sort();
24 extra.dedup();
25 let roots_section = if extra.is_empty() {
26 String::new()
27 } else {
28 let list = extra
29 .iter()
30 .map(|r| format!("- {}", r.display()))
31 .collect::<Vec<_>>()
32 .join("\n");
33 format!(
34 "\nAdditional workspace roots:\n{list}\n\
35 Pass a root's absolute path as the `root` parameter of a file tool to work there."
36 )
37 };
38 let stems_section = if stems_available {
39 "\nStem separation: `mcp__demucs__separate_stems` splits a track into stems (drums, bass, \
40 vocals, other; guitar and piano with the 6-stem model). It runs locally and can take \
41 minutes. When it finishes, tell the user where the stem files are. Separate only music \
42 the user says they have the right to work with."
43 .to_string()
44 } else {
45 // Bounded by NO_STEMS_START / NO_STEMS_END so an embedding host can find and replace
46 // the whole paragraph; keep those markers as the first and last words of it.
47 format!(
48 "\n{NO_STEMS_START} If the user asks for stems, tell \
49 them it needs the `demucs` command installed and on PATH (or SPLITFIRE_DEMUCS_BIN \
50 pointing at it), and that the session must be restarted afterwards. Do not pretend \
51 to separate anything, and do not offer a workaround with the {NO_STEMS_END}"
52 )
53 };
54 format!(
55 "You are SplitFire, a music assistant for musicians, producers, engineers, songwriters \
56 and fans. You cover music theory, production and DAW workflow, the music industry, \
57 music culture and history, and live shows.\n\
58 \n\
59 Working rules:\n\
60 - Use the theory tools (theory_scale, theory_chord, theory_transpose, \
61 theory_progression, theory_tempo, theory_pitch) for note spelling, chords, keys, \
62 transposition, delay and tempo maths and pitch conversion. Do not compute these from \
63 memory; quote the tool's result.\n\
64 - Use analyze_audio for audio files. Its tempo and key are signal-analysis estimates: \
65 call them estimates, mention the confidence, and mention half/double-time readings \
66 when they matter.\n\
67 - Use list_separations to see what the SplitFire app has already separated, instead of \
68 guessing at stem paths. It is read-only: never edit or delete anything inside those \
69 song folders, because the app rebuilds its history from that tree.\n\
70 - Be specific and practical: give settings, ranges and reasoning, and say when \
71 something is taste rather than rule. Match the user's level.\n\
72 - Do not reproduce song lyrics beyond a short quotation; discuss, summarise or \
73 analyse instead.\n\
74 - On sampling, covers, licensing, splits and royalties explain how things generally \
75 work and what to ask a lawyer or collecting society; this is not legal advice.\n\
76 - Do not invent release dates, chart positions, credits, gear specs or tour dates. \
77 Say so when you are unsure or when facts may have changed.\n\
78 - Keep answers concise and use Markdown.\n\
79 {stems_section}\n\
80 \n\
81 Working directory: {cwd}{roots_section}\n\
82 File tools are available for the user's project notes, chord charts and lyric sheets. \
83 Read before editing and prefer edit_file for small changes.\n\
84 When creating git commits, include the trailer:\n\
85 Co-Authored-By: SplitFire v{version}-{surface} <noreply@splitfire.ai>",
86 cwd = cwd.display(),
87 version = env!("CARGO_PKG_VERSION"),
88 )
89 }
90
91 struct SlashCommand {
92 name: &'static str,
93 description: &'static str,
94 hint: &'static str,
95 /// Needs the stem separator to be connected.
96 needs_stems: bool,
97 }
98
99 const COMMANDS: &[SlashCommand] = &[
100 SlashCommand {
101 name: "analyze",
102 description: "Analyze an audio file: loudness, tempo and key estimates",
103 hint: "path to an audio file",
104 needs_stems: false,
105 },
106 SlashCommand {
107 name: "chords",
108 description: "Analyze a chord progression: key, Roman numerals, borrowed chords",
109 hint: "chords, e.g. Dm7 G7 Cmaj7",
110 needs_stems: false,
111 },
112 SlashCommand {
113 name: "library",
114 description: "List the songs the SplitFire app has already separated",
115 hint: "optional song name to filter by",
116 needs_stems: false,
117 },
118 SlashCommand {
119 name: "practice",
120 description: "Build a focused practice plan with tempo targets",
121 hint: "instrument and goal",
122 needs_stems: false,
123 },
124 SlashCommand {
125 name: "stems",
126 description: "Separate a track into drums, bass, vocals and other stems",
127 hint: "path to an audio file",
128 needs_stems: true,
129 },
130 ];
131
132 /// Commands to advertise; `/stems` only when the separator is connected.
133 pub fn slash_commands(stems_available: bool) -> Vec<AvailableCommand> {
134 COMMANDS
135 .iter()
136 .filter(|c| stems_available || !c.needs_stems)
137 .map(|c| {
138 AvailableCommand::new(c.name, c.description).input(AvailableCommandInput::Unstructured(
139 UnstructuredCommandInput::new(c.hint),
140 ))
141 })
142 .collect()
143 }
144
145 /// If `text` starts with a known slash command, the instruction the model should follow.
146 pub fn expand_slash(text: &str, stems_available: bool) -> Option<String> {
147 let rest = text.trim_start().strip_prefix('/')?;
148 let (name, arg) = rest
149 .split_once(char::is_whitespace)
150 .map_or((rest, ""), |(n, a)| (n, a.trim()));
151 let missing = |what: &str| format!("The user ran /{name} without {what}. Ask them for it.");
152 Some(match name {
153 "analyze" if arg.is_empty() => missing("a file path"),
154 "analyze" => format!(
155 "Analyze the audio file {arg} with analyze_audio, then summarise it: duration, \
156 loudness and headroom, tempo and key (labelled as estimates, with confidence), and \
157 anything worth knowing before mixing or mastering."
158 ),
159 "chords" if arg.is_empty() => missing("a progression"),
160 "chords" => format!(
161 "Analyze this progression with theory_progression: {arg}\n\
162 Give the key, Roman numerals and functions, point out borrowed chords and \
163 secondary dominants, and suggest a few substitutions or voicing ideas."
164 ),
165 "library" => {
166 let scope = if arg.is_empty() {
167 "Call list_separations".to_string()
168 } else {
169 format!("Call list_separations with song={arg}")
170 };
171 format!(
172 "{scope} and summarise the user's separation library: which songs, the model \
173 each was separated with, when, and which stems are on this device. Mention any \
174 stem that is listed but not downloaded. If it is empty, say how to separate a \
175 track rather than guessing at paths."
176 )
177 }
178 "practice" if arg.is_empty() => missing("an instrument and goal"),
179 "practice" => format!(
180 "Build a focused practice plan for: {arg}\n\
181 Include a warm-up, two or three targeted exercises, and tempo targets worked out \
182 with theory_tempo. Keep it to one session the user can finish in under an hour."
183 ),
184 "stems" if !stems_available => {
185 "The user ran /stems, but stem separation is not available in this session. Tell \
186 them it needs the `demucs` command installed (see the SplitFire README) and to \
187 restart the session afterwards."
188 .to_string()
189 }
190 "stems" if arg.is_empty() => missing("a file path"),
191 "stems" => format!(
192 "Separate {arg} into stems with mcp__demucs__separate_stems, then list the stem \
193 files that were written."
194 ),
195 _ => return None,
196 })
197 }
198
199 #[cfg(test)]
200 mod tests {
201 use super::*;
202
203 #[test]
204 fn system_prompt_mentions_stems_only_when_connected() {
205 let cwd = Path::new("/work");
206 let off = system_prompt("acp", cwd, &[], false);
207 let on = system_prompt("acp", cwd, &[], true);
208 // Without the separator the prompt must not offer the tool, but must say how to
209 // enable it rather than going silent.
210 assert!(!off.contains("mcp__demucs__separate_stems"));
211 assert!(off.contains("not available") && off.contains("SPLITFIRE_DEMUCS_BIN"));
212 assert!(on.contains("mcp__demucs__separate_stems"));
213 // The read-only library tool is offered either way.
214 assert!(off.contains("list_separations") && on.contains("list_separations"));
215 assert!(off.contains("theory_scale") && off.contains("analyze_audio"));
216 assert!(
217 off.contains("Co-Authored-By: SplitFire v")
218 && off.contains("-acp <noreply@splitfire.ai>")
219 );
220 let tui = system_prompt("tui", cwd, &[PathBuf::from("/a")], false);
221 assert!(tui.contains("-tui <") && tui.contains("- /a"));
222 }
223
224 #[test]
225 fn commands_depend_on_stems() {
226 let names = |s| {
227 slash_commands(s)
228 .into_iter()
229 .map(|c| c.name)
230 .collect::<Vec<_>>()
231 };
232 // `/library` only reads the app's sidecars, so it is offered either way.
233 assert_eq!(names(false), ["analyze", "chords", "library", "practice"]);
234 assert_eq!(
235 names(true),
236 ["analyze", "chords", "library", "practice", "stems"]
237 );
238 }
239
240 #[test]
241 fn slash_expansion() {
242 assert!(
243 expand_slash("/analyze /a/b.wav", false)
244 .unwrap()
245 .contains("/a/b.wav")
246 );
247 assert!(
248 expand_slash("/chords Dm7 G7", false)
249 .unwrap()
250 .contains("theory_progression")
251 );
252 assert!(
253 expand_slash("/analyze", false)
254 .unwrap()
255 .contains("without a file path")
256 );
257 assert!(
258 expand_slash("/stems x.wav", true)
259 .unwrap()
260 .contains("mcp__demucs__separate_stems")
261 );
262 assert!(
263 expand_slash("/stems x.wav", false)
264 .unwrap()
265 .contains("not available")
266 );
267 assert!(expand_slash("/unknown x", false).is_none());
268 assert!(expand_slash("hello /analyze", false).is_none());
269 }
270 }