@splitfireai / agent / commits / fb56509

Add SplitFire AI Agent v1.0.0, an ACP v1 music agent

splitfire-agent speaks the Agent Client Protocol over stdio for editors like Zed and opens its own terminal UI when run from a terminal. It uses Onde Inference (Onde Cloud) for completions; set ONDE_API_KEY or run `splitfire-agent --setup`. The ACP wiring follows OndeCode. Music tools: - theory_scale, theory_chord, theory_transpose, theory_progression, theory_tempo and theory_pitch, so the model looks up note spellings, keys and tempo maths instead of computing them from memory - analyze_audio for duration, peak, RMS, LUFS, and tempo and key estimates - list_separations, a read-only view of the songs the SplitFire Audio Separator app has split, read from its splitfire.json sidecars - stem separation through `demucs --mcp` when demucs is installed (or SPLITFIRE_DEMUCS_BIN points at it), with permission prompts, progress in the tool call, and cancellation ACP v1 support: initialize negotiates v1; terminal auth is offered to clients that can run it, and authenticate and logout are handled. A key stored by --setup is picked up without a restart. Sessions are saved to disk, so list, load (with tool output replayed), resume, close and delete work across restarts. Client stdio MCP servers are connected per session. Prompts accept image, audio and embedded resources; file:// links are inlined, only the selected lines for a selection. Updates carry message ids, usage, tool names and raw output. Permission prompts offer allow, always allow, reject and always reject, and clients with boolean config options get a per-thread auto-approve toggle next to the model picker. Tests run the real binary against a mock Onde endpoint. An end-to-end test drives a real demucs build when SPLITFIRE_E2E_DEMUCS_BIN is set. Co-Authored-By: siGit Code v1.6.4 <noreply@sigit.si> Co-Authored-By: OndeCode v1.0.0 <noreply@ondeinference.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto committed Oct 8, 2026 at 15:30 UTC fb56509533c502c22b8643c930cde8b17a5d4025
37 files changed +15813 -2
.github/workflows/ci.yml new
+26
@@ -0,0 +1,26 @@
1 +name: ci
2 +
3 +on:
4 + push:
5 + branches: [main]
6 + pull_request:
7 +
8 +env:
9 + CARGO_TERM_COLOR: always
10 +
11 +jobs:
12 + check:
13 + strategy:
14 + fail-fast: false
15 + matrix:
16 + os: [ubuntu-latest, macos-latest]
17 + runs-on: ${{ matrix.os }}
18 + steps:
19 + - uses: actions/checkout@v5
20 + - uses: dtolnay/rust-toolchain@stable
21 + with:
22 + components: rustfmt, clippy
23 + - uses: Swatinem/rust-cache@v2
24 + - run: cargo fmt -- --check
25 + - run: cargo clippy --locked --all-targets -- -D warnings
26 + - run: cargo test --locked
.github/workflows/release.yml new
+50
@@ -0,0 +1,50 @@
1 +name: release
2 +
3 +on:
4 + push:
5 + tags: ["v*"]
6 +
7 +permissions:
8 + contents: write
9 +
10 +jobs:
11 + build:
12 + strategy:
13 + fail-fast: false
14 + matrix:
15 + include:
16 + - { target: aarch64-apple-darwin, os: macos-latest, name: darwin-arm64 }
17 + - { target: x86_64-apple-darwin, os: macos-15-intel, name: darwin-x64 }
18 + - { target: x86_64-unknown-linux-gnu, os: ubuntu-latest, name: linux-x64 }
19 + - { target: aarch64-unknown-linux-gnu, os: ubuntu-24.04-arm, name: linux-arm64 }
20 + - { target: x86_64-pc-windows-msvc, os: windows-latest, name: windows-x64 }
21 + runs-on: ${{ matrix.os }}
22 + steps:
23 + - uses: actions/checkout@v5
24 + - uses: dtolnay/rust-toolchain@stable
25 + with:
26 + targets: ${{ matrix.target }}
27 + - run: cargo build --release --locked --bin splitfire-agent --target ${{ matrix.target }}
28 + - name: Package archive
29 + shell: bash
30 + run: |
31 + BIN=target/${{ matrix.target }}/release/splitfire-agent
32 + ARCHIVE=splitfire-agent-${{ matrix.name }}
33 + if [ "${{ matrix.os }}" = "windows-latest" ]; then
34 + 7z a "$ARCHIVE.zip" "$BIN.exe"
35 + echo "asset=$ARCHIVE.zip" >> "$GITHUB_ENV"
36 + else
37 + tar -czf "$ARCHIVE.tar.gz" -C "target/${{ matrix.target }}/release" splitfire-agent
38 + echo "asset=$ARCHIVE.tar.gz" >> "$GITHUB_ENV"
39 + fi
40 + - name: Upload release asset
41 + shell: bash
42 + env:
43 + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
44 + run: |
45 + # Create the release once (first matrix job wins; others no-op).
46 + # Tolerate only the already-exists race: a genuinely failed create
47 + # makes `gh release view` fail too, so the step still fails loudly.
48 + gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes \
49 + || gh release view "$GITHUB_REF_NAME" >/dev/null
50 + gh release upload "$GITHUB_REF_NAME" "$asset" --clobber
.gitignore new
+12
@@ -0,0 +1,12 @@
1 +/target
2 +
3 +# IDE
4 +.idea/
5 +.vscode/
6 +
7 +# OS
8 +.DS_Store
9 +
10 +# Local env
11 +.env
12 +.env.local
Cargo.lock new
+3506
@@ -0,0 +1,3506 @@
1 +# This file is automatically @generated by Cargo.
2 +# It is not intended for manual editing.
3 +version = 4
4 +
5 +[[package]]
6 +name = "agent-client-protocol"
7 +version = "2.2.0"
8 +source = "registry+https://github.com/rust-lang/crates.io-index"
9 +checksum = "13f780597b2c88ab83d02509bcc3e86366751f86fa12114005e35c55ed691763"
10 +dependencies = [
11 + "agent-client-protocol-derive",
12 + "agent-client-protocol-schema",
13 + "async-io",
14 + "async-process",
15 + "blocking",
16 + "futures",
17 + "futures-concurrency",
18 + "rustc-hash",
19 + "rustix",
20 + "schemars 1.2.2",
21 + "serde",
22 + "serde_json",
23 + "shell-words",
24 + "tracing",
25 + "uuid",
26 + "windows-sys 0.61.2",
27 +]
28 +
29 +[[package]]
30 +name = "agent-client-protocol-derive"
31 +version = "2.2.0"
32 +source = "registry+https://github.com/rust-lang/crates.io-index"
33 +checksum = "f4893bfb11e0e193aa5a64611a126ee87567acb1879556519c902722899229de"
34 +dependencies = [
35 + "quote",
36 + "syn 3.0.6",
37 +]
38 +
39 +[[package]]
40 +name = "agent-client-protocol-schema"
41 +version = "1.9.1"
42 +source = "registry+https://github.com/rust-lang/crates.io-index"
43 +checksum = "1d595a79e1665d02c91c7dd1bedd118501e75cc0a3d5b1651b5150ec4c5c1e0d"
44 +dependencies = [
45 + "anyhow",
46 + "derive_more",
47 + "schemars 1.2.2",
48 + "serde",
49 + "serde_json",
50 + "serde_with",
51 + "strum",
52 + "tracing",
53 +]
54 +
55 +[[package]]
56 +name = "aho-corasick"
57 +version = "1.1.5"
58 +source = "registry+https://github.com/rust-lang/crates.io-index"
59 +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
60 +dependencies = [
61 + "memchr",
62 +]
63 +
64 +[[package]]
65 +name = "allocator-api2"
66 +version = "0.2.21"
67 +source = "registry+https://github.com/rust-lang/crates.io-index"
68 +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
69 +
70 +[[package]]
71 +name = "android_system_properties"
72 +version = "0.1.6"
73 +source = "registry+https://github.com/rust-lang/crates.io-index"
74 +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
75 +dependencies = [
76 + "libc",
77 +]
78 +
79 +[[package]]
80 +name = "anyhow"
81 +version = "1.0.104"
82 +source = "registry+https://github.com/rust-lang/crates.io-index"
83 +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
84 +
85 +[[package]]
86 +name = "approx"
87 +version = "0.5.1"
88 +source = "registry+https://github.com/rust-lang/crates.io-index"
89 +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
90 +dependencies = [
91 + "num-traits",
92 +]
93 +
94 +[[package]]
95 +name = "async-channel"
96 +version = "2.5.0"
97 +source = "registry+https://github.com/rust-lang/crates.io-index"
98 +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
99 +dependencies = [
100 + "concurrent-queue",
101 + "event-listener-strategy",
102 + "futures-core",
103 + "pin-project-lite",
104 +]
105 +
106 +[[package]]
107 +name = "async-io"
108 +version = "2.6.0"
109 +source = "registry+https://github.com/rust-lang/crates.io-index"
110 +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
111 +dependencies = [
112 + "autocfg",
113 + "cfg-if",
114 + "concurrent-queue",
115 + "futures-io",
116 + "futures-lite",
117 + "parking",
118 + "polling",
119 + "rustix",
120 + "slab",
121 + "windows-sys 0.61.2",
122 +]
123 +
124 +[[package]]
125 +name = "async-lock"
126 +version = "3.4.2"
127 +source = "registry+https://github.com/rust-lang/crates.io-index"
128 +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
129 +dependencies = [
130 + "event-listener",
131 + "event-listener-strategy",
132 + "pin-project-lite",
133 +]
134 +
135 +[[package]]
136 +name = "async-process"
137 +version = "2.5.0"
138 +source = "registry+https://github.com/rust-lang/crates.io-index"
139 +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
140 +dependencies = [
141 + "async-channel",
142 + "async-io",
143 + "async-lock",
144 + "async-signal",
145 + "async-task",
146 + "blocking",
147 + "cfg-if",
148 + "event-listener",
149 + "futures-lite",
150 + "rustix",
151 +]
152 +
153 +[[package]]
154 +name = "async-signal"
155 +version = "0.2.14"
156 +source = "registry+https://github.com/rust-lang/crates.io-index"
157 +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
158 +dependencies = [
159 + "async-io",
160 + "async-lock",
161 + "atomic-waker",
162 + "cfg-if",
163 + "futures-core",
164 + "futures-io",
165 + "rustix",
166 + "signal-hook-registry",
167 + "slab",
168 + "windows-sys 0.61.2",
169 +]
170 +
171 +[[package]]
172 +name = "async-task"
173 +version = "4.7.1"
174 +source = "registry+https://github.com/rust-lang/crates.io-index"
175 +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
176 +
177 +[[package]]
178 +name = "atomic-waker"
179 +version = "1.1.2"
180 +source = "registry+https://github.com/rust-lang/crates.io-index"
181 +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
182 +
183 +[[package]]
184 +name = "autocfg"
185 +version = "1.5.1"
186 +source = "registry+https://github.com/rust-lang/crates.io-index"
187 +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
188 +
189 +[[package]]
190 +name = "aws-lc-rs"
191 +version = "1.18.1"
192 +source = "registry+https://github.com/rust-lang/crates.io-index"
193 +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
194 +dependencies = [
195 + "aws-lc-sys",
196 + "zeroize",
197 +]
198 +
199 +[[package]]
200 +name = "aws-lc-sys"
201 +version = "0.45.0"
202 +source = "registry+https://github.com/rust-lang/crates.io-index"
203 +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
204 +dependencies = [
205 + "cc",
206 + "cmake",
207 + "dunce",
208 + "fs_extra",
209 + "pkg-config",
210 +]
211 +
212 +[[package]]
213 +name = "base64"
214 +version = "0.23.1"
215 +source = "registry+https://github.com/rust-lang/crates.io-index"
216 +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
217 +
218 +[[package]]
219 +name = "bitflags"
220 +version = "1.3.2"
221 +source = "registry+https://github.com/rust-lang/crates.io-index"
222 +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
223 +
224 +[[package]]
225 +name = "bitflags"
226 +version = "2.13.2"
227 +source = "registry+https://github.com/rust-lang/crates.io-index"
228 +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
229 +
230 +[[package]]
231 +name = "blocking"
232 +version = "1.7.0"
233 +source = "registry+https://github.com/rust-lang/crates.io-index"
234 +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
235 +dependencies = [
236 + "async-channel",
237 + "async-task",
238 + "futures-io",
239 + "futures-lite",
240 + "piper",
241 +]
242 +
243 +[[package]]
244 +name = "bs58"
245 +version = "0.5.1"
246 +source = "registry+https://github.com/rust-lang/crates.io-index"
247 +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
248 +dependencies = [
249 + "tinyvec",
250 +]
251 +
252 +[[package]]
253 +name = "bumpalo"
254 +version = "3.20.3"
255 +source = "registry+https://github.com/rust-lang/crates.io-index"
256 +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
257 +
258 +[[package]]
259 +name = "by_address"
260 +version = "1.2.1"
261 +source = "registry+https://github.com/rust-lang/crates.io-index"
262 +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
263 +
264 +[[package]]
265 +name = "bytemuck"
266 +version = "1.25.2"
267 +source = "registry+https://github.com/rust-lang/crates.io-index"
268 +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
269 +
270 +[[package]]
271 +name = "bytes"
272 +version = "1.12.1"
273 +source = "registry+https://github.com/rust-lang/crates.io-index"
274 +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
275 +
276 +[[package]]
277 +name = "castaway"
278 +version = "0.2.4"
279 +source = "registry+https://github.com/rust-lang/crates.io-index"
280 +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
281 +dependencies = [
282 + "rustversion",
283 +]
284 +
285 +[[package]]
286 +name = "cc"
287 +version = "1.6.0"
288 +source = "registry+https://github.com/rust-lang/crates.io-index"
289 +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
290 +dependencies = [
291 + "find-msvc-tools",
292 + "jobserver",
293 + "libc",
294 + "shlex",
295 +]
296 +
297 +[[package]]
298 +name = "cfg-if"
299 +version = "1.0.5"
300 +source = "registry+https://github.com/rust-lang/crates.io-index"
301 +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
302 +
303 +[[package]]
304 +name = "cfg_aliases"
305 +version = "0.2.2"
306 +source = "registry+https://github.com/rust-lang/crates.io-index"
307 +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
308 +
309 +[[package]]
310 +name = "chacha20"
311 +version = "0.10.2"
312 +source = "registry+https://github.com/rust-lang/crates.io-index"
313 +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
314 +dependencies = [
315 + "cfg-if",
316 + "cpufeatures",
317 + "rand_core",
318 +]
319 +
320 +[[package]]
321 +name = "chrono"
322 +version = "0.4.45"
323 +source = "registry+https://github.com/rust-lang/crates.io-index"
324 +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
325 +dependencies = [
326 + "iana-time-zone",
327 + "num-traits",
328 + "serde",
329 + "windows-link",
330 +]
331 +
332 +[[package]]
333 +name = "cmake"
334 +version = "0.1.58"
335 +source = "registry+https://github.com/rust-lang/crates.io-index"
336 +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
337 +dependencies = [
338 + "cc",
339 +]
340 +
341 +[[package]]
342 +name = "combine"
343 +version = "4.6.8"
344 +source = "registry+https://github.com/rust-lang/crates.io-index"
345 +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
346 +dependencies = [
347 + "bytes",
348 + "memchr",
349 +]
350 +
351 +[[package]]
352 +name = "compact_str"
353 +version = "0.9.1"
354 +source = "registry+https://github.com/rust-lang/crates.io-index"
355 +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
356 +dependencies = [
357 + "castaway",
358 + "cfg-if",
359 + "itoa",
360 + "rustversion",
361 + "ryu",
362 + "static_assertions",
363 +]
364 +
365 +[[package]]
366 +name = "concurrent-queue"
367 +version = "2.5.0"
368 +source = "registry+https://github.com/rust-lang/crates.io-index"
369 +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
370 +dependencies = [
371 + "crossbeam-utils",
372 +]
373 +
374 +[[package]]
375 +name = "convert_case"
376 +version = "0.10.0"
377 +source = "registry+https://github.com/rust-lang/crates.io-index"
378 +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
379 +dependencies = [
380 + "unicode-segmentation",
381 +]
382 +
383 +[[package]]
384 +name = "core-foundation"
385 +version = "0.10.1"
386 +source = "registry+https://github.com/rust-lang/crates.io-index"
387 +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
388 +dependencies = [
389 + "core-foundation-sys",
390 + "libc",
391 +]
392 +
393 +[[package]]
394 +name = "core-foundation-sys"
395 +version = "0.8.7"
396 +source = "registry+https://github.com/rust-lang/crates.io-index"
397 +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
398 +
399 +[[package]]
400 +name = "cpufeatures"
401 +version = "0.3.1"
402 +source = "registry+https://github.com/rust-lang/crates.io-index"
403 +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
404 +dependencies = [
405 + "libc",
406 +]
407 +
408 +[[package]]
409 +name = "critical-section"
410 +version = "1.2.0"
411 +source = "registry+https://github.com/rust-lang/crates.io-index"
412 +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
413 +
414 +[[package]]
415 +name = "crossbeam-utils"
416 +version = "0.8.23"
417 +source = "registry+https://github.com/rust-lang/crates.io-index"
418 +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
419 +
420 +[[package]]
421 +name = "crossterm"
422 +version = "0.29.0"
423 +source = "registry+https://github.com/rust-lang/crates.io-index"
424 +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
425 +dependencies = [
426 + "bitflags 2.13.2",
427 + "crossterm_winapi",
428 + "derive_more",
429 + "document-features",
430 + "futures-core",
431 + "mio",
432 + "parking_lot",
433 + "rustix",
434 + "signal-hook",
435 + "signal-hook-mio",
436 + "winapi",
437 +]
438 +
439 +[[package]]
440 +name = "crossterm_winapi"
441 +version = "0.9.1"
442 +source = "registry+https://github.com/rust-lang/crates.io-index"
443 +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
444 +dependencies = [
445 + "winapi",
446 +]
447 +
448 +[[package]]
449 +name = "darling"
450 +version = "0.24.1"
451 +source = "registry+https://github.com/rust-lang/crates.io-index"
452 +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
453 +dependencies = [
454 + "darling_core",
455 + "darling_macro",
456 +]
457 +
458 +[[package]]
459 +name = "darling_core"
460 +version = "0.24.1"
461 +source = "registry+https://github.com/rust-lang/crates.io-index"
462 +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
463 +dependencies = [
464 + "ident_case",
465 + "proc-macro2",
466 + "quote",
467 + "strsim",
468 + "syn 3.0.6",
469 +]
470 +
471 +[[package]]
472 +name = "darling_macro"
473 +version = "0.24.1"
474 +source = "registry+https://github.com/rust-lang/crates.io-index"
475 +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
476 +dependencies = [
477 + "darling_core",
478 + "quote",
479 + "syn 3.0.6",
480 +]
481 +
482 +[[package]]
483 +name = "dasp_frame"
484 +version = "0.11.0"
485 +source = "registry+https://github.com/rust-lang/crates.io-index"
486 +checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6"
487 +dependencies = [
488 + "dasp_sample",
489 +]
490 +
491 +[[package]]
492 +name = "dasp_sample"
493 +version = "0.11.0"
494 +source = "registry+https://github.com/rust-lang/crates.io-index"
495 +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
496 +
497 +[[package]]
498 +name = "defmt"
499 +version = "1.1.1"
500 +source = "registry+https://github.com/rust-lang/crates.io-index"
501 +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
502 +dependencies = [
503 + "bitflags 1.3.2",
504 + "defmt-macros",
505 +]
506 +
507 +[[package]]
508 +name = "defmt-macros"
509 +version = "1.1.1"
510 +source = "registry+https://github.com/rust-lang/crates.io-index"
511 +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
512 +dependencies = [
513 + "defmt-parser",
514 + "proc-macro2",
515 + "quote",
516 + "syn 2.0.119",
517 +]
518 +
519 +[[package]]
520 +name = "defmt-parser"
521 +version = "1.0.0"
522 +source = "registry+https://github.com/rust-lang/crates.io-index"
523 +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
524 +dependencies = [
525 + "thiserror",
526 +]
527 +
528 +[[package]]
529 +name = "deranged"
530 +version = "0.5.8"
531 +source = "registry+https://github.com/rust-lang/crates.io-index"
532 +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
533 +dependencies = [
534 + "serde_core",
535 +]
536 +
537 +[[package]]
538 +name = "derive_more"
539 +version = "2.1.1"
540 +source = "registry+https://github.com/rust-lang/crates.io-index"
541 +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
542 +dependencies = [
543 + "derive_more-impl",
544 +]
545 +
546 +[[package]]
547 +name = "derive_more-impl"
548 +version = "2.1.1"
549 +source = "registry+https://github.com/rust-lang/crates.io-index"
550 +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
551 +dependencies = [
552 + "convert_case",
553 + "proc-macro2",
554 + "quote",
555 + "rustc_version",
556 + "syn 2.0.119",
557 + "unicode-xid",
558 +]
559 +
560 +[[package]]
561 +name = "directories"
562 +version = "6.0.0"
563 +source = "registry+https://github.com/rust-lang/crates.io-index"
564 +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
565 +dependencies = [
566 + "dirs-sys",
567 +]
568 +
569 +[[package]]
570 +name = "dirs-sys"
571 +version = "0.5.0"
572 +source = "registry+https://github.com/rust-lang/crates.io-index"
573 +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
574 +dependencies = [
575 + "libc",
576 + "option-ext",
577 + "redox_users",
578 + "windows-sys 0.61.2",
579 +]
580 +
581 +[[package]]
582 +name = "displaydoc"
583 +version = "0.2.7"
584 +source = "registry+https://github.com/rust-lang/crates.io-index"
585 +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
586 +dependencies = [
587 + "proc-macro2",
588 + "quote",
589 + "syn 3.0.6",
590 +]
591 +
592 +[[package]]
593 +name = "document-features"
594 +version = "0.2.12"
595 +source = "registry+https://github.com/rust-lang/crates.io-index"
596 +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
597 +dependencies = [
598 + "litrs",
599 +]
600 +
601 +[[package]]
602 +name = "dunce"
603 +version = "1.0.5"
604 +source = "registry+https://github.com/rust-lang/crates.io-index"
605 +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
606 +
607 +[[package]]
608 +name = "dyn-clone"
609 +version = "1.0.20"
610 +source = "registry+https://github.com/rust-lang/crates.io-index"
611 +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
612 +
613 +[[package]]
614 +name = "ebur128"
615 +version = "0.1.10"
616 +source = "registry+https://github.com/rust-lang/crates.io-index"
617 +checksum = "e227cc62d64d6fe01abbef48134b9c1f17d470cef1e7a56337ad05b1f81df7f9"
618 +dependencies = [
619 + "bitflags 1.3.2",
620 + "dasp_frame",
621 + "dasp_sample",
622 + "smallvec",
623 +]
624 +
625 +[[package]]
626 +name = "either"
627 +version = "1.18.0"
628 +source = "registry+https://github.com/rust-lang/crates.io-index"
629 +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
630 +
631 +[[package]]
632 +name = "equivalent"
633 +version = "1.0.2"
634 +source = "registry+https://github.com/rust-lang/crates.io-index"
635 +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
636 +
637 +[[package]]
638 +name = "errno"
639 +version = "0.3.14"
640 +source = "registry+https://github.com/rust-lang/crates.io-index"
641 +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
642 +dependencies = [
643 + "libc",
644 + "windows-sys 0.61.2",
645 +]
646 +
647 +[[package]]
648 +name = "event-listener"
649 +version = "5.4.2"
650 +source = "registry+https://github.com/rust-lang/crates.io-index"
651 +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
652 +dependencies = [
653 + "parking",
654 + "pin-project-lite",
655 +]
656 +
657 +[[package]]
658 +name = "event-listener-strategy"
659 +version = "0.5.4"
660 +source = "registry+https://github.com/rust-lang/crates.io-index"
661 +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
662 +dependencies = [
663 + "event-listener",
664 + "pin-project-lite",
665 +]
666 +
667 +[[package]]
668 +name = "extended"
669 +version = "0.1.0"
670 +source = "registry+https://github.com/rust-lang/crates.io-index"
671 +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
672 +
673 +[[package]]
674 +name = "fastrand"
675 +version = "2.5.0"
676 +source = "registry+https://github.com/rust-lang/crates.io-index"
677 +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
678 +
679 +[[package]]
680 +name = "find-msvc-tools"
681 +version = "0.1.14"
682 +source = "registry+https://github.com/rust-lang/crates.io-index"
683 +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
684 +
685 +[[package]]
686 +name = "fixedbitset"
687 +version = "0.5.7"
688 +source = "registry+https://github.com/rust-lang/crates.io-index"
689 +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
690 +
691 +[[package]]
692 +name = "foldhash"
693 +version = "0.2.0"
694 +source = "registry+https://github.com/rust-lang/crates.io-index"
695 +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
696 +
697 +[[package]]
698 +name = "form_urlencoded"
699 +version = "1.2.2"
700 +source = "registry+https://github.com/rust-lang/crates.io-index"
701 +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
702 +dependencies = [
703 + "percent-encoding",
704 +]
705 +
706 +[[package]]
707 +name = "fs_extra"
708 +version = "1.3.0"
709 +source = "registry+https://github.com/rust-lang/crates.io-index"
710 +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
711 +
712 +[[package]]
713 +name = "futures"
714 +version = "0.3.34"
715 +source = "registry+https://github.com/rust-lang/crates.io-index"
716 +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
717 +dependencies = [
718 + "futures-channel",
719 + "futures-core",
720 + "futures-executor",
721 + "futures-io",
722 + "futures-sink",
723 + "futures-task",
724 + "futures-util",
725 +]
726 +
727 +[[package]]
728 +name = "futures-channel"
729 +version = "0.3.34"
730 +source = "registry+https://github.com/rust-lang/crates.io-index"
731 +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
732 +dependencies = [
733 + "futures-core",
734 + "futures-sink",
735 +]
736 +
737 +[[package]]
738 +name = "futures-concurrency"
739 +version = "7.7.1"
740 +source = "registry+https://github.com/rust-lang/crates.io-index"
741 +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6"
742 +dependencies = [
743 + "fixedbitset",
744 + "futures-core",
745 + "futures-lite",
746 + "pin-project",
747 + "smallvec",
748 +]
749 +
750 +[[package]]
751 +name = "futures-core"
752 +version = "0.3.34"
753 +source = "registry+https://github.com/rust-lang/crates.io-index"
754 +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
755 +
756 +[[package]]
757 +name = "futures-executor"
758 +version = "0.3.34"
759 +source = "registry+https://github.com/rust-lang/crates.io-index"
760 +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
761 +dependencies = [
762 + "futures-core",
763 + "futures-task",
764 + "futures-util",
765 +]
766 +
767 +[[package]]
768 +name = "futures-io"
769 +version = "0.3.34"
770 +source = "registry+https://github.com/rust-lang/crates.io-index"
771 +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
772 +
773 +[[package]]
774 +name = "futures-lite"
775 +version = "2.6.1"
776 +source = "registry+https://github.com/rust-lang/crates.io-index"
777 +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
778 +dependencies = [
779 + "fastrand",
780 + "futures-core",
781 + "futures-io",
782 + "parking",
783 + "pin-project-lite",
784 +]
785 +
786 +[[package]]
787 +name = "futures-macro"
788 +version = "0.3.34"
789 +source = "registry+https://github.com/rust-lang/crates.io-index"
790 +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
791 +dependencies = [
792 + "proc-macro2",
793 + "quote",
794 + "syn 3.0.6",
795 +]
796 +
797 +[[package]]
798 +name = "futures-sink"
799 +version = "0.3.34"
800 +source = "registry+https://github.com/rust-lang/crates.io-index"
801 +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
802 +
803 +[[package]]
804 +name = "futures-task"
805 +version = "0.3.34"
806 +source = "registry+https://github.com/rust-lang/crates.io-index"
807 +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
808 +
809 +[[package]]
810 +name = "futures-util"
811 +version = "0.3.34"
812 +source = "registry+https://github.com/rust-lang/crates.io-index"
813 +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
814 +dependencies = [
815 + "futures-channel",
816 + "futures-core",
817 + "futures-io",
818 + "futures-macro",
819 + "futures-sink",
820 + "futures-task",
821 + "memchr",
822 + "pin-project-lite",
823 + "slab",
824 +]
825 +
826 +[[package]]
827 +name = "getrandom"
828 +version = "0.2.17"
829 +source = "registry+https://github.com/rust-lang/crates.io-index"
830 +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
831 +dependencies = [
832 + "cfg-if",
833 + "js-sys",
834 + "libc",
835 + "wasi",
836 + "wasm-bindgen",
837 +]
838 +
839 +[[package]]
840 +name = "getrandom"
841 +version = "0.4.3"
842 +source = "registry+https://github.com/rust-lang/crates.io-index"
843 +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
844 +dependencies = [
845 + "cfg-if",
846 + "js-sys",
847 + "libc",
848 + "r-efi",
849 + "rand_core",
850 + "wasm-bindgen",
851 +]
852 +
853 +[[package]]
854 +name = "hashbrown"
855 +version = "0.12.3"
856 +source = "registry+https://github.com/rust-lang/crates.io-index"
857 +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
858 +
859 +[[package]]
860 +name = "hashbrown"
861 +version = "0.16.1"
862 +source = "registry+https://github.com/rust-lang/crates.io-index"
863 +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
864 +dependencies = [
865 + "allocator-api2",
866 + "equivalent",
867 + "foldhash",
868 +]
869 +
870 +[[package]]
871 +name = "hashbrown"
872 +version = "0.17.1"
873 +source = "registry+https://github.com/rust-lang/crates.io-index"
874 +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
875 +dependencies = [
876 + "allocator-api2",
877 + "equivalent",
878 + "foldhash",
879 +]
880 +
881 +[[package]]
882 +name = "heck"
883 +version = "0.5.0"
884 +source = "registry+https://github.com/rust-lang/crates.io-index"
885 +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
886 +
887 +[[package]]
888 +name = "hermit-abi"
889 +version = "0.5.3"
890 +source = "registry+https://github.com/rust-lang/crates.io-index"
891 +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
892 +
893 +[[package]]
894 +name = "hex"
895 +version = "0.4.3"
896 +source = "registry+https://github.com/rust-lang/crates.io-index"
897 +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
898 +
899 +[[package]]
900 +name = "hound"
901 +version = "3.5.1"
902 +source = "registry+https://github.com/rust-lang/crates.io-index"
903 +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
904 +
905 +[[package]]
906 +name = "http"
907 +version = "1.5.0"
908 +source = "registry+https://github.com/rust-lang/crates.io-index"
909 +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
910 +dependencies = [
911 + "bytes",
912 + "itoa",
913 +]
914 +
915 +[[package]]
916 +name = "http-body"
917 +version = "1.1.0"
918 +source = "registry+https://github.com/rust-lang/crates.io-index"
919 +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
920 +dependencies = [
921 + "bytes",
922 + "http",
923 +]
924 +
925 +[[package]]
926 +name = "http-body-util"
927 +version = "0.1.5"
928 +source = "registry+https://github.com/rust-lang/crates.io-index"
929 +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
930 +dependencies = [
931 + "bytes",
932 + "futures-core",
933 + "http",
934 + "http-body",
935 + "pin-project-lite",
936 +]
937 +
938 +[[package]]
939 +name = "httparse"
940 +version = "1.10.1"
941 +source = "registry+https://github.com/rust-lang/crates.io-index"
942 +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
943 +
944 +[[package]]
945 +name = "hyper"
946 +version = "1.11.1"
947 +source = "registry+https://github.com/rust-lang/crates.io-index"
948 +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
949 +dependencies = [
950 + "atomic-waker",
951 + "bytes",
952 + "futures-channel",
953 + "futures-core",
954 + "http",
955 + "http-body",
956 + "httparse",
957 + "itoa",
958 + "pin-project-lite",
959 + "smallvec",
960 + "tokio",
961 + "want",
962 +]
963 +
964 +[[package]]
965 +name = "hyper-rustls"
966 +version = "0.27.10"
967 +source = "registry+https://github.com/rust-lang/crates.io-index"
968 +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
969 +dependencies = [
970 + "http",
971 + "hyper",
972 + "hyper-util",
973 + "rustls",
974 + "tokio",
975 + "tokio-rustls",
976 + "tower-service",
977 +]
978 +
979 +[[package]]
980 +name = "hyper-util"
981 +version = "0.1.21"
982 +source = "registry+https://github.com/rust-lang/crates.io-index"
983 +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
984 +dependencies = [
985 + "base64",
986 + "bytes",
987 + "futures-channel",
988 + "futures-util",
989 + "http",
990 + "http-body",
991 + "httparse",
992 + "hyper",
993 + "ipnet",
994 + "libc",
995 + "percent-encoding",
996 + "pin-project-lite",
997 + "socket2",
998 + "tokio",
999 + "tower-service",
1000 + "tracing",
1001 +]
1002 +
1003 +[[package]]
1004 +name = "iana-time-zone"
1005 +version = "0.1.65"
1006 +source = "registry+https://github.com/rust-lang/crates.io-index"
1007 +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
1008 +dependencies = [
1009 + "android_system_properties",
1010 + "core-foundation-sys",
1011 + "iana-time-zone-haiku",
1012 + "js-sys",
1013 + "log",
1014 + "wasm-bindgen",
1015 + "windows-core",
1016 +]
1017 +
1018 +[[package]]
1019 +name = "iana-time-zone-haiku"
1020 +version = "0.1.2"
1021 +source = "registry+https://github.com/rust-lang/crates.io-index"
1022 +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
1023 +dependencies = [
1024 + "cc",
1025 +]
1026 +
1027 +[[package]]
1028 +name = "icu_collections"
1029 +version = "2.3.0"
1030 +source = "registry+https://github.com/rust-lang/crates.io-index"
1031 +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
1032 +dependencies = [
1033 + "displaydoc",
1034 + "potential_utf",
1035 + "utf8_iter",
1036 + "yoke",
1037 + "zerofrom",
1038 + "zerovec",
1039 +]
1040 +
1041 +[[package]]
1042 +name = "icu_locale_core"
1043 +version = "2.3.0"
1044 +source = "registry+https://github.com/rust-lang/crates.io-index"
1045 +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
1046 +dependencies = [
1047 + "displaydoc",
1048 + "litemap",
1049 + "tinystr",
1050 + "writeable",
1051 + "zerovec",
1052 +]
1053 +
1054 +[[package]]
1055 +name = "icu_normalizer"
1056 +version = "2.3.0"
1057 +source = "registry+https://github.com/rust-lang/crates.io-index"
1058 +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
1059 +dependencies = [
1060 + "icu_collections",
1061 + "icu_normalizer_data",
1062 + "icu_properties",
1063 + "icu_provider",
1064 + "smallvec",
1065 + "zerovec",
1066 +]
1067 +
1068 +[[package]]
1069 +name = "icu_normalizer_data"
1070 +version = "2.3.0"
1071 +source = "registry+https://github.com/rust-lang/crates.io-index"
1072 +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
1073 +
1074 +[[package]]
1075 +name = "icu_properties"
1076 +version = "2.3.0"
1077 +source = "registry+https://github.com/rust-lang/crates.io-index"
1078 +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
1079 +dependencies = [
1080 + "displaydoc",
1081 + "icu_collections",
1082 + "icu_locale_core",
1083 + "icu_properties_data",
1084 + "icu_provider",
1085 + "zerotrie",
1086 + "zerovec",
1087 +]
1088 +
1089 +[[package]]
1090 +name = "icu_properties_data"
1091 +version = "2.3.0"
1092 +source = "registry+https://github.com/rust-lang/crates.io-index"
1093 +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
1094 +
1095 +[[package]]
1096 +name = "icu_provider"
1097 +version = "2.3.1"
1098 +source = "registry+https://github.com/rust-lang/crates.io-index"
1099 +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
1100 +dependencies = [
1101 + "displaydoc",
1102 + "icu_locale_core",
1103 + "writeable",
1104 + "yoke",
1105 + "zerofrom",
1106 + "zerotrie",
1107 + "zerovec",
1108 +]
1109 +
1110 +[[package]]
1111 +name = "ident_case"
1112 +version = "1.0.1"
1113 +source = "registry+https://github.com/rust-lang/crates.io-index"
1114 +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
1115 +
1116 +[[package]]
1117 +name = "idna"
1118 +version = "1.1.0"
1119 +source = "registry+https://github.com/rust-lang/crates.io-index"
1120 +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
1121 +dependencies = [
1122 + "idna_adapter",
1123 + "smallvec",
1124 + "utf8_iter",
1125 +]
1126 +
1127 +[[package]]
1128 +name = "idna_adapter"
1129 +version = "1.2.2"
1130 +source = "registry+https://github.com/rust-lang/crates.io-index"
1131 +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
1132 +dependencies = [
1133 + "icu_normalizer",
1134 + "icu_properties",
1135 +]
1136 +
1137 +[[package]]
1138 +name = "indexmap"
1139 +version = "1.9.3"
1140 +source = "registry+https://github.com/rust-lang/crates.io-index"
1141 +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
1142 +dependencies = [
1143 + "autocfg",
1144 + "hashbrown 0.12.3",
1145 + "serde",
1146 +]
1147 +
1148 +[[package]]
1149 +name = "indexmap"
1150 +version = "2.14.2"
1151 +source = "registry+https://github.com/rust-lang/crates.io-index"
1152 +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
1153 +dependencies = [
1154 + "equivalent",
1155 + "hashbrown 0.17.1",
1156 + "serde",
1157 + "serde_core",
1158 +]
1159 +
1160 +[[package]]
1161 +name = "indoc"
1162 +version = "2.0.7"
1163 +source = "registry+https://github.com/rust-lang/crates.io-index"
1164 +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
1165 +dependencies = [
1166 + "rustversion",
1167 +]
1168 +
1169 +[[package]]
1170 +name = "instability"
1171 +version = "0.3.14"
1172 +source = "registry+https://github.com/rust-lang/crates.io-index"
1173 +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20"
1174 +dependencies = [
1175 + "darling",
1176 + "indoc",
1177 + "proc-macro2",
1178 + "quote",
1179 + "syn 3.0.6",
1180 +]
1181 +
1182 +[[package]]
1183 +name = "ipnet"
1184 +version = "2.12.2"
1185 +source = "registry+https://github.com/rust-lang/crates.io-index"
1186 +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
1187 +
1188 +[[package]]
1189 +name = "itertools"
1190 +version = "0.14.0"
1191 +source = "registry+https://github.com/rust-lang/crates.io-index"
1192 +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
1193 +dependencies = [
1194 + "either",
1195 +]
1196 +
1197 +[[package]]
1198 +name = "itoa"
1199 +version = "1.0.18"
1200 +source = "registry+https://github.com/rust-lang/crates.io-index"
1201 +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
1202 +
1203 +[[package]]
1204 +name = "jiff"
1205 +version = "0.2.37"
1206 +source = "registry+https://github.com/rust-lang/crates.io-index"
1207 +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
1208 +dependencies = [
1209 + "defmt",
1210 + "jiff-core",
1211 + "jiff-static",
1212 + "jiff-tzdb-platform",
1213 + "log",
1214 + "portable-atomic",
1215 + "portable-atomic-util",
1216 + "serde_core",
1217 + "windows-link",
1218 +]
1219 +
1220 +[[package]]
1221 +name = "jiff-core"
1222 +version = "0.1.1"
1223 +source = "registry+https://github.com/rust-lang/crates.io-index"
1224 +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
1225 +dependencies = [
1226 + "defmt",
1227 + "log",
1228 +]
1229 +
1230 +[[package]]
1231 +name = "jiff-static"
1232 +version = "0.2.37"
1233 +source = "registry+https://github.com/rust-lang/crates.io-index"
1234 +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
1235 +dependencies = [
1236 + "jiff-core",
1237 + "proc-macro2",
1238 + "quote",
1239 + "syn 2.0.119",
1240 +]
1241 +
1242 +[[package]]
1243 +name = "jiff-tzdb"
1244 +version = "0.1.8"
1245 +source = "registry+https://github.com/rust-lang/crates.io-index"
1246 +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
1247 +
1248 +[[package]]
1249 +name = "jiff-tzdb-platform"
1250 +version = "0.1.3"
1251 +source = "registry+https://github.com/rust-lang/crates.io-index"
1252 +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
1253 +dependencies = [
1254 + "jiff-tzdb",
1255 +]
1256 +
1257 +[[package]]
1258 +name = "jni"
1259 +version = "0.22.4"
1260 +source = "registry+https://github.com/rust-lang/crates.io-index"
1261 +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
1262 +dependencies = [
1263 + "cfg-if",
1264 + "combine",
1265 + "jni-macros",
1266 + "jni-sys",
1267 + "log",
1268 + "simd_cesu8",
1269 + "thiserror",
1270 + "walkdir",
1271 + "windows-link",
1272 +]
1273 +
1274 +[[package]]
1275 +name = "jni-macros"
1276 +version = "0.22.4"
1277 +source = "registry+https://github.com/rust-lang/crates.io-index"
1278 +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
1279 +dependencies = [
1280 + "proc-macro2",
1281 + "quote",
1282 + "rustc_version",
1283 + "simd_cesu8",
1284 + "syn 2.0.119",
1285 +]
1286 +
1287 +[[package]]
1288 +name = "jni-sys"
1289 +version = "0.4.1"
1290 +source = "registry+https://github.com/rust-lang/crates.io-index"
1291 +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
1292 +dependencies = [
1293 + "jni-sys-macros",
1294 +]
1295 +
1296 +[[package]]
1297 +name = "jni-sys-macros"
1298 +version = "0.4.1"
1299 +source = "registry+https://github.com/rust-lang/crates.io-index"
1300 +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
1301 +dependencies = [
1302 + "quote",
1303 + "syn 2.0.119",
1304 +]
1305 +
1306 +[[package]]
1307 +name = "jobserver"
1308 +version = "0.1.35"
1309 +source = "registry+https://github.com/rust-lang/crates.io-index"
1310 +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
1311 +dependencies = [
1312 + "getrandom 0.4.3",
1313 + "libc",
1314 +]
1315 +
1316 +[[package]]
1317 +name = "js-sys"
1318 +version = "0.3.106"
1319 +source = "registry+https://github.com/rust-lang/crates.io-index"
1320 +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
1321 +dependencies = [
1322 + "cfg-if",
1323 + "futures-util",
1324 + "wasm-bindgen",
1325 +]
1326 +
1327 +[[package]]
1328 +name = "kasuari"
1329 +version = "0.4.12"
1330 +source = "registry+https://github.com/rust-lang/crates.io-index"
1331 +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
1332 +dependencies = [
1333 + "hashbrown 0.16.1",
1334 + "portable-atomic",
1335 + "thiserror",
1336 +]
1337 +
1338 +[[package]]
1339 +name = "lazy_static"
1340 +version = "1.5.1"
1341 +source = "registry+https://github.com/rust-lang/crates.io-index"
1342 +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
1343 +
1344 +[[package]]
1345 +name = "libc"
1346 +version = "0.2.190"
1347 +source = "registry+https://github.com/rust-lang/crates.io-index"
1348 +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
1349 +
1350 +[[package]]
1351 +name = "libm"
1352 +version = "0.2.16"
1353 +source = "registry+https://github.com/rust-lang/crates.io-index"
1354 +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
1355 +
1356 +[[package]]
1357 +name = "libredox"
1358 +version = "0.1.25"
1359 +source = "registry+https://github.com/rust-lang/crates.io-index"
1360 +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
1361 +dependencies = [
1362 + "libc",
1363 +]
1364 +
1365 +[[package]]
1366 +name = "line-clipping"
1367 +version = "0.3.8"
1368 +source = "registry+https://github.com/rust-lang/crates.io-index"
1369 +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
1370 +dependencies = [
1371 + "bitflags 2.13.2",
1372 +]
1373 +
1374 +[[package]]
1375 +name = "linux-raw-sys"
1376 +version = "0.12.1"
1377 +source = "registry+https://github.com/rust-lang/crates.io-index"
1378 +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
1379 +
1380 +[[package]]
1381 +name = "litemap"
1382 +version = "0.8.3"
1383 +source = "registry+https://github.com/rust-lang/crates.io-index"
1384 +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
1385 +
1386 +[[package]]
1387 +name = "litrs"
1388 +version = "1.0.0"
1389 +source = "registry+https://github.com/rust-lang/crates.io-index"
1390 +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
1391 +
1392 +[[package]]
1393 +name = "lock_api"
1394 +version = "0.4.14"
1395 +source = "registry+https://github.com/rust-lang/crates.io-index"
1396 +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
1397 +dependencies = [
1398 + "scopeguard",
1399 +]
1400 +
1401 +[[package]]
1402 +name = "log"
1403 +version = "0.4.34"
1404 +source = "registry+https://github.com/rust-lang/crates.io-index"
1405 +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
1406 +
1407 +[[package]]
1408 +name = "lru"
1409 +version = "0.18.5"
1410 +source = "registry+https://github.com/rust-lang/crates.io-index"
1411 +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
1412 +dependencies = [
1413 + "hashbrown 0.17.1",
1414 +]
1415 +
1416 +[[package]]
1417 +name = "lru-slab"
1418 +version = "0.1.3"
1419 +source = "registry+https://github.com/rust-lang/crates.io-index"
1420 +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
1421 +
1422 +[[package]]
1423 +name = "matchers"
1424 +version = "0.2.0"
1425 +source = "registry+https://github.com/rust-lang/crates.io-index"
1426 +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
1427 +dependencies = [
1428 + "regex-automata",
1429 +]
1430 +
1431 +[[package]]
1432 +name = "memchr"
1433 +version = "2.8.3"
1434 +source = "registry+https://github.com/rust-lang/crates.io-index"
1435 +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
1436 +
1437 +[[package]]
1438 +name = "mio"
1439 +version = "1.2.3"
1440 +source = "registry+https://github.com/rust-lang/crates.io-index"
1441 +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
1442 +dependencies = [
1443 + "libc",
1444 + "log",
1445 + "wasi",
1446 + "windows-sys 0.61.2",
1447 +]
1448 +
1449 +[[package]]
1450 +name = "nix"
1451 +version = "0.31.3"
1452 +source = "registry+https://github.com/rust-lang/crates.io-index"
1453 +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
1454 +dependencies = [
1455 + "bitflags 2.13.2",
1456 + "cfg-if",
1457 + "cfg_aliases",
1458 + "libc",
1459 +]
1460 +
1461 +[[package]]
1462 +name = "nu-ansi-term"
1463 +version = "0.50.3"
1464 +source = "registry+https://github.com/rust-lang/crates.io-index"
1465 +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
1466 +dependencies = [
1467 + "windows-sys 0.61.2",
1468 +]
1469 +
1470 +[[package]]
1471 +name = "num-complex"
1472 +version = "0.4.6"
1473 +source = "registry+https://github.com/rust-lang/crates.io-index"
1474 +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
1475 +dependencies = [
1476 + "num-traits",
1477 +]
1478 +
1479 +[[package]]
1480 +name = "num-conv"
1481 +version = "0.2.2"
1482 +source = "registry+https://github.com/rust-lang/crates.io-index"
1483 +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
1484 +
1485 +[[package]]
1486 +name = "num-integer"
1487 +version = "0.1.47"
1488 +source = "registry+https://github.com/rust-lang/crates.io-index"
1489 +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
1490 +dependencies = [
1491 + "num-traits",
1492 +]
1493 +
1494 +[[package]]
1495 +name = "num-traits"
1496 +version = "0.2.19"
1497 +source = "registry+https://github.com/rust-lang/crates.io-index"
1498 +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
1499 +dependencies = [
1500 + "autocfg",
1501 +]
1502 +
1503 +[[package]]
1504 +name = "num_threads"
1505 +version = "0.1.7"
1506 +source = "registry+https://github.com/rust-lang/crates.io-index"
1507 +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
1508 +dependencies = [
1509 + "libc",
1510 +]
1511 +
1512 +[[package]]
1513 +name = "once_cell"
1514 +version = "1.21.4"
1515 +source = "registry+https://github.com/rust-lang/crates.io-index"
1516 +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
1517 +
1518 +[[package]]
1519 +name = "openssl-probe"
1520 +version = "0.2.1"
1521 +source = "registry+https://github.com/rust-lang/crates.io-index"
1522 +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
1523 +
1524 +[[package]]
1525 +name = "option-ext"
1526 +version = "0.2.0"
1527 +source = "registry+https://github.com/rust-lang/crates.io-index"
1528 +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
1529 +
1530 +[[package]]
1531 +name = "palette"
1532 +version = "0.7.7"
1533 +source = "registry+https://github.com/rust-lang/crates.io-index"
1534 +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64"
1535 +dependencies = [
1536 + "approx",
1537 + "libm",
1538 + "palette_derive",
1539 + "palette_math",
1540 +]
1541 +
1542 +[[package]]
1543 +name = "palette_derive"
1544 +version = "0.7.7"
1545 +source = "registry+https://github.com/rust-lang/crates.io-index"
1546 +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be"
1547 +dependencies = [
1548 + "by_address",
1549 + "proc-macro2",
1550 + "quote",
1551 + "syn 2.0.119",
1552 +]
1553 +
1554 +[[package]]
1555 +name = "palette_math"
1556 +version = "0.7.7"
1557 +source = "registry+https://github.com/rust-lang/crates.io-index"
1558 +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12"
1559 +dependencies = [
1560 + "libm",
1561 +]
1562 +
1563 +[[package]]
1564 +name = "parking"
1565 +version = "2.2.1"
1566 +source = "registry+https://github.com/rust-lang/crates.io-index"
1567 +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
1568 +
1569 +[[package]]
1570 +name = "parking_lot"
1571 +version = "0.12.5"
1572 +source = "registry+https://github.com/rust-lang/crates.io-index"
1573 +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
1574 +dependencies = [
1575 + "lock_api",
1576 + "parking_lot_core",
1577 +]
1578 +
1579 +[[package]]
1580 +name = "parking_lot_core"
1581 +version = "0.9.12"
1582 +source = "registry+https://github.com/rust-lang/crates.io-index"
1583 +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
1584 +dependencies = [
1585 + "cfg-if",
1586 + "libc",
1587 + "redox_syscall",
1588 + "smallvec",
1589 + "windows-link",
1590 +]
1591 +
1592 +[[package]]
1593 +name = "percent-encoding"
1594 +version = "2.3.2"
1595 +source = "registry+https://github.com/rust-lang/crates.io-index"
1596 +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1597 +
1598 +[[package]]
1599 +name = "pin-project"
1600 +version = "1.1.13"
1601 +source = "registry+https://github.com/rust-lang/crates.io-index"
1602 +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
1603 +dependencies = [
1604 + "pin-project-internal",
1605 +]
1606 +
1607 +[[package]]
1608 +name = "pin-project-internal"
1609 +version = "1.1.13"
1610 +source = "registry+https://github.com/rust-lang/crates.io-index"
1611 +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
1612 +dependencies = [
1613 + "proc-macro2",
1614 + "quote",
1615 + "syn 2.0.119",
1616 +]
1617 +
1618 +[[package]]
1619 +name = "pin-project-lite"
1620 +version = "0.2.17"
1621 +source = "registry+https://github.com/rust-lang/crates.io-index"
1622 +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1623 +
1624 +[[package]]
1625 +name = "piper"
1626 +version = "0.2.5"
1627 +source = "registry+https://github.com/rust-lang/crates.io-index"
1628 +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
1629 +dependencies = [
1630 + "atomic-waker",
1631 + "fastrand",
1632 + "futures-io",
1633 +]
1634 +
1635 +[[package]]
1636 +name = "pkg-config"
1637 +version = "0.3.34"
1638 +source = "registry+https://github.com/rust-lang/crates.io-index"
1639 +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
1640 +
1641 +[[package]]
1642 +name = "polling"
1643 +version = "3.11.0"
1644 +source = "registry+https://github.com/rust-lang/crates.io-index"
1645 +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
1646 +dependencies = [
1647 + "cfg-if",
1648 + "concurrent-queue",
1649 + "hermit-abi",
1650 + "pin-project-lite",
1651 + "rustix",
1652 + "windows-sys 0.61.2",
1653 +]
1654 +
1655 +[[package]]
1656 +name = "portable-atomic"
1657 +version = "1.15.0"
1658 +source = "registry+https://github.com/rust-lang/crates.io-index"
1659 +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
1660 +
1661 +[[package]]
1662 +name = "portable-atomic-util"
1663 +version = "0.2.8"
1664 +source = "registry+https://github.com/rust-lang/crates.io-index"
1665 +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
1666 +dependencies = [
1667 + "portable-atomic",
1668 +]
1669 +
1670 +[[package]]
1671 +name = "potential_utf"
1672 +version = "0.1.6"
1673 +source = "registry+https://github.com/rust-lang/crates.io-index"
1674 +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
1675 +dependencies = [
1676 + "zerovec",
1677 +]
1678 +
1679 +[[package]]
1680 +name = "powerfmt"
1681 +version = "0.2.0"
1682 +source = "registry+https://github.com/rust-lang/crates.io-index"
1683 +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
1684 +
1685 +[[package]]
1686 +name = "primal-check"
1687 +version = "0.3.4"
1688 +source = "registry+https://github.com/rust-lang/crates.io-index"
1689 +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
1690 +dependencies = [
1691 + "num-integer",
1692 +]
1693 +
1694 +[[package]]
1695 +name = "proc-macro2"
1696 +version = "1.0.107"
1697 +source = "registry+https://github.com/rust-lang/crates.io-index"
1698 +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
1699 +dependencies = [
1700 + "unicode-ident",
1701 +]
1702 +
1703 +[[package]]
1704 +name = "process-wrap"
1705 +version = "10.0.1"
1706 +source = "registry+https://github.com/rust-lang/crates.io-index"
1707 +checksum = "1f21b97672d2dc848e7b25701ab4535618b92f4861c13cc3f7f7bed52ad3c8da"
1708 +dependencies = [
1709 + "futures",
1710 + "indexmap 2.14.2",
1711 + "nix",
1712 + "tokio",
1713 + "tracing",
1714 + "windows",
1715 +]
1716 +
1717 +[[package]]
1718 +name = "quinn"
1719 +version = "0.11.12"
1720 +source = "registry+https://github.com/rust-lang/crates.io-index"
1721 +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
1722 +dependencies = [
1723 + "bytes",
1724 + "cfg_aliases",
1725 + "pin-project-lite",
1726 + "quinn-proto",
1727 + "quinn-udp",
1728 + "rustc-hash",
1729 + "rustls",
1730 + "socket2",
1731 + "thiserror",
1732 + "tokio",
1733 + "tracing",
1734 + "web-time",
1735 +]
1736 +
1737 +[[package]]
1738 +name = "quinn-proto"
1739 +version = "0.11.19"
1740 +source = "registry+https://github.com/rust-lang/crates.io-index"
1741 +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
1742 +dependencies = [
1743 + "aws-lc-rs",
1744 + "bytes",
1745 + "getrandom 0.4.3",
1746 + "lru-slab",
1747 + "rand",
1748 + "rand_pcg",
1749 + "ring",
1750 + "rustc-hash",
1751 + "rustls",
1752 + "rustls-pki-types",
1753 + "slab",
1754 + "thiserror",
1755 + "tinyvec",
1756 + "tracing",
1757 + "web-time",
1758 +]
1759 +
1760 +[[package]]
1761 +name = "quinn-udp"
1762 +version = "0.5.16"
1763 +source = "registry+https://github.com/rust-lang/crates.io-index"
1764 +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
1765 +dependencies = [
1766 + "cfg_aliases",
1767 + "libc",
1768 + "once_cell",
1769 + "socket2",
1770 + "tracing",
1771 + "windows-sys 0.61.2",
1772 +]
1773 +
1774 +[[package]]
1775 +name = "quote"
1776 +version = "1.0.47"
1777 +source = "registry+https://github.com/rust-lang/crates.io-index"
1778 +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
1779 +dependencies = [
1780 + "proc-macro2",
1781 +]
1782 +
1783 +[[package]]
1784 +name = "r-efi"
1785 +version = "6.0.0"
1786 +source = "registry+https://github.com/rust-lang/crates.io-index"
1787 +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1788 +
1789 +[[package]]
1790 +name = "rand"
1791 +version = "0.10.3"
1792 +source = "registry+https://github.com/rust-lang/crates.io-index"
1793 +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
1794 +dependencies = [
1795 + "chacha20",
1796 + "getrandom 0.4.3",
1797 + "rand_core",
1798 +]
1799 +
1800 +[[package]]
1801 +name = "rand_core"
1802 +version = "0.10.1"
1803 +source = "registry+https://github.com/rust-lang/crates.io-index"
1804 +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
1805 +
1806 +[[package]]
1807 +name = "rand_pcg"
1808 +version = "0.10.2"
1809 +source = "registry+https://github.com/rust-lang/crates.io-index"
1810 +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
1811 +dependencies = [
1812 + "rand_core",
1813 +]
1814 +
1815 +[[package]]
1816 +name = "ratatui"
1817 +version = "0.30.2"
1818 +source = "registry+https://github.com/rust-lang/crates.io-index"
1819 +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
1820 +dependencies = [
1821 + "instability",
1822 + "ratatui-core",
1823 + "ratatui-crossterm",
1824 + "ratatui-widgets",
1825 + "serde",
1826 +]
1827 +
1828 +[[package]]
1829 +name = "ratatui-core"
1830 +version = "0.1.2"
1831 +source = "registry+https://github.com/rust-lang/crates.io-index"
1832 +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
1833 +dependencies = [
1834 + "bitflags 2.13.2",
1835 + "compact_str",
1836 + "critical-section",
1837 + "hashbrown 0.17.1",
1838 + "itertools",
1839 + "kasuari",
1840 + "lru",
1841 + "palette",
1842 + "serde",
1843 + "strum",
1844 + "thiserror",
1845 + "unicode-segmentation",
1846 + "unicode-truncate",
1847 + "unicode-width",
1848 +]
1849 +
1850 +[[package]]
1851 +name = "ratatui-crossterm"
1852 +version = "0.1.2"
1853 +source = "registry+https://github.com/rust-lang/crates.io-index"
1854 +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0"
1855 +dependencies = [
1856 + "cfg-if",
1857 + "crossterm",
1858 + "instability",
1859 + "ratatui-core",
1860 +]
1861 +
1862 +[[package]]
1863 +name = "ratatui-widgets"
1864 +version = "0.3.2"
1865 +source = "registry+https://github.com/rust-lang/crates.io-index"
1866 +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
1867 +dependencies = [
1868 + "bitflags 2.13.2",
1869 + "hashbrown 0.17.1",
1870 + "indoc",
1871 + "instability",
1872 + "itertools",
1873 + "line-clipping",
1874 + "ratatui-core",
1875 + "serde",
1876 + "strum",
1877 + "time",
1878 + "unicode-segmentation",
1879 + "unicode-width",
1880 +]
1881 +
1882 +[[package]]
1883 +name = "realfft"
1884 +version = "3.5.0"
1885 +source = "registry+https://github.com/rust-lang/crates.io-index"
1886 +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677"
1887 +dependencies = [
1888 + "rustfft",
1889 +]
1890 +
1891 +[[package]]
1892 +name = "redox_syscall"
1893 +version = "0.5.18"
1894 +source = "registry+https://github.com/rust-lang/crates.io-index"
1895 +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
1896 +dependencies = [
1897 + "bitflags 2.13.2",
1898 +]
1899 +
1900 +[[package]]
1901 +name = "redox_users"
1902 +version = "0.5.3"
1903 +source = "registry+https://github.com/rust-lang/crates.io-index"
1904 +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
1905 +dependencies = [
1906 + "libredox",
1907 + "thiserror",
1908 +]
1909 +
1910 +[[package]]
1911 +name = "ref-cast"
1912 +version = "1.0.27"
1913 +source = "registry+https://github.com/rust-lang/crates.io-index"
1914 +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
1915 +dependencies = [
1916 + "ref-cast-impl",
1917 +]
1918 +
1919 +[[package]]
1920 +name = "ref-cast-impl"
1921 +version = "1.0.27"
1922 +source = "registry+https://github.com/rust-lang/crates.io-index"
1923 +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
1924 +dependencies = [
1925 + "proc-macro2",
1926 + "quote",
1927 + "syn 3.0.6",
1928 +]
1929 +
1930 +[[package]]
1931 +name = "regex-automata"
1932 +version = "0.4.18"
1933 +source = "registry+https://github.com/rust-lang/crates.io-index"
1934 +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
1935 +dependencies = [
1936 + "aho-corasick",
1937 + "memchr",
1938 + "regex-syntax",
1939 +]
1940 +
1941 +[[package]]
1942 +name = "regex-lite"
1943 +version = "0.1.9"
1944 +source = "registry+https://github.com/rust-lang/crates.io-index"
1945 +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
1946 +
1947 +[[package]]
1948 +name = "regex-syntax"
1949 +version = "0.8.11"
1950 +source = "registry+https://github.com/rust-lang/crates.io-index"
1951 +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1952 +
1953 +[[package]]
1954 +name = "reqwest"
1955 +version = "0.13.5"
1956 +source = "registry+https://github.com/rust-lang/crates.io-index"
1957 +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
1958 +dependencies = [
1959 + "base64",
1960 + "bytes",
1961 + "futures-core",
1962 + "futures-util",
1963 + "http",
1964 + "http-body",
1965 + "http-body-util",
1966 + "hyper",
1967 + "hyper-rustls",
1968 + "hyper-util",
1969 + "js-sys",
1970 + "log",
1971 + "percent-encoding",
1972 + "pin-project-lite",
1973 + "quinn",
1974 + "rustls",
1975 + "rustls-pki-types",
1976 + "rustls-platform-verifier",
1977 + "serde",
1978 + "serde_json",
1979 + "sync_wrapper",
1980 + "tokio",
1981 + "tokio-rustls",
1982 + "tokio-util",
1983 + "tower",
1984 + "tower-http",
1985 + "tower-service",
1986 + "url",
1987 + "wasm-bindgen",
1988 + "wasm-bindgen-futures",
1989 + "wasm-streams",
1990 + "web-sys",
1991 +]
1992 +
1993 +[[package]]
1994 +name = "ring"
1995 +version = "0.17.14"
1996 +source = "registry+https://github.com/rust-lang/crates.io-index"
1997 +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
1998 +dependencies = [
1999 + "cc",
2000 + "cfg-if",
2001 + "getrandom 0.2.17",
2002 + "libc",
2003 + "untrusted",
2004 + "windows-sys 0.52.0",
2005 +]
2006 +
2007 +[[package]]
2008 +name = "rmcp"
2009 +version = "3.5.1"
2010 +source = "registry+https://github.com/rust-lang/crates.io-index"
2011 +checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350"
2012 +dependencies = [
2013 + "chrono",
2014 + "futures",
2015 + "indexmap 2.14.2",
2016 + "pin-project-lite",
2017 + "process-wrap",
2018 + "serde",
2019 + "serde_json",
2020 + "thiserror",
2021 + "tokio",
2022 + "tokio-stream",
2023 + "tokio-util",
2024 + "tracing",
2025 +]
2026 +
2027 +[[package]]
2028 +name = "rustc-hash"
2029 +version = "2.1.3"
2030 +source = "registry+https://github.com/rust-lang/crates.io-index"
2031 +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
2032 +
2033 +[[package]]
2034 +name = "rustc_version"
2035 +version = "0.4.1"
2036 +source = "registry+https://github.com/rust-lang/crates.io-index"
2037 +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
2038 +dependencies = [
2039 + "semver",
2040 +]
2041 +
2042 +[[package]]
2043 +name = "rustfft"
2044 +version = "6.4.1"
2045 +source = "registry+https://github.com/rust-lang/crates.io-index"
2046 +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
2047 +dependencies = [
2048 + "num-complex",
2049 + "num-integer",
2050 + "num-traits",
2051 + "primal-check",
2052 + "strength_reduce",
2053 + "transpose",
2054 +]
2055 +
2056 +[[package]]
2057 +name = "rustix"
2058 +version = "1.1.5"
2059 +source = "registry+https://github.com/rust-lang/crates.io-index"
2060 +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
2061 +dependencies = [
2062 + "bitflags 2.13.2",
2063 + "errno",
2064 + "libc",
2065 + "linux-raw-sys",
2066 + "windows-sys 0.61.2",
2067 +]
2068 +
2069 +[[package]]
2070 +name = "rustls"
2071 +version = "0.23.45"
2072 +source = "registry+https://github.com/rust-lang/crates.io-index"
2073 +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
2074 +dependencies = [
2075 + "aws-lc-rs",
2076 + "once_cell",
2077 + "rustls-pki-types",
2078 + "rustls-webpki",
2079 + "subtle",
2080 + "zeroize",
2081 +]
2082 +
2083 +[[package]]
2084 +name = "rustls-native-certs"
2085 +version = "0.8.4"
2086 +source = "registry+https://github.com/rust-lang/crates.io-index"
2087 +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
2088 +dependencies = [
2089 + "openssl-probe",
2090 + "rustls-pki-types",
2091 + "schannel",
2092 + "security-framework",
2093 +]
2094 +
2095 +[[package]]
2096 +name = "rustls-pki-types"
2097 +version = "1.15.1"
2098 +source = "registry+https://github.com/rust-lang/crates.io-index"
2099 +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
2100 +dependencies = [
2101 + "web-time",
2102 + "zeroize",
2103 +]
2104 +
2105 +[[package]]
2106 +name = "rustls-platform-verifier"
2107 +version = "0.7.1"
2108 +source = "registry+https://github.com/rust-lang/crates.io-index"
2109 +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
2110 +dependencies = [
2111 + "core-foundation",
2112 + "core-foundation-sys",
2113 + "jni",
2114 + "log",
2115 + "once_cell",
2116 + "rustls",
2117 + "rustls-native-certs",
2118 + "rustls-platform-verifier-android",
2119 + "rustls-webpki",
2120 + "security-framework",
2121 + "security-framework-sys",
2122 + "webpki-root-certs",
2123 + "windows-sys 0.61.2",
2124 +]
2125 +
2126 +[[package]]
2127 +name = "rustls-platform-verifier-android"
2128 +version = "0.2.0"
2129 +source = "registry+https://github.com/rust-lang/crates.io-index"
2130 +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
2131 +
2132 +[[package]]
2133 +name = "rustls-webpki"
2134 +version = "0.103.15"
2135 +source = "registry+https://github.com/rust-lang/crates.io-index"
2136 +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
2137 +dependencies = [
2138 + "aws-lc-rs",
2139 + "ring",
2140 + "rustls-pki-types",
2141 + "untrusted",
2142 +]
2143 +
2144 +[[package]]
2145 +name = "rustversion"
2146 +version = "1.0.23"
2147 +source = "registry+https://github.com/rust-lang/crates.io-index"
2148 +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
2149 +
2150 +[[package]]
2151 +name = "ryu"
2152 +version = "1.0.23"
2153 +source = "registry+https://github.com/rust-lang/crates.io-index"
2154 +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
2155 +
2156 +[[package]]
2157 +name = "same-file"
2158 +version = "1.0.6"
2159 +source = "registry+https://github.com/rust-lang/crates.io-index"
2160 +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
2161 +dependencies = [
2162 + "winapi-util",
2163 +]
2164 +
2165 +[[package]]
2166 +name = "schannel"
2167 +version = "0.1.29"
2168 +source = "registry+https://github.com/rust-lang/crates.io-index"
2169 +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
2170 +dependencies = [
2171 + "windows-sys 0.61.2",
2172 +]
2173 +
2174 +[[package]]
2175 +name = "schemars"
2176 +version = "0.9.0"
2177 +source = "registry+https://github.com/rust-lang/crates.io-index"
2178 +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
2179 +dependencies = [
2180 + "dyn-clone",
2181 + "ref-cast",
2182 + "serde",
2183 + "serde_json",
2184 +]
2185 +
2186 +[[package]]
2187 +name = "schemars"
2188 +version = "1.2.2"
2189 +source = "registry+https://github.com/rust-lang/crates.io-index"
2190 +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
2191 +dependencies = [
2192 + "dyn-clone",
2193 + "ref-cast",
2194 + "schemars_derive",
2195 + "serde",
2196 + "serde_json",
2197 +]
2198 +
2199 +[[package]]
2200 +name = "schemars_derive"
2201 +version = "1.2.2"
2202 +source = "registry+https://github.com/rust-lang/crates.io-index"
2203 +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
2204 +dependencies = [
2205 + "proc-macro2",
2206 + "quote",
2207 + "serde_derive_internals",
2208 + "syn 3.0.6",
2209 +]
2210 +
2211 +[[package]]
2212 +name = "scopeguard"
2213 +version = "1.2.0"
2214 +source = "registry+https://github.com/rust-lang/crates.io-index"
2215 +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
2216 +
2217 +[[package]]
2218 +name = "security-framework"
2219 +version = "3.7.0"
2220 +source = "registry+https://github.com/rust-lang/crates.io-index"
2221 +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
2222 +dependencies = [
2223 + "bitflags 2.13.2",
2224 + "core-foundation",
2225 + "core-foundation-sys",
2226 + "libc",
2227 + "security-framework-sys",
2228 +]
2229 +
2230 +[[package]]
2231 +name = "security-framework-sys"
2232 +version = "2.17.0"
2233 +source = "registry+https://github.com/rust-lang/crates.io-index"
2234 +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
2235 +dependencies = [
2236 + "core-foundation-sys",
2237 + "libc",
2238 +]
2239 +
2240 +[[package]]
2241 +name = "semver"
2242 +version = "1.0.28"
2243 +source = "registry+https://github.com/rust-lang/crates.io-index"
2244 +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
2245 +
2246 +[[package]]
2247 +name = "serde"
2248 +version = "1.0.229"
2249 +source = "registry+https://github.com/rust-lang/crates.io-index"
2250 +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
2251 +dependencies = [
2252 + "serde_core",
2253 + "serde_derive",
2254 +]
2255 +
2256 +[[package]]
2257 +name = "serde_core"
2258 +version = "1.0.229"
2259 +source = "registry+https://github.com/rust-lang/crates.io-index"
2260 +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
2261 +dependencies = [
2262 + "serde_derive",
2263 +]
2264 +
2265 +[[package]]
2266 +name = "serde_derive"
2267 +version = "1.0.229"
2268 +source = "registry+https://github.com/rust-lang/crates.io-index"
2269 +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
2270 +dependencies = [
2271 + "proc-macro2",
2272 + "quote",
2273 + "syn 3.0.6",
2274 +]
2275 +
2276 +[[package]]
2277 +name = "serde_derive_internals"
2278 +version = "0.30.0"
2279 +source = "registry+https://github.com/rust-lang/crates.io-index"
2280 +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
2281 +dependencies = [
2282 + "proc-macro2",
2283 + "quote",
2284 + "syn 3.0.6",
2285 +]
2286 +
2287 +[[package]]
2288 +name = "serde_json"
2289 +version = "1.0.151"
2290 +source = "registry+https://github.com/rust-lang/crates.io-index"
2291 +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
2292 +dependencies = [
2293 + "indexmap 2.14.2",
2294 + "itoa",
2295 + "memchr",
2296 + "serde",
2297 + "serde_core",
2298 + "zmij",
2299 +]
2300 +
2301 +[[package]]
2302 +name = "serde_with"
2303 +version = "3.24.0"
2304 +source = "registry+https://github.com/rust-lang/crates.io-index"
2305 +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
2306 +dependencies = [
2307 + "base64",
2308 + "bs58",
2309 + "chrono",
2310 + "hex",
2311 + "indexmap 1.9.3",
2312 + "indexmap 2.14.2",
2313 + "jiff",
2314 + "schemars 0.9.0",
2315 + "schemars 1.2.2",
2316 + "serde_core",
2317 + "serde_json",
2318 + "serde_with_macros",
2319 + "time",
2320 +]
2321 +
2322 +[[package]]
2323 +name = "serde_with_macros"
2324 +version = "3.24.0"
2325 +source = "registry+https://github.com/rust-lang/crates.io-index"
2326 +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
2327 +dependencies = [
2328 + "darling",
2329 + "proc-macro2",
2330 + "quote",
2331 + "syn 3.0.6",
2332 +]
2333 +
2334 +[[package]]
2335 +name = "sharded-slab"
2336 +version = "0.1.7"
2337 +source = "registry+https://github.com/rust-lang/crates.io-index"
2338 +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
2339 +dependencies = [
2340 + "lazy_static",
2341 +]
2342 +
2343 +[[package]]
2344 +name = "shell-words"
2345 +version = "1.1.1"
2346 +source = "registry+https://github.com/rust-lang/crates.io-index"
2347 +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
2348 +
2349 +[[package]]
2350 +name = "shlex"
2351 +version = "2.0.1"
2352 +source = "registry+https://github.com/rust-lang/crates.io-index"
2353 +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
2354 +
2355 +[[package]]
2356 +name = "signal-hook"
2357 +version = "0.3.18"
2358 +source = "registry+https://github.com/rust-lang/crates.io-index"
2359 +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
2360 +dependencies = [
2361 + "libc",
2362 + "signal-hook-registry",
2363 +]
2364 +
2365 +[[package]]
2366 +name = "signal-hook-mio"
2367 +version = "0.2.5"
2368 +source = "registry+https://github.com/rust-lang/crates.io-index"
2369 +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
2370 +dependencies = [
2371 + "libc",
2372 + "mio",
2373 + "signal-hook",
2374 +]
2375 +
2376 +[[package]]
2377 +name = "signal-hook-registry"
2378 +version = "1.4.8"
2379 +source = "registry+https://github.com/rust-lang/crates.io-index"
2380 +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
2381 +dependencies = [
2382 + "errno",
2383 + "libc",
2384 +]
2385 +
2386 +[[package]]
2387 +name = "simd_cesu8"
2388 +version = "1.2.0"
2389 +source = "registry+https://github.com/rust-lang/crates.io-index"
2390 +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
2391 +dependencies = [
2392 + "rustc_version",
2393 + "simdutf8",
2394 +]
2395 +
2396 +[[package]]
2397 +name = "simdutf8"
2398 +version = "0.1.5"
2399 +source = "registry+https://github.com/rust-lang/crates.io-index"
2400 +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
2401 +
2402 +[[package]]
2403 +name = "slab"
2404 +version = "0.4.12"
2405 +source = "registry+https://github.com/rust-lang/crates.io-index"
2406 +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
2407 +
2408 +[[package]]
2409 +name = "smallvec"
2410 +version = "1.16.2"
2411 +source = "registry+https://github.com/rust-lang/crates.io-index"
2412 +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
2413 +
2414 +[[package]]
2415 +name = "socket2"
2416 +version = "0.6.5"
2417 +source = "registry+https://github.com/rust-lang/crates.io-index"
2418 +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
2419 +dependencies = [
2420 + "libc",
2421 + "windows-sys 0.61.2",
2422 +]
2423 +
2424 +[[package]]
2425 +name = "splitfire-agent"
2426 +version = "1.0.0"
2427 +dependencies = [
2428 + "agent-client-protocol",
2429 + "anyhow",
2430 + "base64",
2431 + "crossterm",
2432 + "directories",
2433 + "ebur128",
2434 + "futures",
2435 + "hound",
2436 + "ratatui",
2437 + "realfft",
2438 + "reqwest",
2439 + "rmcp",
2440 + "serde",
2441 + "serde_json",
2442 + "symphonia",
2443 + "tokio",
2444 + "tokio-util",
2445 + "tracing",
2446 + "tracing-subscriber",
2447 + "uuid",
2448 +]
2449 +
2450 +[[package]]
2451 +name = "stable_deref_trait"
2452 +version = "1.2.1"
2453 +source = "registry+https://github.com/rust-lang/crates.io-index"
2454 +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
2455 +
2456 +[[package]]
2457 +name = "static_assertions"
2458 +version = "1.1.0"
2459 +source = "registry+https://github.com/rust-lang/crates.io-index"
2460 +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
2461 +
2462 +[[package]]
2463 +name = "strength_reduce"
2464 +version = "0.2.4"
2465 +source = "registry+https://github.com/rust-lang/crates.io-index"
2466 +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
2467 +
2468 +[[package]]
2469 +name = "strsim"
2470 +version = "0.11.1"
2471 +source = "registry+https://github.com/rust-lang/crates.io-index"
2472 +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
2473 +
2474 +[[package]]
2475 +name = "strum"
2476 +version = "0.28.0"
2477 +source = "registry+https://github.com/rust-lang/crates.io-index"
2478 +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
2479 +dependencies = [
2480 + "strum_macros",
2481 +]
2482 +
2483 +[[package]]
2484 +name = "strum_macros"
2485 +version = "0.28.0"
2486 +source = "registry+https://github.com/rust-lang/crates.io-index"
2487 +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
2488 +dependencies = [
2489 + "heck",
2490 + "proc-macro2",
2491 + "quote",
2492 + "syn 2.0.119",
2493 +]
2494 +
2495 +[[package]]
2496 +name = "subtle"
2497 +version = "2.6.1"
2498 +source = "registry+https://github.com/rust-lang/crates.io-index"
2499 +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
2500 +
2501 +[[package]]
2502 +name = "symphonia"
2503 +version = "0.6.1"
2504 +source = "registry+https://github.com/rust-lang/crates.io-index"
2505 +checksum = "a7edef6a96b696d4e0cab5ee9ebb7ca155ed95f30a6b45bbb8b97d2727f02424"
2506 +dependencies = [
2507 + "lazy_static",
2508 + "symphonia-bundle-flac",
2509 + "symphonia-bundle-mp3",
2510 + "symphonia-codec-aac",
2511 + "symphonia-codec-adpcm",
2512 + "symphonia-codec-alac",
2513 + "symphonia-codec-pcm",
2514 + "symphonia-codec-vorbis",
2515 + "symphonia-core",
2516 + "symphonia-format-isomp4",
2517 + "symphonia-format-mkv",
2518 + "symphonia-format-ogg",
2519 + "symphonia-format-riff",
2520 + "symphonia-metadata",
2521 +]
2522 +
2523 +[[package]]
2524 +name = "symphonia-bundle-flac"
2525 +version = "0.6.1"
2526 +source = "registry+https://github.com/rust-lang/crates.io-index"
2527 +checksum = "6405d5c34ff6f8f7ca08a4101efe66d21e180f7322ef9359900a0e55698a7892"
2528 +dependencies = [
2529 + "log",
2530 + "symphonia-common",
2531 + "symphonia-core",
2532 + "symphonia-metadata",
2533 +]
2534 +
2535 +[[package]]
2536 +name = "symphonia-bundle-mp3"
2537 +version = "0.6.1"
2538 +source = "registry+https://github.com/rust-lang/crates.io-index"
2539 +checksum = "98ea5ffc8716bff677dfb3b01b420c7b758de901a72b8c330bf2040ab74b4add"
2540 +dependencies = [
2541 + "lazy_static",
2542 + "log",
2543 + "symphonia-core",
2544 +]
2545 +
2546 +[[package]]
2547 +name = "symphonia-codec-aac"
2548 +version = "0.6.1"
2549 +source = "registry+https://github.com/rust-lang/crates.io-index"
2550 +checksum = "f5bf8e39552d34a3c4c98333370e62f48c92456d2e814f273b5c3ad7c4a5f45c"
2551 +dependencies = [
2552 + "lazy_static",
2553 + "log",
2554 + "symphonia-common",
2555 + "symphonia-core",
2556 +]
2557 +
2558 +[[package]]
2559 +name = "symphonia-codec-adpcm"
2560 +version = "0.6.1"
2561 +source = "registry+https://github.com/rust-lang/crates.io-index"
2562 +checksum = "445932ecb0c59362fde9c082dd63a75380650c5077fdb8b80b8cac850442a74c"
2563 +dependencies = [
2564 + "log",
2565 + "symphonia-core",
2566 +]
2567 +
2568 +[[package]]
2569 +name = "symphonia-codec-alac"
2570 +version = "0.6.1"
2571 +source = "registry+https://github.com/rust-lang/crates.io-index"
2572 +checksum = "920a78f96f3cf62932d0c497959c0cc2100ef36b03c7ca1417b260f432a482d8"
2573 +dependencies = [
2574 + "log",
2575 + "symphonia-common",
2576 + "symphonia-core",
2577 +]
2578 +
2579 +[[package]]
2580 +name = "symphonia-codec-pcm"
2581 +version = "0.6.1"
2582 +source = "registry+https://github.com/rust-lang/crates.io-index"
2583 +checksum = "e04ba75686acbe43542fdd374571195f0530c0b7785ca25cc6840e9c6c4b6eea"
2584 +dependencies = [
2585 + "log",
2586 + "symphonia-core",
2587 +]
2588 +
2589 +[[package]]
2590 +name = "symphonia-codec-vorbis"
2591 +version = "0.6.1"
2592 +source = "registry+https://github.com/rust-lang/crates.io-index"
2593 +checksum = "73d90b4fcf796137cc683c538282804ff9629f8ad9dbfd881fcbba331ac4e986"
2594 +dependencies = [
2595 + "log",
2596 + "symphonia-common",
2597 + "symphonia-core",
2598 +]
2599 +
2600 +[[package]]
2601 +name = "symphonia-common"
2602 +version = "0.6.1"
2603 +source = "registry+https://github.com/rust-lang/crates.io-index"
2604 +checksum = "2acc3fcc18ec9b8cdd48614e259c4cf0d27b71d41e5d9b120b42c5adab12d7c4"
2605 +dependencies = [
2606 + "log",
2607 + "symphonia-core",
2608 + "symphonia-metadata",
2609 +]
2610 +
2611 +[[package]]
2612 +name = "symphonia-core"
2613 +version = "0.6.1"
2614 +source = "registry+https://github.com/rust-lang/crates.io-index"
2615 +checksum = "01c412864d599d4750d0c3d684d7e093ec05e5309681ef5252cc1096a437f6e0"
2616 +dependencies = [
2617 + "bitflags 2.13.2",
2618 + "bytemuck",
2619 + "lazy_static",
2620 + "log",
2621 + "num-complex",
2622 + "rustfft",
2623 + "smallvec",
2624 +]
2625 +
2626 +[[package]]
2627 +name = "symphonia-format-isomp4"
2628 +version = "0.6.1"
2629 +source = "registry+https://github.com/rust-lang/crates.io-index"
2630 +checksum = "0e681a70e1870d34e02abf1dbc51e4267c3f1827801474e8870be8c689fc4dc3"
2631 +dependencies = [
2632 + "log",
2633 + "symphonia-common",
2634 + "symphonia-core",
2635 + "symphonia-metadata",
2636 +]
2637 +
2638 +[[package]]
2639 +name = "symphonia-format-mkv"
2640 +version = "0.6.1"
2641 +source = "registry+https://github.com/rust-lang/crates.io-index"
2642 +checksum = "d015c5c0558864665894b3f4cbd95e10abb01b9c868e751c72670f326a56360e"
2643 +dependencies = [
2644 + "lazy_static",
2645 + "log",
2646 + "symphonia-common",
2647 + "symphonia-core",
2648 +]
2649 +
2650 +[[package]]
2651 +name = "symphonia-format-ogg"
2652 +version = "0.6.1"
2653 +source = "registry+https://github.com/rust-lang/crates.io-index"
2654 +checksum = "0b5495e7f7e3c7035328d82b6d6e377eef289bb0c4105bdeb557fc93a833f994"
2655 +dependencies = [
2656 + "log",
2657 + "symphonia-common",
2658 + "symphonia-core",
2659 + "symphonia-metadata",
2660 +]
2661 +
2662 +[[package]]
2663 +name = "symphonia-format-riff"
2664 +version = "0.6.1"
2665 +source = "registry+https://github.com/rust-lang/crates.io-index"
2666 +checksum = "1ff70929083a8c1a5f6cd7c904b6071c7914ad04739b510c2f7239dfc9b7dabe"
2667 +dependencies = [
2668 + "extended",
2669 + "log",
2670 + "symphonia-core",
2671 + "symphonia-metadata",
2672 +]
2673 +
2674 +[[package]]
2675 +name = "symphonia-metadata"
2676 +version = "0.6.1"
2677 +source = "registry+https://github.com/rust-lang/crates.io-index"
2678 +checksum = "83713a97705d77bdef7cdbc0768fd6e5a54e4cd7e48d60a806ae85639e2c87c6"
2679 +dependencies = [
2680 + "lazy_static",
2681 + "log",
2682 + "regex-lite",
2683 + "smallvec",
2684 + "symphonia-core",
2685 +]
2686 +
2687 +[[package]]
2688 +name = "syn"
2689 +version = "2.0.119"
2690 +source = "registry+https://github.com/rust-lang/crates.io-index"
2691 +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
2692 +dependencies = [
2693 + "proc-macro2",
2694 + "quote",
2695 + "unicode-ident",
2696 +]
2697 +
2698 +[[package]]
2699 +name = "syn"
2700 +version = "3.0.6"
2701 +source = "registry+https://github.com/rust-lang/crates.io-index"
2702 +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
2703 +dependencies = [
2704 + "proc-macro2",
2705 + "quote",
2706 + "unicode-ident",
2707 +]
2708 +
2709 +[[package]]
2710 +name = "sync_wrapper"
2711 +version = "1.0.2"
2712 +source = "registry+https://github.com/rust-lang/crates.io-index"
2713 +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
2714 +dependencies = [
2715 + "futures-core",
2716 +]
2717 +
2718 +[[package]]
2719 +name = "synstructure"
2720 +version = "0.14.0"
2721 +source = "registry+https://github.com/rust-lang/crates.io-index"
2722 +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
2723 +dependencies = [
2724 + "proc-macro2",
2725 + "quote",
2726 + "syn 3.0.6",
2727 +]
2728 +
2729 +[[package]]
2730 +name = "thiserror"
2731 +version = "2.0.21"
2732 +source = "registry+https://github.com/rust-lang/crates.io-index"
2733 +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
2734 +dependencies = [
2735 + "thiserror-impl",
2736 +]
2737 +
2738 +[[package]]
2739 +name = "thiserror-impl"
2740 +version = "2.0.21"
2741 +source = "registry+https://github.com/rust-lang/crates.io-index"
2742 +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
2743 +dependencies = [
2744 + "proc-macro2",
2745 + "quote",
2746 + "syn 3.0.6",
2747 +]
2748 +
2749 +[[package]]
2750 +name = "thread_local"
2751 +version = "1.1.10"
2752 +source = "registry+https://github.com/rust-lang/crates.io-index"
2753 +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
2754 +dependencies = [
2755 + "cfg-if",
2756 +]
2757 +
2758 +[[package]]
2759 +name = "time"
2760 +version = "0.3.55"
2761 +source = "registry+https://github.com/rust-lang/crates.io-index"
2762 +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
2763 +dependencies = [
2764 + "deranged",
2765 + "libc",
2766 + "num-conv",
2767 + "num_threads",
2768 + "powerfmt",
2769 + "serde_core",
2770 + "time-core",
2771 + "time-macros",
2772 +]
2773 +
2774 +[[package]]
2775 +name = "time-core"
2776 +version = "0.1.9"
2777 +source = "registry+https://github.com/rust-lang/crates.io-index"
2778 +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
2779 +
2780 +[[package]]
2781 +name = "time-macros"
2782 +version = "0.2.32"
2783 +source = "registry+https://github.com/rust-lang/crates.io-index"
2784 +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
2785 +dependencies = [
2786 + "num-conv",
2787 + "time-core",
2788 +]
2789 +
2790 +[[package]]
2791 +name = "tinystr"
2792 +version = "0.8.4"
2793 +source = "registry+https://github.com/rust-lang/crates.io-index"
2794 +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
2795 +dependencies = [
2796 + "displaydoc",
2797 + "zerovec",
2798 +]
2799 +
2800 +[[package]]
2801 +name = "tinyvec"
2802 +version = "1.13.3"
2803 +source = "registry+https://github.com/rust-lang/crates.io-index"
2804 +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
2805 +
2806 +[[package]]
2807 +name = "tokio"
2808 +version = "1.53.1"
2809 +source = "registry+https://github.com/rust-lang/crates.io-index"
2810 +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
2811 +dependencies = [
2812 + "bytes",
2813 + "libc",
2814 + "mio",
2815 + "parking_lot",
2816 + "pin-project-lite",
2817 + "signal-hook-registry",
2818 + "socket2",
2819 + "tokio-macros",
2820 + "windows-sys 0.61.2",
2821 +]
2822 +
2823 +[[package]]
2824 +name = "tokio-macros"
2825 +version = "2.7.2"
2826 +source = "registry+https://github.com/rust-lang/crates.io-index"
2827 +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
2828 +dependencies = [
2829 + "proc-macro2",
2830 + "quote",
2831 + "syn 3.0.6",
2832 +]
2833 +
2834 +[[package]]
2835 +name = "tokio-rustls"
2836 +version = "0.26.6"
2837 +source = "registry+https://github.com/rust-lang/crates.io-index"
2838 +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
2839 +dependencies = [
2840 + "rustls",
2841 + "tokio",
2842 +]
2843 +
2844 +[[package]]
2845 +name = "tokio-stream"
2846 +version = "0.1.19"
2847 +source = "registry+https://github.com/rust-lang/crates.io-index"
2848 +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
2849 +dependencies = [
2850 + "futures-core",
2851 + "pin-project-lite",
2852 + "tokio",
2853 +]
2854 +
2855 +[[package]]
2856 +name = "tokio-util"
2857 +version = "0.7.19"
2858 +source = "registry+https://github.com/rust-lang/crates.io-index"
2859 +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
2860 +dependencies = [
2861 + "bytes",
2862 + "futures-core",
2863 + "futures-sink",
2864 + "libc",
2865 + "pin-project-lite",
2866 + "tokio",
2867 +]
2868 +
2869 +[[package]]
2870 +name = "tower"
2871 +version = "0.5.3"
2872 +source = "registry+https://github.com/rust-lang/crates.io-index"
2873 +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
2874 +dependencies = [
2875 + "futures-core",
2876 + "futures-util",
2877 + "pin-project-lite",
2878 + "sync_wrapper",
2879 + "tokio",
2880 + "tower-layer",
2881 + "tower-service",
2882 +]
2883 +
2884 +[[package]]
2885 +name = "tower-http"
2886 +version = "0.6.11"
2887 +source = "registry+https://github.com/rust-lang/crates.io-index"
2888 +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
2889 +dependencies = [
2890 + "bitflags 2.13.2",
2891 + "bytes",
2892 + "futures-util",
2893 + "http",
2894 + "http-body",
2895 + "pin-project-lite",
2896 + "tower",
2897 + "tower-layer",
2898 + "tower-service",
2899 + "url",
2900 +]
2901 +
2902 +[[package]]
2903 +name = "tower-layer"
2904 +version = "0.3.3"
2905 +source = "registry+https://github.com/rust-lang/crates.io-index"
2906 +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
2907 +
2908 +[[package]]
2909 +name = "tower-service"
2910 +version = "0.3.3"
2911 +source = "registry+https://github.com/rust-lang/crates.io-index"
2912 +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
2913 +
2914 +[[package]]
2915 +name = "tracing"
2916 +version = "0.1.44"
2917 +source = "registry+https://github.com/rust-lang/crates.io-index"
2918 +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
2919 +dependencies = [
2920 + "pin-project-lite",
2921 + "tracing-attributes",
2922 + "tracing-core",
2923 +]
2924 +
2925 +[[package]]
2926 +name = "tracing-attributes"
2927 +version = "0.1.31"
2928 +source = "registry+https://github.com/rust-lang/crates.io-index"
2929 +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
2930 +dependencies = [
2931 + "proc-macro2",
2932 + "quote",
2933 + "syn 2.0.119",
2934 +]
2935 +
2936 +[[package]]
2937 +name = "tracing-core"
2938 +version = "0.1.36"
2939 +source = "registry+https://github.com/rust-lang/crates.io-index"
2940 +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2941 +dependencies = [
2942 + "once_cell",
2943 + "valuable",
2944 +]
2945 +
2946 +[[package]]
2947 +name = "tracing-log"
2948 +version = "0.2.0"
2949 +source = "registry+https://github.com/rust-lang/crates.io-index"
2950 +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
2951 +dependencies = [
2952 + "log",
2953 + "once_cell",
2954 + "tracing-core",
2955 +]
2956 +
2957 +[[package]]
2958 +name = "tracing-subscriber"
2959 +version = "0.3.23"
2960 +source = "registry+https://github.com/rust-lang/crates.io-index"
2961 +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
2962 +dependencies = [
2963 + "matchers",
2964 + "nu-ansi-term",
2965 + "once_cell",
2966 + "regex-automata",
2967 + "sharded-slab",
2968 + "smallvec",
2969 + "thread_local",
2970 + "tracing",
2971 + "tracing-core",
2972 + "tracing-log",
2973 +]
2974 +
2975 +[[package]]
2976 +name = "transpose"
2977 +version = "0.2.3"
2978 +source = "registry+https://github.com/rust-lang/crates.io-index"
2979 +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
2980 +dependencies = [
2981 + "num-integer",
2982 + "strength_reduce",
2983 +]
2984 +
2985 +[[package]]
2986 +name = "try-lock"
2987 +version = "0.2.5"
2988 +source = "registry+https://github.com/rust-lang/crates.io-index"
2989 +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
2990 +
2991 +[[package]]
2992 +name = "unicode-ident"
2993 +version = "1.0.26"
2994 +source = "registry+https://github.com/rust-lang/crates.io-index"
2995 +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
2996 +
2997 +[[package]]
2998 +name = "unicode-segmentation"
2999 +version = "1.13.3"
3000 +source = "registry+https://github.com/rust-lang/crates.io-index"
3001 +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
3002 +
3003 +[[package]]
3004 +name = "unicode-truncate"
3005 +version = "2.0.1"
3006 +source = "registry+https://github.com/rust-lang/crates.io-index"
3007 +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
3008 +dependencies = [
3009 + "itertools",
3010 + "unicode-segmentation",
3011 + "unicode-width",
3012 +]
3013 +
3014 +[[package]]
3015 +name = "unicode-width"
3016 +version = "0.2.2"
3017 +source = "registry+https://github.com/rust-lang/crates.io-index"
3018 +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
3019 +
3020 +[[package]]
3021 +name = "unicode-xid"
3022 +version = "0.2.6"
3023 +source = "registry+https://github.com/rust-lang/crates.io-index"
3024 +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
3025 +
3026 +[[package]]
3027 +name = "untrusted"
3028 +version = "0.9.0"
3029 +source = "registry+https://github.com/rust-lang/crates.io-index"
3030 +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
3031 +
3032 +[[package]]
3033 +name = "url"
3034 +version = "2.5.8"
3035 +source = "registry+https://github.com/rust-lang/crates.io-index"
3036 +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
3037 +dependencies = [
3038 + "form_urlencoded",
3039 + "idna",
3040 + "percent-encoding",
3041 + "serde",
3042 +]
3043 +
3044 +[[package]]
3045 +name = "utf8_iter"
3046 +version = "1.0.4"
3047 +source = "registry+https://github.com/rust-lang/crates.io-index"
3048 +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
3049 +
3050 +[[package]]
3051 +name = "uuid"
3052 +version = "1.27.0"
3053 +source = "registry+https://github.com/rust-lang/crates.io-index"
3054 +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
3055 +dependencies = [
3056 + "getrandom 0.4.3",
3057 + "js-sys",
3058 + "wasm-bindgen",
3059 +]
3060 +
3061 +[[package]]
3062 +name = "valuable"
3063 +version = "0.1.1"
3064 +source = "registry+https://github.com/rust-lang/crates.io-index"
3065 +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
3066 +
3067 +[[package]]
3068 +name = "walkdir"
3069 +version = "2.5.0"
3070 +source = "registry+https://github.com/rust-lang/crates.io-index"
3071 +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
3072 +dependencies = [
3073 + "same-file",
3074 + "winapi-util",
3075 +]
3076 +
3077 +[[package]]
3078 +name = "want"
3079 +version = "0.3.1"
3080 +source = "registry+https://github.com/rust-lang/crates.io-index"
3081 +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
3082 +dependencies = [
3083 + "try-lock",
3084 +]
3085 +
3086 +[[package]]
3087 +name = "wasi"
3088 +version = "0.11.1+wasi-snapshot-preview1"
3089 +source = "registry+https://github.com/rust-lang/crates.io-index"
3090 +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
3091 +
3092 +[[package]]
3093 +name = "wasm-bindgen"
3094 +version = "0.2.129"
3095 +source = "registry+https://github.com/rust-lang/crates.io-index"
3096 +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
3097 +dependencies = [
3098 + "cfg-if",
3099 + "once_cell",
3100 + "rustversion",
3101 + "wasm-bindgen-macro",
3102 + "wasm-bindgen-shared",
3103 +]
3104 +
3105 +[[package]]
3106 +name = "wasm-bindgen-futures"
3107 +version = "0.4.79"
3108 +source = "registry+https://github.com/rust-lang/crates.io-index"
3109 +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
3110 +dependencies = [
3111 + "js-sys",
3112 + "tokio",
3113 + "wasm-bindgen",
3114 +]
3115 +
3116 +[[package]]
3117 +name = "wasm-bindgen-macro"
3118 +version = "0.2.129"
3119 +source = "registry+https://github.com/rust-lang/crates.io-index"
3120 +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
3121 +dependencies = [
3122 + "quote",
3123 + "wasm-bindgen-macro-support",
3124 +]
3125 +
3126 +[[package]]
3127 +name = "wasm-bindgen-macro-support"
3128 +version = "0.2.129"
3129 +source = "registry+https://github.com/rust-lang/crates.io-index"
3130 +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
3131 +dependencies = [
3132 + "bumpalo",
3133 + "proc-macro2",
3134 + "quote",
3135 + "syn 3.0.6",
3136 + "wasm-bindgen-shared",
3137 +]
3138 +
3139 +[[package]]
3140 +name = "wasm-bindgen-shared"
3141 +version = "0.2.129"
3142 +source = "registry+https://github.com/rust-lang/crates.io-index"
3143 +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
3144 +dependencies = [
3145 + "unicode-ident",
3146 +]
3147 +
3148 +[[package]]
3149 +name = "wasm-streams"
3150 +version = "0.5.0"
3151 +source = "registry+https://github.com/rust-lang/crates.io-index"
3152 +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
3153 +dependencies = [
3154 + "futures-util",
3155 + "js-sys",
3156 + "wasm-bindgen",
3157 + "wasm-bindgen-futures",
3158 + "web-sys",
3159 +]
3160 +
3161 +[[package]]
3162 +name = "web-sys"
3163 +version = "0.3.106"
3164 +source = "registry+https://github.com/rust-lang/crates.io-index"
3165 +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
3166 +dependencies = [
3167 + "js-sys",
3168 + "wasm-bindgen",
3169 +]
3170 +
3171 +[[package]]
3172 +name = "web-time"
3173 +version = "1.1.0"
3174 +source = "registry+https://github.com/rust-lang/crates.io-index"
3175 +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
3176 +dependencies = [
3177 + "js-sys",
3178 + "wasm-bindgen",
3179 +]
3180 +
3181 +[[package]]
3182 +name = "webpki-root-certs"
3183 +version = "1.0.9"
3184 +source = "registry+https://github.com/rust-lang/crates.io-index"
3185 +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
3186 +dependencies = [
3187 + "rustls-pki-types",
3188 +]
3189 +
3190 +[[package]]
3191 +name = "winapi"
3192 +version = "0.3.9"
3193 +source = "registry+https://github.com/rust-lang/crates.io-index"
3194 +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
3195 +dependencies = [
3196 + "winapi-i686-pc-windows-gnu",
3197 + "winapi-x86_64-pc-windows-gnu",
3198 +]
3199 +
3200 +[[package]]
3201 +name = "winapi-i686-pc-windows-gnu"
3202 +version = "0.4.0"
3203 +source = "registry+https://github.com/rust-lang/crates.io-index"
3204 +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
3205 +
3206 +[[package]]
3207 +name = "winapi-util"
3208 +version = "0.1.11"
3209 +source = "registry+https://github.com/rust-lang/crates.io-index"
3210 +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
3211 +dependencies = [
3212 + "windows-sys 0.61.2",
3213 +]
3214 +
3215 +[[package]]
3216 +name = "winapi-x86_64-pc-windows-gnu"
3217 +version = "0.4.0"
3218 +source = "registry+https://github.com/rust-lang/crates.io-index"
3219 +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
3220 +
3221 +[[package]]
3222 +name = "windows"
3223 +version = "0.62.2"
3224 +source = "registry+https://github.com/rust-lang/crates.io-index"
3225 +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
3226 +dependencies = [
3227 + "windows-collections",
3228 + "windows-core",
3229 + "windows-future",
3230 + "windows-numerics",
3231 +]
3232 +
3233 +[[package]]
3234 +name = "windows-collections"
3235 +version = "0.3.2"
3236 +source = "registry+https://github.com/rust-lang/crates.io-index"
3237 +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
3238 +dependencies = [
3239 + "windows-core",
3240 +]
3241 +
3242 +[[package]]
3243 +name = "windows-core"
3244 +version = "0.62.2"
3245 +source = "registry+https://github.com/rust-lang/crates.io-index"
3246 +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
3247 +dependencies = [
3248 + "windows-implement",
3249 + "windows-interface",
3250 + "windows-link",
3251 + "windows-result",
3252 + "windows-strings",
3253 +]
3254 +
3255 +[[package]]
3256 +name = "windows-future"
3257 +version = "0.3.2"
3258 +source = "registry+https://github.com/rust-lang/crates.io-index"
3259 +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
3260 +dependencies = [
3261 + "windows-core",
3262 + "windows-link",
3263 + "windows-threading",
3264 +]
3265 +
3266 +[[package]]
3267 +name = "windows-implement"
3268 +version = "0.60.2"
3269 +source = "registry+https://github.com/rust-lang/crates.io-index"
3270 +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
3271 +dependencies = [
3272 + "proc-macro2",
3273 + "quote",
3274 + "syn 2.0.119",
3275 +]
3276 +
3277 +[[package]]
3278 +name = "windows-interface"
3279 +version = "0.59.3"
3280 +source = "registry+https://github.com/rust-lang/crates.io-index"
3281 +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
3282 +dependencies = [
3283 + "proc-macro2",
3284 + "quote",
3285 + "syn 2.0.119",
3286 +]
3287 +
3288 +[[package]]
3289 +name = "windows-link"
3290 +version = "0.2.1"
3291 +source = "registry+https://github.com/rust-lang/crates.io-index"
3292 +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
3293 +
3294 +[[package]]
3295 +name = "windows-numerics"
3296 +version = "0.3.1"
3297 +source = "registry+https://github.com/rust-lang/crates.io-index"
3298 +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
3299 +dependencies = [
3300 + "windows-core",
3301 + "windows-link",
3302 +]
3303 +
3304 +[[package]]
3305 +name = "windows-result"
3306 +version = "0.4.1"
3307 +source = "registry+https://github.com/rust-lang/crates.io-index"
3308 +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
3309 +dependencies = [
3310 + "windows-link",
3311 +]
3312 +
3313 +[[package]]
3314 +name = "windows-strings"
3315 +version = "0.5.1"
3316 +source = "registry+https://github.com/rust-lang/crates.io-index"
3317 +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
3318 +dependencies = [
3319 + "windows-link",
3320 +]
3321 +
3322 +[[package]]
3323 +name = "windows-sys"
3324 +version = "0.52.0"
3325 +source = "registry+https://github.com/rust-lang/crates.io-index"
3326 +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
3327 +dependencies = [
3328 + "windows-targets",
3329 +]
3330 +
3331 +[[package]]
3332 +name = "windows-sys"
3333 +version = "0.61.2"
3334 +source = "registry+https://github.com/rust-lang/crates.io-index"
3335 +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
3336 +dependencies = [
3337 + "windows-link",
3338 +]
3339 +
3340 +[[package]]
3341 +name = "windows-targets"
3342 +version = "0.52.6"
3343 +source = "registry+https://github.com/rust-lang/crates.io-index"
3344 +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
3345 +dependencies = [
3346 + "windows_aarch64_gnullvm",
3347 + "windows_aarch64_msvc",
3348 + "windows_i686_gnu",
3349 + "windows_i686_gnullvm",
3350 + "windows_i686_msvc",
3351 + "windows_x86_64_gnu",
3352 + "windows_x86_64_gnullvm",
3353 + "windows_x86_64_msvc",
3354 +]
3355 +
3356 +[[package]]
3357 +name = "windows-threading"
3358 +version = "0.2.1"
3359 +source = "registry+https://github.com/rust-lang/crates.io-index"
3360 +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
3361 +dependencies = [
3362 + "windows-link",
3363 +]
3364 +
3365 +[[package]]
3366 +name = "windows_aarch64_gnullvm"
3367 +version = "0.52.6"
3368 +source = "registry+https://github.com/rust-lang/crates.io-index"
3369 +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
3370 +
3371 +[[package]]
3372 +name = "windows_aarch64_msvc"
3373 +version = "0.52.6"
3374 +source = "registry+https://github.com/rust-lang/crates.io-index"
3375 +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
3376 +
3377 +[[package]]
3378 +name = "windows_i686_gnu"
3379 +version = "0.52.6"
3380 +source = "registry+https://github.com/rust-lang/crates.io-index"
3381 +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
3382 +
3383 +[[package]]
3384 +name = "windows_i686_gnullvm"
3385 +version = "0.52.6"
3386 +source = "registry+https://github.com/rust-lang/crates.io-index"
3387 +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
3388 +
3389 +[[package]]
3390 +name = "windows_i686_msvc"
3391 +version = "0.52.6"
3392 +source = "registry+https://github.com/rust-lang/crates.io-index"
3393 +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
3394 +
3395 +[[package]]
3396 +name = "windows_x86_64_gnu"
3397 +version = "0.52.6"
3398 +source = "registry+https://github.com/rust-lang/crates.io-index"
3399 +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
3400 +
3401 +[[package]]
3402 +name = "windows_x86_64_gnullvm"
3403 +version = "0.52.6"
3404 +source = "registry+https://github.com/rust-lang/crates.io-index"
3405 +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
3406 +
3407 +[[package]]
3408 +name = "windows_x86_64_msvc"
3409 +version = "0.52.6"
3410 +source = "registry+https://github.com/rust-lang/crates.io-index"
3411 +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
3412 +
3413 +[[package]]
3414 +name = "writeable"
3415 +version = "0.6.4"
3416 +source = "registry+https://github.com/rust-lang/crates.io-index"
3417 +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
3418 +
3419 +[[package]]
3420 +name = "yoke"
3421 +version = "0.8.3"
3422 +source = "registry+https://github.com/rust-lang/crates.io-index"
3423 +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
3424 +dependencies = [
3425 + "stable_deref_trait",
3426 + "yoke-derive",
3427 + "zerofrom",
3428 +]
3429 +
3430 +[[package]]
3431 +name = "yoke-derive"
3432 +version = "0.8.4"
3433 +source = "registry+https://github.com/rust-lang/crates.io-index"
3434 +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
3435 +dependencies = [
3436 + "proc-macro2",
3437 + "quote",
3438 + "syn 3.0.6",
3439 + "synstructure",
3440 +]
3441 +
3442 +[[package]]
3443 +name = "zerofrom"
3444 +version = "0.1.8"
3445 +source = "registry+https://github.com/rust-lang/crates.io-index"
3446 +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
3447 +dependencies = [
3448 + "zerofrom-derive",
3449 +]
3450 +
3451 +[[package]]
3452 +name = "zerofrom-derive"
3453 +version = "0.1.8"
3454 +source = "registry+https://github.com/rust-lang/crates.io-index"
3455 +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
3456 +dependencies = [
3457 + "proc-macro2",
3458 + "quote",
3459 + "syn 3.0.6",
3460 + "synstructure",
3461 +]
3462 +
3463 +[[package]]
3464 +name = "zeroize"
3465 +version = "1.9.0"
3466 +source = "registry+https://github.com/rust-lang/crates.io-index"
3467 +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
3468 +
3469 +[[package]]
3470 +name = "zerotrie"
3471 +version = "0.2.5"
3472 +source = "registry+https://github.com/rust-lang/crates.io-index"
3473 +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
3474 +dependencies = [
3475 + "displaydoc",
3476 + "yoke",
3477 + "zerofrom",
3478 +]
3479 +
3480 +[[package]]
3481 +name = "zerovec"
3482 +version = "0.11.8"
3483 +source = "registry+https://github.com/rust-lang/crates.io-index"
3484 +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
3485 +dependencies = [
3486 + "yoke",
3487 + "zerofrom",
3488 + "zerovec-derive",
3489 +]
3490 +
3491 +[[package]]
3492 +name = "zerovec-derive"
3493 +version = "0.11.6"
3494 +source = "registry+https://github.com/rust-lang/crates.io-index"
3495 +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
3496 +dependencies = [
3497 + "proc-macro2",
3498 + "quote",
3499 + "syn 3.0.6",
3500 +]
3501 +
3502 +[[package]]
3503 +name = "zmij"
3504 +version = "1.0.23"
3505 +source = "registry+https://github.com/rust-lang/crates.io-index"
3506 +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Cargo.toml new
+31
@@ -0,0 +1,31 @@
1 +[package]
2 +name = "splitfire-agent"
3 +version = "1.0.0"
4 +edition = "2024"
5 +license = "Apache-2.0"
6 +description = "Music-domain ACP agent: theory, production, industry, culture and live shows, backed by any OpenAI-compatible chat completions API"
7 +repository = "https://github.com/splitfireai/splitfire-agent"
8 +
9 +[dependencies]
10 +agent-client-protocol = "2.2"
11 +anyhow = "1.0.104"
12 +base64 = "0.23.1"
13 +crossterm = { version = "0.29.0", features = ["event-stream"] }
14 +directories = "6.0.0"
15 +ebur128 = "0.1.10"
16 +futures = "0.3.34"
17 +ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "layout-cache", "unstable-rendered-line-info"] }
18 +realfft = "3.5.0"
19 +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "stream"] }
20 +rmcp = { version = "3.5.1", default-features = false, features = ["client", "transport-child-process"] }
21 +serde = { version = "1.0.229", features = ["derive"] }
22 +serde_json = "1.0.151"
23 +symphonia = { version = "0.6.1", features = ["mp3", "flac", "ogg", "vorbis", "wav", "pcm", "aac", "isomp4", "aiff", "alac", "mkv"] }
24 +tokio = { version = "1.53.1", features = ["full"] }
25 +tokio-util = "0.7.19"
26 +tracing = "0.1.44"
27 +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
28 +uuid = { version = "1.27.0", features = ["v4"] }
29 +
30 +[dev-dependencies]
31 +hound = "3.5.1"
LICENSE new
+201
@@ -0,0 +1,201 @@
1 + Apache License
2 + Version 2.0, January 2004
3 + http://www.apache.org/licenses/
4 +
5 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 +
7 + 1. Definitions.
8 +
9 + "License" shall mean the terms and conditions for use, reproduction,
10 + and distribution as defined by Sections 1 through 9 of this document.
11 +
12 + "Licensor" shall mean the copyright owner or entity authorized by
13 + the copyright owner that is granting the License.
14 +
15 + "Legal Entity" shall mean the union of the acting entity and all
16 + other entities that control, are controlled by, or are under common
17 + control with that entity. For the purposes of this definition,
18 + "control" means (i) the power, direct or indirect, to cause the
19 + direction or management of such entity, whether by contract or
20 + otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 + outstanding shares, or (iii) beneficial ownership of such entity.
22 +
23 + "You" (or "Your") shall mean an individual or Legal Entity
24 + exercising permissions granted by this License.
25 +
26 + "Source" form shall mean the preferred form for making modifications,
27 + including but not limited to software source code, documentation
28 + source, and configuration files.
29 +
30 + "Object" form shall mean any form resulting from mechanical
31 + transformation or translation of a Source form, including but
32 + not limited to compiled object code, generated documentation,
33 + and conversions to other media types.
34 +
35 + "Work" shall mean the work of authorship, whether in Source or
36 + Object form, made available under the License, as indicated by a
37 + copyright notice that is included in or attached to the work
38 + (an example is provided in the Appendix below).
39 +
40 + "Derivative Works" shall mean any work, whether in Source or Object
41 + form, that is based on (or derived from) the Work and for which the
42 + editorial revisions, annotations, elaborations, or other modifications
43 + represent, as a whole, an original work of authorship. For the purposes
44 + of this License, Derivative Works shall not include works that remain
45 + separable from, or merely link (or bind by name) to the interfaces of,
46 + the Work and Derivative Works thereof.
47 +
48 + "Contribution" shall mean any work of authorship, including
49 + the original version of the Work and any modifications or additions
50 + to that Work or Derivative Works thereof, that is intentionally
51 + submitted to Licensor for inclusion in the Work by the copyright owner
52 + or by an individual or Legal Entity authorized to submit on behalf of
53 + the copyright owner. For the purposes of this definition, "submitted"
54 + means any form of electronic, verbal, or written communication sent
55 + to the Licensor or its representatives, including but not limited to
56 + communication on electronic mailing lists, source code control systems,
57 + and issue tracking systems that are managed by, or on behalf of, the
58 + Licensor for the purpose of discussing and improving the Work, but
59 + excluding communication that is conspicuously marked or otherwise
60 + designated in writing by the copyright owner as "Not a Contribution."
61 +
62 + "Contributor" shall mean Licensor and any individual or Legal Entity
63 + on behalf of whom a Contribution has been received by Licensor and
64 + subsequently incorporated within the Work.
65 +
66 + 2. Grant of Copyright License. Subject to the terms and conditions of
67 + this License, each Contributor hereby grants to You a perpetual,
68 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 + copyright license to reproduce, prepare Derivative Works of,
70 + publicly display, publicly perform, sublicense, and distribute the
71 + Work and such Derivative Works in Source or Object form.
72 +
73 + 3. Grant of Patent License. Subject to the terms and conditions of
74 + this License, each Contributor hereby grants to You a perpetual,
75 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 + (except as stated in this section) patent license to make, have made,
77 + use, offer to sell, sell, import, and otherwise transfer the Work,
78 + where such license applies only to those patent claims licensable
79 + by such Contributor that are necessarily infringed by their
80 + Contribution(s) alone or by combination of their Contribution(s)
81 + with the Work to which such Contribution(s) was submitted. If You
82 + institute patent litigation against any entity (including a
83 + cross-claim or counterclaim in a lawsuit) alleging that the Work
84 + or a Contribution incorporated within the Work constitutes direct
85 + or contributory patent infringement, then any patent licenses
86 + granted to You under this License for that Work shall terminate
87 + as of the date such litigation is filed.
88 +
89 + 4. Redistribution. You may reproduce and distribute copies of the
90 + Work or Derivative Works thereof in any medium, with or without
91 + modifications, and in Source or Object form, provided that You
92 + meet the following conditions:
93 +
94 + (a) You must give any other recipients of the Work or
95 + Derivative Works a copy of this License; and
96 +
97 + (b) You must cause any modified files to carry prominent notices
98 + stating that You changed the files; and
99 +
100 + (c) You must retain, in the Source form of any Derivative Works
101 + that You distribute, all copyright, patent, trademark, and
102 + attribution notices from the Source form of the Work,
103 + excluding those notices that do not pertain to any part of
104 + the Derivative Works; and
105 +
106 + (d) If the Work includes a "NOTICE" text file as part of its
107 + distribution, then any Derivative Works that You distribute must
108 + include a readable copy of the attribution notices contained
109 + within such NOTICE file, excluding those notices that do not
110 + pertain to any part of the Derivative Works, in at least one
111 + of the following places: within a NOTICE text file distributed
112 + as part of the Derivative Works; within the Source form or
113 + documentation, if provided along with the Derivative Works; or,
114 + within a display generated by the Derivative Works, if and
115 + wherever such third-party notices normally appear. The contents
116 + of the NOTICE file are for informational purposes only and
117 + do not modify the License. You may add Your own attribution
118 + notices within Derivative Works that You distribute, alongside
119 + or as an addendum to the NOTICE text from the Work, provided
120 + that such additional attribution notices cannot be construed
121 + as modifying the License.
122 +
123 + You may add Your own copyright statement to Your modifications and
124 + may provide additional or different license terms and conditions
125 + for use, reproduction, or distribution of Your modifications, or
126 + for any such Derivative Works as a whole, provided Your use,
127 + reproduction, and distribution of the Work otherwise complies with
128 + the conditions stated in this License.
129 +
130 + 5. Submission of Contributions. Unless You explicitly state otherwise,
131 + any Contribution intentionally submitted for inclusion in the Work
132 + by You to the Licensor shall be under the terms and conditions of
133 + this License, without any additional terms or conditions.
134 + Notwithstanding the above, nothing herein shall supersede or modify
135 + the terms of any separate license agreement you may have executed
136 + with Licensor regarding such Contributions.
137 +
138 + 6. Trademarks. This License does not grant permission to use the trade
139 + names, trademarks, service marks, or product names of the Licensor,
140 + except as required for reasonable and customary use in describing the
141 + origin of the Work and reproducing the content of the NOTICE file.
142 +
143 + 7. Disclaimer of Warranty. Unless required by applicable law or
144 + agreed to in writing, Licensor provides the Work (and each
145 + Contributor provides its Contributions) on an "AS IS" BASIS,
146 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 + implied, including, without limitation, any warranties or conditions
148 + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 + PARTICULAR PURPOSE. You are solely responsible for determining the
150 + appropriateness of using or redistributing the Work and assume any
151 + risks associated with Your exercise of permissions under this License.
152 +
153 + 8. Limitation of Liability. In no event and under no legal theory,
154 + whether in tort (including negligence), contract, or otherwise,
155 + unless required by applicable law (such as deliberate and grossly
156 + negligent acts) or agreed to in writing, shall any Contributor be
157 + liable to You for damages, including any direct, indirect, special,
158 + incidental, or consequential damages of any character arising as a
159 + result of this License or out of the use or inability to use the
160 + Work (including but not limited to damages for loss of goodwill,
161 + work stoppage, computer failure or malfunction, or any and all
162 + other commercial damages or losses), even if such Contributor
163 + has been advised of the possibility of such damages.
164 +
165 + 9. Accepting Warranty or Additional Liability. While redistributing
166 + the Work or Derivative Works thereof, You may choose to offer,
167 + and charge a fee for, acceptance of support, warranty, indemnity,
168 + or other liability obligations and/or rights consistent with this
169 + License. However, in accepting such obligations, You may act only
170 + on Your own behalf and on Your sole responsibility, not on behalf
171 + of any other Contributor, and only if You agree to indemnify,
172 + defend, and hold each Contributor harmless for any liability
173 + incurred by, or claims asserted against, such Contributor by reason
174 + of your accepting any such warranty or additional liability.
175 +
176 + END OF TERMS AND CONDITIONS
177 +
178 + APPENDIX: How to apply the Apache License to your work.
179 +
180 + To apply the Apache License to your work, attach the following
181 + boilerplate notice, with the fields enclosed by brackets "[]"
182 + replaced with your own identifying information. (Don't include
183 + the brackets!) The text should be enclosed in the appropriate
184 + comment syntax for the file format. We also recommend that a
185 + file or class name and description of purpose be included on the
186 + same "printed page" as the copyright notice for easier
187 + identification within third-party archives.
188 +
189 + Copyright 2026 OndeCode contributors
190 +
191 + Licensed under the Apache License, Version 2.0 (the "License");
192 + you may not use this file except in compliance with the License.
193 + You may obtain a copy of the License at
194 +
195 + http://www.apache.org/licenses/LICENSE-2.0
196 +
197 + Unless required by applicable law or agreed to in writing, software
198 + distributed under the License is distributed on an "AS IS" BASIS,
199 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 + See the License for the specific language governing permissions and
201 + limitations under the License.
README.md
+158 -2
@@ -1,7 +1,163 @@
1 -# SplitFire CLI
1 +# SplitFire AI Agent
2
3 Play like you mean it.
4
5 +A music assistant that speaks the [Agent Client Protocol](https://agentclientprotocol.com) and
6 +ships its own terminal UI. It covers music theory, production and DAW workflow, the music
7 +industry, culture and history, and live shows, and it does the arithmetic with tools instead of
8 +guessing: note spelling, chords, keys, tempo maths and audio measurements are computed, not
9 +recalled by the model.
10 +
11 +It runs on [Onde Inference](https://ondeinference.com) (Onde Cloud). Built on the official
12 +[Rust SDK](https://github.com/agentclientprotocol/rust-sdk).
13 +
14 +## What it can do
15 +
16 +**Music theory tools** (exact, read-only, no permission prompts)
17 +
18 +| Tool | Does |
19 +|------|------|
20 +| `theory_scale` | Spell a scale or mode from any root, with intervals, key signature and the diatonic chords (C# major really has E# and B#) |
21 +| `theory_chord` | Spell a chord symbol (`F#m7b5`, `Bb13#11/D`, `C6/9`) or identify chords from notes |
22 +| `theory_transpose` | Transpose chords or notes by semitones or between keys, with correct enharmonic spelling |
23 +| `theory_progression` | Roman numerals, functions, secondary dominants, borrowed chords and ranked key candidates; or realize numerals (`ii7 V7 Imaj7`) in a key |
24 +| `theory_tempo` | Note values (straight, dotted, triplet) in ms and Hz, bar length, sample counts, bpm from a loop length |
25 +| `theory_pitch` | Note name, MIDI number and frequency conversion, with cents offset and adjustable A4 |
26 +
27 +**Audio analysis**: `analyze_audio` reads WAV, FLAC, MP3, OGG, M4A/AAC and AIFF and reports
28 +duration, sample rate, channels, peak, RMS, integrated loudness (LUFS), and estimated tempo
29 +(including half and double time) and key with a confidence. Tempo and key are signal-analysis
30 +estimates, and the agent says so. Audio attached to a prompt is saved to a scratch file and the
31 +model is given its path.
32 +
33 +**Project files**: `read_file`, `write_file`, `edit_file`, `list_directory`, `run_command`, with
34 +permission prompts for writes and commands, multi-root workspaces, and the client's own
35 +`fs/*` and `terminal/*` methods when the editor offers them.
36 +
37 +**Slash commands**: `/analyze <file>`, `/chords <progression>`, `/practice <goal>`, and
38 +`/stems <file>` when stem separation is installed.
39 +
40 +**Sessions** persist across restarts: `session/list`, `load` (with tool calls and their output
41 +replayed), `resume`, `close` and `delete`.
42 +
43 +### Stem separation (optional)
44 +
45 +Stem separation is not built in. Install [`demucs`](https://github.com/splitfireai/demucs-rs) and
46 +put it on your `PATH` (or set `SPLITFIRE_DEMUCS_BIN`). SplitFire starts `demucs --mcp` the first
47 +time a session begins, and offers `separate_stems` and `list_models` as tools. Separation runs
48 +locally and can take minutes; the first use of a model downloads its weights, and the permission
49 +prompt tells you how large the download is. If `demucs` is missing or fails to start, SplitFire
50 +logs one line and carries on without it. An MCP server named `demucs` supplied by your editor
51 +takes precedence. `SPLITFIRE_MCP_DEMUCS=off` disables it.
52 +
53 +Separate only music you have the right to work with.
54 +
55 +### Other MCP servers
56 +
57 +Stdio MCP servers configured in your editor are connected for each session. Their tools show up
58 +as `mcp__<server>__<tool>`, ask for permission unless the server marks them read-only, stream
59 +progress into the tool call, and are cancelled when you cancel the turn. A server that fails to
60 +start is skipped.
61 +
62 +## Install and set up
63 +
64 +```sh
65 +cargo build --release # -> target/release/splitfire-agent
66 +splitfire-agent --setup
67 +```
68 +
69 +`--setup` asks for your Onde credentials (`app-id:app-secret`, from
70 +[ondeinference.com](https://ondeinference.com/root/login): register an app and assign a model),
71 +checks them against Onde Cloud, and stores them in the platform config directory
72 +(`~/Library/Application Support/splitfire-agent/env` on macOS, `~/.config/splitfire-agent/env` on
73 +Linux, `%APPDATA%\splitfire-agent\env` on Windows). Alternatively set `ONDE_API_KEY`.
74 +
75 +Editors that can run terminal auth (`clientCapabilities.auth.terminal`) are offered `--setup` as
76 +the authentication method. Until a key exists the agent answers `session/new`, `session/load`,
77 +`session/resume` and `session/prompt` with `AUTH_REQUIRED`. A key stored by `--setup` is picked up
78 +without a restart, whether or not the editor calls `authenticate` afterwards. `logout` removes the
79 +stored key (a key exported as `ONDE_API_KEY` stays in effect).
80 +
81 +## Use from the terminal
82 +
83 +```sh
84 +cd my-project
85 +splitfire-agent # asks before edits and commands
86 +splitfire-agent --yolo # approves everything
87 +```
88 +
89 +Enter sends, Esc cancels a running turn, Up/Down and PgUp/PgDn scroll, Ctrl-C or `/quit` exits.
90 +Approval prompts take `y` (allow), `a` (always allow this tool), `n` (reject) or `r` (always reject this tool). The TUI is itself
91 +an ACP client: it starts `splitfire-agent --acp` as a subprocess.
92 +
93 +## Use with Zed
94 +
95 +Editors launch the agent with piped stdio, which selects ACP mode (or pass `--acp`).
96 +
97 +```json
98 +{
99 + "agent_servers": {
100 + "splitfire-agent": {
101 + "command": "/path/to/splitfire-agent",
102 + "args": ["--acp"]
103 + }
104 + }
105 +}
106 +```
107 +
108 +The model picker lists the models Onde Cloud serves for your app. Editors that support boolean
109 +config options also get an "Auto-approve actions" toggle per thread. Threads are saved to disk, so
110 +Zed can reopen and import them after a restart; a thread whose history is gone reopens empty with
111 +a short notice instead of failing. Linked files (`@`-mentions and selections) are inlined, only
112 +the selected lines for a selection; links to audio files are handed to `analyze_audio` instead.
113 +Token usage is reported against `SPLITFIRE_CONTEXT_WINDOW`.
114 +
115 +## Configuration
116 +
117 +| Env var | Default | |
118 +|---------|---------|---|
119 +| `ONDE_API_KEY` | from the config `env` file | `app-id:app-secret`; required |
120 +| `SPLITFIRE_BASE_URL` | `https://cloud.ondeinference.com/v1` | override the OpenAI-compatible endpoint |
121 +| `SPLITFIRE_MODEL` | `onde-kkk` | default model |
122 +| `SPLITFIRE_MODELS` | listed from `/models` | comma-separated list for the model picker |
123 +| `SPLITFIRE_YOLO` | unset | `1` skips permission prompts (the default for each thread's auto-approve toggle) |
124 +| `SPLITFIRE_CONTEXT_WINDOW` | `128000` | context size reported in `usage_update` |
125 +| `SPLITFIRE_STEMS_DIR` | the app's iCloud and local folders | where `list_separations` looks for separated songs |
126 +| `SPLITFIRE_DATA_DIR` | platform config dir | where `env`, sessions and scratch audio live |
127 +| `SPLITFIRE_SURFACE` | `acp` | `tui` when launched by the terminal UI; used in the commit trailer |
128 +| `SPLITFIRE_DEMUCS_BIN` | `demucs` on `PATH` | stem separation binary |
129 +| `SPLITFIRE_MCP_DEMUCS` | on | `off` disables stem separation |
130 +| `RUST_LOG` | unset | logs go to stderr |
131 +
132 +`splitfire-agent --list-models` prints the models your endpoint serves.
133 +
134 +## Development
135 +
136 +```sh
137 +cargo fmt -- --check
138 +cargo clippy --all-targets -- -D warnings
139 +cargo test --locked
140 +```
141 +
142 +The integration tests run the real binary against a scripted mock model, plus a stub MCP server
143 +(`src/bin/mcp_stdio_stub.rs`). `tests/live.rs` talks to Onde Cloud and is skipped unless
144 +`ONDE_API_KEY` is set.
145 +
146 +```
147 +src/main.rs flags, mode dispatch, ACP handlers
148 +src/agent.rs sessions, turn loop, persistence
149 +src/prompt.rs system prompt, slash commands, prompt content
150 +src/llm.rs streaming Onde Cloud client
151 +src/session_store.rs on-disk sessions
152 +src/mcp.rs MCP client (client-supplied servers + demucs)
153 +src/tools/ workspace, theory and audio tools
154 +src/theory/ pitch, interval, scale, chord, key, tempo
155 +src/audio/ decoding and analysis
156 +src/tui.rs terminal UI
157 +registry/ ACP registry submission
158 +```
159 +
160 ## Copyright
161
7 -© 2026 [Splitfire AB](https://5mb.app) ([SplitFire AI](https://splitfire.ai)).
162 +© 2026 [Splitfire AB](https://5mb.app) ([SplitFire AI](https://splitfire.ai)). Licensed under the
163 +Apache License, Version 2.0; see [LICENSE](LICENSE).
assets/icon.svg new
+4
@@ -0,0 +1,4 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
2 + <path fill="currentColor" fill-rule="evenodd" d="M10.2 2a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm0 1.8a4.2 4.2 0 1 0 0 8.4 4.2 4.2 0 0 0 0-8.4Z"/>
3 + <path fill="currentColor" d="M.9 6 3 8 .9 10l.9 1.2L5.1 8 1.8 4.8.9 6Z"/>
4 +</svg>
registry/README.md new
+32
@@ -0,0 +1,32 @@
1 +# ACP Registry submission
2 +
3 +Everything needed to list splitfire-agent in the [ACP registry](https://agentclientprotocol.com/get-started/registry)
4 +([agentclientprotocol/registry](https://github.com/agentclientprotocol/registry)).
5 +
6 +## Prerequisites (done in this repo)
7 +
8 +- `initialize` advertises a `terminal` auth method (`terminal-setup`, runs `--setup`); registry CI
9 + requires agent or terminal auth.
10 +- `session/new` and `session/prompt` return `AUTH_REQUIRED` when no Onde API key is configured;
11 + `authenticate` re-reads the stored key without a restart.
12 +- The release workflow builds binaries for all five registry platforms on `v*` tags.
13 +- 16x16 monochrome `currentColor` icon at `assets/icon.svg`.
14 +
15 +## Submitting
16 +
17 +1. Tag and push the release: `git tag v1.0.0 && git push origin v1.0.0`. Wait for the `release`
18 + workflow to attach the five archives.
19 +2. Optionally add `sha256` fields to `agent.json` for each archive (recommended, not required).
20 + The archive names are fixed by the workflow.
21 +3. Fork the registry repo, then from its checkout:
22 +
23 + ```sh
24 + mkdir splitfire-agent
25 + cp agent.json splitfire-agent/agent.json
26 + cp ../assets/icon.svg splitfire-agent/icon.svg # the icon must sit next to agent.json
27 + SKIP_URL_VALIDATION=1 uv run --with jsonschema .github/workflows/build_registry.py
28 + ```
29 +
30 + Drop `SKIP_URL_VALIDATION=1` once the release exists; the archive URLs are probed.
31 +4. Open the PR. Registry CI validates the schema and icon, and probes the binary for a valid
32 + `authMethods` response with an empty sandbox `HOME`.
registry/agent.json new
+51
@@ -0,0 +1,51 @@
1 +{
2 + "id": "splitfire-agent",
3 + "name": "SplitFire",
4 + "version": "1.0.0",
5 + "description": "Music assistant for musicians, producers and engineers: exact music theory tools (scales, chords, keys, tempo maths), audio analysis (loudness, tempo and key estimates), optional stem separation, and project file tools. Backed by Onde Inference; ships its own TUI and speaks ACP over stdio.",
6 + "repository": "https://github.com/splitfireai/splitfire-agent",
7 + "authors": [
8 + "SplitFire AI"
9 + ],
10 + "license": "Apache-2.0",
11 + "license_url": "https://github.com/splitfireai/splitfire-agent/blob/main/LICENSE",
12 + "distribution": {
13 + "binary": {
14 + "darwin-aarch64": {
15 + "archive": "https://github.com/splitfireai/splitfire-agent/releases/download/v1.0.0/splitfire-agent-darwin-arm64.tar.gz",
16 + "cmd": "./splitfire-agent",
17 + "args": [
18 + "--acp"
19 + ]
20 + },
21 + "darwin-x86_64": {
22 + "archive": "https://github.com/splitfireai/splitfire-agent/releases/download/v1.0.0/splitfire-agent-darwin-x64.tar.gz",
23 + "cmd": "./splitfire-agent",
24 + "args": [
25 + "--acp"
26 + ]
27 + },
28 + "linux-aarch64": {
29 + "archive": "https://github.com/splitfireai/splitfire-agent/releases/download/v1.0.0/splitfire-agent-linux-arm64.tar.gz",
30 + "cmd": "./splitfire-agent",
31 + "args": [
32 + "--acp"
33 + ]
34 + },
35 + "linux-x86_64": {
36 + "archive": "https://github.com/splitfireai/splitfire-agent/releases/download/v1.0.0/splitfire-agent-linux-x64.tar.gz",
37 + "cmd": "./splitfire-agent",
38 + "args": [
39 + "--acp"
40 + ]
41 + },
42 + "windows-x86_64": {
43 + "archive": "https://github.com/splitfireai/splitfire-agent/releases/download/v1.0.0/splitfire-agent-windows-x64.zip",
44 + "cmd": "splitfire-agent.exe",
45 + "args": [
46 + "--acp"
47 + ]
48 + }
49 + }
50 + }
51 +}
src/agent.rs new
+1237
@@ -0,0 +1,1237 @@
1 +//! `SplitFireAgent`: the ACP v1 agent. Owns sessions (in memory, backed by
2 +//! [`SessionStore`]), the turn loop, and the config-option model picker.
3 +
4 +use std::collections::{HashMap, HashSet};
5 +use std::path::{Path, PathBuf};
6 +use std::sync::atomic::{AtomicBool, Ordering};
7 +use std::sync::{Arc, Mutex};
8 +use std::time::{SystemTime, UNIX_EPOCH};
9 +
10 +use agent_client_protocol::schema::ProtocolVersion;
11 +use agent_client_protocol::schema::v1::{
12 + AuthMethod, AuthMethodTerminal, AuthenticateRequest, AuthenticateResponse,
13 + AvailableCommandsUpdate, ClientCapabilities, CloseSessionRequest, CloseSessionResponse,
14 + ConfigOptionUpdate, ContentBlock, ContentChunk, DeleteSessionRequest, DeleteSessionResponse,
15 + ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse,
16 + LogoutResponse, McpServer, MessageId, NewSessionRequest, NewSessionResponse, PromptRequest,
17 + PromptResponse, ResumeSessionRequest, ResumeSessionResponse, SessionConfigOption,
18 + SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigSelectOptions, SessionId,
19 + SessionInfo, SessionInfoUpdate, SessionNotification, SessionUpdate,
20 + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
21 + ToolCallContent, ToolCallLocation, ToolCallStatus, ToolCallUpdateFields, UsageUpdate,
22 +};
23 +use agent_client_protocol::{Client, ConnectionTo, Responder};
24 +use serde_json::{Value, json};
25 +use tokio_util::sync::CancellationToken;
26 +
27 +use crate::llm::{Delta, LlmClient, LlmConfig};
28 +use crate::mcp::{self, McpToolset};
29 +use crate::prompt;
30 +use crate::session_store::{SessionStore, StoredSession};
31 +use crate::tools::{self, ToolCtx, ToolOutcome, absolutize, normalize};
32 +
33 +const MAX_TURNS: usize = 50;
34 +/// Id of the session config option that selects the model.
35 +pub const MODEL_CONFIG_ID: &str = "model";
36 +/// Id of the boolean session config option that skips permission prompts.
37 +pub const AUTO_APPROVE_CONFIG_ID: &str = "auto_approve";
38 +/// Context window reported in `usage_update` when SPLITFIRE_CONTEXT_WINDOW is unset.
39 +const DEFAULT_CONTEXT_WINDOW: u64 = 128_000;
40 +/// Id of the terminal auth method advertised on initialize.
41 +pub const AUTH_METHOD_ID: &str = "terminal-setup";
42 +/// Cap on the sessions returned by `session/list`.
43 +const MAX_LISTED: usize = 200;
44 +
45 +/// The terminal auth method: clients run `splitfire-agent --setup`.
46 +pub fn auth_methods() -> Vec<AuthMethod> {
47 + vec![AuthMethod::Terminal(
48 + AuthMethodTerminal::new(AUTH_METHOD_ID, "Run in terminal")
49 + .description("Interactive setup: store your Onde Inference API key")
50 + .args(vec!["--setup".into()]),
51 + )]
52 +}
53 +
54 +pub fn auth_required_error() -> agent_client_protocol::Error {
55 + agent_client_protocol::Error::auth_required().data(
56 + "No Onde API key configured. Authenticate with the terminal method (`splitfire-agent --setup`).",
57 + )
58 +}
59 +
60 +/// The only protocol version this agent speaks is v1. A client proposing a newer version gets
61 +/// v1 back (it can then decide whether to continue); an older (v0) client is told v1 too, as
62 +/// the spec requires the latest supported version when the requested one isn't supported.
63 +pub fn negotiate_version(client: ProtocolVersion) -> ProtocolVersion {
64 + let _ = client;
65 + ProtocolVersion::V1
66 +}
67 +
68 +struct Session {
69 + cwd: PathBuf,
70 + /// Additional workspace roots (from the request's `additional_directories`).
71 + roots: Vec<PathBuf>,
72 + model: String,
73 + /// Conversation history without the system prompt (rebuilt every turn).
74 + messages: Vec<Value>,
75 + cancel: CancellationToken,
76 + always_allowed: Arc<Mutex<HashSet<String>>>,
77 + always_rejected: Arc<Mutex<HashSet<String>>>,
78 + /// Approve tool calls without asking; the `auto_approve` config option. Not persisted:
79 + /// a reopened session starts from the process default (`--yolo`).
80 + auto_approve: Arc<AtomicBool>,
81 + title: Option<String>,
82 + /// Last activity, seconds since the Unix epoch.
83 + updated_at: u64,
84 + mcp: Arc<McpToolset>,
85 + /// A turn is running; its history is checked out of `messages`.
86 + busy: bool,
87 +}
88 +
89 +impl Session {
90 + fn fresh(cwd: PathBuf, roots: Vec<PathBuf>, model: String, yolo: bool) -> Self {
91 + Self::restored(
92 + StoredSession {
93 + cwd,
94 + roots,
95 + model,
96 + title: None,
97 + updated_at: now_secs(),
98 + messages: Vec::new(),
99 + },
100 + yolo,
101 + )
102 + }
103 +
104 + /// A session brought back from the store. Permission grants start fresh; MCP servers are
105 + /// connected by the request that reopens it.
106 + fn restored(stored: StoredSession, yolo: bool) -> Self {
107 + Self {
108 + cwd: stored.cwd,
109 + roots: stored.roots,
110 + model: stored.model,
111 + messages: stored.messages,
112 + cancel: CancellationToken::new(),
113 + always_allowed: Arc::default(),
114 + always_rejected: Arc::default(),
115 + auto_approve: Arc::new(AtomicBool::new(yolo)),
116 + title: stored.title,
117 + updated_at: stored.updated_at,
118 + mcp: Arc::new(McpToolset::default()),
119 + busy: false,
120 + }
121 + }
122 +
123 + fn stored(&self) -> StoredSession {
124 + StoredSession {
125 + cwd: self.cwd.clone(),
126 + roots: self.roots.clone(),
127 + model: self.model.clone(),
128 + title: self.title.clone(),
129 + updated_at: self.updated_at,
130 + messages: self.messages.clone(),
131 + }
132 + }
133 +}
134 +
135 +fn new_message_id() -> MessageId {
136 + MessageId::new(uuid::Uuid::new_v4().to_string())
137 +}
138 +
139 +/// ACP v1 requires absolute `cwd` and `additionalDirectories`.
140 +fn check_absolute(cwd: &Path, roots: &[PathBuf]) -> agent_client_protocol::Result<()> {
141 + if !cwd.is_absolute() {
142 + return Err(invalid("cwd must be an absolute path"));
143 + }
144 + if roots.iter().any(|r| !r.is_absolute()) {
145 + return Err(invalid(
146 + "additionalDirectories entries must be absolute paths",
147 + ));
148 + }
149 + Ok(())
150 +}
151 +
152 +pub fn now_secs() -> u64 {
153 + SystemTime::now()
154 + .duration_since(UNIX_EPOCH)
155 + .map(|d| d.as_secs())
156 + .unwrap_or(0)
157 +}
158 +
159 +/// ISO 8601 UTC timestamp without sub-second precision (no chrono dependency).
160 +pub fn iso8601(secs: u64) -> String {
161 + let days = secs / 86_400;
162 + let secs_of_day = secs % 86_400;
163 + let (h, m, s) = (
164 + secs_of_day / 3600,
165 + secs_of_day % 3600 / 60,
166 + secs_of_day % 60,
167 + );
168 + // Civil-from-days algorithm (Howard Hinnant).
169 + let z = days as i64 + 719_468;
170 + let era = z.div_euclid(146_097);
171 + let doe = z.rem_euclid(146_097);
172 + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
173 + let y = yoe + era * 400;
174 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
175 + let mp = (5 * doy + 2) / 153;
176 + let d = doy - (153 * mp + 2) / 5 + 1;
177 + let mo = if mp < 10 { mp + 3 } else { mp - 9 };
178 + let y = if mo <= 2 { y + 1 } else { y };
179 + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
180 +}
181 +
182 +/// Derive a short session title from the first user prompt.
183 +pub fn session_title(prompt: &str) -> String {
184 + const MAX: usize = 80;
185 + let first = prompt.lines().next().unwrap_or("").trim();
186 + if first.chars().count() <= MAX {
187 + first.to_string()
188 + } else {
189 + let cut: String = first.chars().take(MAX - 1).collect();
190 + format!("{}…", cut.trim_end())
191 + }
192 +}
193 +
194 +fn invalid(msg: impl Into<String>) -> agent_client_protocol::Error {
195 + agent_client_protocol::Error::invalid_params().data(msg.into())
196 +}
197 +
198 +fn unknown_session(id: &SessionId) -> agent_client_protocol::Error {
199 + invalid(format!("unknown session {id}"))
200 +}
201 +
202 +/// A tool message; `failed` is kept in a private field that never reaches the model.
203 +fn tool_message(id: &str, content: &str, failed: bool) -> Value {
204 + let mut m = json!({ "role": "tool", "tool_call_id": id, "content": content });
205 + if failed {
206 + m["x_failed"] = json!(true);
207 + }
208 + m
209 +}
210 +
211 +fn wire_message(m: &Value) -> Value {
212 + match m.as_object() {
213 + Some(o) if o.contains_key("x_failed") => {
214 + let mut o = o.clone();
215 + o.remove("x_failed");
216 + Value::Object(o)
217 + }
218 + _ => m.clone(),
219 + }
220 +}
221 +
222 +fn locations_of(paths: &[PathBuf]) -> Vec<ToolCallLocation> {
223 + paths
224 + .iter()
225 + .map(|p| ToolCallLocation::new(absolutize(p)))
226 + .collect()
227 +}
228 +
229 +/// Text of a stored user message (`content` is a string, or an array of parts).
230 +fn user_text(msg: &Value) -> String {
231 + match msg.get("content") {
232 + Some(Value::String(s)) => s.clone(),
233 + Some(Value::Array(parts)) => parts
234 + .iter()
235 + .filter_map(|p| match p.get("type").and_then(Value::as_str) {
236 + Some("text") => p.get("text").and_then(Value::as_str).map(String::from),
237 + Some("image_url") => Some("[image]".into()),
238 + _ => None,
239 + })
240 + .collect::<Vec<_>>()
241 + .join("\n\n"),
242 + _ => String::new(),
243 + }
244 +}
245 +
246 +#[derive(Clone)]
247 +pub struct SplitFireAgent {
248 + /// Replaced when credentials change: `authenticate`, `logout`, or a key stored by
249 + /// `--setup` in another process.
250 + llm: Arc<Mutex<LlmClient>>,
251 + /// Process default for each session's `auto_approve` (`--yolo` / SPLITFIRE_YOLO).
252 + yolo: bool,
253 + /// How the agent was launched: `tui` (interactive terminal UI) or `acp` (editor).
254 + surface: &'static str,
255 + client_caps: Arc<Mutex<ClientCapabilities>>,
256 + sessions: Arc<Mutex<HashMap<SessionId, Session>>>,
257 + /// Models offered for selection, fetched from `GET /models` on first use and dropped
258 + /// when credentials change.
259 + models: Arc<tokio::sync::Mutex<Option<Vec<String>>>>,
260 + store: SessionStore,
261 + data_dir: PathBuf,
262 +}
263 +
264 +impl SplitFireAgent {
265 + pub fn new(config: LlmConfig, yolo: bool, surface: &'static str, data_dir: PathBuf) -> Self {
266 + Self {
267 + llm: Arc::new(Mutex::new(LlmClient::new(config))),
268 + yolo,
269 + surface,
270 + client_caps: Arc::default(),
271 + sessions: Arc::default(),
272 + models: Arc::default(),
273 + store: SessionStore::new(&data_dir),
274 + data_dir,
275 + }
276 + }
277 +
278 + pub fn set_client_caps(&self, caps: ClientCapabilities) {
279 + *self.client_caps.lock().unwrap() = caps;
280 + }
281 +
282 + fn llm(&self) -> LlmClient {
283 + self.llm.lock().unwrap().clone()
284 + }
285 +
286 + pub fn has_api_key(&self) -> bool {
287 + self.llm().has_api_key()
288 + }
289 +
290 + pub fn model(&self) -> String {
291 + self.llm().model().to_string()
292 + }
293 +
294 + /// Re-read credentials (environment + stored `env` file) and report whether a key is
295 + /// configured. Editors run terminal auth (`splitfire-agent --setup`) in a separate process
296 + /// and then retry `session/new` or `session/load` on this one without calling
297 + /// `authenticate`, so the stored key has to be picked up here. The model list is only
298 + /// dropped when the configuration actually changed.
299 + pub async fn refresh_credentials(&self) -> bool {
300 + let fresh = LlmConfig::from_env();
301 + let changed = fresh != *self.llm().config();
302 + if changed {
303 + tracing::info!("credentials changed; reloading the Onde client");
304 + *self.llm.lock().unwrap() = LlmClient::new(fresh);
305 + *self.models.lock().await = None;
306 + }
307 + self.has_api_key()
308 + }
309 +
310 + /// `authenticate`: the client has run `splitfire-agent --setup`; pick up the stored key.
311 + pub async fn authenticate(
312 + &self,
313 + req: AuthenticateRequest,
314 + ) -> agent_client_protocol::Result<AuthenticateResponse> {
315 + if &*req.method_id.0 != AUTH_METHOD_ID {
316 + return Err(invalid(format!("unknown auth method {}", req.method_id.0)));
317 + }
318 + if self.refresh_credentials().await {
319 + Ok(AuthenticateResponse::new())
320 + } else {
321 + Err(auth_required_error())
322 + }
323 + }
324 +
325 + /// `logout`: remove the stored key and forget it. A key exported in the agent's own
326 + /// environment can't be removed from here and keeps the agent signed in.
327 + pub async fn logout(&self) -> agent_client_protocol::Result<LogoutResponse> {
328 + let removed = match crate::llm::config_file() {
329 + Some(path) => match std::fs::remove_file(&path) {
330 + Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
331 + Err(format!("removing {}: {e}", path.display()))
332 + }
333 + _ => Ok(()),
334 + },
335 + None => Ok(()),
336 + };
337 + self.refresh_credentials().await;
338 + removed.map_err(|e| agent_client_protocol::Error::internal_error().data(e))?;
339 + Ok(LogoutResponse::new())
340 + }
341 +
342 + fn scratch_dir(&self, id: &SessionId) -> Option<PathBuf> {
343 + let id = crate::session_store::sanitize_id(&id.0).ok()?;
344 + Some(self.data_dir.join("scratch").join(id))
345 + }
346 +
347 + fn remove_scratch(&self, id: &SessionId) {
348 + if let Some(dir) = self.scratch_dir(id) {
349 + let _ = std::fs::remove_dir_all(dir);
350 + }
351 + }
352 +
353 + fn system_prompt(&self, cwd: &Path, roots: &[PathBuf], stems: bool) -> String {
354 + prompt::system_prompt(self.surface, cwd, roots, stems)
355 + }
356 +
357 + /// The selectable models. Falls back to just the configured model if the endpoint
358 + /// can't list them; the configured model is always offered.
359 + async fn models(&self) -> Vec<String> {
360 + let mut cache = self.models.lock().await;
361 + if let Some(ids) = &*cache {
362 + return ids.clone();
363 + }
364 + let llm = self.llm();
365 + let default = llm.model().to_string();
366 + // An explicit list wins; editors show this list as the model picker.
367 + let configured: Vec<String> = std::env::var("SPLITFIRE_MODELS")
368 + .unwrap_or_default()
369 + .split(',')
370 + .map(|m| m.trim().to_string())
371 + .filter(|m| !m.is_empty())
372 + .collect();
373 + let mut ids = if !configured.is_empty() {
374 + configured
375 + } else {
376 + match llm.models().await {
377 + Ok(models) => models.into_iter().map(|m| m.id).collect(),
378 + Err(e) => {
379 + tracing::warn!("listing models failed, offering only {default}: {e:#}");
380 + Vec::new()
381 + }
382 + }
383 + };
384 + if !ids.contains(&default) {
385 + ids.insert(0, default);
386 + }
387 + *cache = Some(ids.clone());
388 + ids
389 + }
390 +
391 + /// Whether the client advertised `session.configOptions.boolean`, which ACP requires
392 + /// before an agent may offer `type: "boolean"` options.
393 + fn boolean_options_supported(&self) -> bool {
394 + self.client_caps
395 + .lock()
396 + .unwrap()
397 + .session
398 + .as_ref()
399 + .and_then(|s| s.config_options.as_ref())
400 + .is_some_and(|c| c.boolean.is_some())
401 + }
402 +
403 + async fn config_options(&self, model: &str, auto_approve: bool) -> Vec<SessionConfigOption> {
404 + let options = self
405 + .models()
406 + .await
407 + .into_iter()
408 + .map(|id| SessionConfigSelectOption::new(id.clone(), id))
409 + .collect();
410 + let mut config = vec![
411 + SessionConfigOption::select(
412 + MODEL_CONFIG_ID,
413 + "Model",
414 + model.to_string(),
415 + SessionConfigSelectOptions::Ungrouped(options),
416 + )
417 + .category(SessionConfigOptionCategory::Model),
418 + ];
419 + if self.boolean_options_supported() {
420 + config.push(
421 + SessionConfigOption::boolean(
422 + AUTO_APPROVE_CONFIG_ID,
423 + "Auto-approve actions",
424 + auto_approve,
425 + )
426 + .description(
427 + "Edit files, run commands and separate stems without asking for permission",
428 + ),
429 + );
430 + }
431 + config
432 + }
433 +
434 + /// The model and auto-approve setting of a live session.
435 + fn session_settings(&self, id: &SessionId) -> Option<(String, bool)> {
436 + self.sessions
437 + .lock()
438 + .unwrap()
439 + .get(id)
440 + .map(|s| (s.model.clone(), s.auto_approve.load(Ordering::Relaxed)))
441 + }
442 +
443 + async fn session_config_options(
444 + &self,
445 + id: &SessionId,
446 + ) -> agent_client_protocol::Result<Vec<SessionConfigOption>> {
447 + let (model, auto_approve) = self
448 + .session_settings(id)
449 + .ok_or_else(|| unknown_session(id))?;
450 + Ok(self.config_options(&model, auto_approve).await)
451 + }
452 +
453 + pub async fn set_config_option(
454 + &self,
455 + req: SetSessionConfigOptionRequest,
456 + connection: &ConnectionTo<Client>,
457 + ) -> agent_client_protocol::Result<SetSessionConfigOptionResponse> {
458 + let id = req.session_id.clone();
459 + self.restore(&id).await?;
460 + match &*req.config_id.0 {
461 + MODEL_CONFIG_ID => {
462 + let Some(model) = req.value.as_value_id().map(|v| v.0.to_string()) else {
463 + return Err(invalid("model must be a value id"));
464 + };
465 + if !self.models().await.contains(&model) {
466 + return Err(invalid(format!("unknown model {model}")));
467 + }
468 + match self.sessions.lock().unwrap().get_mut(&id) {
469 + Some(s) => s.model = model.clone(),
470 + None => return Err(unknown_session(&id)),
471 + }
472 + self.persist(&id).await;
473 + tracing::info!("session {id} now uses model {model}");
474 + }
475 + AUTO_APPROVE_CONFIG_ID if self.boolean_options_supported() => {
476 + let Some(on) = req.value.as_bool() else {
477 + return Err(invalid("auto_approve must be a boolean"));
478 + };
479 + match self.sessions.lock().unwrap().get(&id) {
480 + Some(s) => s.auto_approve.store(on, Ordering::Relaxed),
481 + None => return Err(unknown_session(&id)),
482 + }
483 + tracing::info!("session {id} auto-approve {on}");
484 + }
485 + other => return Err(invalid(format!("unknown config option {other}"))),
486 + }
487 + let options = self.session_config_options(&id).await?;
488 + // Tell every view of the session about the change, not just the requester.
489 + connection.send_notification(SessionNotification::new(
490 + id,
491 + SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(options.clone())),
492 + ))?;
493 + Ok(SetSessionConfigOptionResponse::new(options))
494 + }
495 +
496 + /// Save a session that is not running a turn (a turn checks the history out; the
497 + /// end-of-turn save covers it).
498 + async fn persist(&self, id: &SessionId) {
499 + let stored = self
500 + .sessions
501 + .lock()
502 + .unwrap()
503 + .get(id)
504 + .filter(|s| !s.busy)
505 + .map(Session::stored);
506 + let Some(stored) = stored else { return };
507 + let store = self.store.clone();
508 + let sid = id.0.to_string();
509 + match tokio::task::spawn_blocking(move || store.save(&sid, &stored)).await {
510 + Ok(Ok(())) => {}
511 + Ok(Err(e)) => tracing::warn!("saving session {id} failed: {e:#}"),
512 + Err(e) => tracing::warn!("saving session {id} failed: {e}"),
513 + }
514 + }
515 +
516 + /// Bring a stored session into memory without rebinding it (a prompt or config change
517 + /// for a thread the client reopened without `session/load`). Errors if it is unknown.
518 + async fn restore(&self, id: &SessionId) -> agent_client_protocol::Result<()> {
519 + if self.sessions.lock().unwrap().contains_key(id) {
520 + return Ok(());
521 + }
522 + let stored = self
523 + .load_stored(id)
524 + .await?
525 + .ok_or_else(|| unknown_session(id))?;
526 + tracing::info!("restored session {id} from disk");
527 + self.sessions
528 + .lock()
529 + .unwrap()
530 + .entry(id.clone())
531 + .or_insert_with(|| Session::restored(stored, self.yolo));
532 + Ok(())
533 + }
534 +
535 + async fn load_stored(
536 + &self,
537 + id: &SessionId,
538 + ) -> agent_client_protocol::Result<Option<StoredSession>> {
539 + let store = self.store.clone();
540 + let sid = id.0.to_string();
541 + tokio::task::spawn_blocking(move || store.load(&sid))
542 + .await
543 + .map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string()))?
544 + .map_err(|e| agent_client_protocol::Error::internal_error().data(format!("{e:#}")))
545 + }
546 +
547 + fn stems_available(&self, id: &SessionId) -> bool {
548 + self.sessions
549 + .lock()
550 + .unwrap()
551 + .get(id)
552 + .is_some_and(|s| s.mcp.has_stem_separation())
553 + }
554 +
555 + /// `AvailableCommandsUpdate` for a session, sent after the response that created it.
556 + pub fn send_commands(
557 + &self,
558 + connection: &ConnectionTo<Client>,
559 + id: &SessionId,
560 + ) -> agent_client_protocol::Result<()> {
561 + let commands = prompt::slash_commands(self.stems_available(id));
562 + connection.send_notification(SessionNotification::new(
563 + id.clone(),
564 + SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(commands)),
565 + ))
566 + }
567 +
568 + pub async fn new_session(
569 + &self,
570 + req: NewSessionRequest,
571 + ) -> agent_client_protocol::Result<NewSessionResponse> {
572 + check_absolute(&req.cwd, &req.additional_directories)?;
573 + // Lexical normalization only: the client sees `cwd` again in `session/list`, so
574 + // symlinks in it are not rewritten.
575 + let cwd = normalize(&req.cwd);
576 + let roots: Vec<PathBuf> = req
577 + .additional_directories
578 + .iter()
579 + .map(|r| normalize(r))
580 + .collect();
581 + let id = SessionId::new(uuid::Uuid::new_v4().to_string());
582 + let model = self.model();
583 + let mut session = Session::fresh(cwd, roots, model.clone(), self.yolo);
584 + session.mcp = Arc::new(McpToolset::connect(&req.mcp_servers).await);
585 + self.sessions.lock().unwrap().insert(id.clone(), session);
586 + // Saved right away so the thread survives a restart before its first prompt.
587 + self.persist(&id).await;
588 + let options = self.config_options(&model, self.yolo).await;
589 + Ok(NewSessionResponse::new(id).config_options(options))
590 + }
591 +
592 + /// Live and stored sessions, optionally filtered by working directory, newest first.
593 + pub fn list_sessions(
594 + &self,
595 + req: ListSessionsRequest,
596 + ) -> agent_client_protocol::Result<ListSessionsResponse> {
597 + // Every session fits on one page, so no cursor is ever handed out; any cursor is stale.
598 + if let Some(cursor) = &req.cursor {
599 + return Err(invalid(format!("invalid cursor {cursor}")));
600 + }
601 + struct Row {
602 + id: SessionId,
603 + cwd: PathBuf,
604 + roots: Vec<PathBuf>,
605 + title: Option<String>,
606 + updated_at: u64,
607 + }
608 + let mut rows: HashMap<String, Row> = self
609 + .store
610 + .list()
611 + .into_iter()
612 + .map(|(id, s)| {
613 + (
614 + id.clone(),
615 + Row {
616 + id: SessionId::new(id),
617 + cwd: s.cwd,
618 + roots: s.roots,
619 + title: s.title,
620 + updated_at: s.updated_at,
621 + },
622 + )
623 + })
624 + .collect();
625 + // Live state wins over the (possibly older) stored copy.
626 + for (id, s) in self.sessions.lock().unwrap().iter() {
627 + rows.insert(
628 + id.0.to_string(),
629 + Row {
630 + id: id.clone(),
631 + cwd: s.cwd.clone(),
632 + roots: s.roots.clone(),
633 + title: s.title.clone(),
634 + updated_at: s.updated_at,
635 + },
636 + );
637 + }
638 + // Stored cwds are normalized, so normalize the filter the same way.
639 + let wanted = req.cwd.as_deref().map(normalize);
640 + let mut rows: Vec<Row> = rows
641 + .into_values()
642 + .filter(|r| wanted.as_ref().is_none_or(|cwd| *cwd == normalize(&r.cwd)))
643 + .collect();
644 + rows.sort_by(|a, b| {
645 + b.updated_at
646 + .cmp(&a.updated_at)
647 + .then_with(|| a.id.0.cmp(&b.id.0))
648 + });
649 + rows.truncate(MAX_LISTED);
650 + Ok(ListSessionsResponse::new(
651 + rows.into_iter()
652 + .map(|r| {
653 + SessionInfo::new(r.id, r.cwd)
654 + .additional_directories(r.roots)
655 + .title(r.title)
656 + .updated_at(iso8601(r.updated_at))
657 + })
658 + .collect(),
659 + ))
660 + }
661 +
662 + pub fn cancel(&self, session_id: &SessionId) {
663 + if let Some(s) = self.sessions.lock().unwrap().get(session_id) {
664 + s.cancel.cancel();
665 + }
666 + }
667 +
668 + /// Make `id` live for `session/load` and `session/resume`: from memory, else from the
669 + /// store; rebind cwd and roots, and reconnect MCP servers from the request. A well-formed
670 + /// id with no saved history (a thread from a process that never saved it) reopens empty
671 + /// under the same id, so the editor's thread opens instead of failing to launch. Returns
672 + /// whether the history was lost.
673 + async fn reopen(
674 + &self,
675 + id: &SessionId,
676 + cwd: &Path,
677 + roots: &[PathBuf],
678 + mcp_servers: &[McpServer],
679 + ) -> agent_client_protocol::Result<bool> {
680 + check_absolute(cwd, roots)?;
681 + if crate::session_store::sanitize_id(&id.0).is_err() {
682 + return Err(unknown_session(id));
683 + }
684 + let live = self.sessions.lock().unwrap().contains_key(id);
685 + let stored = if live {
686 + None
687 + } else {
688 + self.load_stored(id).await?
689 + };
690 + let history_lost = !live && stored.is_none();
691 + let cwd = normalize(cwd);
692 + let roots: Vec<PathBuf> = roots.iter().map(|r| normalize(r)).collect();
693 + let mcp = Arc::new(McpToolset::connect(mcp_servers).await);
694 +
695 + let old_mcp = {
696 + let mut sessions = self.sessions.lock().unwrap();
697 + if !sessions.contains_key(id) {
698 + let session = match stored {
699 + Some(stored) => Session::restored(stored, self.yolo),
700 + None => {
701 + tracing::warn!("session {id} has no saved history; reopening it empty");
702 + Session::fresh(cwd.clone(), roots.clone(), self.model(), self.yolo)
703 + }
704 + };
705 + sessions.insert(id.clone(), session);
706 + }
707 + let s = sessions.get_mut(id).ok_or_else(|| unknown_session(id))?;
708 + s.cwd = cwd;
709 + if !roots.is_empty() {
710 + s.roots = roots;
711 + }
712 + s.updated_at = now_secs();
713 + std::mem::replace(&mut s.mcp, mcp)
714 + };
715 + if let Some(old) = Arc::into_inner(old_mcp) {
716 + tokio::spawn(old.shutdown());
717 + }
718 + self.persist(id).await;
719 + Ok(history_lost)
720 + }
721 +
722 + /// Tell the user a reopened thread starts without its earlier history. Shown in the
723 + /// thread only; the model doesn't see it.
724 + fn notify_history_lost(
725 + &self,
726 + connection: &ConnectionTo<Client>,
727 + id: &SessionId,
728 + ) -> agent_client_protocol::Result<()> {
729 + connection.send_notification(SessionNotification::new(
730 + id.clone(),
731 + SessionUpdate::AgentMessageChunk(
732 + ContentChunk::new(
733 + "SplitFire no longer has this conversation's history, so it starts fresh \
734 + from here."
735 + .to_string()
736 + .into(),
737 + )
738 + .message_id(new_message_id()),
739 + ),
740 + ))
741 + }
742 +
743 + /// Replay a session's conversation as `session/update` notifications (text, tool calls
744 + /// with their status and output), then respond with config options. `session/load`.
745 + pub async fn load_session(
746 + &self,
747 + req: LoadSessionRequest,
748 + connection: &ConnectionTo<Client>,
749 + ) -> agent_client_protocol::Result<LoadSessionResponse> {
750 + let id = req.session_id.clone();
751 + let history_lost = self
752 + .reopen(&id, &req.cwd, &req.additional_directories, &req.mcp_servers)
753 + .await?;
754 + let (messages, cwd, roots) = {
755 + let sessions = self.sessions.lock().unwrap();
756 + let s = sessions.get(&id).ok_or_else(|| unknown_session(&id))?;
757 + (s.messages.clone(), s.cwd.clone(), s.roots.clone())
758 + };
759 + let notify = |update: SessionUpdate| {
760 + connection.send_notification(SessionNotification::new(id.clone(), update))
761 + };
762 + // Tool output by call id, from the stored `tool` messages.
763 + let results: HashMap<&str, (&str, bool)> = messages
764 + .iter()
765 + .filter(|m| m.get("role").and_then(Value::as_str) == Some("tool"))
766 + .filter_map(|m| {
767 + Some((
768 + m.get("tool_call_id")?.as_str()?,
769 + (
770 + m.get("content").and_then(Value::as_str).unwrap_or(""),
771 + m.get("x_failed").and_then(Value::as_bool).unwrap_or(false),
772 + ),
773 + ))
774 + })
775 + .collect();
776 + for msg in &messages {
777 + match msg.get("role").and_then(Value::as_str).unwrap_or("") {
778 + "user" => {
779 + let text = user_text(msg);
780 + if !text.is_empty() {
781 + notify(SessionUpdate::UserMessageChunk(
782 + ContentChunk::new(text.into()).message_id(new_message_id()),
783 + ))?;
784 + }
785 + }
786 + "assistant" => {
787 + let text = msg.get("content").and_then(Value::as_str).unwrap_or("");
788 + if !text.is_empty() {
789 + notify(SessionUpdate::AgentMessageChunk(
790 + ContentChunk::new(text.to_string().into()).message_id(new_message_id()),
791 + ))?;
792 + }
793 + let calls = msg.get("tool_calls").and_then(Value::as_array);
794 + for tc in calls.into_iter().flatten() {
795 + let call_id = tc.get("id").and_then(Value::as_str).unwrap_or("unknown");
796 + let name = tc
797 + .pointer("/function/name")
798 + .and_then(Value::as_str)
799 + .unwrap_or("unknown");
800 + let args: Value = tc
801 + .pointer("/function/arguments")
802 + .and_then(Value::as_str)
803 + .and_then(|a| serde_json::from_str(a).ok())
804 + .unwrap_or_else(|| json!({}));
805 + let (title, kind, locations) = tools::describe(&cwd, &roots, name, &args);
806 + let mut call = ToolCall::new(call_id.to_string(), title)
807 + .name(name.to_string())
808 + .kind(kind)
809 + .locations(locations)
810 + .raw_input(args);
811 + match results.get(call_id) {
812 + Some((output, failed)) => {
813 + call = call
814 + .status(if *failed {
815 + ToolCallStatus::Failed
816 + } else {
817 + ToolCallStatus::Completed
818 + })
819 + .content(vec![ToolCallContent::from(output.to_string())])
820 + .raw_output(json!({ "output": output, "failed": failed }));
821 + }
822 + // The turn was interrupted before this call produced a result.
823 + None => call = call.status(ToolCallStatus::Failed),
824 + }
825 + notify(SessionUpdate::ToolCall(call))?;
826 + }
827 + }
828 + // system and tool messages are not replayed on their own.
829 + _ => {}
830 + }
831 + }
832 + if history_lost {
833 + self.notify_history_lost(connection, &id)?;
834 + }
835 + Ok(LoadSessionResponse::new().config_options(self.session_config_options(&id).await?))
836 + }
837 +
838 + /// Rebind a session to a new cwd / additional directories without replaying it.
839 + pub async fn resume_session(
840 + &self,
841 + req: ResumeSessionRequest,
842 + connection: &ConnectionTo<Client>,
843 + ) -> agent_client_protocol::Result<ResumeSessionResponse> {
844 + let id = req.session_id.clone();
845 + let history_lost = self
846 + .reopen(&id, &req.cwd, &req.additional_directories, &req.mcp_servers)
847 + .await?;
848 + if history_lost {
849 + self.notify_history_lost(connection, &id)?;
850 + }
851 + Ok(ResumeSessionResponse::new().config_options(self.session_config_options(&id).await?))
852 + }
853 +
854 + /// Cancel any in-progress work, release MCP servers and scratch audio. The stored
855 + /// conversation stays; `session/delete` removes it.
856 + pub fn close_session(
857 + &self,
858 + req: CloseSessionRequest,
859 + ) -> agent_client_protocol::Result<CloseSessionResponse> {
860 + let id = req.session_id.clone();
861 + let removed = self.sessions.lock().unwrap().remove(&id);
862 + match removed {
863 + Some(s) => self.release(&id, s),
864 + // A session from an earlier process that was never reopened has nothing to close.
865 + None if self.store.load(&id.0).ok().flatten().is_some() => {}
866 + None => return Err(unknown_session(&id)),
867 + }
868 + Ok(CloseSessionResponse::new())
869 + }
870 +
871 + /// Forget a session, in memory and on disk, with its scratch audio. Idempotent: deleting
872 + /// an unknown session succeeds.
873 + pub fn delete_session(
874 + &self,
875 + req: DeleteSessionRequest,
876 + ) -> agent_client_protocol::Result<DeleteSessionResponse> {
877 + let id = req.session_id.clone();
878 + if let Some(s) = self.sessions.lock().unwrap().remove(&id) {
879 + self.release(&id, s);
880 + }
881 + // A malformed id can't name a stored session; nothing to remove.
882 + if crate::session_store::sanitize_id(&id.0).is_ok() {
883 + self.store.delete(&id.0).map_err(|e| {
884 + agent_client_protocol::Error::internal_error().data(format!("{e:#}"))
885 + })?;
886 + }
887 + self.remove_scratch(&id);
888 + Ok(DeleteSessionResponse::new())
889 + }
890 +
891 + fn release(&self, id: &SessionId, s: Session) {
892 + s.cancel.cancel();
893 + self.remove_scratch(id);
894 + if let Some(mcp) = Arc::into_inner(s.mcp) {
895 + tokio::spawn(mcp.shutdown());
896 + }
897 + }
898 +
899 + /// Stop everything: used when the connection ends.
900 + pub async fn shutdown(&self) {
901 + let sessions: Vec<Session> = self
902 + .sessions
903 + .lock()
904 + .unwrap()
905 + .drain()
906 + .map(|(_, s)| s)
907 + .collect();
908 + for s in sessions {
909 + s.cancel.cancel();
910 + if let Some(mcp) = Arc::into_inner(s.mcp) {
911 + mcp.shutdown().await;
912 + }
913 + }
914 + mcp::shutdown_demucs().await;
915 + }
916 +
917 + pub async fn prompt(
918 + &self,
919 + request: PromptRequest,
920 + responder: Responder<PromptResponse>,
921 + connection: ConnectionTo<Client>,
922 + ) -> agent_client_protocol::Result<()> {
923 + // A client may prompt a reopened thread without loading or resuming it first.
924 + if let Err(e) = self.restore(&request.session_id).await {
925 + return responder.respond_with_error(e);
926 + }
927 + match self.run_turn(request, connection).await {
928 + Ok(stop) => responder.respond(PromptResponse::new(stop)),
929 + Err(e) if e.downcast_ref::<UnknownSession>().is_some() => {
930 + responder.respond_with_error(invalid(e.to_string()))
931 + }
932 + Err(e) => responder.respond_with_error(
933 + agent_client_protocol::Error::internal_error().data(format!("{e:#}")),
934 + ),
935 + }
936 + }
937 +
938 + async fn run_turn(
939 + &self,
940 + request: PromptRequest,
941 + connection: ConnectionTo<Client>,
942 + ) -> anyhow::Result<StopReason> {
943 + let session_id = request.session_id.clone();
944 + let caps = self.client_caps.lock().unwrap().clone();
945 + let blocks =
946 + prompt::resolve_resource_links(&request.prompt, &caps, &connection, &session_id).await;
947 + let prompt_text = prompt::prompt_to_text(&blocks);
948 + // Check the session out for the turn.
949 + let (
950 + cwd,
951 + roots,
952 + model,
953 + history,
954 + cancel,
955 + always_allowed,
956 + always_rejected,
957 + auto_approve,
958 + mcp,
959 + new_title,
960 + ) = {
961 + let mut sessions = self.sessions.lock().unwrap();
962 + let s = sessions
963 + .get_mut(&session_id)
964 + .ok_or_else(|| UnknownSession(session_id.to_string()))?;
965 + if s.busy {
966 + anyhow::bail!("a prompt is already running for session {session_id}");
967 + }
968 + s.busy = true;
969 + s.cancel = CancellationToken::new();
970 + s.updated_at = now_secs();
971 + let new_title = if s.title.is_none() && !prompt_text.trim().is_empty() {
972 + s.title = Some(session_title(&prompt_text));
973 + s.title.clone()
974 + } else {
975 + None
976 + };
977 + (
978 + s.cwd.clone(),
979 + s.roots.clone(),
980 + s.model.clone(),
981 + std::mem::take(&mut s.messages),
982 + s.cancel.clone(),
983 + s.always_allowed.clone(),
984 + s.always_rejected.clone(),
985 + s.auto_approve.clone(),
986 + s.mcp.clone(),
987 + new_title,
988 + )
989 + };
990 + if let Some(title) = new_title {
991 + let update = SessionInfoUpdate::new()
992 + .title(title)
993 + .updated_at(iso8601(now_secs()));
994 + let _ = connection.send_notification(SessionNotification::new(
995 + session_id.clone(),
996 + SessionUpdate::SessionInfoUpdate(update),
997 + ));
998 + }
999 +
1000 + let stems = mcp.has_stem_separation();
1001 + let mut messages = vec![json!({
1002 + "role": "system",
1003 + "content": self.system_prompt(&cwd, &roots, stems),
1004 + })];
1005 + messages.extend(history);
1006 +
1007 + let ctx = ToolCtx {
1008 + connection: connection.clone(),
1009 + session_id: session_id.clone(),
1010 + cwd,
1011 + roots,
1012 + caps,
1013 + cancel,
1014 + auto_approve,
1015 + always_allowed,
1016 + always_rejected,
1017 + mcp,
1018 + };
1019 + let result = self.turn(&ctx, &model, &mut messages, &blocks, stems).await;
1020 +
1021 + // Check the history back in, even when the turn failed.
1022 + let history = messages.split_off(1);
1023 + let stored = {
1024 + let mut sessions = self.sessions.lock().unwrap();
1025 + sessions.get_mut(&session_id).map(|s| {
1026 + s.messages = history;
1027 + s.busy = false;
1028 + s.updated_at = now_secs();
1029 + s.stored()
1030 + })
1031 + };
1032 + if let Some(stored) = stored {
1033 + let updated_at = stored.updated_at;
1034 + let store = self.store.clone();
1035 + let id = session_id.0.to_string();
1036 + let saved = tokio::task::spawn_blocking(move || store.save(&id, &stored)).await;
1037 + if let Ok(Err(e)) | Err(e) = saved.map_err(anyhow::Error::from) {
1038 + tracing::warn!("saving session {session_id} failed: {e:#}");
1039 + }
1040 + let _ = connection.send_notification(SessionNotification::new(
1041 + session_id.clone(),
1042 + SessionUpdate::SessionInfoUpdate(
1043 + SessionInfoUpdate::new().updated_at(iso8601(updated_at)),
1044 + ),
1045 + ));
1046 + }
1047 + result
1048 + }
1049 +
1050 + /// Everything inside a turn after the session is checked out.
1051 + async fn turn(
1052 + &self,
1053 + ctx: &ToolCtx,
1054 + model: &str,
1055 + messages: &mut Vec<Value>,
1056 + blocks: &[ContentBlock],
1057 + stems: bool,
1058 + ) -> anyhow::Result<StopReason> {
1059 + let audio_paths = match self.scratch_dir(&ctx.session_id) {
1060 + Some(dir) => prompt::save_audio_blocks(blocks, &dir).await?,
1061 + None => Vec::new(),
1062 + };
1063 + let slash = prompt::expand_slash(&prompt::prompt_to_text(blocks), stems);
1064 + let content = prompt::prompt_to_content(blocks, &audio_paths, slash.as_deref());
1065 + messages.push(json!({ "role": "user", "content": content }));
1066 + self.agent_loop(ctx, model, messages).await
1067 + }
1068 +
1069 + async fn agent_loop(
1070 + &self,
1071 + ctx: &ToolCtx,
1072 + model: &str,
1073 + messages: &mut Vec<Value>,
1074 + ) -> anyhow::Result<StopReason> {
1075 + let llm = self.llm();
1076 + let tool_defs = tools::definitions(&ctx.mcp);
1077 + let notify = |update: SessionUpdate| {
1078 + ctx.connection
1079 + .send_notification(SessionNotification::new(ctx.session_id.clone(), update))
1080 + };
1081 + // `size` is the model's context window, which OpenAI-style endpoints don't report.
1082 + let context_window = std::env::var("SPLITFIRE_CONTEXT_WINDOW")
1083 + .ok()
1084 + .and_then(|v| v.parse().ok())
1085 + .unwrap_or(DEFAULT_CONTEXT_WINDOW);
1086 +
1087 + for _ in 0..MAX_TURNS {
1088 + // One message id per assistant message (and per thought), so clients can group
1089 + // the streamed chunks.
1090 + let message_id = new_message_id();
1091 + let thought_id = new_message_id();
1092 + let wire: Vec<Value> = messages.iter().map(wire_message).collect();
1093 + let completion = tokio::select! {
1094 + r = llm.complete(model, &wire, &tool_defs, |delta| {
1095 + let update = match delta {
1096 + Delta::Text(t) => SessionUpdate::AgentMessageChunk(
1097 + ContentChunk::new(t.to_string().into()).message_id(message_id.clone()),
1098 + ),
1099 + Delta::Reasoning(t) => SessionUpdate::AgentThoughtChunk(
1100 + ContentChunk::new(t.to_string().into()).message_id(thought_id.clone()),
1101 + ),
1102 + };
1103 + if let Err(e) = notify(update) {
1104 + tracing::warn!("failed to send update: {e}");
1105 + }
1106 + }) => r?,
1107 + () = ctx.cancel.cancelled() => return Ok(StopReason::Cancelled),
1108 + };
1109 + messages.push(completion.to_message());
1110 + if let Some(usage) = completion.usage {
1111 + let used = usage.used();
1112 + notify(SessionUpdate::UsageUpdate(UsageUpdate::new(
1113 + used,
1114 + context_window.max(used),
1115 + )))?;
1116 + }
1117 +
1118 + if completion.tool_calls.is_empty() {
1119 + return Ok(match completion.finish_reason.as_deref() {
1120 + Some("length") => StopReason::MaxTokens,
1121 + Some("content_filter") => StopReason::Refusal,
1122 + _ => StopReason::EndTurn,
1123 + });
1124 + }
1125 +
1126 + for tc in &completion.tool_calls {
1127 + // Every tool call needs a matching tool message, even after cancellation.
1128 + if ctx.cancel.is_cancelled() {
1129 + messages.push(tool_message(&tc.id, "Cancelled by user.", true));
1130 + continue;
1131 + }
1132 + let parsed: Result<Value, _> = serde_json::from_str(&tc.arguments);
1133 + let args = parsed.as_ref().cloned().unwrap_or_else(|_| json!({}));
1134 + let (title, kind, locations) =
1135 + tools::describe(&ctx.cwd, &ctx.roots, &tc.name, &args);
1136 + notify(SessionUpdate::ToolCall(
1137 + ToolCall::new(tc.id.clone(), title)
1138 + .name(tc.name.clone())
1139 + .kind(kind)
1140 + .status(ToolCallStatus::InProgress)
1141 + .locations(locations)
1142 + .raw_input(args.clone()),
1143 + ))?;
1144 +
1145 + let outcome: ToolOutcome = if parsed.is_err() {
1146 + ToolOutcome::err(format!("Invalid JSON arguments: {}", tc.arguments))
1147 + } else {
1148 + tools::execute(ctx, &tc.id, &tc.name, args).await
1149 + };
1150 +
1151 + let mut fields = ToolCallUpdateFields::new()
1152 + .status(if outcome.failed {
1153 + ToolCallStatus::Failed
1154 + } else {
1155 + ToolCallStatus::Completed
1156 + })
1157 + .content(outcome.content)
1158 + .raw_output(json!({ "output": outcome.text, "failed": outcome.failed }));
1159 + if !outcome.locations.is_empty() {
1160 + fields = fields.locations(locations_of(&outcome.locations));
1161 + }
1162 + ctx.update(&tc.id, fields)?;
1163 + messages.push(tool_message(&tc.id, &outcome.text, outcome.failed));
1164 + }
1165 + if ctx.cancel.is_cancelled() {
1166 + return Ok(StopReason::Cancelled);
1167 + }
1168 + }
1169 + Ok(StopReason::MaxTurnRequests)
1170 + }
1171 +}
1172 +
1173 +/// Marker error: the prompt named a session that doesn't exist.
1174 +#[derive(Debug)]
1175 +struct UnknownSession(String);
1176 +
1177 +impl std::fmt::Display for UnknownSession {
1178 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1179 + write!(f, "unknown session {}", self.0)
1180 + }
1181 +}
1182 +
1183 +impl std::error::Error for UnknownSession {}
1184 +
1185 +#[cfg(test)]
1186 +mod tests {
1187 + use super::*;
1188 +
1189 + #[test]
1190 + fn iso8601_known_dates() {
1191 + assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
1192 + assert_eq!(iso8601(1_700_000_000), "2023-11-14T22:13:20Z");
1193 + assert_eq!(iso8601(951_782_400), "2000-02-29T00:00:00Z");
1194 + }
1195 +
1196 + #[test]
1197 + fn titles_are_trimmed_and_single_line() {
1198 + assert_eq!(session_title(" hello\nworld"), "hello");
1199 + let long = "x".repeat(200);
1200 + let t = session_title(&long);
1201 + assert_eq!(t.chars().count(), 80);
1202 + assert!(t.ends_with('…'));
1203 + }
1204 +
1205 + #[test]
1206 + fn version_negotiation_always_v1() {
1207 + assert_eq!(negotiate_version(ProtocolVersion::V1), ProtocolVersion::V1);
1208 + assert_eq!(
1209 + negotiate_version(ProtocolVersion::from(7)),
1210 + ProtocolVersion::V1
1211 + );
1212 + assert_eq!(
1213 + negotiate_version(ProtocolVersion::from(0)),
1214 + ProtocolVersion::V1
1215 + );
1216 + }
1217 +
1218 + #[test]
1219 + fn private_failure_flag_never_reaches_the_wire() {
1220 + let m = tool_message("c1", "boom", true);
1221 + assert_eq!(m["x_failed"], true);
1222 + let w = wire_message(&m);
1223 + assert!(w.get("x_failed").is_none());
1224 + assert_eq!(w["content"], "boom");
1225 + assert!(tool_message("c1", "ok", false).get("x_failed").is_none());
1226 + }
1227 +
1228 + #[test]
1229 + fn user_text_handles_parts() {
1230 + let m = json!({"role": "user", "content": [
1231 + {"type": "text", "text": "hi"},
1232 + {"type": "image_url", "image_url": {"url": "x"}}
1233 + ]});
1234 + assert_eq!(user_text(&m), "hi\n\n[image]");
1235 + assert_eq!(user_text(&json!({"content": "plain"})), "plain");
1236 + }
1237 +}
src/audio/analysis.rs new
+611
@@ -0,0 +1,611 @@
1 +//! Loudness, peak/RMS, tempo (spectral flux + autocorrelation) and key
2 +//! (chromagram + Krumhansl-Schmuckler) estimates. Tempo and key are estimates, not facts.
3 +
4 +use std::f32::consts::PI;
5 +use std::path::Path;
6 +
7 +use anyhow::{Result, anyhow};
8 +use ebur128::{EbuR128, Mode};
9 +use realfft::RealFftPlanner;
10 +
11 +use super::decode::{StreamInfo, decode_file};
12 +
13 +/// Mono analysis signal is decimated to roughly this rate.
14 +const ANALYSIS_RATE: u32 = 11_025;
15 +/// Tempo and key look at the first ten minutes only.
16 +const MAX_ANALYSIS_SECS: f64 = 600.0;
17 +
18 +#[derive(Debug, Clone)]
19 +pub struct TempoEstimate {
20 + pub bpm: f64,
21 + /// Normalized autocorrelation at the chosen lag, 0..1.
22 + pub strength: f64,
23 + /// Other plausible readings: (bpm, why).
24 + pub alternatives: Vec<(f64, &'static str)>,
25 +}
26 +
27 +#[derive(Debug, Clone)]
28 +pub struct KeyEstimate {
29 + pub name: String,
30 + pub correlation: f64,
31 + pub alternatives: Vec<(String, f64)>,
32 +}
33 +
34 +#[derive(Debug, Clone)]
35 +pub struct Analysis {
36 + pub info: StreamInfo,
37 + pub peak_db: f64,
38 + pub rms_db: f64,
39 + pub lufs: Option<f64>,
40 + pub lra: Option<f64>,
41 + pub tempo: Option<TempoEstimate>,
42 + pub key: Option<KeyEstimate>,
43 + pub analysed_secs: f64,
44 +}
45 +
46 +fn to_db(x: f64) -> f64 {
47 + if x <= 0.0 {
48 + f64::NEG_INFINITY
49 + } else {
50 + 20.0 * x.log10()
51 + }
52 +}
53 +
54 +pub fn analyze_file(path: &Path) -> Result<Analysis> {
55 + let mut peak = 0f32;
56 + let mut sum_sq = 0f64;
57 + let mut count = 0u64;
58 + let mut meter: Option<EbuR128> = None;
59 + let mut meter_failed = false;
60 + let mut mono: Vec<f32> = Vec::new();
61 + let mut decim = 1usize;
62 + let mut acc = 0f32;
63 + let mut acc_n = 0usize;
64 + let mut rate_seen = 0u32;
65 +
66 + let info = decode_file(path, |info, samples| {
67 + if rate_seen != info.sample_rate {
68 + rate_seen = info.sample_rate;
69 + decim = (info.sample_rate / ANALYSIS_RATE).max(1) as usize;
70 + }
71 + for &s in samples {
72 + peak = peak.max(s.abs());
73 + sum_sq += (s as f64) * (s as f64);
74 + }
75 + count += samples.len() as u64;
76 + if meter.is_none() && !meter_failed {
77 + match EbuR128::new(
78 + info.channels as u32,
79 + info.sample_rate,
80 + Mode::I | Mode::LRA | Mode::SAMPLE_PEAK,
81 + ) {
82 + Ok(m) => meter = Some(m),
83 + Err(_) => meter_failed = true,
84 + }
85 + }
86 + if let Some(m) = meter.as_mut() {
87 + let _ = m.add_frames_f32(samples);
88 + }
89 + let ch = info.channels.max(1);
90 + let max_len = (MAX_ANALYSIS_SECS * info.sample_rate as f64 / decim as f64) as usize;
91 + if mono.len() < max_len {
92 + for frame in samples.chunks_exact(ch) {
93 + acc += frame.iter().sum::<f32>() / ch as f32;
94 + acc_n += 1;
95 + if acc_n == decim {
96 + mono.push(acc / decim as f32);
97 + acc = 0.0;
98 + acc_n = 0;
99 + }
100 + }
101 + }
102 + })?;
103 +
104 + let rms = if count == 0 {
105 + 0.0
106 + } else {
107 + (sum_sq / count as f64).sqrt()
108 + };
109 + let lufs = meter
110 + .as_ref()
111 + .and_then(|m| m.loudness_global().ok())
112 + .filter(|v| v.is_finite());
113 + let lra = meter
114 + .as_ref()
115 + .and_then(|m| m.loudness_range().ok())
116 + .filter(|v| v.is_finite());
117 + let analysis_rate = info.sample_rate as f32 / decim as f32;
118 + let silent = peak < 1e-5;
119 + Ok(Analysis {
120 + peak_db: to_db(peak as f64),
121 + rms_db: to_db(rms),
122 + lufs,
123 + lra,
124 + tempo: if silent {
125 + None
126 + } else {
127 + estimate_tempo(&mono, analysis_rate)
128 + },
129 + key: if silent {
130 + None
131 + } else {
132 + estimate_key(&mono, analysis_rate)
133 + },
134 + analysed_secs: mono.len() as f64 / analysis_rate as f64,
135 + info,
136 + })
137 +}
138 +
139 +fn hann(n: usize) -> Vec<f32> {
140 + (0..n)
141 + .map(|i| 0.5 - 0.5 * (2.0 * PI * i as f32 / n as f32).cos())
142 + .collect()
143 +}
144 +
145 +/// Log-magnitude spectral flux per frame, mean-subtracted and half-wave rectified.
146 +fn onset_envelope(mono: &[f32], win: usize, hop: usize) -> Vec<f32> {
147 + let mut planner = RealFftPlanner::<f32>::new();
148 + let fft = planner.plan_fft_forward(win);
149 + let window = hann(win);
150 + let mut input = fft.make_input_vec();
151 + let mut output = fft.make_output_vec();
152 + let mut prev = vec![0f32; win / 2 + 1];
153 + let mut env = Vec::new();
154 + let mut start = 0;
155 + while start + win <= mono.len() {
156 + for (i, w) in window.iter().enumerate() {
157 + input[i] = mono[start + i] * w;
158 + }
159 + if fft.process(&mut input, &mut output).is_err() {
160 + break;
161 + }
162 + let mut flux = 0f32;
163 + for (k, c) in output.iter().enumerate().skip(2) {
164 + let mag = (1.0 + 100.0 * c.norm() / win as f32).ln();
165 + flux += (mag - prev[k]).max(0.0);
166 + prev[k] = mag;
167 + }
168 + env.push(flux);
169 + start += hop;
170 + }
171 + // Subtract a local mean (~0.5 s) so steady loudness doesn't dominate the autocorrelation.
172 + let half = 22usize;
173 + let mut out = Vec::with_capacity(env.len());
174 + let mut prefix = vec![0f64];
175 + for v in &env {
176 + prefix.push(prefix.last().unwrap() + *v as f64);
177 + }
178 + for i in 0..env.len() {
179 + let lo = i.saturating_sub(half);
180 + let hi = (i + half + 1).min(env.len());
181 + let mean = (prefix[hi] - prefix[lo]) / (hi - lo) as f64;
182 + out.push((env[i] as f64 - mean).max(0.0) as f32);
183 + }
184 + out
185 +}
186 +
187 +pub fn estimate_tempo(mono: &[f32], rate: f32) -> Option<TempoEstimate> {
188 + const WIN: usize = 1024;
189 + const HOP: usize = 128;
190 + const MIN_BPM: f64 = 40.0;
191 + const MAX_BPM: f64 = 240.0;
192 + if (mono.len() as f32) < rate * 4.0 {
193 + return None;
194 + }
195 + let env = onset_envelope(mono, WIN, HOP);
196 + let fps = rate as f64 / HOP as f64;
197 + let min_lag = (fps * 60.0 / MAX_BPM).floor() as usize;
198 + let max_lag = (fps * 60.0 / MIN_BPM).ceil() as usize;
199 + if env.len() < max_lag * 3 {
200 + return None;
201 + }
202 + let n = env.len();
203 + let mean = env.iter().map(|v| *v as f64).sum::<f64>() / n as f64;
204 + let centered: Vec<f64> = env.iter().map(|v| *v as f64 - mean).collect();
205 + let r0: f64 = centered.iter().map(|v| v * v).sum::<f64>() / n as f64;
206 + if r0 <= 1e-12 {
207 + return None;
208 + }
209 + let acf: Vec<f64> = (0..=2 * max_lag)
210 + .map(|lag| {
211 + let s: f64 = (0..n - lag).map(|i| centered[i] * centered[i + lag]).sum();
212 + s / (n - lag) as f64 / r0
213 + })
214 + .collect();
215 + let bpm_at = |lag: f64| 60.0 * fps / lag;
216 + let prior = |bpm: f64| (-0.5 * (bpm / 120.0).log2().powi(2)).exp();
217 + let score = |lag: usize| {
218 + let mut s = acf[lag].max(0.0);
219 + if 2 * lag < acf.len() {
220 + s += 0.5 * acf[2 * lag].max(0.0);
221 + }
222 + s * prior(bpm_at(lag as f64))
223 + };
224 + let mut best = min_lag.max(1);
225 + for lag in min_lag.max(1)..=max_lag {
226 + if score(lag) > score(best) {
227 + best = lag;
228 + }
229 + }
230 + // Parabolic interpolation around the peak for sub-frame lag.
231 + let refined = if best > 1 && best + 1 < acf.len() {
232 + let (a, b, c) = (acf[best - 1], acf[best], acf[best + 1]);
233 + let denom = a - 2.0 * b + c;
234 + if denom.abs() > 1e-12 {
235 + best as f64 + 0.5 * (a - c) / denom
236 + } else {
237 + best as f64
238 + }
239 + } else {
240 + best as f64
241 + };
242 + let bpm = bpm_at(refined.clamp(best as f64 - 1.0, best as f64 + 1.0));
243 + let mut alternatives = Vec::new();
244 + if bpm / 2.0 >= MIN_BPM {
245 + alternatives.push((bpm / 2.0, "half-time"));
246 + }
247 + if bpm * 2.0 <= MAX_BPM * 1.25 {
248 + alternatives.push((bpm * 2.0, "double-time"));
249 + }
250 + Some(TempoEstimate {
251 + bpm,
252 + strength: acf[best].clamp(0.0, 1.0),
253 + alternatives,
254 + })
255 +}
256 +
257 +const MAJOR_PROFILE: [f64; 12] = [
258 + 6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88,
259 +];
260 +const MINOR_PROFILE: [f64; 12] = [
261 + 6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17,
262 +];
263 +const MAJOR_NAMES: [&str; 12] = [
264 + "C", "Db", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B",
265 +];
266 +const MINOR_NAMES: [&str; 12] = [
267 + "C", "C#", "D", "Eb", "E", "F", "F#", "G", "G#", "A", "Bb", "B",
268 +];
269 +
270 +fn pearson(a: &[f64], b: &[f64]) -> f64 {
271 + let n = a.len() as f64;
272 + let (ma, mb) = (a.iter().sum::<f64>() / n, b.iter().sum::<f64>() / n);
273 + let (mut num, mut da, mut db) = (0.0, 0.0, 0.0);
274 + for (x, y) in a.iter().zip(b) {
275 + num += (x - ma) * (y - mb);
276 + da += (x - ma).powi(2);
277 + db += (y - mb).powi(2);
278 + }
279 + if da <= 0.0 || db <= 0.0 {
280 + 0.0
281 + } else {
282 + num / (da * db).sqrt()
283 + }
284 +}
285 +
286 +pub fn chromagram(mono: &[f32], rate: f32) -> [f64; 12] {
287 + const WIN: usize = 4096;
288 + const HOP: usize = 2048;
289 + let mut planner = RealFftPlanner::<f32>::new();
290 + let fft = planner.plan_fft_forward(WIN);
291 + let window = hann(WIN);
292 + let mut input = fft.make_input_vec();
293 + let mut output = fft.make_output_vec();
294 + let bin_hz = rate as f64 / WIN as f64;
295 + let lo = (100.0 / bin_hz).ceil() as usize;
296 + let hi = ((2000.0 / bin_hz) as usize).min(WIN / 2);
297 + let mut chroma = [0f64; 12];
298 + let mut start = 0;
299 + while start + WIN <= mono.len() {
300 + for (i, w) in window.iter().enumerate() {
301 + input[i] = mono[start + i] * w;
302 + }
303 + if fft.process(&mut input, &mut output).is_err() {
304 + break;
305 + }
306 + for (k, c) in output.iter().enumerate().take(hi + 1).skip(lo) {
307 + let hz = k as f64 * bin_hz;
308 + let midi = 69.0 + 12.0 * (hz / 440.0).log2();
309 + let pc = (midi.round() as i64).rem_euclid(12) as usize;
310 + chroma[pc] += c.norm() as f64;
311 + }
312 + start += HOP;
313 + }
314 + chroma
315 +}
316 +
317 +pub fn estimate_key(mono: &[f32], rate: f32) -> Option<KeyEstimate> {
318 + if (mono.len() as f32) < rate * 3.0 {
319 + return None;
320 + }
321 + let chroma = chromagram(mono, rate);
322 + if chroma.iter().sum::<f64>() <= 0.0 {
323 + return None;
324 + }
325 + let mut scored: Vec<(String, f64)> = Vec::with_capacity(24);
326 + for tonic in 0..12 {
327 + for (profile, names, suffix) in [
328 + (&MAJOR_PROFILE, &MAJOR_NAMES, "major"),
329 + (&MINOR_PROFILE, &MINOR_NAMES, "minor"),
330 + ] {
331 + let rotated: Vec<f64> = (0..12).map(|i| profile[(i + 12 - tonic) % 12]).collect();
332 + scored.push((
333 + format!("{} {suffix}", names[tonic]),
334 + pearson(&chroma, &rotated),
335 + ));
336 + }
337 + }
338 + scored.sort_by(|a, b| b.1.total_cmp(&a.1));
339 + let (name, correlation) = scored[0].clone();
340 + Some(KeyEstimate {
341 + name,
342 + correlation,
343 + alternatives: scored[1..4].to_vec(),
344 + })
345 +}
346 +
347 +fn channel_label(n: usize) -> String {
348 + match n {
349 + 1 => "mono".into(),
350 + 2 => "stereo".into(),
351 + n => format!("{n} channels"),
352 + }
353 +}
354 +
355 +fn fmt_db(v: f64) -> String {
356 + if v.is_finite() {
357 + format!("{v:.1}")
358 + } else {
359 + "-inf".into()
360 + }
361 +}
362 +
363 +/// Human-readable report, written for the model to relay. Tempo and key are labelled estimates.
364 +pub fn report(path: &Path, a: &Analysis) -> String {
365 + let i = &a.info;
366 + let secs = i.duration_secs();
367 + let mut out = format!(
368 + "File: {}\nFormat: {} ({})\nDuration: {}:{:04.1} ({secs:.1} s), {} Hz, {}{}\n",
369 + path.display(),
370 + if i.extension.is_empty() {
371 + "unknown"
372 + } else {
373 + &i.extension
374 + },
375 + i.codec,
376 + (secs / 60.0).floor() as u64,
377 + secs % 60.0,
378 + i.sample_rate,
379 + channel_label(i.channels),
380 + i.bits_per_sample
381 + .map_or(String::new(), |b| format!(", {b}-bit")),
382 + );
383 + out.push_str(&format!(
384 + "Peak: {} dBFS, RMS: {} dBFS\n",
385 + fmt_db(a.peak_db),
386 + fmt_db(a.rms_db)
387 + ));
388 + match (a.lufs, a.lra) {
389 + (Some(l), Some(r)) => out.push_str(&format!(
390 + "Loudness: {l:.1} LUFS integrated, loudness range {r:.1} LU\n"
391 + )),
392 + (Some(l), None) => out.push_str(&format!("Loudness: {l:.1} LUFS integrated\n")),
393 + _ => out.push_str("Loudness: not measurable (silence or too short)\n"),
394 + }
395 + if a.peak_db.is_infinite() || a.peak_db < -100.0 {
396 + out.push_str("The file is silent; tempo and key were not estimated.");
397 + return out;
398 + }
399 + match &a.tempo {
400 + Some(t) => {
401 + let conf = if t.strength > 0.5 {
402 + "high"
403 + } else if t.strength > 0.25 {
404 + "medium"
405 + } else {
406 + "low"
407 + };
408 + let alts: Vec<String> = t
409 + .alternatives
410 + .iter()
411 + .map(|(b, why)| format!("{b:.1} ({why})"))
412 + .collect();
413 + out.push_str(&format!(
414 + "Tempo (estimate): {:.1} BPM, {conf} confidence; also plausible: {}\n",
415 + t.bpm,
416 + alts.join(", ")
417 + ));
418 + }
419 + None => out.push_str("Tempo (estimate): not enough rhythmic material to estimate\n"),
420 + }
421 + match &a.key {
422 + Some(k) => {
423 + let alts: Vec<String> = k
424 + .alternatives
425 + .iter()
426 + .map(|(n, r)| format!("{n} ({r:.2})"))
427 + .collect();
428 + let conf = if k.correlation > 0.75 {
429 + "high"
430 + } else if k.correlation > 0.55 {
431 + "medium"
432 + } else {
433 + "low"
434 + };
435 + out.push_str(&format!(
436 + "Key (estimate): {} (correlation {:.2}, {conf} confidence); alternatives: {}\n",
437 + k.name,
438 + k.correlation,
439 + alts.join(", ")
440 + ));
441 + }
442 + None => out.push_str("Key (estimate): not enough tonal material to estimate\n"),
443 + }
444 + if a.analysed_secs + 1.0 < secs {
445 + out.push_str(&format!(
446 + "Tempo and key come from the first {:.0} s only.\n",
447 + a.analysed_secs
448 + ));
449 + }
450 + out.push_str("Tempo and key are signal-analysis estimates; confirm by ear.");
451 + out
452 +}
453 +
454 +/// Convenience wrapper for the tool: errors carry context for the model.
455 +pub fn analyze_and_report(path: &Path) -> Result<String> {
456 + let a = analyze_file(path).map_err(|e| anyhow!("{e:#}"))?;
457 + Ok(report(path, &a))
458 +}
459 +
460 +#[cfg(test)]
461 +mod tests {
462 + use super::*;
463 +
464 + const SR: u32 = 44_100;
465 +
466 + fn write_wav(path: &Path, channels: u16, samples: &[f32]) {
467 + let spec = hound::WavSpec {
468 + channels,
469 + sample_rate: SR,
470 + bits_per_sample: 16,
471 + sample_format: hound::SampleFormat::Int,
472 + };
473 + let mut w = hound::WavWriter::create(path, spec).unwrap();
474 + for s in samples {
475 + w.write_sample((s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16)
476 + .unwrap();
477 + }
478 + w.finalize().unwrap();
479 + }
480 +
481 + /// Decaying 1 kHz plus broadband-ish clicks on every beat.
482 + fn click_track(bpm: f64, secs: f64) -> Vec<f32> {
483 + let n = (secs * SR as f64) as usize;
484 + let mut out = vec![0f32; n];
485 + let beat = 60.0 / bpm * SR as f64;
486 + let mut t = 0.0;
487 + let mut seed = 12345u32;
488 + while (t as usize) < n {
489 + for j in 0..1500 {
490 + let i = t as usize + j;
491 + if i >= n {
492 + break;
493 + }
494 + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
495 + let noise = (seed >> 8) as f32 / (1 << 23) as f32 - 1.0;
496 + let env = (-(j as f32) / 300.0).exp();
497 + out[i] += 0.6
498 + * env
499 + * (0.6 * noise + 0.4 * (2.0 * PI * 1000.0 * j as f32 / SR as f32).sin());
500 + }
501 + t += beat;
502 + }
503 + out
504 + }
505 +
506 + fn chord_progression(chords: &[&[f32]], secs_each: f64) -> Vec<f32> {
507 + let mut out = Vec::new();
508 + for freqs in chords {
509 + let n = (secs_each * SR as f64) as usize;
510 + for i in 0..n {
511 + let t = i as f32 / SR as f32;
512 + let mut s = 0.0;
513 + for f in freqs.iter() {
514 + for h in 1..=4 {
515 + s += (2.0 * PI * f * h as f32 * t).sin() / h as f32;
516 + }
517 + }
518 + out.push(0.1 * s);
519 + }
520 + }
521 + out
522 + }
523 +
524 + fn tmp_wav(name: &str) -> std::path::PathBuf {
525 + std::env::temp_dir().join(format!("sf-audio-{name}-{}.wav", uuid::Uuid::new_v4()))
526 + }
527 +
528 + #[test]
529 + fn tempo_of_click_tracks() {
530 + for bpm in [90.0, 120.0, 140.0] {
531 + let path = tmp_wav("tempo");
532 + write_wav(&path, 1, &click_track(bpm, 24.0));
533 + let a = analyze_file(&path).unwrap();
534 + let t = a.tempo.expect("tempo");
535 + assert!(
536 + (t.bpm - bpm).abs() / bpm < 0.02,
537 + "wanted {bpm}, got {}",
538 + t.bpm
539 + );
540 + assert!(t.alternatives.iter().any(|(_, w)| *w == "half-time"));
541 + std::fs::remove_file(path).ok();
542 + }
543 + }
544 +
545 + #[test]
546 + fn key_of_chord_progressions() {
547 + // C F G C
548 + let c = [130.81, 164.81, 196.0];
549 + let f = [174.61, 220.0, 261.63];
550 + let g = [196.0, 246.94, 293.66];
551 + let path = tmp_wav("keyc");
552 + write_wav(&path, 1, &chord_progression(&[&c, &f, &g, &c], 3.0));
553 + let k = analyze_file(&path).unwrap().key.expect("key");
554 + assert_eq!(k.name, "C major", "{k:?}");
555 + std::fs::remove_file(path).ok();
556 + // Am Dm E Am
557 + let am = [110.0, 130.81, 164.81];
558 + let dm = [146.83, 174.61, 220.0];
559 + let e = [164.81, 207.65, 246.94];
560 + let path = tmp_wav("keya");
561 + write_wav(&path, 1, &chord_progression(&[&am, &dm, &e, &am], 3.0));
562 + let k = analyze_file(&path).unwrap().key.expect("key");
563 + assert_eq!(k.name, "A minor", "{k:?}");
564 + std::fs::remove_file(path).ok();
565 + }
566 +
567 + #[test]
568 + fn levels_loudness_and_format() {
569 + // 5 s of a 997 Hz sine at -20 dBFS peak, stereo.
570 + let n = 5 * SR as usize;
571 + let amp = 0.1f32;
572 + let mut samples = Vec::with_capacity(n * 2);
573 + for i in 0..n {
574 + let s = amp * (2.0 * PI * 997.0 * i as f32 / SR as f32).sin();
575 + samples.push(s);
576 + samples.push(s);
577 + }
578 + let path = tmp_wav("levels");
579 + write_wav(&path, 2, &samples);
580 + let a = analyze_file(&path).unwrap();
581 + assert_eq!(a.info.sample_rate, SR);
582 + assert_eq!(a.info.channels, 2);
583 + assert!((a.info.duration_secs() - 5.0).abs() < 0.01);
584 + assert!((a.peak_db + 20.0).abs() < 0.2, "{}", a.peak_db);
585 + // RMS of a sine is 3 dB below its peak.
586 + assert!((a.rms_db + 23.0).abs() < 0.3, "{}", a.rms_db);
587 + // A 997 Hz sine at -23 dBFS RMS per channel, two channels summed: about -20 LUFS.
588 + let lufs = a.lufs.expect("lufs");
589 + assert!((lufs + 20.0).abs() < 1.0, "{lufs}");
590 + let text = report(&path, &a);
591 + assert!(text.contains("44100 Hz, stereo, 16-bit"), "{text}");
592 + assert!(text.contains("estimates"), "{text}");
593 + std::fs::remove_file(path).ok();
594 + }
595 +
596 + #[test]
597 + fn silence_and_garbage() {
598 + let path = tmp_wav("silence");
599 + write_wav(&path, 1, &vec![0.0; SR as usize * 2]);
600 + let a = analyze_file(&path).unwrap();
601 + assert!(a.tempo.is_none() && a.key.is_none());
602 + assert!(report(&path, &a).contains("silent"));
603 + std::fs::remove_file(&path).ok();
604 +
605 + let bad = std::env::temp_dir().join(format!("sf-audio-bad-{}.wav", uuid::Uuid::new_v4()));
606 + std::fs::write(&bad, b"definitely not audio").unwrap();
607 + assert!(analyze_file(&bad).is_err());
608 + std::fs::remove_file(bad).ok();
609 + assert!(analyze_file(Path::new("/no/such/file.wav")).is_err());
610 + }
611 +}
src/audio/decode.rs new
+103
@@ -0,0 +1,103 @@
1 +//! Streaming decode to interleaved `f32` with symphonia 0.6.
2 +
3 +use std::path::Path;
4 +
5 +use anyhow::{Context, Result, anyhow, bail};
6 +use symphonia::core::codecs::audio::AudioDecoderOptions;
7 +use symphonia::core::errors::Error;
8 +use symphonia::core::formats::probe::Hint;
9 +use symphonia::core::formats::{FormatOptions, TrackType};
10 +use symphonia::core::io::MediaSourceStream;
11 +use symphonia::core::meta::MetadataOptions;
12 +
13 +#[derive(Debug, Clone, Default)]
14 +pub struct StreamInfo {
15 + pub extension: String,
16 + pub codec: String,
17 + pub sample_rate: u32,
18 + pub channels: usize,
19 + pub bits_per_sample: Option<u32>,
20 + pub frames: u64,
21 +}
22 +
23 +impl StreamInfo {
24 + pub fn duration_secs(&self) -> f64 {
25 + self.frames as f64 / self.sample_rate.max(1) as f64
26 + }
27 +}
28 +
29 +/// Decode `path`, handing every decoded packet to `sink` as interleaved samples.
30 +/// `sink` receives `(info_so_far, interleaved_samples)`; the rate and channel count are
31 +/// fixed from the first packet onward.
32 +pub fn decode_file(path: &Path, mut sink: impl FnMut(&StreamInfo, &[f32])) -> Result<StreamInfo> {
33 + let file = std::fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
34 + let mss = MediaSourceStream::new(Box::new(file), Default::default());
35 + let mut hint = Hint::new();
36 + let extension = path
37 + .extension()
38 + .and_then(|e| e.to_str())
39 + .unwrap_or("")
40 + .to_lowercase();
41 + if !extension.is_empty() {
42 + hint.with_extension(&extension);
43 + }
44 + let mut format = symphonia::default::get_probe()
45 + .probe(
46 + &hint,
47 + mss,
48 + FormatOptions::default(),
49 + MetadataOptions::default(),
50 + )
51 + .map_err(|e| anyhow!("unsupported or unreadable audio file ({e})"))?;
52 + let track = format
53 + .default_track(TrackType::Audio)
54 + .ok_or_else(|| anyhow!("no audio track in {}", path.display()))?;
55 + let track_id = track.id;
56 + let params = track
57 + .codec_params
58 + .as_ref()
59 + .and_then(|p| p.audio())
60 + .ok_or_else(|| anyhow!("no decodable audio track in {}", path.display()))?;
61 + let mut decoder = symphonia::default::get_codecs()
62 + .make_audio_decoder(params, &AudioDecoderOptions::default())
63 + .map_err(|e| anyhow!("no decoder for this audio codec ({e})"))?;
64 +
65 + let mut info = StreamInfo {
66 + extension,
67 + codec: decoder.codec_info().long_name.to_string(),
68 + sample_rate: params.sample_rate.unwrap_or(0),
69 + channels: params.channels.as_ref().map_or(0, |c| c.count()),
70 + bits_per_sample: params.bits_per_sample.or(params.bits_per_coded_sample),
71 + frames: 0,
72 + };
73 + let mut buf: Vec<f32> = Vec::new();
74 + loop {
75 + let packet = match format.next_packet() {
76 + Ok(Some(p)) => p,
77 + Ok(None) => break,
78 + Err(Error::ResetRequired) => break,
79 + Err(e) => return Err(anyhow!("reading audio failed: {e}")),
80 + };
81 + if packet.track_id != track_id {
82 + continue;
83 + }
84 + match decoder.decode(&packet) {
85 + Ok(audio) => {
86 + let spec = audio.spec();
87 + info.sample_rate = spec.rate();
88 + info.channels = spec.channels().count();
89 + buf.resize(audio.samples_interleaved(), 0.0);
90 + audio.copy_to_slice_interleaved(&mut buf);
91 + info.frames += audio.frames() as u64;
92 + sink(&info, &buf);
93 + }
94 + // A corrupt packet is skipped; the rest of the file is still analysable.
95 + Err(Error::DecodeError(_)) => {}
96 + Err(e) => return Err(anyhow!("decoding audio failed: {e}")),
97 + }
98 + }
99 + if info.frames == 0 || info.sample_rate == 0 || info.channels == 0 {
100 + bail!("no audio could be decoded from {}", path.display());
101 + }
102 + Ok(info)
103 +}
src/audio/mod.rs new
+4
@@ -0,0 +1,4 @@
1 +//! Audio file decoding and analysis (loudness, tempo and key estimates).
2 +
3 +pub mod analysis;
4 +pub mod decode;
src/bin/mcp_stdio_stub.rs new
+181
@@ -0,0 +1,181 @@
1 +//! Test-only MCP server over stdio (newline-delimited JSON-RPC). Stands in for third-party
2 +//! servers and for `demucs --mcp`: it ignores its arguments.
3 +//!
4 +//! Tools: `echo` (read-only), `slow_progress` (three progress notifications),
5 +//! `wait_for_cancel` (answers only after `notifications/cancelled`), and the demucs-shaped
6 +//! `list_models` / `separate_stems`.
7 +
8 +use std::collections::HashSet;
9 +use std::sync::{Arc, Mutex};
10 +use std::time::Duration;
11 +
12 +use serde_json::{Value, json};
13 +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
14 +
15 +fn tools() -> Value {
16 + json!([
17 + {
18 + "name": "echo",
19 + "description": "Echo the text back.",
20 + "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]},
21 + "annotations": {"readOnlyHint": true}
22 + },
23 + {
24 + "name": "slow_progress",
25 + "description": "Report progress three times, then finish.",
26 + "inputSchema": {"type": "object", "properties": {}}
27 + },
28 + {
29 + "name": "wait_for_cancel",
30 + "description": "Blocks until the call is cancelled.",
31 + "inputSchema": {"type": "object", "properties": {}}
32 + },
33 + {
34 + "name": "list_models",
35 + "description": "List stem separation models.",
36 + "inputSchema": {"type": "object", "properties": {}},
37 + "annotations": {"readOnlyHint": true}
38 + },
39 + {
40 + "name": "separate_stems",
41 + "description": "Pretend to separate stems.",
42 + "inputSchema": {"type": "object", "properties": {"input": {"type": "string"}, "model": {"type": "string"}}, "required": ["input"]}
43 + }
44 + ])
45 +}
46 +
47 +#[tokio::main]
48 +async fn main() {
49 + // Lets tests find out whether this process is still alive after the agent closes it.
50 + if let Some(path) = std::env::var_os("STUB_PID_FILE") {
51 + let _ = std::fs::write(path, std::process::id().to_string());
52 + }
53 + let out = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout()));
54 + let cancelled: Arc<Mutex<HashSet<String>>> = Arc::default();
55 + let mut lines = BufReader::new(tokio::io::stdin()).lines();
56 +
57 + let send = {
58 + let out = out.clone();
59 + move |msg: Value| {
60 + let out = out.clone();
61 + async move {
62 + let mut o = out.lock().await;
63 + let _ = o.write_all(format!("{msg}\n").as_bytes()).await;
64 + let _ = o.flush().await;
65 + }
66 + }
67 + };
68 +
69 + while let Ok(Some(line)) = lines.next_line().await {
70 + let Ok(msg) = serde_json::from_str::<Value>(&line) else {
71 + continue;
72 + };
73 + let method = msg
74 + .get("method")
75 + .and_then(Value::as_str)
76 + .unwrap_or("")
77 + .to_string();
78 + let id = msg.get("id").cloned();
79 + let params = msg.get("params").cloned().unwrap_or(Value::Null);
80 + let Some(id) = id else {
81 + if method == "notifications/cancelled"
82 + && let Some(rid) = params.get("requestId")
83 + {
84 + cancelled.lock().unwrap().insert(rid.to_string());
85 + }
86 + continue;
87 + };
88 + match method.as_str() {
89 + "initialize" => {
90 + let version = params
91 + .get("protocolVersion")
92 + .cloned()
93 + .unwrap_or(json!("2025-06-18"));
94 + send(json!({"jsonrpc": "2.0", "id": id, "result": {
95 + "protocolVersion": version,
96 + "capabilities": {"tools": {}},
97 + "serverInfo": {"name": "stub", "version": "0.0.0"}
98 + }}))
99 + .await;
100 + }
101 + "ping" => send(json!({"jsonrpc": "2.0", "id": id, "result": {}})).await,
102 + "tools/list" => {
103 + send(json!({"jsonrpc": "2.0", "id": id, "result": {"tools": tools()}})).await
104 + }
105 + "tools/call" => {
106 + let send = send.clone();
107 + let cancelled = cancelled.clone();
108 + tokio::spawn(async move {
109 + let name = params
110 + .get("name")
111 + .and_then(Value::as_str)
112 + .unwrap_or("")
113 + .to_string();
114 + let args = params.get("arguments").cloned().unwrap_or(json!({}));
115 + let token = params.pointer("/_meta/progressToken").cloned();
116 + let progress = |n: u32, msg: &str| {
117 + token.as_ref().map(|t| {
118 + json!({"jsonrpc": "2.0", "method": "notifications/progress",
119 + "params": {"progressToken": t, "progress": n, "total": 100, "message": msg}})
120 + })
121 + };
122 + let result = match name.as_str() {
123 + "echo" => {
124 + let text = args.get("text").and_then(Value::as_str).unwrap_or("");
125 + json!({"content": [{"type": "text", "text": format!("echo: {text}")}], "isError": false})
126 + }
127 + "slow_progress" | "separate_stems" => {
128 + for (n, m) in [(10, "starting"), (50, "halfway"), (90, "almost")] {
129 + if let Some(p) = progress(n, m) {
130 + send(p).await;
131 + }
132 + tokio::time::sleep(Duration::from_millis(40)).await;
133 + }
134 + if name == "slow_progress" {
135 + json!({"content": [{"type": "text", "text": "done"}], "isError": false})
136 + } else {
137 + let input = args
138 + .get("input")
139 + .and_then(Value::as_str)
140 + .unwrap_or("/tmp/x.wav");
141 + let dir = format!("{}_stems", input.trim_end_matches(".wav"));
142 + let stems: Vec<Value> = ["drums", "bass", "vocals", "other"]
143 + .iter()
144 + .map(|s| json!({"id": s, "path": format!("{dir}/{s}.wav")}))
145 + .collect();
146 + json!({
147 + "content": [{"type": "text", "text": format!("Separated into {} stems", stems.len())}],
148 + "structuredContent": {"stems": stems},
149 + "isError": false
150 + })
151 + }
152 + }
153 + "list_models" => json!({
154 + "content": [{"type": "text", "text": "htdemucs 80 MB"}],
155 + "structuredContent": {"models": [
156 + {"id": "htdemucs", "size_mb": 80, "cached": false},
157 + {"id": "htdemucs_6s", "size_mb": 55, "cached": true}
158 + ]},
159 + "isError": false
160 + }),
161 + "wait_for_cancel" => {
162 + let key = id.to_string();
163 + while !cancelled.lock().unwrap().contains(&key) {
164 + tokio::time::sleep(Duration::from_millis(20)).await;
165 + }
166 + json!({"content": [{"type": "text", "text": "cancelled"}], "isError": true})
167 + }
168 + other => {
169 + send(json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32602, "message": format!("unknown tool {other}")}})).await;
170 + return;
171 + }
172 + };
173 + send(json!({"jsonrpc": "2.0", "id": id, "result": result})).await;
174 + });
175 + }
176 + other => {
177 + send(json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32601, "message": format!("method not found: {other}")}})).await;
178 + }
179 + }
180 + }
181 +}
src/llm.rs new
+507
@@ -0,0 +1,507 @@
1 +//! Minimal streaming client for the Onde Inference (Onde Cloud) OpenAI-compatible
2 +//! `/chat/completions` endpoint.
3 +
4 +use anyhow::{Context, Result, bail};
5 +use futures::StreamExt;
6 +use serde::Deserialize;
7 +use serde_json::{Value, json};
8 +
9 +const DEFAULT_BASE_URL: &str = "https://cloud.ondeinference.com/v1";
10 +const DEFAULT_MODEL: &str = "onde-kkk";
11 +
12 +#[derive(Debug, Clone, PartialEq, Eq)]
13 +pub struct LlmConfig {
14 + pub base_url: String,
15 + /// Onde credentials: `app-id:app-secret`, sent as the bearer token.
16 + pub api_key: Option<String>,
17 + pub model: String,
18 +}
19 +
20 +impl LlmConfig {
21 + /// `ONDE_API_KEY` from the environment, falling back to `config_dir()/env`.
22 + /// `SPLITFIRE_BASE_URL` and `SPLITFIRE_MODEL` override the endpoint and default model.
23 + /// Empty values are ignored: GUI launchers (e.g. Zed via launchd) often export
24 + /// variables set to "", which must not mask the config file.
25 + pub fn from_env() -> Self {
26 + let file_vars = load_config_env_vars();
27 + Self::from_lookup(|name| {
28 + std::env::var(name)
29 + .ok()
30 + .filter(|v| !v.is_empty())
31 + .or_else(|| file_vars.get(name).cloned())
32 + })
33 + }
34 +
35 + /// Build a config from an arbitrary variable lookup (tests, setup).
36 + pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Self {
37 + let env = |name: &str| lookup(name).filter(|v| !v.is_empty());
38 + Self {
39 + base_url: env("SPLITFIRE_BASE_URL")
40 + .unwrap_or_else(|| DEFAULT_BASE_URL.into())
41 + .trim_end_matches('/')
42 + .to_string(),
43 + api_key: env("ONDE_API_KEY"),
44 + model: env("SPLITFIRE_MODEL").unwrap_or_else(|| DEFAULT_MODEL.into()),
45 + }
46 + }
47 +}
48 +
49 +/// Directory for the stored `env` file:
50 +/// `~/.config/splitfire-agent` on Linux, `~/Library/Application Support/splitfire-agent` on
51 +/// macOS, `%APPDATA%\splitfire-agent` on Windows. `SPLITFIRE_DATA_DIR` overrides it (tests).
52 +/// `None` when the user has no config/home directory.
53 +pub fn config_dir() -> Option<std::path::PathBuf> {
54 + if let Some(dir) = std::env::var_os("SPLITFIRE_DATA_DIR").filter(|v| !v.is_empty()) {
55 + return Some(dir.into());
56 + }
57 + directories::BaseDirs::new().map(|dirs| {
58 + #[cfg(target_os = "linux")]
59 + let base = dirs.config_dir();
60 + #[cfg(not(target_os = "linux"))]
61 + let base = dirs.data_dir();
62 + base.join("splitfire-agent")
63 + })
64 +}
65 +
66 +/// The stored `env` file `--setup` writes and `logout` removes.
67 +pub fn config_file() -> Option<std::path::PathBuf> {
68 + config_dir().map(|d| d.join("env"))
69 +}
70 +
71 +/// Where durable state (sessions, audio scratch) lives. Same directory as the `env` file.
72 +pub fn data_dir() -> std::path::PathBuf {
73 + config_dir().unwrap_or_else(|| std::env::temp_dir().join("splitfire-agent"))
74 +}
75 +
76 +fn load_config_env_vars() -> std::collections::HashMap<String, String> {
77 + let mut vars = std::collections::HashMap::new();
78 + let content = config_file().and_then(|p| std::fs::read_to_string(p).ok());
79 + let Some(content) = content else {
80 + return vars;
81 + };
82 + for line in content.lines() {
83 + let line = line.trim();
84 + if line.is_empty() || line.starts_with('#') {
85 + continue;
86 + }
87 + let line = line.strip_prefix("export ").unwrap_or(line).trim();
88 + if let Some((k, v)) = line.split_once('=') {
89 + let mut v = v.trim();
90 + if v.len() >= 2
91 + && ((v.starts_with('"') && v.ends_with('"'))
92 + || (v.starts_with('\'') && v.ends_with('\'')))
93 + {
94 + v = &v[1..v.len() - 1];
95 + }
96 + vars.insert(k.trim().to_string(), v.to_string());
97 + }
98 + }
99 + vars
100 +}
101 +
102 +#[cfg(test)]
103 +mod tests {
104 + use super::*;
105 +
106 + fn config(vars: &[(&str, &str)]) -> LlmConfig {
107 + LlmConfig::from_lookup(|name| {
108 + vars.iter()
109 + .find(|(k, _)| *k == name)
110 + .map(|(_, v)| v.to_string())
111 + })
112 + }
113 +
114 + #[test]
115 + fn defaults_point_at_onde_cloud() {
116 + let c = config(&[("ONDE_API_KEY", "app:secret")]);
117 + assert_eq!(c.base_url, "https://cloud.ondeinference.com/v1");
118 + assert_eq!(c.api_key.as_deref(), Some("app:secret"));
119 + assert_eq!(c.model, "onde-kkk");
120 + assert_eq!(config(&[]).api_key, None);
121 + assert_eq!(config(&[("ONDE_API_KEY", "")]).api_key, None);
122 + }
123 +
124 + #[test]
125 + fn base_url_and_model_overrides_apply() {
126 + let c = config(&[
127 + ("ONDE_API_KEY", "app:secret"),
128 + ("SPLITFIRE_MODEL", "onde-prism"),
129 + ("SPLITFIRE_BASE_URL", "http://x/v1/"),
130 + ]);
131 + assert_eq!(
132 + (c.base_url.as_str(), c.model.as_str()),
133 + ("http://x/v1", "onde-prism")
134 + );
135 + }
136 +
137 + /// One test touches the process environment, so it can't race another.
138 + #[test]
139 + fn key_from_env_then_file_ignoring_empty_env() {
140 + let dir = std::env::temp_dir().join(format!("sf-llm-{}", uuid::Uuid::new_v4()));
141 + std::fs::create_dir_all(&dir).unwrap();
142 + std::fs::write(
143 + dir.join("env"),
144 + "# comment\nexport ONDE_API_KEY=\"file:key\"\nSPLITFIRE_MODEL=m1\n",
145 + )
146 + .unwrap();
147 + // SAFETY: only this test mutates these variables.
148 + unsafe {
149 + std::env::set_var("SPLITFIRE_DATA_DIR", &dir);
150 + std::env::set_var("ONDE_API_KEY", "");
151 + }
152 + let c = LlmConfig::from_env();
153 + assert_eq!(c.api_key.as_deref(), Some("file:key"));
154 + assert_eq!(c.model, "m1");
155 + unsafe { std::env::set_var("ONDE_API_KEY", "env:key") };
156 + assert_eq!(LlmConfig::from_env().api_key.as_deref(), Some("env:key"));
157 + unsafe {
158 + std::env::remove_var("ONDE_API_KEY");
159 + std::env::remove_var("SPLITFIRE_DATA_DIR");
160 + }
161 + let _ = std::fs::remove_dir_all(dir);
162 + }
163 +
164 + #[test]
165 + fn usage_tolerates_missing_total() {
166 + let chunk: Chunk = serde_json::from_str(
167 + r#"{"choices":[],"usage":{"prompt_tokens":7,"completion_tokens":3}}"#,
168 + )
169 + .unwrap();
170 + assert_eq!(chunk.usage.unwrap().used(), 10);
171 + let full = Usage {
172 + prompt_tokens: 1,
173 + completion_tokens: 1,
174 + total_tokens: 5,
175 + };
176 + assert_eq!(full.used(), 5);
177 + }
178 +
179 + #[test]
180 + fn parses_models_response() {
181 + let body = r#"{
182 + "object": "list",
183 + "data": [
184 + {"id": "onde-balanced", "object": "model", "created": 1700000000, "owned_by": "onde"},
185 + {"id": "custom-model"}
186 + ]
187 + }"#;
188 + let parsed: ModelsResponse = serde_json::from_str(body).unwrap();
189 + assert_eq!(parsed.data.len(), 2);
190 + assert_eq!(parsed.data[0].owned_by.as_deref(), Some("onde"));
191 + assert_eq!(parsed.data[1].owned_by, None);
192 + }
193 +}
194 +
195 +#[derive(Debug, Clone, Default)]
196 +pub struct ToolCallRequest {
197 + pub id: String,
198 + pub name: String,
199 + pub arguments: String,
200 +}
201 +
202 +#[derive(Debug, Default)]
203 +pub struct Completion {
204 + pub content: String,
205 + pub tool_calls: Vec<ToolCallRequest>,
206 + pub finish_reason: Option<String>,
207 + /// Token usage, when the endpoint reports it (`stream_options.include_usage`).
208 + pub usage: Option<Usage>,
209 +}
210 +
211 +#[derive(Debug, Clone, Copy, Default, Deserialize)]
212 +pub struct Usage {
213 + #[serde(default)]
214 + pub prompt_tokens: u64,
215 + #[serde(default)]
216 + pub completion_tokens: u64,
217 + #[serde(default)]
218 + pub total_tokens: u64,
219 +}
220 +
221 +impl Usage {
222 + /// Tokens in the context after this completion. Some servers leave `total_tokens` at 0.
223 + pub fn used(&self) -> u64 {
224 + self.total_tokens
225 + .max(self.prompt_tokens + self.completion_tokens)
226 + }
227 +}
228 +
229 +impl Completion {
230 + /// The assistant message to append to the conversation history.
231 + pub fn to_message(&self) -> Value {
232 + let mut msg = json!({ "role": "assistant", "content": self.content });
233 + if !self.tool_calls.is_empty() {
234 + msg["tool_calls"] = self
235 + .tool_calls
236 + .iter()
237 + .map(|tc| {
238 + json!({
239 + "id": tc.id,
240 + "type": "function",
241 + "function": { "name": tc.name, "arguments": tc.arguments },
242 + })
243 + })
244 + .collect();
245 + }
246 + msg
247 + }
248 +}
249 +
250 +/// Streamed pieces surfaced to the caller as they arrive.
251 +pub enum Delta<'a> {
252 + Text(&'a str),
253 + Reasoning(&'a str),
254 +}
255 +
256 +#[derive(Deserialize)]
257 +struct Chunk {
258 + #[serde(default)]
259 + choices: Vec<Choice>,
260 + usage: Option<Usage>,
261 +}
262 +
263 +#[derive(Deserialize)]
264 +struct Choice {
265 + #[serde(default)]
266 + delta: ChunkDelta,
267 + finish_reason: Option<String>,
268 +}
269 +
270 +#[derive(Deserialize, Default)]
271 +struct ChunkDelta {
272 + content: Option<String>,
273 + // Non-standard but common (DeepSeek, vLLM, OpenRouter, llama.cpp).
274 + reasoning_content: Option<String>,
275 + reasoning: Option<String>,
276 + #[serde(default)]
277 + tool_calls: Vec<ToolCallDelta>,
278 +}
279 +
280 +#[derive(Deserialize)]
281 +struct ToolCallDelta {
282 + #[serde(default)]
283 + index: usize,
284 + id: Option<String>,
285 + function: Option<FunctionDelta>,
286 +}
287 +
288 +#[derive(Deserialize)]
289 +struct FunctionDelta {
290 + name: Option<String>,
291 + arguments: Option<String>,
292 +}
293 +
294 +/// A model from `GET /v1/models`. Extra fields in the response are ignored.
295 +#[derive(Debug, Clone, Deserialize)]
296 +pub struct ModelInfo {
297 + pub id: String,
298 + #[serde(default)]
299 + pub owned_by: Option<String>,
300 +}
301 +
302 +#[derive(Deserialize)]
303 +struct ModelsResponse {
304 + data: Vec<ModelInfo>,
305 +}
306 +
307 +#[derive(Debug, Clone)]
308 +pub struct LlmClient {
309 + http: reqwest::Client,
310 + config: LlmConfig,
311 +}
312 +
313 +impl LlmClient {
314 + pub fn new(config: LlmConfig) -> Self {
315 + Self {
316 + http: reqwest::Client::new(),
317 + config,
318 + }
319 + }
320 +
321 + pub fn model(&self) -> &str {
322 + &self.config.model
323 + }
324 +
325 + pub fn config(&self) -> &LlmConfig {
326 + &self.config
327 + }
328 +
329 + /// Whether an API key is configured.
330 + pub fn has_api_key(&self) -> bool {
331 + self.config.api_key.is_some()
332 + }
333 +
334 + /// Verify the configured key with a minimal chat completion.
335 + /// Returns `Err` when no key is set, the key is rejected, or the endpoint is unreachable.
336 + pub async fn check_auth(&self) -> Result<()> {
337 + let Some(key) = &self.config.api_key else {
338 + bail!("no ONDE_API_KEY configured");
339 + };
340 + let body = json!({
341 + "model": self.config.model,
342 + "messages": [{ "role": "user", "content": "ping" }],
343 + "max_tokens": 1,
344 + "stream": false,
345 + });
346 + let resp = self
347 + .http
348 + .post(format!("{}/chat/completions", self.config.base_url))
349 + .json(&body)
350 + .bearer_auth(key)
351 + .send()
352 + .await
353 + .context("reaching Onde Cloud")?;
354 + let status = resp.status();
355 + if matches!(status.as_u16(), 401 | 403) {
356 + bail!("Onde Cloud rejected the API key ({status})");
357 + }
358 + if !status.is_success() {
359 + let text = resp.text().await.unwrap_or_default();
360 + bail!("Onde Cloud returned {status}: {text}");
361 + }
362 + Ok(())
363 + }
364 +
365 + /// List the models the configured endpoint serves (`GET {base_url}/models`).
366 + pub async fn models(&self) -> Result<Vec<ModelInfo>> {
367 + let url = format!("{}/models", self.config.base_url);
368 + let mut req = self.http.get(&url);
369 + if let Some(key) = &self.config.api_key {
370 + req = req.bearer_auth(key);
371 + }
372 + let mut resp = req.send().await.context("sending models request")?;
373 + // Some endpoints (Onde Cloud) list models publicly but reject an invalid key, so a
374 + // bad key shouldn't hide the catalog: retry without auth.
375 + if self.config.api_key.is_some() && matches!(resp.status().as_u16(), 401 | 403) {
376 + resp = self
377 + .http
378 + .get(&url)
379 + .send()
380 + .await
381 + .context("sending models request")?;
382 + }
383 + if !resp.status().is_success() {
384 + let status = resp.status();
385 + let text = resp.text().await.unwrap_or_default();
386 + bail!("models endpoint returned {status}: {text}");
387 + }
388 + let mut models = resp
389 + .json::<ModelsResponse>()
390 + .await
391 + .context("parsing models response")?
392 + .data;
393 + models.sort_by(|a, b| a.id.cmp(&b.id));
394 + Ok(models)
395 + }
396 +
397 + /// Run one streaming chat completion, invoking `on_delta` for every text fragment.
398 + pub async fn complete(
399 + &self,
400 + model: &str,
401 + messages: &[Value],
402 + tools: &Value,
403 + mut on_delta: impl FnMut(Delta<'_>),
404 + ) -> Result<Completion> {
405 + if self.config.api_key.is_none() {
406 + bail!(
407 + "No API key configured. Set ONDE_API_KEY in your environment or run `splitfire-agent --setup`"
408 + );
409 + }
410 + let mut body = json!({
411 + "model": model,
412 + "messages": messages,
413 + "tools": tools,
414 + "stream": true,
415 + "stream_options": { "include_usage": true },
416 + });
417 + let send = |body: &Value| {
418 + let mut req = self
419 + .http
420 + .post(format!("{}/chat/completions", self.config.base_url))
421 + .json(body);
422 + if let Some(key) = &self.config.api_key {
423 + req = req.bearer_auth(key);
424 + }
425 + req.send()
426 + };
427 + let mut resp = send(&body)
428 + .await
429 + .context("sending chat completion request")?;
430 + // Not every OpenAI-compatible server knows `stream_options`; retry without it.
431 + if matches!(resp.status().as_u16(), 400 | 422) {
432 + body.as_object_mut().unwrap().remove("stream_options");
433 + resp = send(&body)
434 + .await
435 + .context("sending chat completion request")?;
436 + }
437 + if !resp.status().is_success() {
438 + let status = resp.status();
439 + let text = resp.text().await.unwrap_or_default();
440 + bail!("LLM endpoint returned {status}: {text}");
441 + }
442 +
443 + let mut out = Completion::default();
444 + let mut stream = resp.bytes_stream();
445 + let mut buf: Vec<u8> = Vec::new();
446 + while let Some(bytes) = stream.next().await {
447 + buf.extend_from_slice(&bytes.context("reading response stream")?);
448 + while let Some(pos) = buf.iter().position(|&b| b == b'\n') {
449 + let line: Vec<u8> = buf.drain(..=pos).collect();
450 + let line = String::from_utf8_lossy(&line);
451 + let Some(data) = line.trim().strip_prefix("data:") else {
452 + continue;
453 + };
454 + let data = data.trim();
455 + if data == "[DONE]" {
456 + return Ok(out);
457 + }
458 + let chunk: Chunk = match serde_json::from_str(data) {
459 + Ok(c) => c,
460 + Err(e) => {
461 + tracing::warn!("skipping unparseable chunk ({e}): {data}");
462 + continue;
463 + }
464 + };
465 + if chunk.usage.is_some() {
466 + out.usage = chunk.usage;
467 + }
468 + for choice in chunk.choices {
469 + let d = choice.delta;
470 + if let Some(r) = d.reasoning_content.as_deref().or(d.reasoning.as_deref())
471 + && !r.is_empty()
472 + {
473 + on_delta(Delta::Reasoning(r));
474 + }
475 + if let Some(t) = d.content.as_deref()
476 + && !t.is_empty()
477 + {
478 + out.content.push_str(t);
479 + on_delta(Delta::Text(t));
480 + }
481 + for tc in d.tool_calls {
482 + if out.tool_calls.len() <= tc.index {
483 + out.tool_calls
484 + .resize(tc.index + 1, ToolCallRequest::default());
485 + }
486 + let slot = &mut out.tool_calls[tc.index];
487 + if let Some(id) = tc.id {
488 + slot.id = id;
489 + }
490 + if let Some(f) = tc.function {
491 + if let Some(n) = f.name {
492 + slot.name.push_str(&n);
493 + }
494 + if let Some(a) = f.arguments {
495 + slot.arguments.push_str(&a);
496 + }
497 + }
498 + }
499 + if choice.finish_reason.is_some() {
500 + out.finish_reason = choice.finish_reason;
501 + }
502 + }
503 + }
504 + }
505 + Ok(out)
506 + }
507 +}
src/main.rs new
+484
@@ -0,0 +1,484 @@
1 +//! splitfire-agent: a music-domain ACP agent (theory, production, industry, culture, live shows)
2 +//! backed by Onde Inference.
3 +//!
4 +//! Run from a terminal it opens a TUI; launched by an editor (stdin not a TTY) or with `--acp`
5 +//! it speaks ACP over stdio. Set ONDE_API_KEY (`app-id:app-secret`) or run `--setup`.
6 +//! SPLITFIRE_YOLO=1 skips permission prompts. Logs go to stderr (RUST_LOG).
7 +
8 +mod agent;
9 +mod audio;
10 +mod llm;
11 +mod mcp;
12 +mod prompt;
13 +mod session_store;
14 +mod theory;
15 +mod tools;
16 +mod tui;
17 +
18 +use std::io::IsTerminal;
19 +use std::path::PathBuf;
20 +
21 +use agent_client_protocol::schema::v1::{
22 + AgentAuthCapabilities, AgentCapabilities, AuthenticateRequest, CancelNotification,
23 + CloseSessionRequest, DeleteSessionRequest, Implementation, InitializeRequest,
24 + InitializeResponse, ListSessionsRequest, LoadSessionRequest, LogoutCapabilities, LogoutRequest,
25 + NewSessionRequest, PromptCapabilities, PromptRequest, ResumeSessionRequest,
26 + SessionAdditionalDirectoriesCapabilities, SessionCapabilities, SessionCloseCapabilities,
27 + SessionDeleteCapabilities, SessionListCapabilities, SessionResumeCapabilities,
28 + SetSessionConfigOptionRequest,
29 +};
30 +use agent_client_protocol::{Agent, Stdio};
31 +use anyhow::Context;
32 +
33 +use agent::SplitFireAgent;
34 +use llm::{LlmClient, LlmConfig};
35 +
36 +const USAGE: &str = "\
37 +Usage: splitfire-agent [--acp] [--yolo] [--list-models] [--root <path>...] [--setup] [--version]
38 +
39 + (no args) interactive terminal UI (when run from a terminal)
40 + --acp speak ACP over stdio for an editor (default when stdin is not a terminal)
41 + --yolo approve file edits and commands without asking
42 + --list-models list the models the Onde endpoint serves, then exit
43 + --root <path> add an extra workspace root (repeatable; TUI mode only)
44 + --setup interactive first-run setup: store your Onde API key (app-id:app-secret)
45 + --version print the version
46 +
47 +Environment: ONDE_API_KEY (required unless stored by --setup), SPLITFIRE_BASE_URL,
48 +SPLITFIRE_MODEL, SPLITFIRE_MODELS, SPLITFIRE_YOLO, SPLITFIRE_DATA_DIR, SPLITFIRE_CONTEXT_WINDOW,
49 +SPLITFIRE_DEMUCS_BIN, SPLITFIRE_MCP_DEMUCS=off, SPLITFIRE_STEMS_DIR
50 +";
51 +
52 +#[tokio::main]
53 +async fn main() -> anyhow::Result<()> {
54 + let args: Vec<String> = std::env::args().skip(1).collect();
55 + let has = |flag: &str| args.iter().any(|a| a == flag);
56 + if has("-h") || has("--help") {
57 + print!("{USAGE}");
58 + return Ok(());
59 + }
60 + if has("-V") || has("--version") {
61 + println!("splitfire-agent {}", env!("CARGO_PKG_VERSION"));
62 + return Ok(());
63 + }
64 + // Collect `--root <path>` pairs; validate everything else is a known flag.
65 + let mut roots: Vec<PathBuf> = Vec::new();
66 + let mut i = 0;
67 + while i < args.len() {
68 + match args[i].as_str() {
69 + "--root" => {
70 + i += 1;
71 + let Some(path) = args.get(i) else {
72 + anyhow::bail!("--root requires a path\n\n{USAGE}");
73 + };
74 + let path = PathBuf::from(path);
75 + let path = if path.is_absolute() {
76 + path
77 + } else {
78 + std::env::current_dir()?.join(path)
79 + };
80 + if !path.is_dir() {
81 + anyhow::bail!("--root {} is not a directory", path.display());
82 + }
83 + roots.push(path);
84 + }
85 + "--acp" | "--yolo" | "--list-models" | "--setup" => {}
86 + bad => anyhow::bail!("unknown argument {bad}\n\n{USAGE}"),
87 + }
88 + i += 1;
89 + }
90 + let yolo = has("--yolo") || env_flag("SPLITFIRE_YOLO");
91 + if has("--setup") {
92 + setup().await?;
93 + } else if has("--list-models") {
94 + list_models().await?;
95 + } else if has("--acp") || !std::io::stdin().is_terminal() {
96 + if !roots.is_empty() {
97 + anyhow::bail!("--root is only supported in the interactive TUI\n\n{USAGE}");
98 + }
99 + run_agent(yolo).await?;
100 + } else {
101 + if !LlmConfig::from_env().api_key.is_some() {
102 + anyhow::bail!(
103 + "No Onde API key configured. Set ONDE_API_KEY or run `splitfire-agent --setup`."
104 + );
105 + }
106 + tui::run(yolo, roots).await?;
107 + }
108 + Ok(())
109 +}
110 +
111 +fn env_flag(name: &str) -> bool {
112 + std::env::var(name).is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
113 +}
114 +
115 +/// Onde credentials are `app-id:app-secret`: exactly one colon, both halves non-empty.
116 +fn valid_onde_key(key: &str) -> bool {
117 + let mut parts = key.split(':');
118 + matches!(
119 + (parts.next(), parts.next(), parts.next()),
120 + (Some(id), Some(secret), None) if !id.is_empty() && !secret.is_empty()
121 + )
122 +}
123 +
124 +/// `env` file content with `ONDE_API_KEY` set, keeping any other lines.
125 +fn with_api_key(existing: &str, key: &str) -> String {
126 + let mut lines: Vec<String> = existing
127 + .lines()
128 + .filter(|l| {
129 + let l = l.trim().strip_prefix("export ").unwrap_or(l.trim());
130 + !l.starts_with("ONDE_API_KEY=")
131 + })
132 + .map(String::from)
133 + .collect();
134 + lines.push(format!("ONDE_API_KEY={key}"));
135 + lines.join("\n") + "\n"
136 +}
137 +
138 +/// Interactive first-run setup (`--setup`): prompt for the Onde key, verify it against Onde
139 +/// Cloud, and store it in the platform config dir (`config_dir()/env`, mode 0600).
140 +async fn setup() -> anyhow::Result<()> {
141 + use std::io::Write;
142 +
143 + if !std::io::stdin().is_terminal() {
144 + anyhow::bail!("--setup needs an interactive terminal");
145 + }
146 + println!("splitfire-agent setup\n");
147 + println!("Get credentials: sign in at https://ondeinference.com/root/login,");
148 + println!("register an app and assign a model. Your key is \"app-id:app-secret\".\n");
149 + let key = loop {
150 + print!("Paste your ONDE_API_KEY: ");
151 + std::io::stdout().flush()?;
152 + let mut line = String::new();
153 + if std::io::stdin().read_line(&mut line)? == 0 {
154 + anyhow::bail!("no input");
155 + }
156 + let key = line.trim().to_string();
157 + if valid_onde_key(&key) {
158 + break key;
159 + }
160 + println!("Onde credentials look like \"app-id:app-secret\" (exactly one colon).");
161 + };
162 +
163 + // Verify before writing anything.
164 + let mut config = LlmConfig::from_env();
165 + config.api_key = Some(key.clone());
166 + print!("Verifying the key with Onde Cloud… ");
167 + std::io::stdout().flush()?;
168 + match LlmClient::new(config).check_auth().await {
169 + Ok(()) => println!("OK"),
170 + Err(e) => anyhow::bail!("\nKey check failed: {e:#}\nNothing was written."),
171 + }
172 +
173 + let dir = llm::config_dir().context("no config directory available")?;
174 + std::fs::create_dir_all(&dir)?;
175 + let path = dir.join("env");
176 + let existing = std::fs::read_to_string(&path).unwrap_or_default();
177 + std::fs::write(&path, with_api_key(&existing, &key))?;
178 + #[cfg(unix)]
179 + {
180 + use std::os::unix::fs::PermissionsExt;
181 + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
182 + }
183 + println!("\nWrote {}. You're all set.", path.display());
184 + Ok(())
185 +}
186 +
187 +async fn list_models() -> anyhow::Result<()> {
188 + let client = LlmClient::new(LlmConfig::from_env());
189 + for m in client.models().await? {
190 + match m.owned_by {
191 + Some(owner) => println!("{}\t{}", m.id, owner),
192 + None => println!("{}", m.id),
193 + }
194 + }
195 + Ok(())
196 +}
197 +
198 +/// How the agent was launched: `tui` (interactive terminal UI) or `acp` (editor).
199 +/// The TUI sets `SPLITFIRE_SURFACE=tui` on its subprocess; editors launch `--acp`
200 +/// directly so the default is `acp`.
201 +fn surface() -> &'static str {
202 + match std::env::var("SPLITFIRE_SURFACE") {
203 + Ok(s) if s == "tui" => "tui",
204 + _ => "acp",
205 + }
206 +}
207 +
208 +async fn run_agent(yolo: bool) -> agent_client_protocol::Result<()> {
209 + tracing_subscriber::fmt()
210 + .with_writer(std::io::stderr)
211 + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
212 + .init();
213 +
214 + let agent = SplitFireAgent::new(LlmConfig::from_env(), yolo, surface(), llm::data_dir());
215 + tracing::info!("splitfire-agent starting with model {}", agent.model());
216 +
217 + let result = Agent
218 + .builder()
219 + .name("splitfire-agent")
220 + .on_receive_request(
221 + {
222 + let agent = agent.clone();
223 + async move |req: InitializeRequest, responder, _cx| {
224 + agent.set_client_caps(req.client_capabilities.clone());
225 + // Terminal auth is only usable by clients that can run it.
226 + let methods = if req.client_capabilities.auth.terminal {
227 + agent::auth_methods()
228 + } else {
229 + Vec::new()
230 + };
231 + responder.respond(
232 + InitializeResponse::new(agent::negotiate_version(req.protocol_version))
233 + .auth_methods(methods)
234 + .agent_capabilities(
235 + AgentCapabilities::new()
236 + .prompt_capabilities(
237 + PromptCapabilities::new()
238 + .embedded_context(true)
239 + .image(true)
240 + .audio(true),
241 + )
242 + .session_capabilities(
243 + SessionCapabilities::new()
244 + .list(SessionListCapabilities::new())
245 + .additional_directories(
246 + SessionAdditionalDirectoriesCapabilities::new(),
247 + )
248 + .resume(SessionResumeCapabilities::new())
249 + .close(SessionCloseCapabilities::new())
250 + .delete(SessionDeleteCapabilities::new()),
251 + )
252 + .auth(
253 + AgentAuthCapabilities::new()
254 + .logout(LogoutCapabilities::new()),
255 + )
256 + .load_session(true),
257 + )
258 + .agent_info(Implementation::new(
259 + "splitfire-agent",
260 + env!("CARGO_PKG_VERSION"),
261 + )),
262 + )
263 + }
264 + },
265 + agent_client_protocol::on_receive_request!(),
266 + )
267 + .on_receive_request(
268 + {
269 + let agent = agent.clone();
270 + async move |req: AuthenticateRequest, responder, cx| {
271 + let agent = agent.clone();
272 + cx.spawn(async move {
273 + match agent.authenticate(req).await {
274 + Ok(resp) => responder.respond(resp),
275 + Err(e) => responder.respond_with_error(e),
276 + }
277 + })
278 + }
279 + },
280 + agent_client_protocol::on_receive_request!(),
281 + )
282 + .on_receive_request(
283 + {
284 + let agent = agent.clone();
285 + async move |_req: LogoutRequest, responder, cx| {
286 + let agent = agent.clone();
287 + cx.spawn(async move {
288 + match agent.logout().await {
289 + Ok(resp) => responder.respond(resp),
290 + Err(e) => responder.respond_with_error(e),
291 + }
292 + })
293 + }
294 + },
295 + agent_client_protocol::on_receive_request!(),
296 + )
297 + .on_receive_request(
298 + {
299 + let agent = agent.clone();
300 + async move |req: NewSessionRequest, responder, cx| {
301 + // Listing models and starting MCP servers are slow; keep them off the
302 + // dispatch loop.
303 + let agent = agent.clone();
304 + let connection = cx.clone();
305 + cx.spawn(async move {
306 + // Picks up a key stored by `--setup` since this process started.
307 + if !agent.refresh_credentials().await {
308 + return responder.respond_with_error(agent::auth_required_error());
309 + }
310 + match agent.new_session(req).await {
311 + Ok(resp) => {
312 + let id = resp.session_id.clone();
313 + responder.respond(resp)?;
314 + agent.send_commands(&connection, &id)
315 + }
316 + Err(e) => responder.respond_with_error(e),
317 + }
318 + })
319 + }
320 + },
321 + agent_client_protocol::on_receive_request!(),
322 + )
323 + .on_receive_request(
324 + {
325 + let agent = agent.clone();
326 + async move |req: ListSessionsRequest, responder, cx| {
327 + // Reads the session directory from disk.
328 + let agent = agent.clone();
329 + cx.spawn(async move {
330 + match tokio::task::spawn_blocking(move || agent.list_sessions(req)).await {
331 + Ok(Ok(resp)) => responder.respond(resp),
332 + Ok(Err(e)) => responder.respond_with_error(e),
333 + Err(e) => responder.respond_with_internal_error(e.to_string()),
334 + }
335 + })
336 + }
337 + },
338 + agent_client_protocol::on_receive_request!(),
339 + )
340 + .on_receive_request(
341 + {
342 + let agent = agent.clone();
343 + async move |req: LoadSessionRequest, responder, cx| {
344 + let agent = agent.clone();
345 + let connection = cx.clone();
346 + cx.spawn(async move {
347 + // Editors reopen a thread with session/load after terminal auth, so
348 + // AUTH_REQUIRED here shows sign-in instead of a dead thread.
349 + if !agent.refresh_credentials().await {
350 + return responder.respond_with_error(agent::auth_required_error());
351 + }
352 + let id = req.session_id.clone();
353 + match agent.load_session(req, &connection).await {
354 + Ok(resp) => {
355 + responder.respond(resp)?;
356 + agent.send_commands(&connection, &id)
357 + }
358 + Err(e) => responder.respond_with_error(e),
359 + }
360 + })
361 + }
362 + },
363 + agent_client_protocol::on_receive_request!(),
364 + )
365 + .on_receive_request(
366 + {
367 + let agent = agent.clone();
368 + async move |req: ResumeSessionRequest, responder, cx| {
369 + let agent = agent.clone();
370 + let connection = cx.clone();
371 + cx.spawn(async move {
372 + if !agent.refresh_credentials().await {
373 + return responder.respond_with_error(agent::auth_required_error());
374 + }
375 + let id = req.session_id.clone();
376 + match agent.resume_session(req, &connection).await {
377 + Ok(resp) => {
378 + responder.respond(resp)?;
379 + agent.send_commands(&connection, &id)
380 + }
381 + Err(e) => responder.respond_with_error(e),
382 + }
383 + })
384 + }
385 + },
386 + agent_client_protocol::on_receive_request!(),
387 + )
388 + .on_receive_request(
389 + {
390 + let agent = agent.clone();
391 + async move |req: CloseSessionRequest, responder, _cx| match agent.close_session(req)
392 + {
393 + Ok(resp) => responder.respond(resp),
394 + Err(e) => responder.respond_with_error(e),
395 + }
396 + },
397 + agent_client_protocol::on_receive_request!(),
398 + )
399 + .on_receive_request(
400 + {
401 + let agent = agent.clone();
402 + async move |req: DeleteSessionRequest, responder, _cx| match agent
403 + .delete_session(req)
404 + {
405 + Ok(resp) => responder.respond(resp),
406 + Err(e) => responder.respond_with_error(e),
407 + }
408 + },
409 + agent_client_protocol::on_receive_request!(),
410 + )
411 + .on_receive_request(
412 + {
413 + let agent = agent.clone();
414 + async move |req: SetSessionConfigOptionRequest, responder, cx| {
415 + let agent = agent.clone();
416 + let connection = cx.clone();
417 + cx.spawn(async move {
418 + match agent.set_config_option(req, &connection).await {
419 + Ok(resp) => responder.respond(resp),
420 + Err(e) => responder.respond_with_error(e),
421 + }
422 + })
423 + }
424 + },
425 + agent_client_protocol::on_receive_request!(),
426 + )
427 + .on_receive_request(
428 + {
429 + let agent = agent.clone();
430 + async move |req: PromptRequest, responder, cx| {
431 + // Run the turn off the dispatch loop so it can make requests to the client.
432 + let agent = agent.clone();
433 + let connection = cx.clone();
434 + cx.spawn(async move {
435 + if !agent.has_api_key() && !agent.refresh_credentials().await {
436 + return responder.respond_with_error(agent::auth_required_error());
437 + }
438 + agent.prompt(req, responder, connection).await
439 + })
440 + }
441 + },
442 + agent_client_protocol::on_receive_request!(),
443 + )
444 + .on_receive_notification(
445 + {
446 + let agent = agent.clone();
447 + async move |n: CancelNotification, _cx| {
448 + agent.cancel(&n.session_id);
449 + Ok(())
450 + }
451 + },
452 + agent_client_protocol::on_receive_notification!(),
453 + )
454 + .connect_to(Stdio::new())
455 + .await;
456 + // The editor went away: stop MCP servers instead of leaving them to be orphaned.
457 + agent.shutdown().await;
458 + result
459 +}
460 +
461 +#[cfg(test)]
462 +mod tests {
463 + use super::*;
464 +
465 + #[test]
466 + fn onde_key_shape() {
467 + assert!(valid_onde_key("app:secret"));
468 + assert!(!valid_onde_key("secret"));
469 + assert!(!valid_onde_key("a:b:c"));
470 + assert!(!valid_onde_key(":secret"));
471 + assert!(!valid_onde_key("app:"));
472 + assert!(!valid_onde_key(""));
473 + }
474 +
475 + #[test]
476 + fn env_file_keeps_other_lines_and_replaces_key() {
477 + let out = with_api_key(
478 + "# hi\nSPLITFIRE_MODEL=m\nexport ONDE_API_KEY=old:old\n",
479 + "new:key",
480 + );
481 + assert_eq!(out, "# hi\nSPLITFIRE_MODEL=m\nONDE_API_KEY=new:key\n");
482 + assert_eq!(with_api_key("", "a:b"), "ONDE_API_KEY=a:b\n");
483 + }
484 +}
src/mcp.rs new
+553
@@ -0,0 +1,553 @@
1 +//! MCP client over stdio (rmcp): servers supplied by the ACP client in `session/new|load|resume`,
2 +//! plus the optional built-in `demucs --mcp` stem-separation server.
3 +//!
4 +//! Tools are exposed to the model as `mcp__<server>__<tool>`. A failing server is never fatal:
5 +//! it is logged and its tools are simply absent.
6 +
7 +use std::collections::HashMap;
8 +use std::path::{Path, PathBuf};
9 +use std::sync::Arc;
10 +use std::time::Duration;
11 +
12 +use agent_client_protocol::schema::v1::{McpServer, ToolCallContent, ToolCallUpdateFields};
13 +use anyhow::{Context, Result, anyhow, bail};
14 +use futures::StreamExt;
15 +use rmcp::handler::client::progress::ProgressDispatcher;
16 +use rmcp::model::{
17 + CallToolRequestParams, ClientCapabilities, ClientRequest, ContentBlock, Implementation,
18 + ProgressNotificationParam, Request, ServerResult, Tool,
19 +};
20 +use rmcp::service::{NotificationContext, PeerRequestOptions, RunningService};
21 +use rmcp::transport::TokioChildProcess;
22 +use rmcp::{ClientHandler, RoleClient, ServiceExt};
23 +use serde_json::{Map, Value, json};
24 +use tokio::sync::Mutex;
25 +
26 +use crate::tools::{ToolCtx, ToolOutcome, absolutize, function_def, truncate};
27 +
28 +const PREFIX: &str = "mcp__";
29 +/// Server name of the built-in stem separator; a client-supplied server of this name wins.
30 +pub const DEMUCS: &str = "demucs";
31 +const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
32 +/// Upper bound for one tool call: separation of a long track can take many minutes.
33 +const CALL_TIMEOUT: Duration = Duration::from_secs(60 * 60);
34 +const MAX_TOOL_NAME: usize = 64;
35 +
36 +/// Split `mcp__<server>__<tool>` into its parts.
37 +pub fn split_qualified(name: &str) -> Option<(&str, &str)> {
38 + let rest = name.strip_prefix(PREFIX)?;
39 + let (server, tool) = rest.split_once("__")?;
40 + (!server.is_empty() && !tool.is_empty()).then_some((server, tool))
41 +}
42 +
43 +fn sanitize(part: &str) -> String {
44 + let mut out: String = part
45 + .chars()
46 + .map(|c| {
47 + if c.is_ascii_alphanumeric() || c == '-' {
48 + c
49 + } else {
50 + '_'
51 + }
52 + })
53 + .collect();
54 + while out.contains("__") {
55 + out = out.replace("__", "_");
56 + }
57 + out.trim_matches('_').to_string()
58 +}
59 +
60 +fn qualified(server: &str, tool: &str) -> String {
61 + let mut name = format!("{PREFIX}{}__{}", sanitize(server), sanitize(tool));
62 + name.truncate(MAX_TOOL_NAME);
63 + name
64 +}
65 +
66 +struct McpClient {
67 + progress: ProgressDispatcher,
68 +}
69 +
70 +impl ClientHandler for McpClient {
71 + async fn on_progress(
72 + &self,
73 + params: ProgressNotificationParam,
74 + _context: NotificationContext<RoleClient>,
75 + ) {
76 + self.progress.handle_notification(params).await;
77 + }
78 +
79 + fn get_info(&self) -> rmcp::model::ClientConfig {
80 + rmcp::model::ClientConfig::new(
81 + ClientCapabilities::default(),
82 + Implementation::new("splitfire-agent", env!("CARGO_PKG_VERSION")),
83 + )
84 + }
85 +}
86 +
87 +/// One live connection to an MCP server process.
88 +pub struct McpConn {
89 + name: String,
90 + service: RunningService<RoleClient, McpClient>,
91 + tools: Vec<Tool>,
92 +}
93 +
94 +impl McpConn {
95 + async fn connect(
96 + name: &str,
97 + command: &Path,
98 + args: &[String],
99 + env: &[(String, String)],
100 + ) -> Result<McpConn> {
101 + let mut cmd = tokio::process::Command::new(command);
102 + cmd.args(args).envs(env.iter().map(|(k, v)| (k, v)));
103 + let transport = TokioChildProcess::new(cmd)
104 + .with_context(|| format!("starting {}", command.display()))?;
105 + let client = McpClient {
106 + progress: ProgressDispatcher::new(),
107 + };
108 + let service = tokio::time::timeout(CONNECT_TIMEOUT, client.serve(transport))
109 + .await
110 + .map_err(|_| anyhow!("no MCP handshake within {}s", CONNECT_TIMEOUT.as_secs()))?
111 + .map_err(|e| anyhow!("MCP handshake failed: {e}"))?;
112 + let tools = tokio::time::timeout(CONNECT_TIMEOUT, service.peer().list_all_tools())
113 + .await
114 + .map_err(|_| anyhow!("tools/list timed out"))?
115 + .map_err(|e| anyhow!("tools/list failed: {e}"))?;
116 + Ok(McpConn {
117 + name: name.to_string(),
118 + service,
119 + tools,
120 + })
121 + }
122 +
123 + async fn close(mut self) {
124 + let _ = self
125 + .service
126 + .close_with_timeout(Duration::from_secs(2))
127 + .await;
128 + }
129 +}
130 +
131 +struct Entry {
132 + conn: Arc<McpConn>,
133 + raw: String,
134 + description: String,
135 + schema: Value,
136 + read_only: bool,
137 +}
138 +
139 +/// The MCP tools available to one session.
140 +#[derive(Default)]
141 +pub struct McpToolset {
142 + tools: HashMap<String, Entry>,
143 + conns: Vec<Arc<McpConn>>,
144 +}
145 +
146 +/// The process-wide `demucs --mcp` server: started at most once, on first need.
147 +enum DemucsState {
148 + NotStarted,
149 + Unavailable,
150 + Running(Arc<McpConn>),
151 +}
152 +
153 +static DEMUCS_CONN: Mutex<DemucsState> = Mutex::const_new(DemucsState::NotStarted);
154 +
155 +/// `SPLITFIRE_DEMUCS_BIN`, else `demucs` on `PATH`.
156 +fn demucs_binary() -> Option<PathBuf> {
157 + if let Some(bin) = std::env::var_os("SPLITFIRE_DEMUCS_BIN").filter(|v| !v.is_empty()) {
158 + return Some(bin.into());
159 + }
160 + let path = std::env::var_os("PATH")?;
161 + std::env::split_paths(&path)
162 + .map(|dir| dir.join("demucs"))
163 + .find(|candidate| candidate.is_file())
164 +}
165 +
166 +fn demucs_disabled() -> bool {
167 + std::env::var("SPLITFIRE_MCP_DEMUCS").is_ok_and(|v| {
168 + matches!(
169 + v.to_ascii_lowercase().as_str(),
170 + "off" | "0" | "false" | "no"
171 + )
172 + })
173 +}
174 +
175 +/// Start `demucs --mcp` once per process, on first need. `None` (logged once) when it is
176 +/// disabled, missing, or fails its handshake.
177 +async fn demucs() -> Option<Arc<McpConn>> {
178 + let mut state = DEMUCS_CONN.lock().await;
179 + match &*state {
180 + DemucsState::Running(conn) => return Some(conn.clone()),
181 + DemucsState::Unavailable => return None,
182 + DemucsState::NotStarted => {}
183 + }
184 + *state = DemucsState::Unavailable;
185 + if demucs_disabled() {
186 + tracing::info!("stem separation disabled (SPLITFIRE_MCP_DEMUCS=off)");
187 + return None;
188 + }
189 + let Some(bin) = demucs_binary() else {
190 + tracing::info!(
191 + "stem separation unavailable: `demucs` not found (install it or set SPLITFIRE_DEMUCS_BIN)"
192 + );
193 + return None;
194 + };
195 + match McpConn::connect(DEMUCS, &bin, &["--mcp".to_string()], &[]).await {
196 + Ok(conn) => {
197 + tracing::info!("stem separation available via {}", bin.display());
198 + let conn = Arc::new(conn);
199 + *state = DemucsState::Running(conn.clone());
200 + Some(conn)
201 + }
202 + Err(e) => {
203 + tracing::info!(
204 + "stem separation unavailable: {} --mcp failed: {e:#}",
205 + bin.display()
206 + );
207 + None
208 + }
209 + }
210 +}
211 +
212 +/// Stop the process-wide demucs server, if one was started. Call after every session's
213 +/// toolset has been shut down so this is the last owner.
214 +pub async fn shutdown_demucs() {
215 + let state = std::mem::replace(&mut *DEMUCS_CONN.lock().await, DemucsState::Unavailable);
216 + if let DemucsState::Running(conn) = state
217 + && let Some(conn) = Arc::into_inner(conn)
218 + {
219 + conn.close().await;
220 + }
221 +}
222 +
223 +impl McpToolset {
224 + /// Connect the client-supplied stdio servers (HTTP/SSE are not advertised, so ignored)
225 + /// and, unless the client supplied its own `demucs`, the built-in separator.
226 + pub async fn connect(servers: &[McpServer]) -> Self {
227 + let mut set = Self::default();
228 + let mut supplied_demucs = false;
229 + for server in servers {
230 + let McpServer::Stdio(s) = server else {
231 + tracing::info!("ignoring non-stdio MCP server");
232 + continue;
233 + };
234 + supplied_demucs |= s.name == DEMUCS;
235 + let env: Vec<(String, String)> = s
236 + .env
237 + .iter()
238 + .map(|e| (e.name.clone(), e.value.clone()))
239 + .collect();
240 + match McpConn::connect(&s.name, &s.command, &s.args, &env).await {
241 + Ok(conn) => set.add(Arc::new(conn)),
242 + Err(e) => tracing::warn!("MCP server `{}` unavailable: {e:#}", s.name),
243 + }
244 + }
245 + if !supplied_demucs && let Some(conn) = demucs().await {
246 + set.add(conn);
247 + }
248 + set
249 + }
250 +
251 + fn add(&mut self, conn: Arc<McpConn>) {
252 + for tool in &conn.tools {
253 + let name = qualified(&conn.name, &tool.name);
254 + if self.tools.contains_key(&name) {
255 + tracing::warn!("duplicate MCP tool name {name}; keeping the first");
256 + continue;
257 + }
258 + let mut schema = Value::Object((*tool.input_schema).clone());
259 + if schema.get("type").is_none() {
260 + schema["type"] = json!("object");
261 + }
262 + let read_only = tool
263 + .annotations
264 + .as_ref()
265 + .and_then(|a| a.read_only_hint)
266 + .unwrap_or(false);
267 + self.tools.insert(
268 + name,
269 + Entry {
270 + conn: conn.clone(),
271 + raw: tool.name.to_string(),
272 + description: format!(
273 + "[{} MCP server] {}",
274 + conn.name,
275 + tool.description.as_deref().unwrap_or("")
276 + ),
277 + schema,
278 + read_only,
279 + },
280 + );
281 + }
282 + self.conns.push(conn);
283 + }
284 +
285 + pub fn definitions(&self) -> Vec<Value> {
286 + let mut names: Vec<&String> = self.tools.keys().collect();
287 + names.sort();
288 + names
289 + .into_iter()
290 + .map(|n| {
291 + let e = &self.tools[n];
292 + function_def(n, &e.description, e.schema.clone())
293 + })
294 + .collect()
295 + }
296 +
297 + pub fn has(&self, name: &str) -> bool {
298 + self.tools.contains_key(name)
299 + }
300 +
301 + pub fn is_read_only(&self, name: &str) -> bool {
302 + self.tools.get(name).is_some_and(|e| e.read_only)
303 + }
304 +
305 + /// Whether the built-in (or a client-supplied) stem separator is connected.
306 + pub fn has_stem_separation(&self) -> bool {
307 + self.tools
308 + .contains_key(&qualified(DEMUCS, "separate_stems"))
309 + }
310 +
311 + /// What the permission prompt shows for a call. For stem separation this consults
312 + /// `list_models` so the prompt can state the download size of uncached weights.
313 + pub async fn permission_preview(&self, name: &str, args: &Value) -> Vec<ToolCallContent> {
314 + let Some(entry) = self.tools.get(name) else {
315 + return Vec::new();
316 + };
317 + let mut text = format!("Run `{}` on the {} MCP server.", entry.raw, entry.conn.name);
318 + if entry.raw == "separate_stems" && entry.conn.name == DEMUCS {
319 + let input = args.get("input").and_then(Value::as_str).unwrap_or("?");
320 + let model = args
321 + .get("model")
322 + .and_then(Value::as_str)
323 + .unwrap_or("htdemucs");
324 + text = format!(
325 + "Separate {input} into stems with {model}. Runs locally and can take minutes."
326 + );
327 + match model_download_note(entry, model).await {
328 + Some(note) => text.push_str(&format!("\n{note}")),
329 + None => text.push_str("\nCould not check whether the model weights are cached."),
330 + }
331 + } else if args.as_object().is_some_and(|o| !o.is_empty()) {
332 + let shown = serde_json::to_string_pretty(args).unwrap_or_default();
333 + text.push_str(&format!("\nArguments:\n```json\n{}\n```", truncate(shown)));
334 + }
335 + vec![ToolCallContent::from(text)]
336 + }
337 +
338 + /// Call a tool, streaming MCP progress into the tool-call card and forwarding
339 + /// session cancellation as `notifications/cancelled`.
340 + pub async fn call(
341 + &self,
342 + ctx: &ToolCtx,
343 + tool_call_id: &str,
344 + name: &str,
345 + args: Value,
346 + ) -> Result<ToolOutcome> {
347 + let entry = self
348 + .tools
349 + .get(name)
350 + .ok_or_else(|| anyhow!("unknown tool `{name}`"))?;
351 + let arguments: Map<String, Value> = match args {
352 + Value::Object(o) => o,
353 + Value::Null => Map::new(),
354 + _ => bail!("tool arguments must be a JSON object"),
355 + };
356 + let params = CallToolRequestParams::new(entry.raw.clone()).with_arguments(arguments);
357 + let mut handle = entry
358 + .conn
359 + .service
360 + .peer()
361 + .send_cancellable_request(
362 + ClientRequest::CallToolRequest(Request::new(params)),
363 + PeerRequestOptions::no_options(),
364 + )
365 + .await
366 + .map_err(|e| anyhow!("MCP server `{}` is not responding: {e}", entry.conn.name))?;
367 + let mut progress = entry
368 + .conn
369 + .service
370 + .service()
371 + .progress
372 + .subscribe(handle.progress_token.clone())
373 + .await;
374 + let deadline = tokio::time::sleep(CALL_TIMEOUT);
375 + tokio::pin!(deadline);
376 +
377 + enum End {
378 + Done(Box<Result<ServerResult, rmcp::ServiceError>>),
379 + Cancelled,
380 + TimedOut,
381 + }
382 + let end = loop {
383 + tokio::select! {
384 + r = &mut handle.rx => {
385 + break End::Done(Box::new(r.unwrap_or(Err(rmcp::ServiceError::TransportClosed))));
386 + }
387 + Some(p) = progress.next() => {
388 + let line = progress_line(&p);
389 + let _ = ctx.update(
390 + tool_call_id,
391 + ToolCallUpdateFields::new().content(vec![ToolCallContent::from(line)]),
392 + );
393 + }
394 + () = ctx.cancel.cancelled() => break End::Cancelled,
395 + () = &mut deadline => break End::TimedOut,
396 + }
397 + };
398 + match end {
399 + End::Done(done) => match *done {
400 + Ok(ServerResult::CallToolResult(r)) => Ok(outcome_from(r)),
401 + Ok(_) => Ok(ToolOutcome::err(
402 + "The MCP server sent an unexpected response.",
403 + )),
404 + Err(e) => Ok(ToolOutcome::err(format!("MCP call failed: {e}"))),
405 + },
406 + End::Cancelled => {
407 + let _ = handle.cancel(Some("cancelled by user".into())).await;
408 + Ok(ToolOutcome::err("Cancelled by user."))
409 + }
410 + End::TimedOut => {
411 + let _ = handle.cancel(Some("timed out".into())).await;
412 + Ok(ToolOutcome::err(format!(
413 + "Timed out after {} minutes.",
414 + CALL_TIMEOUT.as_secs() / 60
415 + )))
416 + }
417 + }
418 + }
419 +
420 + /// Close this session's servers. Shared servers (the built-in demucs) stay up.
421 + pub async fn shutdown(self) {
422 + drop(self.tools);
423 + for conn in self.conns {
424 + if let Some(conn) = Arc::into_inner(conn) {
425 + conn.close().await;
426 + }
427 + }
428 + }
429 +}
430 +
431 +fn progress_line(p: &ProgressNotificationParam) -> String {
432 + let pct = p
433 + .total
434 + .filter(|t| *t > 0.0)
435 + .map(|t| format!(" ({:.0}%)", 100.0 * p.progress / t));
436 + format!(
437 + "{}{}",
438 + p.message.as_deref().unwrap_or("Working…"),
439 + pct.unwrap_or_default()
440 + )
441 +}
442 +
443 +fn outcome_from(r: rmcp::model::CallToolResult) -> ToolOutcome {
444 + let mut parts = Vec::new();
445 + for block in &r.content {
446 + match block {
447 + ContentBlock::Text(t) => parts.push(t.text.clone()),
448 + ContentBlock::Image(i) => parts.push(format!("[image: {}]", i.mime_type)),
449 + ContentBlock::Audio(a) => parts.push(format!("[audio: {}]", a.mime_type)),
450 + ContentBlock::ResourceLink(l) => parts.push(format!("[resource: {}]", l.uri)),
451 + ContentBlock::Resource(_) => parts.push("[embedded resource]".into()),
452 + _ => parts.push("[unsupported content]".into()),
453 + }
454 + }
455 + let mut text = parts.join("\n");
456 + if text.is_empty() {
457 + text = r
458 + .structured_content
459 + .as_ref()
460 + .map(|v| v.to_string())
461 + .unwrap_or_else(|| "(no output)".into());
462 + }
463 + let mut out = if r.is_error == Some(true) {
464 + ToolOutcome::err(truncate(text))
465 + } else {
466 + ToolOutcome::ok(truncate(text))
467 + };
468 + out.locations = stem_paths(r.structured_content.as_ref());
469 + out
470 +}
471 +
472 +/// `structuredContent.stems[].path`, absolute paths only.
473 +fn stem_paths(structured: Option<&Value>) -> Vec<PathBuf> {
474 + structured
475 + .and_then(|v| v.get("stems"))
476 + .and_then(Value::as_array)
477 + .map(|stems| {
478 + stems
479 + .iter()
480 + .filter_map(|s| s.get("path").and_then(Value::as_str))
481 + .map(PathBuf::from)
482 + .filter(|p| p.is_absolute())
483 + .map(|p| absolutize(&p))
484 + .collect()
485 + })
486 + .unwrap_or_default()
487 +}
488 +
489 +/// "Downloads N MB…" when `model` is not cached, per demucs's `list_models`.
490 +async fn model_download_note(entry: &Entry, model: &str) -> Option<String> {
491 + let params = CallToolRequestParams::new("list_models");
492 + let result = tokio::time::timeout(
493 + Duration::from_secs(10),
494 + entry.conn.service.peer().call_tool(params),
495 + )
496 + .await
497 + .ok()?
498 + .ok()?;
499 + let models = result
500 + .structured_content?
501 + .get("models")?
502 + .as_array()?
503 + .clone();
504 + let m = models
505 + .iter()
506 + .find(|m| m.get("id").and_then(Value::as_str) == Some(model))?;
507 + let size = m.get("size_mb").and_then(Value::as_u64);
508 + Some(match (m.get("cached").and_then(Value::as_bool), size) {
509 + (Some(true), _) => "The model weights are already downloaded.".to_string(),
510 + (_, Some(mb)) => format!("First use downloads about {mb} MB of model weights."),
511 + _ => "The model weights may need to be downloaded first.".to_string(),
512 + })
513 +}
514 +
515 +#[cfg(test)]
516 +mod tests {
517 + use super::*;
518 +
519 + #[test]
520 + fn qualified_names_round_trip() {
521 + let q = qualified("My Server!", "do.thing");
522 + assert_eq!(q, "mcp__My_Server__do_thing");
523 + assert_eq!(split_qualified(&q), Some(("My_Server", "do_thing")));
524 + assert_eq!(split_qualified("read_file"), None);
525 + assert_eq!(split_qualified("mcp__x"), None);
526 + assert_eq!(split_qualified("mcp____t"), None);
527 + assert!(qualified("s", &"t".repeat(100)).len() <= MAX_TOOL_NAME);
528 + }
529 +
530 + #[test]
531 + fn stems_become_absolute_locations() {
532 + let v = json!({"stems": [
533 + {"id": "drums", "path": "/tmp/a/drums.wav"},
534 + {"id": "bass", "path": "relative/bass.wav"},
535 + {"id": "x"}
536 + ]});
537 + let paths = stem_paths(Some(&v));
538 + assert_eq!(paths.len(), 1);
539 + assert!(paths[0].ends_with("drums.wav"));
540 + assert!(stem_paths(None).is_empty());
541 + }
542 +
543 + #[test]
544 + fn progress_text() {
545 + let p = ProgressNotificationParam::new(
546 + rmcp::model::ProgressToken(rmcp::model::NumberOrString::Number(1)),
547 + 25.0,
548 + )
549 + .with_total(100.0)
550 + .with_message("Separating");
551 + assert_eq!(progress_line(&p), "Separating (25%)");
552 + }
553 +}
src/prompt.rs new
+637
@@ -0,0 +1,637 @@
1 +//! The system prompt, slash commands, and the conversion of ACP prompt content into
2 +//! model content.
3 +
4 +use std::path::{Path, PathBuf};
5 +
6 +use agent_client_protocol::schema::v1::{
7 + AvailableCommand, AvailableCommandInput, ClientCapabilities, ContentBlock, EmbeddedResource,
8 + EmbeddedResourceResource, ReadTextFileRequest, ResourceLink, SessionId, TextResourceContents,
9 + UnstructuredCommandInput,
10 +};
11 +use agent_client_protocol::{Client, ConnectionTo};
12 +use anyhow::{Context, Result};
13 +use base64::Engine;
14 +use serde_json::{Value, json};
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 + } else {
44 + "\nStem separation is not available in this session. If the user asks for stems, tell \
45 + them it needs the `demucs` command installed and on PATH (or SPLITFIRE_DEMUCS_BIN \
46 + pointing at it), and that the session must be restarted afterwards. Do not pretend to \
47 + separate anything, and do not offer a workaround with the file or shell tools."
48 + };
49 + format!(
50 + "You are SplitFire, a music assistant for musicians, producers, engineers, songwriters \
51 + and fans. You cover music theory, production and DAW workflow, the music industry, \
52 + music culture and history, and live shows.\n\
53 + \n\
54 + Working rules:\n\
55 + - Use the theory tools (theory_scale, theory_chord, theory_transpose, \
56 + theory_progression, theory_tempo, theory_pitch) for note spelling, chords, keys, \
57 + transposition, delay and tempo maths and pitch conversion. Do not compute these from \
58 + memory; quote the tool's result.\n\
59 + - Use analyze_audio for audio files. Its tempo and key are signal-analysis estimates: \
60 + call them estimates, mention the confidence, and mention half/double-time readings \
61 + when they matter.\n\
62 + - Use list_separations to see what the SplitFire app has already separated, instead of \
63 + guessing at stem paths. It is read-only: never edit or delete anything inside those \
64 + song folders, because the app rebuilds its history from that tree.\n\
65 + - Be specific and practical: give settings, ranges and reasoning, and say when \
66 + something is taste rather than rule. Match the user's level.\n\
67 + - Do not reproduce song lyrics beyond a short quotation; discuss, summarise or \
68 + analyse instead.\n\
69 + - On sampling, covers, licensing, splits and royalties explain how things generally \
70 + work and what to ask a lawyer or collecting society; this is not legal advice.\n\
71 + - Do not invent release dates, chart positions, credits, gear specs or tour dates. \
72 + Say so when you are unsure or when facts may have changed.\n\
73 + - Keep answers concise and use Markdown.\n\
74 + {stems_section}\n\
75 + \n\
76 + Working directory: {cwd}{roots_section}\n\
77 + File tools are available for the user's project notes, chord charts and lyric sheets. \
78 + Read before editing and prefer edit_file for small changes.\n\
79 + When creating git commits, include the trailer:\n\
80 + Co-Authored-By: SplitFire v{version}-{surface} <noreply@splitfire.ai>",
81 + cwd = cwd.display(),
82 + version = env!("CARGO_PKG_VERSION"),
83 + )
84 +}
85 +
86 +struct SlashCommand {
87 + name: &'static str,
88 + description: &'static str,
89 + hint: &'static str,
90 + /// Needs the stem separator to be connected.
91 + needs_stems: bool,
92 +}
93 +
94 +const COMMANDS: &[SlashCommand] = &[
95 + SlashCommand {
96 + name: "analyze",
97 + description: "Analyze an audio file: loudness, tempo and key estimates",
98 + hint: "path to an audio file",
99 + needs_stems: false,
100 + },
101 + SlashCommand {
102 + name: "chords",
103 + description: "Analyze a chord progression: key, Roman numerals, borrowed chords",
104 + hint: "chords, e.g. Dm7 G7 Cmaj7",
105 + needs_stems: false,
106 + },
107 + SlashCommand {
108 + name: "library",
109 + description: "List the songs the SplitFire app has already separated",
110 + hint: "optional song name to filter by",
111 + needs_stems: false,
112 + },
113 + SlashCommand {
114 + name: "practice",
115 + description: "Build a focused practice plan with tempo targets",
116 + hint: "instrument and goal",
117 + needs_stems: false,
118 + },
119 + SlashCommand {
120 + name: "stems",
121 + description: "Separate a track into drums, bass, vocals and other stems",
122 + hint: "path to an audio file",
123 + needs_stems: true,
124 + },
125 +];
126 +
127 +/// Commands to advertise; `/stems` only when the separator is connected.
128 +pub fn slash_commands(stems_available: bool) -> Vec<AvailableCommand> {
129 + COMMANDS
130 + .iter()
131 + .filter(|c| stems_available || !c.needs_stems)
132 + .map(|c| {
133 + AvailableCommand::new(c.name, c.description).input(AvailableCommandInput::Unstructured(
134 + UnstructuredCommandInput::new(c.hint),
135 + ))
136 + })
137 + .collect()
138 +}
139 +
140 +/// If `text` starts with a known slash command, the instruction the model should follow.
141 +pub fn expand_slash(text: &str, stems_available: bool) -> Option<String> {
142 + let rest = text.trim_start().strip_prefix('/')?;
143 + let (name, arg) = rest
144 + .split_once(char::is_whitespace)
145 + .map_or((rest, ""), |(n, a)| (n, a.trim()));
146 + let missing = |what: &str| format!("The user ran /{name} without {what}. Ask them for it.");
147 + Some(match name {
148 + "analyze" if arg.is_empty() => missing("a file path"),
149 + "analyze" => format!(
150 + "Analyze the audio file {arg} with analyze_audio, then summarise it: duration, \
151 + loudness and headroom, tempo and key (labelled as estimates, with confidence), and \
152 + anything worth knowing before mixing or mastering."
153 + ),
154 + "chords" if arg.is_empty() => missing("a progression"),
155 + "chords" => format!(
156 + "Analyze this progression with theory_progression: {arg}\n\
157 + Give the key, Roman numerals and functions, point out borrowed chords and \
158 + secondary dominants, and suggest a few substitutions or voicing ideas."
159 + ),
160 + "library" => {
161 + let scope = if arg.is_empty() {
162 + "Call list_separations".to_string()
163 + } else {
164 + format!("Call list_separations with song={arg}")
165 + };
166 + format!(
167 + "{scope} and summarise the user's separation library: which songs, the model \
168 + each was separated with, when, and which stems are on this device. Mention any \
169 + stem that is listed but not downloaded. If it is empty, say how to separate a \
170 + track rather than guessing at paths."
171 + )
172 + }
173 + "practice" if arg.is_empty() => missing("an instrument and goal"),
174 + "practice" => format!(
175 + "Build a focused practice plan for: {arg}\n\
176 + Include a warm-up, two or three targeted exercises, and tempo targets worked out \
177 + with theory_tempo. Keep it to one session the user can finish in under an hour."
178 + ),
179 + "stems" if !stems_available => {
180 + "The user ran /stems, but stem separation is not available in this session. Tell \
181 + them it needs the `demucs` command installed (see the SplitFire README) and to \
182 + restart the session afterwards."
183 + .to_string()
184 + }
185 + "stems" if arg.is_empty() => missing("a file path"),
186 + "stems" => format!(
187 + "Separate {arg} into stems with mcp__demucs__separate_stems, then list the stem \
188 + files that were written."
189 + ),
190 + _ => return None,
191 + })
192 +}
193 +
194 +fn embedded_text(res: &EmbeddedResource) -> Option<String> {
195 + match &res.resource {
196 + EmbeddedResourceResource::TextResourceContents(r) => {
197 + Some(format!("<file uri=\"{}\">\n{}\n</file>", r.uri, r.text))
198 + }
199 + // Describe binary resources rather than dropping them, so the model knows they exist.
200 + EmbeddedResourceResource::BlobResourceContents(b) => Some(format!(
201 + "[Attached binary resource: {} ({})]",
202 + b.uri,
203 + b.mime_type.as_deref().unwrap_or("unknown type")
204 + )),
205 + _ => None,
206 + }
207 +}
208 +
209 +/// How a resource link is shown to the model. Audio files are pointed at `analyze_audio`
210 +/// (and the separator) instead of being presented as text to read.
211 +fn link_text(link: &ResourceLink) -> String {
212 + match parse_file_link(&link.uri) {
213 + Some(FileLink { path, .. }) if is_audio(&path, link.mime_type.as_deref()) => format!(
214 + "[Referenced audio file: {}. Use analyze_audio on that path to inspect it.]",
215 + path.display()
216 + ),
217 + _ => format!("[Referenced: {}]", link.uri),
218 + }
219 +}
220 +
221 +/// File extensions the SplitFire app accepts, plus the other formats symphonia decodes.
222 +const AUDIO_EXTENSIONS: &[&str] = &[
223 + "wav", "wave", "mp3", "flac", "m4a", "aac", "mp4", "ogg", "oga", "opus", "aif", "aiff", "aifc",
224 + "caf", "webm", "mkv",
225 +];
226 +
227 +fn is_audio(path: &Path, mime: Option<&str>) -> bool {
228 + mime.is_some_and(|m| m.starts_with("audio/"))
229 + || path
230 + .extension()
231 + .and_then(|e| e.to_str())
232 + .is_some_and(|e| AUDIO_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str()))
233 +}
234 +
235 +/// Largest file a `file://` resource link is inlined for; bigger ones stay as references.
236 +const MAX_LINKED_FILE_BYTES: usize = 256 * 1024;
237 +
238 +/// Replace `file://` resource links to text files with their embedded contents, so the model
239 +/// sees the file. Reads through the client when it supports `fs/read_text_file` (unsaved
240 +/// buffers), else from disk. A link to a selection (`#L10:20`) is inlined as just those
241 +/// lines. Audio files, links that can't be read and oversized files stay as references.
242 +pub async fn resolve_resource_links(
243 + blocks: &[ContentBlock],
244 + caps: &ClientCapabilities,
245 + connection: &ConnectionTo<Client>,
246 + session_id: &SessionId,
247 +) -> Vec<ContentBlock> {
248 + let mut out = Vec::with_capacity(blocks.len());
249 + for block in blocks {
250 + if let ContentBlock::ResourceLink(link) = block
251 + && let Some(FileLink { path, lines }) = parse_file_link(&link.uri)
252 + && !is_audio(&path, link.mime_type.as_deref())
253 + {
254 + let text = if caps.fs.read_text_file {
255 + connection
256 + .send_request(ReadTextFileRequest::new(session_id.clone(), path.clone()))
257 + .block_task()
258 + .await
259 + .map(|r| r.content)
260 + .map_err(|e| e.to_string())
261 + } else {
262 + tokio::fs::read_to_string(&path)
263 + .await
264 + .map_err(|e| e.to_string())
265 + };
266 + let text = match lines {
267 + Some(range) => text.map(|t| slice_lines(&t, range)),
268 + None => text,
269 + };
270 + match text {
271 + Ok(text) if lines.is_some() && text.is_empty() => {
272 + tracing::debug!("{} selects no lines", link.uri)
273 + }
274 + Ok(text) if text.len() <= MAX_LINKED_FILE_BYTES => {
275 + out.push(ContentBlock::Resource(EmbeddedResource::new(
276 + EmbeddedResourceResource::TextResourceContents(
277 + TextResourceContents::new(text, link.uri.clone())
278 + .mime_type(link.mime_type.clone()),
279 + ),
280 + )));
281 + continue;
282 + }
283 + Ok(_) => tracing::debug!("{} too large to inline", link.uri),
284 + Err(e) => tracing::debug!("could not read {}: {e}", link.uri),
285 + }
286 + }
287 + out.push(block.clone());
288 + }
289 + out
290 +}
291 +
292 +/// A `file://` resource link: the absolute path, and the lines it selects, if any.
293 +#[derive(Debug, PartialEq)]
294 +struct FileLink {
295 + path: PathBuf,
296 + /// 1-based, inclusive.
297 + lines: Option<(usize, usize)>,
298 +}
299 +
300 +/// Parse a `file://` URI, percent-decoding the path. Editors put more than the path in it:
301 +/// Zed links a selection as `file:///a.md?column=5#L10:20` and a symbol as
302 +/// `file:///a.rs?symbol=main#L3:9`. The query is dropped and the fragment read as a line
303 +/// range; a literal `?` or `#` in a file name arrives percent-encoded, so splitting first is
304 +/// safe.
305 +fn parse_file_link(uri: &str) -> Option<FileLink> {
306 + let rest = uri.strip_prefix("file://")?;
307 + let (rest, fragment) = match rest.split_once('#') {
308 + Some((rest, fragment)) => (rest, Some(fragment)),
309 + None => (rest, None),
310 + };
311 + let rest = rest.split_once('?').map_or(rest, |(rest, _)| rest);
312 + // Allow an authority of "" or "localhost"; reject other hosts.
313 + let path = if rest.starts_with('/') {
314 + rest
315 + } else {
316 + rest.strip_prefix("localhost")?
317 + };
318 + let bytes = path.as_bytes();
319 + let mut decoded = Vec::with_capacity(bytes.len());
320 + let mut i = 0;
321 + while i < bytes.len() {
322 + if bytes[i] == b'%'
323 + && i + 2 < bytes.len()
324 + && let Ok(b) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).ok()?, 16)
325 + {
326 + decoded.push(b);
327 + i += 3;
328 + } else {
329 + decoded.push(bytes[i]);
330 + i += 1;
331 + }
332 + }
333 + let path = PathBuf::from(String::from_utf8(decoded).ok()?);
334 + path.is_absolute().then(|| FileLink {
335 + path,
336 + lines: fragment.and_then(parse_line_range),
337 + })
338 +}
339 +
340 +/// A `#L10:20` fragment as a 1-based inclusive range. Accepts `L10:20`, `L10-20`, `L10-L20`
341 +/// and a single line `L10`.
342 +fn parse_line_range(fragment: &str) -> Option<(usize, usize)> {
343 + let range = fragment.strip_prefix('L')?;
344 + let (start, end) = range
345 + .split_once(':')
346 + .or_else(|| range.split_once('-'))
347 + .unwrap_or((range, range));
348 + let end = end.strip_prefix('L').unwrap_or(end);
349 + let (start, end) = (start.parse::<usize>().ok()?, end.parse::<usize>().ok()?);
350 + (start >= 1 && end >= start).then_some((start, end))
351 +}
352 +
353 +/// Lines `start..=end` (1-based) of `text`, keeping their line endings.
354 +fn slice_lines(text: &str, (start, end): (usize, usize)) -> String {
355 + text.split_inclusive('\n')
356 + .skip(start - 1)
357 + .take(end - start + 1)
358 + .collect()
359 +}
360 +
361 +/// Flatten text-like prompt blocks into one string (used for titles and slash commands).
362 +pub fn prompt_to_text(blocks: &[ContentBlock]) -> String {
363 + let mut parts = Vec::new();
364 + for block in blocks {
365 + match block {
366 + ContentBlock::Text(t) => parts.push(t.text.clone()),
367 + ContentBlock::ResourceLink(link) => parts.push(link_text(link)),
368 + ContentBlock::Resource(res) => parts.extend(embedded_text(res)),
369 + _ => {}
370 + }
371 + }
372 + parts.join("\n\n")
373 +}
374 +
375 +fn audio_extension(mime: &str) -> &'static str {
376 + match mime.split(';').next().unwrap_or("").trim() {
377 + "audio/wav" | "audio/x-wav" | "audio/wave" | "audio/vnd.wave" => "wav",
378 + "audio/mpeg" | "audio/mp3" => "mp3",
379 + "audio/flac" | "audio/x-flac" => "flac",
380 + "audio/ogg" | "audio/vorbis" | "application/ogg" => "ogg",
381 + "audio/mp4" | "audio/m4a" | "audio/x-m4a" | "audio/aac" => "m4a",
382 + "audio/aiff" | "audio/x-aiff" => "aiff",
383 + "audio/webm" | "video/webm" => "webm",
384 + _ => "audio",
385 + }
386 +}
387 +
388 +/// Write each audio block under `dir`; returns the saved path per audio block, in order.
389 +pub async fn save_audio_blocks(blocks: &[ContentBlock], dir: &Path) -> Result<Vec<PathBuf>> {
390 + let mut saved = Vec::new();
391 + for block in blocks {
392 + let ContentBlock::Audio(audio) = block else {
393 + continue;
394 + };
395 + let bytes = base64::engine::general_purpose::STANDARD
396 + .decode(audio.data.trim())
397 + .context("audio block is not valid base64")?;
398 + tokio::fs::create_dir_all(dir).await?;
399 + let path = dir.join(format!(
400 + "audio-{}.{}",
401 + uuid::Uuid::new_v4().simple(),
402 + audio_extension(&audio.mime_type)
403 + ));
404 + tokio::fs::write(&path, bytes).await?;
405 + saved.push(path);
406 + }
407 + Ok(saved)
408 +}
409 +
410 +/// Convert prompt blocks to an OpenAI `content` value: a string for text only, or parts when
411 +/// images are present. Audio is referenced by the path it was saved to; the model reads it
412 +/// with `analyze_audio`. `slash` replaces the first text block (an expanded slash command).
413 +pub fn prompt_to_content(
414 + blocks: &[ContentBlock],
415 + audio_paths: &[PathBuf],
416 + slash: Option<&str>,
417 +) -> Value {
418 + let mut audio = audio_paths.iter();
419 + let mut parts: Vec<Value> = Vec::new();
420 + let mut slash = slash;
421 + let mut has_image = false;
422 + let text = |t: String| json!({ "type": "text", "text": t });
423 + for block in blocks {
424 + match block {
425 + ContentBlock::Text(t) => match slash.take() {
426 + Some(expanded) => parts.push(text(expanded.to_string())),
427 + None => parts.push(text(t.text.clone())),
428 + },
429 + ContentBlock::ResourceLink(link) => parts.push(text(link_text(link))),
430 + ContentBlock::Resource(res) => {
431 + if let Some(t) = embedded_text(res) {
432 + parts.push(text(t));
433 + }
434 + }
435 + ContentBlock::Image(img) => {
436 + has_image = true;
437 + let url = format!("data:{};base64,{}", img.mime_type, img.data);
438 + parts.push(json!({ "type": "image_url", "image_url": { "url": url } }));
439 + }
440 + ContentBlock::Audio(a) => match audio.next() {
441 + Some(path) => parts.push(text(format!(
442 + "[Attached audio ({}) saved at {}. Use analyze_audio on that path to inspect it.]",
443 + a.mime_type,
444 + path.display()
445 + ))),
446 + None => parts.push(text("[Attached audio could not be saved]".into())),
447 + },
448 + _ => {}
449 + }
450 + }
451 + if has_image {
452 + return Value::Array(parts);
453 + }
454 + let joined = parts
455 + .iter()
456 + .filter_map(|p| p.get("text").and_then(Value::as_str))
457 + .collect::<Vec<_>>()
458 + .join("\n\n");
459 + Value::String(joined)
460 +}
461 +
462 +#[cfg(test)]
463 +mod tests {
464 + use super::*;
465 +
466 + fn link(uri: &str) -> Option<FileLink> {
467 + parse_file_link(uri)
468 + }
469 +
470 + #[test]
471 + fn plain_paths_decode_and_must_be_absolute() {
472 + assert_eq!(
473 + link("file:///a%20b/c.md"),
474 + Some(FileLink {
475 + path: "/a b/c.md".into(),
476 + lines: None
477 + })
478 + );
479 + assert_eq!(
480 + link("file://localhost/x.md").unwrap().path,
481 + Path::new("/x.md")
482 + );
483 + assert_eq!(link("file://other-host/x.md"), None);
484 + assert_eq!(link("https://example.com/x.md"), None);
485 + }
486 +
487 + #[test]
488 + fn zed_selection_and_symbol_links() {
489 + let sel = link("file:///notes/song.md?column=5#L10:20").unwrap();
490 + assert_eq!(sel.path, Path::new("/notes/song.md"));
491 + assert_eq!(sel.lines, Some((10, 20)));
492 + assert_eq!(
493 + link("file:///a.rs?symbol=main#L3:9").unwrap().lines,
494 + Some((3, 9))
495 + );
496 + }
497 +
498 + #[test]
499 + fn line_range_forms() {
500 + assert_eq!(parse_line_range("L10:20"), Some((10, 20)));
501 + assert_eq!(parse_line_range("L10-20"), Some((10, 20)));
502 + assert_eq!(parse_line_range("L10-L20"), Some((10, 20)));
503 + assert_eq!(parse_line_range("L7"), Some((7, 7)));
504 + assert_eq!(parse_line_range("L0"), None);
505 + assert_eq!(parse_line_range("L5:2"), None);
506 + assert_eq!(parse_line_range("x"), None);
507 + }
508 +
509 + #[test]
510 + fn slices_inclusive_lines() {
511 + assert_eq!(slice_lines("a\nb\nc\nd\n", (2, 3)), "b\nc\n");
512 + assert_eq!(slice_lines("a\nb", (2, 9)), "b");
513 + assert_eq!(slice_lines("a\n", (5, 6)), "");
514 + }
515 +
516 + #[test]
517 + fn audio_links_point_at_the_analyzer() {
518 + let wav = ResourceLink::new("mix.wav", "file:///music/mix%20v2.WAV");
519 + assert_eq!(
520 + link_text(&wav),
521 + "[Referenced audio file: /music/mix v2.WAV. Use analyze_audio on that path to inspect it.]"
522 + );
523 + let by_mime =
524 + ResourceLink::new("take", "file:///t/take").mime_type("audio/flac".to_string());
525 + assert!(link_text(&by_mime).contains("analyze_audio"));
526 + let md = ResourceLink::new("notes", "file:///t/notes.md");
527 + assert_eq!(link_text(&md), "[Referenced: file:///t/notes.md]");
528 + }
529 + use agent_client_protocol::schema::v1::{AudioContent, ImageContent, TextContent};
530 +
531 + #[test]
532 + fn system_prompt_mentions_stems_only_when_connected() {
533 + let cwd = Path::new("/work");
534 + let off = system_prompt("acp", cwd, &[], false);
535 + let on = system_prompt("acp", cwd, &[], true);
536 + // Without the separator the prompt must not offer the tool, but must say how to
537 + // enable it rather than going silent.
538 + assert!(!off.contains("mcp__demucs__separate_stems"));
539 + assert!(off.contains("not available") && off.contains("SPLITFIRE_DEMUCS_BIN"));
540 + assert!(on.contains("mcp__demucs__separate_stems"));
541 + // The read-only library tool is offered either way.
542 + assert!(off.contains("list_separations") && on.contains("list_separations"));
543 + assert!(off.contains("theory_scale") && off.contains("analyze_audio"));
544 + assert!(
545 + off.contains("Co-Authored-By: SplitFire v")
546 + && off.contains("-acp <noreply@splitfire.ai>")
547 + );
548 + let tui = system_prompt("tui", cwd, &[PathBuf::from("/a")], false);
549 + assert!(tui.contains("-tui <") && tui.contains("- /a"));
550 + }
551 +
552 + #[test]
553 + fn commands_depend_on_stems() {
554 + let names = |s| {
555 + slash_commands(s)
556 + .into_iter()
557 + .map(|c| c.name)
558 + .collect::<Vec<_>>()
559 + };
560 + // `/library` only reads the app's sidecars, so it is offered either way.
561 + assert_eq!(names(false), ["analyze", "chords", "library", "practice"]);
562 + assert_eq!(
563 + names(true),
564 + ["analyze", "chords", "library", "practice", "stems"]
565 + );
566 + }
567 +
568 + #[test]
569 + fn slash_expansion() {
570 + assert!(
571 + expand_slash("/analyze /a/b.wav", false)
572 + .unwrap()
573 + .contains("/a/b.wav")
574 + );
575 + assert!(
576 + expand_slash("/chords Dm7 G7", false)
577 + .unwrap()
578 + .contains("theory_progression")
579 + );
580 + assert!(
581 + expand_slash("/analyze", false)
582 + .unwrap()
583 + .contains("without a file path")
584 + );
585 + assert!(
586 + expand_slash("/stems x.wav", true)
587 + .unwrap()
588 + .contains("mcp__demucs__separate_stems")
589 + );
590 + assert!(
591 + expand_slash("/stems x.wav", false)
592 + .unwrap()
593 + .contains("not available")
594 + );
595 + assert!(expand_slash("/unknown x", false).is_none());
596 + assert!(expand_slash("hello /analyze", false).is_none());
597 + }
598 +
599 + #[tokio::test]
600 + async fn audio_is_saved_and_referenced() {
601 + let dir = std::env::temp_dir().join(format!("sf-prompt-{}", uuid::Uuid::new_v4()));
602 + let data = base64::engine::general_purpose::STANDARD.encode(b"RIFFfake");
603 + let blocks = vec![
604 + ContentBlock::Text(TextContent::new("what key?")),
605 + ContentBlock::Audio(AudioContent::new(data, "audio/wav")),
606 + ];
607 + let saved = save_audio_blocks(&blocks, &dir).await.unwrap();
608 + assert_eq!(saved.len(), 1);
609 + assert_eq!(saved[0].extension().unwrap(), "wav");
610 + assert_eq!(std::fs::read(&saved[0]).unwrap(), b"RIFFfake");
611 + let content = prompt_to_content(&blocks, &saved, None);
612 + let s = content.as_str().unwrap();
613 + assert!(s.contains("what key?") && s.contains(&saved[0].display().to_string()));
614 + std::fs::remove_dir_all(dir).ok();
615 +
616 + let bad = vec![ContentBlock::Audio(AudioContent::new("!!!", "audio/wav"))];
617 + assert!(
618 + save_audio_blocks(&bad, &std::env::temp_dir())
619 + .await
620 + .is_err()
621 + );
622 + }
623 +
624 + #[test]
625 + fn images_use_parts_and_slash_replaces_text() {
626 + let blocks = vec![
627 + ContentBlock::Text(TextContent::new("/chords Am F")),
628 + ContentBlock::Image(ImageContent::new("AAAA", "image/png")),
629 + ];
630 + let v = prompt_to_content(&blocks, &[], Some("EXPANDED"));
631 + let parts = v.as_array().unwrap();
632 + assert_eq!(parts[0]["text"], "EXPANDED");
633 + assert_eq!(parts[1]["type"], "image_url");
634 + let plain = prompt_to_content(&blocks[..1], &[], None);
635 + assert_eq!(plain, json!("/chords Am F"));
636 + }
637 +}
src/session_store.rs new
+174
@@ -0,0 +1,174 @@
1 +//! Durable sessions: one JSON file per session under `<data_dir>/sessions/`.
2 +//!
3 +//! The history stored here is the OpenAI-style message list without the system prompt
4 +//! (that is rebuilt every turn). Tool results are `role: "tool"` messages, which is what
5 +//! lets `session/load` replay tool calls with their output.
6 +
7 +use std::path::{Path, PathBuf};
8 +
9 +use anyhow::{Context, Result, bail};
10 +use serde::{Deserialize, Serialize};
11 +use serde_json::Value;
12 +
13 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14 +pub struct StoredSession {
15 + pub cwd: PathBuf,
16 + #[serde(default)]
17 + pub roots: Vec<PathBuf>,
18 + pub model: String,
19 + #[serde(default)]
20 + pub title: Option<String>,
21 + pub updated_at: u64,
22 + #[serde(default)]
23 + pub messages: Vec<Value>,
24 +}
25 +
26 +#[derive(Debug, Clone)]
27 +pub struct SessionStore {
28 + dir: PathBuf,
29 +}
30 +
31 +/// Session ids end up in file names: allow only characters that cannot escape the directory.
32 +pub fn sanitize_id(id: &str) -> Result<&str> {
33 + let ok = !id.is_empty()
34 + && id.len() <= 128
35 + && id
36 + .chars()
37 + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
38 + if !ok {
39 + bail!("invalid session id");
40 + }
41 + Ok(id)
42 +}
43 +
44 +impl SessionStore {
45 + pub fn new(data_dir: &Path) -> Self {
46 + Self {
47 + dir: data_dir.join("sessions"),
48 + }
49 + }
50 +
51 + fn path(&self, id: &str) -> Result<PathBuf> {
52 + Ok(self.dir.join(format!("{}.json", sanitize_id(id)?)))
53 + }
54 +
55 + /// Atomic write: temp file in the same directory, then rename.
56 + pub fn save(&self, id: &str, session: &StoredSession) -> Result<()> {
57 + let path = self.path(id)?;
58 + std::fs::create_dir_all(&self.dir)
59 + .with_context(|| format!("creating {}", self.dir.display()))?;
60 + let tmp = self.dir.join(format!(".{id}.{}.tmp", uuid::Uuid::new_v4()));
61 + std::fs::write(&tmp, serde_json::to_vec(session)?)?;
62 + std::fs::rename(&tmp, &path).inspect_err(|_| {
63 + let _ = std::fs::remove_file(&tmp);
64 + })?;
65 + Ok(())
66 + }
67 +
68 + /// `Ok(None)` when no such session exists (or the id is not a valid one).
69 + pub fn load(&self, id: &str) -> Result<Option<StoredSession>> {
70 + let Ok(path) = self.path(id) else {
71 + return Ok(None);
72 + };
73 + match std::fs::read(&path) {
74 + Ok(bytes) => Ok(Some(
75 + serde_json::from_slice(&bytes)
76 + .with_context(|| format!("parsing {}", path.display()))?,
77 + )),
78 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
79 + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
80 + }
81 + }
82 +
83 + /// All stored sessions, newest first. Unreadable files are skipped.
84 + pub fn list(&self) -> Vec<(String, StoredSession)> {
85 + let Ok(rd) = std::fs::read_dir(&self.dir) else {
86 + return Vec::new();
87 + };
88 + let mut out: Vec<(String, StoredSession)> = rd
89 + .filter_map(|e| {
90 + let path = e.ok()?.path();
91 + if path.extension()? != "json" {
92 + return None;
93 + }
94 + let id = path.file_stem()?.to_str()?.to_string();
95 + let session = serde_json::from_slice(&std::fs::read(&path).ok()?).ok()?;
96 + Some((id, session))
97 + })
98 + .collect();
99 + out.sort_by(|a, b| b.1.updated_at.cmp(&a.1.updated_at).then(a.0.cmp(&b.0)));
100 + out
101 + }
102 +
103 + /// Returns whether a file was removed.
104 + pub fn delete(&self, id: &str) -> Result<bool> {
105 + let Ok(path) = self.path(id) else {
106 + return Ok(false);
107 + };
108 + match std::fs::remove_file(&path) {
109 + Ok(()) => Ok(true),
110 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
111 + Err(e) => Err(e).with_context(|| format!("deleting {}", path.display())),
112 + }
113 + }
114 +}
115 +
116 +#[cfg(test)]
117 +mod tests {
118 + use super::*;
119 + use serde_json::json;
120 +
121 + fn tmp() -> PathBuf {
122 + let d = std::env::temp_dir().join(format!("sf-store-{}", uuid::Uuid::new_v4()));
123 + std::fs::create_dir_all(&d).unwrap();
124 + d
125 + }
126 +
127 + fn sample(updated_at: u64) -> StoredSession {
128 + StoredSession {
129 + cwd: "/work".into(),
130 + roots: vec!["/other".into()],
131 + model: "m".into(),
132 + title: Some("hello".into()),
133 + updated_at,
134 + messages: vec![
135 + json!({"role": "user", "content": "hi"}),
136 + json!({"role": "tool", "tool_call_id": "c1", "content": "out"}),
137 + ],
138 + }
139 + }
140 +
141 + #[test]
142 + fn round_trip_list_delete() {
143 + let dir = tmp();
144 + let store = SessionStore::new(&dir);
145 + assert!(store.load("a").unwrap().is_none());
146 + store.save("a", &sample(10)).unwrap();
147 + store.save("b", &sample(20)).unwrap();
148 + assert_eq!(store.load("a").unwrap().unwrap(), sample(10));
149 + let ids: Vec<_> = store.list().into_iter().map(|(id, _)| id).collect();
150 + assert_eq!(ids, ["b", "a"]);
151 + assert!(store.delete("a").unwrap());
152 + assert!(!store.delete("a").unwrap());
153 + assert!(store.load("a").unwrap().is_none());
154 + // No temp files left behind.
155 + let leftovers = std::fs::read_dir(dir.join("sessions"))
156 + .unwrap()
157 + .filter(|e| e.as_ref().unwrap().path().extension().unwrap() != "json")
158 + .count();
159 + assert_eq!(leftovers, 0);
160 + std::fs::remove_dir_all(dir).ok();
161 + }
162 +
163 + #[test]
164 + fn rejects_path_traversal_ids() {
165 + let dir = tmp();
166 + let store = SessionStore::new(&dir);
167 + for bad in ["../x", "a/b", "", "a.b", "..", "a\\b"] {
168 + assert!(store.save(bad, &sample(1)).is_err(), "{bad}");
169 + assert!(store.load(bad).unwrap().is_none());
170 + assert!(!store.delete(bad).unwrap());
171 + }
172 + std::fs::remove_dir_all(dir).ok();
173 + }
174 +}
src/theory/chord.rs new
+730
@@ -0,0 +1,730 @@
1 +//! Chord symbols: parsing (`F#m7b5`, `Bb13#11/D`, `C6/9`), spelling, and identification
2 +//! from a set of notes.
3 +
4 +use anyhow::{Result, bail};
5 +
6 +use super::interval::Interval;
7 +use super::pitch::Note;
8 +
9 +#[derive(Debug, Clone, PartialEq)]
10 +pub struct Chord {
11 + pub root: Note,
12 + pub bass: Option<Note>,
13 + /// Chord tones above the root, sorted by degree, root (`1`) included.
14 + pub intervals: Vec<Interval>,
15 + pub symbol: String,
16 +}
17 +
18 +#[derive(Default)]
19 +struct Spec {
20 + major: bool,
21 + minor: bool,
22 + dim: bool,
23 + half_dim: bool,
24 + aug: bool,
25 + sus: Option<u8>,
26 + power: bool,
27 + sixth: bool,
28 + seventh: bool,
29 + nine: bool,
30 + eleven: bool,
31 + thirteen: bool,
32 + adds: Vec<Interval>,
33 + no3: bool,
34 + no5: bool,
35 + alt: bool,
36 + /// (accidental, degree number) from tokens such as `b5`, `#9`, `#11`, `b13`.
37 + alterations: Vec<(i32, u32)>,
38 + seen_number: bool,
39 +}
40 +
41 +fn take<'a>(r: &'a str, options: &[&str]) -> Option<&'a str> {
42 + options.iter().find_map(|o| r.strip_prefix(o))
43 +}
44 +
45 +impl Chord {
46 + pub fn parse(symbol: &str) -> Result<Chord> {
47 + let original = symbol.trim();
48 + let s = original
49 + .replace("6/9", "69")
50 + .replace(['(', ')', ',', ' '], "");
51 + let mut chars = s.chars();
52 + let letter = chars.next().filter(|c| matches!(c, 'A'..='G'));
53 + let Some(letter) = letter else {
54 + bail!(
55 + "not a chord symbol: `{original}` (must start with a note A-G, e.g. Cmaj7, F#m7b5)"
56 + );
57 + };
58 + let mut rest = &s[1..];
59 + let mut acc = 0i8;
60 + if let Some(c) = rest.chars().next() {
61 + match c {
62 + '#' | '♯' => acc = 1,
63 + 'b' | '♭' => acc = -1,
64 + _ => {}
65 + }
66 + if acc != 0 {
67 + rest = &rest[c.len_utf8()..];
68 + }
69 + }
70 + let root = Note::parse(&format!(
71 + "{letter}{}",
72 + match acc {
73 + 1 => "#",
74 + -1 => "b",
75 + _ => "",
76 + }
77 + ))?;
78 +
79 + let mut bass = None;
80 + if let Some((head, tail)) = rest.rsplit_once('/') {
81 + match Note::parse(tail) {
82 + Ok(n) => bass = Some(n),
83 + Err(_) => bail!("bad bass note `{tail}` in `{original}`"),
84 + }
85 + rest = head;
86 + }
87 +
88 + let spec = parse_suffix(rest, original)?;
89 + Ok(Chord {
90 + root,
91 + bass,
92 + intervals: build_intervals(&spec),
93 + symbol: original.to_string(),
94 + })
95 + }
96 +
97 + pub fn notes(&self) -> Vec<Note> {
98 + self.intervals
99 + .iter()
100 + .map(|i| self.root.up(i.steps, i.semis))
101 + .collect()
102 + }
103 +
104 + pub fn formula(&self) -> String {
105 + self.intervals
106 + .iter()
107 + .map(|i| i.degree_token())
108 + .collect::<Vec<_>>()
109 + .join(" ")
110 + }
111 +
112 + /// Pitch classes of the chord tones, sorted and de-duplicated.
113 + pub fn pcs(&self) -> Vec<i32> {
114 + let mut v: Vec<i32> = self.notes().iter().map(|n| n.pc()).collect();
115 + v.sort_unstable();
116 + v.dedup();
117 + v
118 + }
119 +
120 + pub fn has(&self, steps: usize, semis: i32) -> bool {
121 + self.intervals
122 + .iter()
123 + .any(|i| i.steps == steps && i.semis == semis)
124 + }
125 +
126 + pub fn third(&self) -> Option<Interval> {
127 + self.intervals.iter().copied().find(|i| i.steps == 2)
128 + }
129 +
130 + pub fn fifth(&self) -> Option<Interval> {
131 + self.intervals.iter().copied().find(|i| i.steps == 4)
132 + }
133 +
134 + pub fn seventh(&self) -> Option<Interval> {
135 + self.intervals.iter().copied().find(|i| i.steps == 6)
136 + }
137 +
138 + pub fn is_minor_third(&self) -> bool {
139 + self.has(2, 3)
140 + }
141 +
142 + /// A major third plus a minor seventh: functions as a dominant.
143 + pub fn is_dominant(&self) -> bool {
144 + self.has(2, 4) && self.has(6, 10)
145 + }
146 +
147 + /// Re-spell the chord on a different root (used by transposition).
148 + pub fn with_root(&self, root: Note, bass: Option<Note>) -> Chord {
149 + Chord {
150 + root,
151 + bass,
152 + intervals: self.intervals.clone(),
153 + symbol: String::new(),
154 + }
155 + }
156 +
157 + /// Canonical symbol, e.g. `Bb13#11/D`.
158 + pub fn name(&self) -> String {
159 + let mut s = format!("{}{}", self.root.name(), self.suffix());
160 + if let Some(b) = self.bass {
161 + s.push('/');
162 + s.push_str(&b.name());
163 + }
164 + s
165 + }
166 +
167 + /// The quality suffix in a compact canonical form (`m7b5`, `maj9`, `7#9`, `sus4`, ...).
168 + pub fn suffix(&self) -> String {
169 + let third = self.third().map(|i| i.semis);
170 + let fifth = self.fifth().map(|i| i.semis);
171 + let seventh = self.seventh().map(|i| i.semis);
172 + let nat = |steps: usize, semis: i32| self.has(steps, semis);
173 + let sus2 = self.intervals.iter().any(|i| i.steps == 1 && i.semis == 2) && third.is_none();
174 + let sus4 = nat(3, 5) && third.is_none();
175 + let mut out = String::new();
176 + let mut alts: Vec<String> = Vec::new();
177 +
178 + // Highest stacked natural extension replaces the plain 7.
179 + let ext = if nat(12, 21) {
180 + Some(13)
181 + } else if nat(10, 17) && nat(8, 14) {
182 + Some(11)
183 + } else if nat(8, 14) {
184 + Some(9)
185 + } else {
186 + None
187 + };
188 + let dim_triad = third == Some(3) && fifth == Some(6);
189 + match (third, fifth, seventh) {
190 + (Some(3), Some(6), Some(10)) => out.push_str("m7b5"),
191 + (Some(3), Some(6), Some(9)) => out.push_str("dim7"),
192 + (Some(3), Some(6), None) => out.push_str("dim"),
193 + (Some(4), Some(8), None) => out.push_str("aug"),
194 + (Some(4), _, Some(11)) => {
195 + out.push_str("maj");
196 + out.push_str(&ext.map_or("7".into(), |e| e.to_string()));
197 + }
198 + (Some(3), _, Some(11)) => {
199 + out.push_str("mMaj");
200 + out.push_str(&ext.map_or("7".into(), |e| e.to_string()));
201 + }
202 + (Some(3), _, Some(10)) => {
203 + out.push('m');
204 + out.push_str(&ext.map_or("7".into(), |e| e.to_string()));
205 + }
206 + (Some(3), _, Some(9)) => out.push_str("dim7"),
207 + (Some(3), _, None) => out.push('m'),
208 + (Some(4), _, Some(10)) => out.push_str(&ext.map_or("7".into(), |e| e.to_string())),
209 + (None, _, Some(10)) if sus4 || sus2 => {
210 + out.push_str(&ext.map_or("7".into(), |e| e.to_string()));
211 + }
212 + (Some(4), _, Some(9)) => out.push('7'),
213 + _ => {}
214 + }
215 + if third.is_none() {
216 + if sus4 {
217 + out.push_str("sus4");
218 + } else if sus2 {
219 + out.push_str("sus2");
220 + } else if fifth.is_some() && self.intervals.len() == 2 {
221 + out.push('5');
222 + }
223 + }
224 + if matches!(third, Some(3 | 4)) && seventh.is_none() && nat(5, 9) {
225 + out.push_str(if nat(8, 14) { "69" } else { "6" });
226 + }
227 + if seventh.is_none() && !nat(5, 9) {
228 + for (steps, semis, n) in [(1, 2, 2), (8, 14, 9), (3, 5, 4), (10, 17, 11), (12, 21, 13)]
229 + {
230 + if nat(steps, semis) && !(n == 4 && sus4) && !(n == 2 && sus2) {
231 + alts.push(format!("add{n}"));
232 + }
233 + }
234 + }
235 + // Altered tones.
236 + match fifth {
237 + Some(6) if !dim_triad => alts.push("b5".into()),
238 + Some(8) if !(third == Some(4) && seventh.is_none()) => alts.push("#5".into()),
239 + _ => {}
240 + }
241 + for i in &self.intervals {
242 + if i.steps == 8 && i.semis == 13 {
243 + alts.push("b9".into());
244 + }
245 + if i.steps == 8 && i.semis == 15 {
246 + alts.push("#9".into());
247 + }
248 + if i.steps == 10 && i.semis == 18 {
249 + alts.push("#11".into());
250 + }
251 + if i.steps == 12 && i.semis == 20 {
252 + alts.push("b13".into());
253 + }
254 + }
255 + if third == Some(4)
256 + && seventh == Some(10)
257 + && fifth.is_none()
258 + && ["b9", "#9", "#11", "b13"]
259 + .iter()
260 + .all(|a| alts.iter().any(|x| x == a))
261 + {
262 + return "7alt".into();
263 + }
264 + for a in alts {
265 + out.push_str(&a);
266 + }
267 + out
268 + }
269 +
270 + /// Plain-language description of the chord's quality.
271 + pub fn quality(&self) -> String {
272 + let third = self.third().map(|i| i.semis);
273 + let fifth = self.fifth().map(|i| i.semis);
274 + let seventh = self.seventh().map(|i| i.semis);
275 + let ext = if self.has(12, 21) {
276 + "13th"
277 + } else if self.has(10, 17) && self.has(8, 14) {
278 + "11th"
279 + } else if self.has(8, 14) {
280 + "9th"
281 + } else {
282 + "7th"
283 + };
284 + let sus = if self.has(3, 5) && third.is_none() {
285 + Some("sus4")
286 + } else if self.has(1, 2) && third.is_none() {
287 + Some("sus2")
288 + } else {
289 + None
290 + };
291 + let base = match (third, fifth, seventh, sus) {
292 + (_, _, Some(10), Some(s)) => format!("dominant {ext} ({s})"),
293 + (_, _, None, Some(s)) => s.to_string(),
294 + (None, Some(_), None, None) => "power chord (no third)".into(),
295 + (Some(3), Some(6), Some(10), _) => "half-diminished (minor 7 flat 5)".into(),
296 + (Some(3), Some(6), Some(9), _) => "diminished 7th".into(),
297 + (Some(3), Some(6), None, _) => "diminished triad".into(),
298 + (Some(4), Some(8), None, _) => "augmented triad".into(),
299 + (Some(4), Some(8), Some(10), _) => "augmented 7th (dominant 7 sharp 5)".into(),
300 + (Some(4), _, Some(11), _) => format!("major {ext}"),
301 + (Some(3), _, Some(11), _) => format!("minor-major {ext}"),
302 + (Some(3), _, Some(10), _) => format!("minor {ext}"),
303 + (Some(4), _, Some(10), _) => format!("dominant {ext}"),
304 + (Some(3), _, None, _) => "minor triad".into(),
305 + (Some(4), _, None, _) => "major triad".into(),
306 + _ => "non-tertian or incomplete chord".into(),
307 + };
308 + let mut extras: Vec<&str> = Vec::new();
309 + if seventh.is_none() && self.has(5, 9) {
310 + extras.push("added 6th");
311 + }
312 + let alts: Vec<String> = self
313 + .intervals
314 + .iter()
315 + .filter(|i| matches!((i.steps, i.semis), (8, 13) | (8, 15) | (10, 18) | (12, 20)))
316 + .map(|i| i.degree_token())
317 + .collect();
318 + let mut text = base;
319 + for e in extras {
320 + text.push_str(&format!(", {e}"));
321 + }
322 + if !alts.is_empty() {
323 + text.push_str(&format!(", altered tones: {}", alts.join(" ")));
324 + }
325 + text
326 + }
327 +
328 + /// Inversion / slash-bass description, if the chord has a bass note.
329 + pub fn bass_description(&self) -> Option<String> {
330 + let bass = self.bass?;
331 + if bass == self.root {
332 + return Some("root position".into());
333 + }
334 + let pos = self.notes().iter().position(|n| n.pc() == bass.pc());
335 + Some(match pos.map(|p| self.intervals[p]) {
336 + Some(i) if i.steps == 2 => "first inversion (third in the bass)".into(),
337 + Some(i) if i.steps == 4 => "second inversion (fifth in the bass)".into(),
338 + Some(i) if i.steps == 6 => "third inversion (seventh in the bass)".into(),
339 + Some(i) => format!("{} in the bass", i.name()),
340 + None => "slash chord (bass note is not a chord tone)".into(),
341 + })
342 + }
343 +}
344 +
345 +fn parse_suffix(rest: &str, original: &str) -> Result<Spec> {
346 + let mut st = Spec::default();
347 + let mut r = rest;
348 + while !r.is_empty() {
349 + // Alterations like b5, #9, #11, b13 (and +5 / -9 once a number has been seen).
350 + let alt_marks: &[char] = if st.seen_number {
351 + &['#', 'b', '+', '-', '♯', '♭']
352 + } else {
353 + &['#', 'b', '♯', '♭']
354 + };
355 + if r.starts_with(alt_marks) {
356 + let body = r.trim_start_matches(alt_marks);
357 + let marks = &r[..r.len() - body.len()];
358 + let digits: String = body.chars().take_while(char::is_ascii_digit).collect();
359 + if !digits.is_empty() {
360 + let acc: i32 = marks
361 + .chars()
362 + .map(|c| {
363 + if matches!(c, '#' | '+' | '♯') {
364 + 1
365 + } else {
366 + -1
367 + }
368 + })
369 + .sum();
370 + let n: u32 = digits.parse()?;
371 + if !matches!(n, 5 | 9 | 11 | 13) {
372 + bail!("unsupported alteration `{marks}{digits}` in `{original}`");
373 + }
374 + st.alterations.push((acc, n));
375 + r = &body[digits.len()..];
376 + continue;
377 + }
378 + }
379 + if let Some(t) = take(r, &["maj", "Maj", "MAJ", "M", "Δ", "△"]) {
380 + st.major = true;
381 + r = t;
382 + } else if let Some(t) = take(r, &["min", "mi", "m", "-", "−"]) {
383 + st.minor = true;
384 + r = t;
385 + } else if let Some(t) = take(r, &["half-dim", "halfdim", "ø", "Ø"]) {
386 + st.half_dim = true;
387 + st.seventh = true;
388 + r = t;
389 + } else if let Some(t) = take(r, &["dim", "°", "º", "o"]) {
390 + st.dim = true;
391 + r = t;
392 + } else if let Some(t) = take(r, &["aug", "+"]) {
393 + st.aug = true;
394 + r = t;
395 + } else if let Some(t) = take(r, &["sus2"]) {
396 + st.sus = Some(2);
397 + r = t;
398 + } else if let Some(t) = take(r, &["sus4", "sus"]) {
399 + st.sus = Some(4);
400 + r = t;
401 + } else if let Some(t) = take(r, &["add", "Add"]) {
402 + let digits: String = t.chars().take_while(char::is_ascii_digit).collect();
403 + let interval = match digits.as_str() {
404 + "2" => Interval::new(1, 2),
405 + "4" => Interval::new(3, 5),
406 + "6" => Interval::new(5, 9),
407 + "9" => Interval::new(8, 14),
408 + "11" => Interval::new(10, 17),
409 + "13" => Interval::new(12, 21),
410 + _ => bail!("unsupported `add{digits}` in `{original}`"),
411 + };
412 + st.adds.push(interval);
413 + r = &t[digits.len()..];
414 + } else if let Some(t) = take(r, &["no3", "omit3"]) {
415 + st.no3 = true;
416 + r = t;
417 + } else if let Some(t) = take(r, &["no5", "omit5"]) {
418 + st.no5 = true;
419 + r = t;
420 + } else if let Some(t) = take(r, &["alt"]) {
421 + st.alt = true;
422 + st.seventh = true;
423 + r = t;
424 + } else if let Some(t) = take(r, &["69"]) {
425 + st.sixth = true;
426 + st.adds.push(Interval::new(8, 14));
427 + st.seen_number = true;
428 + r = t;
429 + } else if let Some(t) = take(r, &["13"]) {
430 + st.seventh = true;
431 + st.nine = true;
432 + st.thirteen = true;
433 + st.seen_number = true;
434 + r = t;
435 + } else if let Some(t) = take(r, &["11"]) {
436 + st.seventh = true;
437 + st.nine = true;
438 + st.eleven = true;
439 + st.seen_number = true;
440 + r = t;
441 + } else if let Some(t) = take(r, &["9"]) {
442 + st.seventh = true;
443 + st.nine = true;
444 + st.seen_number = true;
445 + r = t;
446 + } else if let Some(t) = take(r, &["7"]) {
447 + st.seventh = true;
448 + st.seen_number = true;
449 + r = t;
450 + } else if let Some(t) = take(r, &["6"]) {
451 + st.sixth = true;
452 + st.seen_number = true;
453 + r = t;
454 + } else if let Some(t) = take(r, &["5"]) {
455 + st.power = true;
456 + st.seen_number = true;
457 + r = t;
458 + } else if let Some(t) = take(r, &["4"]) {
459 + st.adds.push(Interval::new(3, 5));
460 + st.seen_number = true;
461 + r = t;
462 + } else if let Some(t) = take(r, &["2"]) {
463 + st.adds.push(Interval::new(1, 2));
464 + st.seen_number = true;
465 + r = t;
466 + } else {
467 + bail!("unrecognized chord suffix `{r}` in `{original}`");
468 + }
469 + }
470 + if st.minor && st.major && !st.seventh {
471 + // "mM" with no number: treat as minor-major 7th.
472 + st.seventh = true;
473 + }
474 + Ok(st)
475 +}
476 +
477 +fn build_intervals(st: &Spec) -> Vec<Interval> {
478 + let mut v = vec![Interval::new(0, 0)];
479 + let minor_third = st.minor || st.dim || st.half_dim;
480 + let half_dim_or_dim = st.dim || st.half_dim;
481 + if st.power && !st.seventh && !st.sixth && !st.major && !st.minor && !st.dim {
482 + v.push(Interval::new(4, 7));
483 + } else {
484 + if !st.no3 {
485 + match st.sus {
486 + Some(2) => v.push(Interval::new(1, 2)),
487 + Some(_) => v.push(Interval::new(3, 5)),
488 + None => v.push(Interval::new(2, if minor_third { 3 } else { 4 })),
489 + }
490 + }
491 + let mut fifth = if half_dim_or_dim {
492 + 6
493 + } else if st.aug {
494 + 8
495 + } else {
496 + 7
497 + };
498 + for (acc, n) in &st.alterations {
499 + if *n == 5 {
500 + fifth = 7 + acc;
501 + }
502 + }
503 + if !st.no5 && !st.alt {
504 + v.push(Interval::new(4, fifth));
505 + }
506 + }
507 + if st.sixth {
508 + v.push(Interval::new(5, 9));
509 + }
510 + if st.seventh {
511 + let semis = if st.dim && !st.half_dim {
512 + 9
513 + } else if st.major && !st.half_dim {
514 + 11
515 + } else {
516 + 10
517 + };
518 + v.push(Interval::new(6, semis));
519 + }
520 + let mut nine = st.nine;
521 + let mut eleven = st.eleven;
522 + let mut thirteen = st.thirteen;
523 + if st.thirteen && minor_third && !st.no3 {
524 + eleven = true;
525 + }
526 + for a in &st.adds {
527 + match (a.steps, a.semis) {
528 + (8, 14) => nine = true,
529 + (10, 17) => eleven = true,
530 + (12, 21) => thirteen = true,
531 + _ => v.push(*a),
532 + }
533 + }
534 + let mut altered = |steps: usize, base: i32, flag: &mut bool, n: u32| {
535 + for (acc, num) in &st.alterations {
536 + if *num == n {
537 + v.push(Interval::new(steps, base + acc));
538 + *flag = false;
539 + }
540 + }
541 + };
542 + altered(8, 14, &mut nine, 9);
543 + altered(10, 17, &mut eleven, 11);
544 + altered(12, 21, &mut thirteen, 13);
545 + if st.alt {
546 + v.push(Interval::new(8, 13));
547 + v.push(Interval::new(8, 15));
548 + v.push(Interval::new(10, 18));
549 + v.push(Interval::new(12, 20));
550 + }
551 + if nine {
552 + v.push(Interval::new(8, 14));
553 + }
554 + if eleven {
555 + v.push(Interval::new(10, 17));
556 + }
557 + if thirteen {
558 + v.push(Interval::new(12, 21));
559 + }
560 + v.sort_by_key(|i| (i.steps, i.semis));
561 + v.dedup();
562 + v
563 +}
564 +
565 +/// Suffixes tried when identifying a chord from notes, most common first.
566 +const IDENTIFY_SUFFIXES: &[&str] = &[
567 + "", "m", "7", "maj7", "m7", "dim", "aug", "sus4", "sus2", "5", "6", "m6", "dim7", "m7b5",
568 + "mMaj7", "add9", "madd9", "9", "maj9", "m9", "7sus4", "69", "7b9", "7#9", "7#5", "7b5", "11",
569 + "m11", "13", "maj7#11", "maj13", "m13", "7#11", "7b13", "7alt", "maj7#5", "maj7b5",
570 +];
571 +
572 +#[derive(Debug, Clone)]
573 +pub struct Candidate {
574 + pub chord: Chord,
575 + /// 0 is best: root in the bass first, then simpler chord types.
576 + pub rank: usize,
577 +}
578 +
579 +/// Identify chords from note names. The first note is taken as the bass.
580 +pub fn identify(notes: &[Note]) -> Vec<Candidate> {
581 + let Some(&bass) = notes.first() else {
582 + return Vec::new();
583 + };
584 + let mut pcs: Vec<i32> = notes.iter().map(|n| n.pc()).collect();
585 + pcs.sort_unstable();
586 + pcs.dedup();
587 + let mut out: Vec<Candidate> = Vec::new();
588 + let mut seen_roots: Vec<Note> = Vec::new();
589 + for &root in notes {
590 + if seen_roots.iter().any(|r| r.pc() == root.pc()) {
591 + continue;
592 + }
593 + seen_roots.push(root);
594 + for (idx, suffix) in IDENTIFY_SUFFIXES.iter().enumerate() {
595 + let Ok(mut chord) = Chord::parse(&format!("{}{suffix}", root.name())) else {
596 + continue;
597 + };
598 + let relative: Vec<i32> = {
599 + let mut v: Vec<i32> = chord.notes().iter().map(|n| n.pc()).collect();
600 + v.sort_unstable();
601 + v.dedup();
602 + v
603 + };
604 + if relative != pcs {
605 + continue;
606 + }
607 + if bass.pc() != root.pc() {
608 + chord.bass = Some(bass);
609 + }
610 + let rank = if bass.pc() == root.pc() { 0 } else { 100 } + idx;
611 + out.push(Candidate { chord, rank });
612 + }
613 + }
614 + out.sort_by_key(|c| c.rank);
615 + out
616 +}
617 +
618 +#[cfg(test)]
619 +mod tests {
620 + use super::*;
621 +
622 + fn names(symbol: &str) -> String {
623 + Chord::parse(symbol)
624 + .unwrap()
625 + .notes()
626 + .iter()
627 + .map(|n| n.name())
628 + .collect::<Vec<_>>()
629 + .join(" ")
630 + }
631 +
632 + #[test]
633 + fn triads_and_sevenths() {
634 + assert_eq!(names("C"), "C E G");
635 + assert_eq!(names("Cm"), "C Eb G");
636 + assert_eq!(names("Cdim"), "C Eb Gb");
637 + assert_eq!(names("Caug"), "C E G#");
638 + assert_eq!(names("C7"), "C E G Bb");
639 + assert_eq!(names("Cmaj7"), "C E G B");
640 + assert_eq!(names("Dm7"), "D F A C");
641 + assert_eq!(names("Cdim7"), "C Eb Gb Bbb");
642 + assert_eq!(names("CmMaj7"), "C Eb G B");
643 + assert_eq!(names("C5"), "C G");
644 + }
645 +
646 + #[test]
647 + fn half_diminished_spelling() {
648 + assert_eq!(names("F#m7b5"), "F# A C E");
649 + assert_eq!(names("F#ø7"), "F# A C E");
650 + assert_eq!(Chord::parse("F#m7b5").unwrap().name(), "F#m7b5");
651 + }
652 +
653 + #[test]
654 + fn extended_and_altered() {
655 + assert_eq!(names("Bb13#11/D"), "Bb D F Ab C E G");
656 + assert_eq!(names("C9"), "C E G Bb D");
657 + assert_eq!(names("C13"), "C E G Bb D A");
658 + assert_eq!(names("Cm13"), "C Eb G Bb D F A");
659 + assert_eq!(names("G7b9"), "G B D F Ab");
660 + assert_eq!(names("G7#9"), "G B D F A#");
661 + assert_eq!(names("C7(b5)"), "C E Gb Bb");
662 + assert_eq!(names("C7alt"), "C E Bb Db D# F# Ab");
663 + assert_eq!(names("Cmaj9"), "C E G B D");
664 + }
665 +
666 + #[test]
667 + fn sus_add_and_sixth() {
668 + assert_eq!(names("Csus4"), "C F G");
669 + assert_eq!(names("C7sus4"), "C F G Bb");
670 + assert_eq!(names("Cadd9"), "C E G D");
671 + assert_eq!(names("C6/9"), "C E G A D");
672 + assert_eq!(names("Am6"), "A C E F#");
673 + }
674 +
675 + #[test]
676 + fn slash_bass_and_inversions() {
677 + let c = Chord::parse("Bb13#11/D").unwrap();
678 + assert_eq!(c.bass.unwrap().name(), "D");
679 + assert_eq!(
680 + c.bass_description().unwrap(),
681 + "first inversion (third in the bass)"
682 + );
683 + assert_eq!(c.name(), "Bb13#11/D");
684 + let s = Chord::parse("C/F").unwrap();
685 + assert!(s.bass_description().unwrap().starts_with("slash chord"));
686 + }
687 +
688 + #[test]
689 + fn canonical_names() {
690 + for sym in [
691 + "Cmaj7", "Dm7", "G7", "Bdim7", "Ebm9", "A7#9", "C6", "Csus4", "F#7b9",
692 + ] {
693 + assert_eq!(Chord::parse(sym).unwrap().name(), sym, "{sym}");
694 + }
695 + assert_eq!(Chord::parse("C-7").unwrap().name(), "Cm7");
696 + assert_eq!(Chord::parse("CΔ7").unwrap().name(), "Cmaj7");
697 + assert_eq!(Chord::parse("C6/9").unwrap().name(), "C69");
698 + }
699 +
700 + #[test]
701 + fn rejects_garbage() {
702 + assert!(Chord::parse("H7").is_err());
703 + assert!(Chord::parse("Cxyz").is_err());
704 + assert!(Chord::parse("C/Q").is_err());
705 + assert!(Chord::parse("").is_err());
706 + }
707 +
708 + #[test]
709 + fn identifies_from_notes() {
710 + let n = |s: &str| {
711 + s.split_whitespace()
712 + .map(|x| Note::parse(x).unwrap())
713 + .collect::<Vec<_>>()
714 + };
715 + let c = identify(&n("C E G Bb"));
716 + assert_eq!(c[0].chord.name(), "C7");
717 + let c = identify(&n("E C G"));
718 + assert_eq!(c[0].chord.name(), "C/E");
719 + // C6 and Am7/C are the same notes; both are reported.
720 + let all: Vec<String> = identify(&n("C E G A"))
721 + .iter()
722 + .map(|c| c.chord.name())
723 + .collect();
724 + assert!(
725 + all.contains(&"C6".to_string()) && all.contains(&"Am7/C".to_string()),
726 + "{all:?}"
727 + );
728 + assert!(identify(&n("C Db D")).is_empty());
729 + }
730 +}
src/theory/interval.rs new
+101
@@ -0,0 +1,101 @@
1 +//! Diatonic intervals: a letter distance plus a semitone count.
2 +
3 +/// An interval measured as `steps` letters (0 = unison, 2 = third, 8 = ninth) and `semis`.
4 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5 +pub struct Interval {
6 + pub steps: usize,
7 + pub semis: i32,
8 +}
9 +
10 +/// Semitones of the major-scale degree for a letter distance (compound steps fold down).
11 +fn major_semis(steps: usize) -> i32 {
12 + const MAJOR: [i32; 7] = [0, 2, 4, 5, 7, 9, 11];
13 + MAJOR[steps % 7] + 12 * (steps / 7) as i32
14 +}
15 +
16 +impl Interval {
17 + pub fn new(steps: usize, semis: i32) -> Self {
18 + Self { steps, semis }
19 + }
20 +
21 + /// Parse a degree token relative to the major scale: `1`, `b3`, `#4`, `bb7`, `b9`, `13`.
22 + pub fn from_token(token: &str) -> Option<Self> {
23 + let number = token.trim_start_matches(['#', 'b']);
24 + let acc = token[..token.len() - number.len()]
25 + .chars()
26 + .map(|c| if c == '#' { 1 } else { -1 })
27 + .sum::<i32>();
28 + let n: usize = number.parse().ok().filter(|n| *n >= 1)?;
29 + Some(Self::new(n - 1, major_semis(n - 1) + acc))
30 + }
31 +
32 + /// Short name such as `P5`, `M3`, `m7`, `A4`, `d5`, `M9`, `b13` style qualities included.
33 + pub fn name(self) -> String {
34 + let number = self.steps + 1;
35 + let perfect_type = matches!(self.steps % 7, 0 | 3 | 4);
36 + let diff = self.semis - major_semis(self.steps);
37 + let quality = if perfect_type {
38 + match diff {
39 + 0 => "P".to_string(),
40 + d if d > 0 => "A".repeat(d as usize),
41 + d => "d".repeat((-d) as usize),
42 + }
43 + } else {
44 + match diff {
45 + 0 => "M".to_string(),
46 + -1 => "m".to_string(),
47 + d if d > 0 => "A".repeat(d as usize),
48 + d => "d".repeat((-d - 1) as usize),
49 + }
50 + };
51 + format!("{quality}{number}")
52 + }
53 +
54 + /// Scale-degree formula token relative to the major scale: `1`, `b3`, `#4`, `b9`.
55 + pub fn degree_token(self) -> String {
56 + let diff = self.semis - major_semis(self.steps);
57 + let acc = match diff {
58 + 0 => String::new(),
59 + d if d > 0 => "#".repeat(d as usize),
60 + d => "b".repeat((-d) as usize),
61 + };
62 + format!("{acc}{}", self.steps + 1)
63 + }
64 +}
65 +
66 +#[cfg(test)]
67 +mod tests {
68 + use super::*;
69 +
70 + #[test]
71 + fn names() {
72 + assert_eq!(Interval::new(4, 7).name(), "P5");
73 + assert_eq!(Interval::new(2, 4).name(), "M3");
74 + assert_eq!(Interval::new(2, 3).name(), "m3");
75 + assert_eq!(Interval::new(6, 10).name(), "m7");
76 + assert_eq!(Interval::new(3, 6).name(), "A4");
77 + assert_eq!(Interval::new(4, 6).name(), "d5");
78 + assert_eq!(Interval::new(6, 9).name(), "d7");
79 + assert_eq!(Interval::new(8, 14).name(), "M9");
80 + assert_eq!(Interval::new(8, 13).name(), "m9");
81 + assert_eq!(Interval::new(0, 0).name(), "P1");
82 + }
83 +
84 + #[test]
85 + fn parse_tokens_round_trip() {
86 + for t in ["1", "b3", "#4", "b9", "b13", "13", "bb7", "#11"] {
87 + assert_eq!(Interval::from_token(t).unwrap().degree_token(), t);
88 + }
89 + assert_eq!(Interval::from_token("bb7"), Some(Interval::new(6, 9)));
90 + assert!(Interval::from_token("x").is_none());
91 + assert!(Interval::from_token("0").is_none());
92 + }
93 +
94 + #[test]
95 + fn tokens() {
96 + assert_eq!(Interval::new(2, 3).degree_token(), "b3");
97 + assert_eq!(Interval::new(3, 6).degree_token(), "#4");
98 + assert_eq!(Interval::new(8, 13).degree_token(), "b9");
99 + assert_eq!(Interval::new(12, 20).degree_token(), "b13");
100 + }
101 +}
src/theory/key.rs new
+632
@@ -0,0 +1,632 @@
1 +//! Keys, Roman-numeral analysis, key detection from a progression, and realizing numerals.
2 +
3 +use anyhow::{Result, bail};
4 +
5 +use super::chord::Chord;
6 +use super::pitch::Note;
7 +use super::scale;
8 +
9 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10 +pub enum Mode {
11 + Major,
12 + Minor,
13 +}
14 +
15 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16 +pub struct Key {
17 + pub tonic: Note,
18 + pub mode: Mode,
19 +}
20 +
21 +const MAJOR_REF: [i32; 7] = [0, 2, 4, 5, 7, 9, 11];
22 +const MINOR_REF: [i32; 7] = [0, 2, 3, 5, 7, 8, 10];
23 +const ROMAN: [&str; 7] = ["I", "II", "III", "IV", "V", "VI", "VII"];
24 +
25 +impl Key {
26 + /// `C`, `C major`, `Am`, `A minor`, `F#m`, `Bb`, `Ebmin`.
27 + pub fn parse(s: &str) -> Result<Key> {
28 + let s = s.trim();
29 + let mut chars = s.chars();
30 + let Some(first) = chars.next().filter(|c| c.is_ascii_alphabetic()) else {
31 + bail!("not a key: `{s}` (use e.g. C, Am, F# minor, Bb major)");
32 + };
33 + let mut rest = &s[1..];
34 + let mut note = first.to_ascii_uppercase().to_string();
35 + if let Some(c) = rest
36 + .chars()
37 + .next()
38 + .filter(|c| matches!(c, '#' | '♯' | 'b' | '♭'))
39 + {
40 + // `Bb`, `F#`; but not a bare mode word like `b minor` → handled since `b` then space.
41 + note.push(if matches!(c, '#' | '♯') { '#' } else { 'b' });
42 + rest = &rest[c.len_utf8()..];
43 + }
44 + let tonic = Note::parse(&note)?;
45 + let mode = match rest.trim().to_lowercase().as_str() {
46 + "" | "major" | "maj" | "ionian" => Mode::Major,
47 + "m" | "min" | "minor" | "aeolian" => Mode::Minor,
48 + other => bail!("unknown key mode `{other}` in `{s}` (use major or minor)"),
49 + };
50 + Ok(Key { tonic, mode })
51 + }
52 +
53 + pub fn name(&self) -> String {
54 + format!(
55 + "{} {}",
56 + self.tonic.name(),
57 + match self.mode {
58 + Mode::Major => "major",
59 + Mode::Minor => "minor",
60 + }
61 + )
62 + }
63 +
64 + fn reference(&self) -> &'static [i32; 7] {
65 + match self.mode {
66 + Mode::Major => &MAJOR_REF,
67 + Mode::Minor => &MINOR_REF,
68 + }
69 + }
70 +
71 + /// The seven scale notes (major or natural minor), spelled.
72 + pub fn notes(&self) -> Vec<Note> {
73 + let r = self.reference();
74 + (0..7).map(|i| self.tonic.up(i, r[i])).collect()
75 + }
76 +
77 + pub fn pcs(&self) -> Vec<i32> {
78 + self.notes().iter().map(|n| n.pc()).collect()
79 + }
80 +
81 + pub fn signature(&self) -> i32 {
82 + scale::signature(&self.notes())
83 + }
84 +
85 + /// The note on `degree` (0-based) raised/lowered by `acc` semitones from the key's scale.
86 + fn degree_note(&self, degree: usize, acc: i32) -> Note {
87 + self.tonic.up(degree, self.reference()[degree] + acc)
88 + }
89 +
90 + pub fn relative(&self) -> Key {
91 + match self.mode {
92 + Mode::Major => Key {
93 + tonic: self.tonic.up(5, 9),
94 + mode: Mode::Minor,
95 + },
96 + Mode::Minor => Key {
97 + tonic: self.tonic.up(2, 3),
98 + mode: Mode::Major,
99 + },
100 + }
101 + }
102 +}
103 +
104 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105 +pub enum Kind {
106 + Diatonic,
107 + DiatonicHarmonic,
108 + SecondaryDominant,
109 + SecondaryLeadingTone,
110 + TritoneSub,
111 + Neapolitan,
112 + Borrowed,
113 + Chromatic,
114 +}
115 +
116 +#[derive(Debug, Clone)]
117 +pub struct ChordAnalysis {
118 + pub numeral: String,
119 + pub kind: Kind,
120 + pub role: String,
121 +}
122 +
123 +fn semis_diff(semis: i32, reference: i32) -> i32 {
124 + (semis - reference + 6).rem_euclid(12) - 6
125 +}
126 +
127 +fn accidental_prefix(diff: i32) -> String {
128 + if diff > 0 {
129 + "#".repeat(diff as usize)
130 + } else {
131 + "b".repeat((-diff) as usize)
132 + }
133 +}
134 +
135 +/// Numeral decoration derived from the canonical chord suffix (`ii7`, `viiø7`, `V7#9`, `I+`).
136 +fn numeral_suffix(suffix: &str) -> String {
137 + if let Some(rest) = suffix.strip_prefix("m7b5") {
138 + format!("ø7{rest}")
139 + } else if let Some(rest) = suffix.strip_prefix("dim7") {
140 + format!("°7{rest}")
141 + } else if let Some(rest) = suffix.strip_prefix("dim") {
142 + format!("°{rest}")
143 + } else if let Some(rest) = suffix.strip_prefix("aug") {
144 + format!("+{rest}")
145 + } else if let Some(rest) = suffix.strip_prefix("mMaj") {
146 + format!("(maj{rest})")
147 + } else if suffix.starts_with("maj") {
148 + suffix.to_string()
149 + } else if let Some(rest) = suffix.strip_prefix('m') {
150 + rest.to_string()
151 + } else {
152 + suffix.to_string()
153 + }
154 +}
155 +
156 +fn numeral_for(key: &Key, chord: &Chord) -> (String, usize) {
157 + let steps = (chord.root.letter as usize + 7 - key.tonic.letter as usize) % 7;
158 + let semis = key.tonic.semis_to(chord.root);
159 + let mut diff = semis_diff(semis, key.reference()[steps]);
160 + if key.mode == Mode::Minor && steps == 6 && diff == 1 {
161 + diff = 0; // leading-tone vii° of harmonic minor
162 + }
163 + let minor_like = chord.is_minor_third();
164 + let base = if minor_like {
165 + ROMAN[steps].to_lowercase()
166 + } else {
167 + ROMAN[steps].to_string()
168 + };
169 + let mut numeral = format!(
170 + "{}{}{}",
171 + accidental_prefix(diff),
172 + base,
173 + numeral_suffix(&chord.suffix())
174 + );
175 + if let Some(b) = chord.bass.filter(|b| b.pc() != chord.root.pc()) {
176 + numeral.push('/');
177 + numeral.push_str(&b.name());
178 + }
179 + (numeral, steps)
180 +}
181 +
182 +/// Triad numeral of the diatonic chord on scale degree `idx` (`ii`, `V`, `vii°`).
183 +fn diatonic_numeral(key: &Key, idx: usize) -> String {
184 + let notes = key.notes();
185 + let third = notes[idx].semis_to(notes[(idx + 2) % 7]);
186 + let fifth = notes[idx].semis_to(notes[(idx + 4) % 7]);
187 + match (third, fifth) {
188 + (3, 6) => format!("{}°", ROMAN[idx].to_lowercase()),
189 + (3, _) => ROMAN[idx].to_lowercase(),
190 + _ => ROMAN[idx].to_string(),
191 + }
192 +}
193 +
194 +fn mode_pcs(tonic: Note, name: &str) -> Vec<i32> {
195 + scale::find(name)
196 + .map(|d| d.spell(tonic).iter().map(|n| n.pc()).collect())
197 + .unwrap_or_default()
198 +}
199 +
200 +fn function_name(key: &Key, steps: usize) -> &'static str {
201 + match steps {
202 + 0 => "tonic",
203 + 1 => "supertonic (predominant)",
204 + 2 => match key.mode {
205 + Mode::Major => "mediant (tonic function)",
206 + Mode::Minor => "mediant (relative major, tonic function)",
207 + },
208 + 3 => "subdominant (predominant)",
209 + 4 => "dominant",
210 + 5 => match key.mode {
211 + Mode::Major => "submediant (tonic function)",
212 + Mode::Minor => "submediant (predominant function)",
213 + },
214 + _ => match key.mode {
215 + Mode::Major => "leading-tone (dominant function)",
216 + Mode::Minor => "subtonic (dominant function)",
217 + },
218 + }
219 +}
220 +
221 +pub fn analyze(key: &Key, chord: &Chord) -> ChordAnalysis {
222 + let (numeral, steps) = numeral_for(key, chord);
223 + let done = |kind, role: String| ChordAnalysis {
224 + numeral: numeral.clone(),
225 + kind,
226 + role,
227 + };
228 + let scale_pcs = key.pcs();
229 + let chord_pcs = chord.pcs();
230 + let in_scale = |pcs: &[i32], scale: &[i32]| pcs.iter().all(|p| scale.contains(p));
231 +
232 + if in_scale(&chord_pcs, &scale_pcs) {
233 + let in_scale_root = scale_pcs.contains(&chord.root.pc());
234 + let role = if in_scale_root {
235 + function_name(key, steps).to_string()
236 + } else {
237 + "diatonic".to_string()
238 + };
239 + return done(Kind::Diatonic, role);
240 + }
241 + if key.mode == Mode::Minor && matches!(steps, 4 | 6) {
242 + let harmonic = mode_pcs(key.tonic, "harmonic minor");
243 + if in_scale(&chord_pcs, &harmonic) {
244 + return done(
245 + Kind::DiatonicHarmonic,
246 + format!(
247 + "{} (raised leading tone, harmonic minor)",
248 + function_name(key, steps)
249 + ),
250 + );
251 + }
252 + }
253 +
254 + let rel = key.tonic.semis_to(chord.root);
255 + let notes = key.notes();
256 + let target_for = |target_pc: i32| -> Option<usize> {
257 + notes
258 + .iter()
259 + .position(|n| n.pc() == target_pc)
260 + .filter(|&i| i != 0 && !diatonic_numeral(key, i).ends_with('°'))
261 + };
262 + let dominant_type = chord.has(2, 4) && (chord.seventh().is_none() || chord.has(6, 10));
263 + let suffix = chord.suffix();
264 + if dominant_type && let Some(t) = target_for((chord.root.pc() + 5) % 12) {
265 + return done(
266 + Kind::SecondaryDominant,
267 + format!(
268 + "secondary dominant (V{suffix}/{})",
269 + diatonic_numeral(key, t)
270 + ),
271 + );
272 + }
273 + let dim_type = chord.is_minor_third() && chord.has(4, 6);
274 + if dim_type && let Some(t) = target_for((chord.root.pc() + 1) % 12) {
275 + let kind = if chord.has(6, 9) {
276 + "°7"
277 + } else if chord.has(6, 10) {
278 + "ø7"
279 + } else {
280 + "°"
281 + };
282 + return done(
283 + Kind::SecondaryLeadingTone,
284 + format!(
285 + "secondary leading-tone chord (vii{kind}/{})",
286 + diatonic_numeral(key, t)
287 + ),
288 + );
289 + }
290 + if chord.is_dominant() {
291 + let down = (chord.root.pc() + 11) % 12;
292 + if down == key.tonic.pc() {
293 + return done(
294 + Kind::TritoneSub,
295 + "tritone substitution of V7 (subV7), resolves down by half step to the tonic"
296 + .into(),
297 + );
298 + }
299 + if let Some(t) = target_for(down) {
300 + return done(
301 + Kind::TritoneSub,
302 + format!(
303 + "tritone substitution of V7/{} (subV7/{})",
304 + diatonic_numeral(key, t),
305 + diatonic_numeral(key, t)
306 + ),
307 + );
308 + }
309 + }
310 + if rel == 1 && chord.has(2, 4) && chord.seventh().is_none() {
311 + return done(
312 + Kind::Neapolitan,
313 + "Neapolitan chord (bII), usually in first inversion before V".into(),
314 + );
315 + }
316 + let parallel: &[(&str, &str)] = match key.mode {
317 + Mode::Major => &[
318 + ("natural minor", "parallel minor"),
319 + ("dorian", "dorian"),
320 + ("mixolydian", "mixolydian"),
321 + ("lydian", "lydian"),
322 + ("phrygian", "phrygian"),
323 + ("harmonic minor", "parallel harmonic minor"),
324 + ("melodic minor", "parallel melodic minor"),
325 + ],
326 + Mode::Minor => &[
327 + ("major", "parallel major"),
328 + ("dorian", "dorian"),
329 + ("mixolydian", "mixolydian"),
330 + ("lydian", "lydian"),
331 + ("phrygian", "phrygian"),
332 + ("melodic minor", "melodic minor"),
333 + ],
334 + };
335 + for (scale_name, label) in parallel {
336 + if in_scale(&chord_pcs, &mode_pcs(key.tonic, scale_name)) {
337 + return done(
338 + Kind::Borrowed,
339 + format!(
340 + "borrowed chord (modal interchange from {} {label})",
341 + key.tonic.name()
342 + ),
343 + );
344 + }
345 + }
346 + done(
347 + Kind::Chromatic,
348 + "chromatic: outside the key and not a common function".into(),
349 + )
350 +}
351 +
352 +#[derive(Debug, Clone)]
353 +pub struct KeyGuess {
354 + pub key: Key,
355 + pub score: f64,
356 + pub confidence: f64,
357 + pub diatonic: usize,
358 +}
359 +
360 +const MAJOR_TONICS: [&str; 15] = [
361 + "C", "G", "D", "A", "E", "B", "F#", "C#", "F", "Bb", "Eb", "Ab", "Db", "Gb", "Cb",
362 +];
363 +const MINOR_TONICS: [&str; 15] = [
364 + "A", "E", "B", "F#", "C#", "G#", "D#", "A#", "D", "G", "C", "F", "Bb", "Eb", "Ab",
365 +];
366 +
367 +/// Rank candidate keys for a progression. Scores reward diatonic fit, a tonic at the start
368 +/// and end, and V→I motion; borrowed chords and secondary dominants count for less.
369 +pub fn detect(chords: &[Chord]) -> Vec<KeyGuess> {
370 + if chords.is_empty() {
371 + return Vec::new();
372 + }
373 + let mut guesses = Vec::new();
374 + for (tonics, mode) in [(MAJOR_TONICS, Mode::Major), (MINOR_TONICS, Mode::Minor)] {
375 + for t in tonics {
376 + let key = Key {
377 + tonic: Note::parse(t).unwrap(),
378 + mode,
379 + };
380 + let analyses: Vec<ChordAnalysis> = chords.iter().map(|c| analyze(&key, c)).collect();
381 + let mut score = 0.0;
382 + let mut diatonic = 0;
383 + for a in &analyses {
384 + score += match a.kind {
385 + Kind::Diatonic | Kind::DiatonicHarmonic => {
386 + diatonic += 1;
387 + 1.0
388 + }
389 + Kind::SecondaryDominant | Kind::SecondaryLeadingTone => 0.5,
390 + Kind::TritoneSub | Kind::Neapolitan => 0.4,
391 + Kind::Borrowed => 0.3,
392 + Kind::Chromatic => 0.0,
393 + };
394 + }
395 + let is_tonic = |c: &Chord| {
396 + c.root.pc() == key.tonic.pc()
397 + && (c.is_minor_third() == (mode == Mode::Minor))
398 + && c.third().is_some()
399 + };
400 + if is_tonic(&chords[0]) {
401 + score += 0.9;
402 + }
403 + if chords.len() > 1 && is_tonic(&chords[chords.len() - 1]) {
404 + score += 0.75;
405 + }
406 + let has_cadence = chords.windows(2).any(|w| {
407 + w[0].root.pc() == (key.tonic.pc() + 7) % 12 && w[0].has(2, 4) && is_tonic(&w[1])
408 + });
409 + if has_cadence {
410 + score += 0.5;
411 + }
412 + // Prefer the spelling with fewer accidentals when keys are enharmonic.
413 + score -= key.signature().unsigned_abs() as f64 * 0.001;
414 + let max = chords.len() as f64 + 2.15;
415 + guesses.push(KeyGuess {
416 + key,
417 + score,
418 + confidence: (score / max).clamp(0.0, 1.0),
419 + diatonic,
420 + });
421 + }
422 + }
423 + guesses.sort_by(|a, b| b.score.total_cmp(&a.score));
424 + guesses
425 +}
426 +
427 +fn parse_roman(s: &str) -> Option<(usize, &str)> {
428 + let upper = s.to_ascii_uppercase();
429 + // Longest match first so IV isn't read as I + V.
430 + for (i, name) in ROMAN.iter().enumerate().rev() {
431 + if upper.starts_with(name) {
432 + return Some((i, &s[name.len()..]));
433 + }
434 + }
435 + None
436 +}
437 +
438 +/// Realize a Roman numeral (`ii7`, `V7`, `Imaj7`, `bVII`, `vii°7`, `V7/V`) in `key`.
439 +pub fn realize(key: &Key, numeral: &str) -> Result<Chord> {
440 + let numeral = numeral.trim();
441 + if let Some((head, target)) = numeral.split_once('/') {
442 + let target_chord = realize(key, target)?;
443 + let sub_key = Key {
444 + tonic: target_chord.root,
445 + mode: Mode::Major,
446 + };
447 + return realize(&sub_key, head);
448 + }
449 + let body = numeral.trim_start_matches(['#', 'b']);
450 + let acc: i32 = numeral[..numeral.len() - body.len()]
451 + .chars()
452 + .map(|c| if c == '#' { 1 } else { -1 })
453 + .sum();
454 + let Some((degree, suffix)) = parse_roman(body) else {
455 + bail!("not a Roman numeral: `{numeral}` (use e.g. ii7, V7, Imaj7, bVII, vii°7)");
456 + };
457 + let letters = &body[..body.len() - suffix.len()];
458 + let lower = letters.chars().all(|c| c.is_ascii_lowercase());
459 + let root = key.degree_note(degree, acc);
460 + let quality = if let Some(r) = suffix
461 + .strip_prefix(['°', 'º', 'o'])
462 + .or_else(|| suffix.strip_prefix("dim"))
463 + {
464 + format!("dim{r}")
465 + } else if let Some(r) = suffix.strip_prefix(['ø', 'Ø']) {
466 + format!("m7b5{}", r.strip_prefix('7').unwrap_or(r))
467 + } else if let Some(r) = suffix
468 + .strip_prefix('+')
469 + .or_else(|| suffix.strip_prefix("aug"))
470 + {
471 + format!("aug{r}")
472 + } else if lower {
473 + match suffix.strip_prefix("maj") {
474 + Some(r) => format!("mMaj{r}"),
475 + None => format!("m{suffix}"),
476 + }
477 + } else {
478 + suffix.to_string()
479 + };
480 + Chord::parse(&format!("{}{quality}", root.name()))
481 +}
482 +
483 +/// Split a progression string into chord symbols (whitespace, `|` and `,` separate).
484 +pub fn split_progression(s: &str) -> Vec<String> {
485 + s.split(|c: char| c.is_whitespace() || matches!(c, '|' | ','))
486 + .map(str::trim)
487 + .filter(|t| !t.is_empty() && t.chars().any(|c| c.is_alphanumeric()))
488 + .map(String::from)
489 + .collect()
490 +}
491 +
492 +#[cfg(test)]
493 +mod tests {
494 + use super::*;
495 +
496 + fn chords(s: &str) -> Vec<Chord> {
497 + split_progression(s)
498 + .iter()
499 + .map(|c| Chord::parse(c).unwrap())
500 + .collect()
501 + }
502 +
503 + fn numerals(key: &str, s: &str) -> Vec<String> {
504 + let key = Key::parse(key).unwrap();
505 + chords(s).iter().map(|c| analyze(&key, c).numeral).collect()
506 + }
507 +
508 + #[test]
509 + fn parse_keys() {
510 + assert_eq!(Key::parse("Am").unwrap().name(), "A minor");
511 + assert_eq!(Key::parse("F# minor").unwrap().name(), "F# minor");
512 + assert_eq!(Key::parse("Bb").unwrap().name(), "Bb major");
513 + assert_eq!(Key::parse("c major").unwrap().name(), "C major");
514 + assert_eq!(Key::parse("Ebmin").unwrap().name(), "Eb minor");
515 + assert!(Key::parse("H").is_err());
516 + assert!(Key::parse("C lydian").is_err());
517 + }
518 +
519 + #[test]
520 + fn roman_numerals_in_major() {
521 + assert_eq!(numerals("C", "Dm7 G7 Cmaj7"), ["ii7", "V7", "Imaj7"]);
522 + assert_eq!(numerals("C", "C Am F G7"), ["I", "vi", "IV", "V7"]);
523 + assert_eq!(
524 + numerals("C", "Bdim Bm7b5 Bdim7"),
525 + ["vii°", "viiø7", "vii°7"]
526 + );
527 + assert_eq!(numerals("C", "Bb Ab Fm"), ["bVII", "bVI", "iv"]);
528 + }
529 +
530 + #[test]
531 + fn roman_numerals_in_minor() {
532 + assert_eq!(numerals("Am", "Am Dm E7 Am"), ["i", "iv", "V7", "i"]);
533 + assert_eq!(numerals("Am", "Am G F E"), ["i", "VII", "VI", "V"]);
534 + assert_eq!(numerals("Am", "G#dim7"), ["vii°7"]);
535 + }
536 +
537 + #[test]
538 + fn secondary_dominants_and_borrowed() {
539 + let key = Key::parse("C").unwrap();
540 + let a = analyze(&key, &Chord::parse("A7").unwrap());
541 + assert_eq!(a.kind, Kind::SecondaryDominant);
542 + assert!(a.role.contains("V7/ii"), "{}", a.role);
543 + let a = analyze(&key, &Chord::parse("D7").unwrap());
544 + assert!(a.role.contains("V7/V"), "{}", a.role);
545 + let a = analyze(&key, &Chord::parse("F#dim7").unwrap());
546 + assert_eq!(a.kind, Kind::SecondaryLeadingTone);
547 + assert!(a.role.contains("vii°7/V"), "{}", a.role);
548 + let a = analyze(&key, &Chord::parse("Fm").unwrap());
549 + assert_eq!(a.kind, Kind::Borrowed);
550 + assert!(a.role.contains("parallel minor"));
551 + assert_eq!(
552 + analyze(&key, &Chord::parse("Db7").unwrap()).kind,
553 + Kind::TritoneSub
554 + );
555 + assert_eq!(
556 + analyze(&key, &Chord::parse("Db").unwrap()).kind,
557 + Kind::Neapolitan
558 + );
559 + assert_eq!(
560 + analyze(&key, &Chord::parse("F#maj7").unwrap()).kind,
561 + Kind::Chromatic
562 + );
563 + }
564 +
565 + #[test]
566 + fn detects_keys() {
567 + let top = |s: &str| detect(&chords(s))[0].key.name();
568 + assert_eq!(top("Dm7 G7 Cmaj7"), "C major");
569 + assert_eq!(top("C F G7 C"), "C major");
570 + assert_eq!(top("Am Dm E7 Am"), "A minor");
571 + assert_eq!(top("F#m7 B7 Emaj7"), "E major");
572 + assert_eq!(top("Bbmaj7 Gm7 Cm7 F7"), "Bb major");
573 + let guesses = detect(&chords("Dm7 G7 Cmaj7"));
574 + assert!(guesses[0].confidence > 0.8);
575 + assert!(guesses[0].confidence > guesses[1].confidence);
576 + assert!(detect(&[]).is_empty());
577 + }
578 +
579 + #[test]
580 + fn realizes_ii_v_i_in_all_keys() {
581 + let expected = [
582 + ("C", "Dm7 G7 Cmaj7"),
583 + ("G", "Am7 D7 Gmaj7"),
584 + ("D", "Em7 A7 Dmaj7"),
585 + ("A", "Bm7 E7 Amaj7"),
586 + ("E", "F#m7 B7 Emaj7"),
587 + ("B", "C#m7 F#7 Bmaj7"),
588 + ("F#", "G#m7 C#7 F#maj7"),
589 + ("C#", "D#m7 G#7 C#maj7"),
590 + ("F", "Gm7 C7 Fmaj7"),
591 + ("Bb", "Cm7 F7 Bbmaj7"),
592 + ("Eb", "Fm7 Bb7 Ebmaj7"),
593 + ("Ab", "Bbm7 Eb7 Abmaj7"),
594 + ("Db", "Ebm7 Ab7 Dbmaj7"),
595 + ("Gb", "Abm7 Db7 Gbmaj7"),
596 + ("Cb", "Dbm7 Gb7 Cbmaj7"),
597 + ];
598 + for (key, want) in expected {
599 + let k = Key::parse(key).unwrap();
600 + let got: Vec<String> = ["ii7", "V7", "Imaj7"]
601 + .iter()
602 + .map(|n| realize(&k, n).unwrap().name())
603 + .collect();
604 + assert_eq!(got.join(" "), want, "key {key}");
605 + }
606 + }
607 +
608 + #[test]
609 + fn realizes_borrowed_secondary_and_minor() {
610 + let c = Key::parse("C").unwrap();
611 + let name = |k: &Key, n: &str| realize(k, n).unwrap().name();
612 + assert_eq!(name(&c, "bVII"), "Bb");
613 + assert_eq!(name(&c, "V7/V"), "D7");
614 + assert_eq!(name(&c, "vii°7/ii"), "C#dim7");
615 + assert_eq!(name(&c, "viiø7"), "Bm7b5");
616 + assert_eq!(name(&c, "IVmaj7"), "Fmaj7");
617 + let am = Key::parse("Am").unwrap();
618 + assert_eq!(name(&am, "i7"), "Am7");
619 + assert_eq!(name(&am, "iv"), "Dm");
620 + assert_eq!(name(&am, "VI"), "F");
621 + assert_eq!(name(&am, "V7"), "E7");
622 + assert!(realize(&c, "IX").is_err());
623 + }
624 +
625 + #[test]
626 + fn splits_progressions() {
627 + assert_eq!(
628 + split_progression("| Dm7 | G7, Cmaj7 - |"),
629 + ["Dm7", "G7", "Cmaj7"]
630 + );
631 + }
632 +}
src/theory/mod.rs new
+9
@@ -0,0 +1,9 @@
1 +//! Deterministic music theory: pure functions, no I/O. Spelling is diatonic (letter plus
2 +//! accidental), never bare pitch classes, so C# major has E# and B#.
3 +
4 +pub mod chord;
5 +pub mod interval;
6 +pub mod key;
7 +pub mod pitch;
8 +pub mod scale;
9 +pub mod tempo;
src/theory/pitch.rs new
+219
@@ -0,0 +1,219 @@
1 +//! Spelled notes, MIDI numbers and frequencies.
2 +
3 +use anyhow::{Result, bail};
4 +
5 +/// Pitch class of each natural letter, C D E F G A B.
6 +pub const NATURAL_PC: [i32; 7] = [0, 2, 4, 5, 7, 9, 11];
7 +const LETTERS: [char; 7] = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];
8 +
9 +/// A spelled pitch class: a letter plus an accidental (+1 sharp, -1 flat, ...).
10 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11 +pub struct Note {
12 + /// 0 = C ... 6 = B.
13 + pub letter: u8,
14 + pub acc: i8,
15 +}
16 +
17 +impl Note {
18 + pub fn new(letter: u8, acc: i8) -> Self {
19 + Self {
20 + letter: letter % 7,
21 + acc,
22 + }
23 + }
24 +
25 + pub fn pc(self) -> i32 {
26 + (NATURAL_PC[self.letter as usize] + self.acc as i32).rem_euclid(12)
27 + }
28 +
29 + pub fn name(self) -> String {
30 + let mut s = LETTERS[self.letter as usize].to_string();
31 + match self.acc {
32 + 0 => {}
33 + n if n > 0 => s.push_str(&"#".repeat(n as usize)),
34 + n => s.push_str(&"b".repeat((-n) as usize)),
35 + }
36 + s
37 + }
38 +
39 + /// Parse `C`, `F#`, `Bb`, `Ebb`, `C##`, `Fx`, `B♭`. Returns the note and the unparsed rest.
40 + pub fn parse_prefix(s: &str) -> Option<(Note, &str)> {
41 + let mut chars = s.chars();
42 + let first = chars.next()?;
43 + let letter = LETTERS
44 + .iter()
45 + .position(|&l| l == first.to_ascii_uppercase())?;
46 + // Lowercase letters are only accepted by the caller when it asks for them.
47 + if !first.is_ascii_uppercase() {
48 + return None;
49 + }
50 + let mut acc = 0i8;
51 + let mut rest = &s[first.len_utf8()..];
52 + while let Some(c) = rest.chars().next() {
53 + let delta = match c {
54 + '#' | '♯' => 1,
55 + 'x' | '𝄪' => 2,
56 + // `b` is an accidental here; the caller decides whether a trailing `b` after a
57 + // root could instead be part of a quality (it never is for Bb/Ebb style roots).
58 + 'b' | '♭' => -1,
59 + _ => break,
60 + };
61 + acc += delta;
62 + rest = &rest[c.len_utf8()..];
63 + }
64 + Some((Note::new(letter as u8, acc), rest))
65 + }
66 +
67 + pub fn parse(s: &str) -> Result<Note> {
68 + let s = s.trim();
69 + match Note::parse_prefix(s) {
70 + Some((n, "")) => Ok(n),
71 + _ => bail!("not a note name: `{s}` (use e.g. C, F#, Bb)"),
72 + }
73 + }
74 +
75 + /// Move up by `steps` letters and `semitones` semitones, spelling the result diatonically.
76 + pub fn up(self, steps: usize, semitones: i32) -> Note {
77 + let letter = (self.letter as usize + steps) % 7;
78 + let target = (self.pc() + semitones).rem_euclid(12);
79 + let mut acc = (target - NATURAL_PC[letter]).rem_euclid(12);
80 + if acc > 6 {
81 + acc -= 12;
82 + }
83 + Note {
84 + letter: letter as u8,
85 + acc: acc as i8,
86 + }
87 + }
88 +
89 + /// Semitones from `self` up to `other`, 0..12.
90 + pub fn semis_to(self, other: Note) -> i32 {
91 + (other.pc() - self.pc()).rem_euclid(12)
92 + }
93 +}
94 +
95 +/// Spell a pitch class using the conventional sharp or flat set.
96 +pub fn spell_pc(pc: i32, prefer_flats: bool) -> Note {
97 + const SHARP: [(u8, i8); 12] = [
98 + (0, 0),
99 + (0, 1),
100 + (1, 0),
101 + (1, 1),
102 + (2, 0),
103 + (3, 0),
104 + (3, 1),
105 + (4, 0),
106 + (4, 1),
107 + (5, 0),
108 + (5, 1),
109 + (6, 0),
110 + ];
111 + const FLAT: [(u8, i8); 12] = [
112 + (0, 0),
113 + (1, -1),
114 + (1, 0),
115 + (2, -1),
116 + (2, 0),
117 + (3, 0),
118 + (4, -1),
119 + (4, 0),
120 + (5, -1),
121 + (5, 0),
122 + (6, -1),
123 + (6, 0),
124 + ];
125 + let (l, a) = if prefer_flats { FLAT } else { SHARP }[pc.rem_euclid(12) as usize];
126 + Note { letter: l, acc: a }
127 +}
128 +
129 +/// A note with an octave in scientific pitch notation (C4 = middle C = MIDI 60).
130 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131 +pub struct Pitch {
132 + pub note: Note,
133 + pub octave: i32,
134 +}
135 +
136 +impl Pitch {
137 + pub fn parse(s: &str) -> Result<Pitch> {
138 + let s = s.trim();
139 + let Some((note, rest)) = Note::parse_prefix(s) else {
140 + bail!("not a pitch: `{s}` (use e.g. A4, C#3, Bb2)");
141 + };
142 + let octave: i32 = rest
143 + .parse()
144 + .map_err(|_| anyhow::anyhow!("not a pitch: `{s}` (octave number missing, e.g. A4)"))?;
145 + Ok(Pitch { note, octave })
146 + }
147 +
148 + pub fn midi(self) -> i32 {
149 + let base = NATURAL_PC[self.note.letter as usize] + self.note.acc as i32;
150 + (self.octave + 1) * 12 + base
151 + }
152 +
153 + pub fn name(self) -> String {
154 + format!("{}{}", self.note.name(), self.octave)
155 + }
156 +
157 + pub fn from_midi(midi: i32, prefer_flats: bool) -> Pitch {
158 + let note = spell_pc(midi, prefer_flats);
159 + // Octave follows the letter, so B#3 / Cb4 style spellings keep the right octave.
160 + let octave = (midi - NATURAL_PC[note.letter as usize] - note.acc as i32).div_euclid(12) - 1;
161 + Pitch { note, octave }
162 + }
163 +}
164 +
165 +pub fn midi_to_hz(midi: f64, a4: f64) -> f64 {
166 + a4 * 2f64.powf((midi - 69.0) / 12.0)
167 +}
168 +
169 +pub fn hz_to_midi(hz: f64, a4: f64) -> f64 {
170 + 69.0 + 12.0 * (hz / a4).log2()
171 +}
172 +
173 +pub fn cents_between(hz_a: f64, hz_b: f64) -> f64 {
174 + 1200.0 * (hz_b / hz_a).log2()
175 +}
176 +
177 +#[cfg(test)]
178 +mod tests {
179 + use super::*;
180 +
181 + #[test]
182 + fn a4_is_midi_69_and_440() {
183 + let p = Pitch::parse("A4").unwrap();
184 + assert_eq!(p.midi(), 69);
185 + assert!((midi_to_hz(69.0, 440.0) - 440.0).abs() < 1e-9);
186 + assert!((hz_to_midi(440.0, 440.0) - 69.0).abs() < 1e-9);
187 + assert_eq!(Pitch::parse("C4").unwrap().midi(), 60);
188 + assert_eq!(Pitch::parse("C-1").unwrap().midi(), 0);
189 + }
190 +
191 + #[test]
192 + fn a4_432_shifts_frequencies() {
193 + assert!((midi_to_hz(69.0, 432.0) - 432.0).abs() < 1e-9);
194 + assert!((midi_to_hz(60.0, 432.0) - 256.87).abs() < 0.01);
195 + }
196 +
197 + #[test]
198 + fn enharmonic_octaves() {
199 + assert_eq!(Pitch::parse("Cb4").unwrap().midi(), 59);
200 + assert_eq!(Pitch::parse("B#3").unwrap().midi(), 60);
201 + assert_eq!(Pitch::from_midi(61, false).name(), "C#4");
202 + assert_eq!(Pitch::from_midi(61, true).name(), "Db4");
203 + }
204 +
205 + #[test]
206 + fn spelling_up() {
207 + let c_sharp = Note::parse("C#").unwrap();
208 + // major third above C# is E#, not F.
209 + assert_eq!(c_sharp.up(2, 4).name(), "E#");
210 + assert_eq!(c_sharp.up(6, 11).name(), "B#");
211 + assert_eq!(Note::parse("Bb").unwrap().up(2, 4).name(), "D");
212 + assert_eq!(Note::parse("Fb").unwrap().up(4, 7).name(), "Cb");
213 + }
214 +
215 + #[test]
216 + fn cents() {
217 + assert!((cents_between(440.0, 440.0 * 2f64.powf(1.0 / 12.0)) - 100.0).abs() < 1e-6);
218 + }
219 +}
src/theory/scale.rs new
+213
@@ -0,0 +1,213 @@
1 +//! Scales and modes, spelled diatonically from a formula of degree tokens.
2 +
3 +use anyhow::{Result, bail};
4 +
5 +use super::interval::Interval;
6 +use super::pitch::Note;
7 +
8 +pub struct ScaleDef {
9 + pub name: &'static str,
10 + pub aliases: &'static [&'static str],
11 + /// Degree tokens relative to the major scale.
12 + pub formula: &'static str,
13 +}
14 +
15 +const fn def(
16 + name: &'static str,
17 + aliases: &'static [&'static str],
18 + formula: &'static str,
19 +) -> ScaleDef {
20 + ScaleDef {
21 + name,
22 + aliases,
23 + formula,
24 + }
25 +}
26 +
27 +pub const SCALES: &[ScaleDef] = &[
28 + def("major", &["ionian", "maj"], "1 2 3 4 5 6 7"),
29 + def(
30 + "natural minor",
31 + &["minor", "aeolian", "min"],
32 + "1 2 b3 4 5 b6 b7",
33 + ),
34 + def("dorian", &[], "1 2 b3 4 5 6 b7"),
35 + def("phrygian", &[], "1 b2 b3 4 5 b6 b7"),
36 + def("lydian", &[], "1 2 3 #4 5 6 7"),
37 + def("mixolydian", &["dominant"], "1 2 3 4 5 6 b7"),
38 + def("locrian", &[], "1 b2 b3 4 b5 b6 b7"),
39 + def("harmonic minor", &[], "1 2 b3 4 5 b6 7"),
40 + def("melodic minor", &["jazz minor"], "1 2 b3 4 5 6 7"),
41 + def("harmonic major", &[], "1 2 3 4 5 b6 7"),
42 + def("dorian b2", &["phrygian #6"], "1 b2 b3 4 5 6 b7"),
43 + def("lydian augmented", &[], "1 2 3 #4 #5 6 7"),
44 + def(
45 + "lydian dominant",
46 + &["overtone", "acoustic"],
47 + "1 2 3 #4 5 6 b7",
48 + ),
49 + def(
50 + "mixolydian b6",
51 + &["hindu", "melodic major"],
52 + "1 2 3 4 5 b6 b7",
53 + ),
54 + def(
55 + "locrian #2",
56 + &["half-diminished", "aeolian b5"],
57 + "1 2 b3 4 b5 b6 b7",
58 + ),
59 + def(
60 + "altered",
61 + &["super locrian", "diminished whole tone"],
62 + "1 b2 b3 b4 b5 b6 b7",
63 + ),
64 + def(
65 + "phrygian dominant",
66 + &["spanish", "freygish"],
67 + "1 b2 3 4 5 b6 b7",
68 + ),
69 + def("major pentatonic", &["pentatonic major"], "1 2 3 5 6"),
70 + def(
71 + "minor pentatonic",
72 + &["pentatonic minor", "pentatonic"],
73 + "1 b3 4 5 b7",
74 + ),
75 + def("blues", &["minor blues"], "1 b3 4 b5 5 b7"),
76 + def("major blues", &[], "1 2 b3 3 5 6"),
77 + def("whole tone", &[], "1 2 3 #4 #5 b7"),
78 + def(
79 + "diminished whole-half",
80 + &["octatonic whole-half", "diminished"],
81 + "1 2 b3 4 b5 b6 6 7",
82 + ),
83 + def(
84 + "diminished half-whole",
85 + &["octatonic half-whole", "dominant diminished"],
86 + "1 b2 b3 3 #4 5 6 b7",
87 + ),
88 + def("bebop dominant", &[], "1 2 3 4 5 6 b7 7"),
89 +];
90 +
91 +fn norm(s: &str) -> String {
92 + s.trim()
93 + .to_lowercase()
94 + .replace(['_', '-'], " ")
95 + .split_whitespace()
96 + .collect::<Vec<_>>()
97 + .join(" ")
98 +}
99 +
100 +pub fn find(name: &str) -> Option<&'static ScaleDef> {
101 + let n = norm(name);
102 + SCALES
103 + .iter()
104 + .find(|d| norm(d.name) == n || d.aliases.iter().any(|a| norm(a) == n))
105 +}
106 +
107 +pub fn names() -> Vec<&'static str> {
108 + SCALES.iter().map(|d| d.name).collect()
109 +}
110 +
111 +impl ScaleDef {
112 + pub fn intervals(&self) -> Vec<Interval> {
113 + self.formula
114 + .split_whitespace()
115 + .filter_map(Interval::from_token)
116 + .collect()
117 + }
118 +
119 + pub fn spell(&self, root: Note) -> Vec<Note> {
120 + self.intervals()
121 + .into_iter()
122 + .map(|i| root.up(i.steps, i.semis))
123 + .collect()
124 + }
125 +}
126 +
127 +pub fn parse_scale(name: &str) -> Result<&'static ScaleDef> {
128 + match find(name) {
129 + Some(d) => Ok(d),
130 + None => bail!("unknown scale `{name}`; known: {}", names().join(", ")),
131 + }
132 +}
133 +
134 +/// Signed accidental count of a diatonic scale's key signature (+ sharps, - flats).
135 +pub fn signature(notes: &[Note]) -> i32 {
136 + notes.iter().map(|n| n.acc as i32).sum()
137 +}
138 +
139 +/// Names the accidentals of a key signature in order of appearance, e.g. `F# C#`.
140 +pub fn signature_text(count: i32) -> String {
141 + const SHARPS: [&str; 7] = ["F#", "C#", "G#", "D#", "A#", "E#", "B#"];
142 + const FLATS: [&str; 7] = ["Bb", "Eb", "Ab", "Db", "Gb", "Cb", "Fb"];
143 + match count {
144 + 0 => "no sharps or flats".into(),
145 + n if n > 0 && n <= 7 => format!(
146 + "{n} sharp{} ({})",
147 + plural(n),
148 + SHARPS[..n as usize].join(" ")
149 + ),
150 + n if (-7..0).contains(&n) => {
151 + let n = -n;
152 + format!("{n} flat{} ({})", plural(n), FLATS[..n as usize].join(" "))
153 + }
154 + n => format!("{n} accidentals (theoretical key with double accidentals)"),
155 + }
156 +}
157 +
158 +fn plural(n: i32) -> &'static str {
159 + if n == 1 { "" } else { "s" }
160 +}
161 +
162 +#[cfg(test)]
163 +mod tests {
164 + use super::*;
165 +
166 + fn spelled(root: &str, scale: &str) -> String {
167 + let notes = parse_scale(scale)
168 + .unwrap()
169 + .spell(Note::parse(root).unwrap());
170 + notes.iter().map(|n| n.name()).collect::<Vec<_>>().join(" ")
171 + }
172 +
173 + #[test]
174 + fn c_sharp_major_has_e_sharp_and_b_sharp() {
175 + assert_eq!(spelled("C#", "major"), "C# D# E# F# G# A# B#");
176 + let notes = parse_scale("major")
177 + .unwrap()
178 + .spell(Note::parse("C#").unwrap());
179 + assert_eq!(signature(&notes), 7);
180 + }
181 +
182 + #[test]
183 + fn flat_keys_and_modes() {
184 + assert_eq!(spelled("Gb", "major"), "Gb Ab Bb Cb Db Eb F");
185 + assert_eq!(spelled("D", "dorian"), "D E F G A B C");
186 + assert_eq!(spelled("F#", "locrian"), "F# G A B C D E");
187 + assert_eq!(spelled("A", "harmonic minor"), "A B C D E F G#");
188 + assert_eq!(spelled("C", "lydian"), "C D E F# G A B");
189 + assert_eq!(spelled("C", "altered"), "C Db Eb Fb Gb Ab Bb");
190 + }
191 +
192 + #[test]
193 + fn pentatonic_and_blues() {
194 + assert_eq!(spelled("A", "minor pentatonic"), "A C D E G");
195 + assert_eq!(spelled("C", "major pentatonic"), "C D E G A");
196 + assert_eq!(spelled("E", "blues"), "E G A Bb B D");
197 + assert_eq!(spelled("C", "whole tone"), "C D E F# G# Bb");
198 + }
199 +
200 + #[test]
201 + fn aliases_and_unknown() {
202 + assert!(find("Aeolian").is_some());
203 + assert!(find("natural-minor").is_some());
204 + assert!(parse_scale("klingon").is_err());
205 + }
206 +
207 + #[test]
208 + fn signature_text_names_accidentals() {
209 + assert_eq!(signature_text(2), "2 sharps (F# C#)");
210 + assert_eq!(signature_text(-1), "1 flat (Bb)");
211 + assert_eq!(signature_text(0), "no sharps or flats");
212 + }
213 +}
src/theory/tempo.rs new
+139
@@ -0,0 +1,139 @@
1 +//! Tempo maths: note-value durations, delay times, bar length, sample counts.
2 +
3 +use anyhow::{Result, bail};
4 +
5 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6 +pub enum Feel {
7 + Straight,
8 + Dotted,
9 + Triplet,
10 +}
11 +
12 +impl Feel {
13 + fn factor(self) -> f64 {
14 + match self {
15 + Feel::Straight => 1.0,
16 + Feel::Dotted => 1.5,
17 + Feel::Triplet => 2.0 / 3.0,
18 + }
19 + }
20 +}
21 +
22 +/// (name, denominator): 1/1 ... 1/64.
23 +pub const NOTE_VALUES: [(&str, u32); 7] = [
24 + ("1/1", 1),
25 + ("1/2", 2),
26 + ("1/4", 4),
27 + ("1/8", 8),
28 + ("1/16", 16),
29 + ("1/32", 32),
30 + ("1/64", 64),
31 +];
32 +
33 +pub fn quarter_ms(bpm: f64) -> f64 {
34 + 60_000.0 / bpm
35 +}
36 +
37 +/// Duration in milliseconds of `1/denominator` of a whole note at `bpm` (quarter-note beats).
38 +pub fn note_ms(bpm: f64, denominator: u32, feel: Feel) -> f64 {
39 + quarter_ms(bpm) * 4.0 / denominator as f64 * feel.factor()
40 +}
41 +
42 +pub fn hz_from_ms(ms: f64) -> f64 {
43 + 1000.0 / ms
44 +}
45 +
46 +pub fn samples(ms: f64, sample_rate: f64) -> f64 {
47 + ms * sample_rate / 1000.0
48 +}
49 +
50 +/// A bar of `numerator/denominator`, in milliseconds. Compound meters (6/8, 12/8) are
51 +/// measured in notated note values, so 6/8 is six eighth notes.
52 +pub fn bar_ms(bpm: f64, numerator: u32, denominator: u32) -> f64 {
53 + numerator as f64 * note_ms(bpm, denominator, Feel::Straight)
54 +}
55 +
56 +pub fn parse_time_signature(s: &str) -> Result<(u32, u32)> {
57 + let parsed = s.split_once('/').and_then(|(n, d)| {
58 + let n: u32 = n.trim().parse().ok()?;
59 + let d: u32 = d.trim().parse().ok()?;
60 + (n > 0 && d.is_power_of_two() && d <= 64).then_some((n, d))
61 + });
62 + match parsed {
63 + Some(ts) => Ok(ts),
64 + None => bail!("bad time signature `{s}` (use e.g. 4/4, 6/8, 7/8)"),
65 + }
66 +}
67 +
68 +pub fn parse_note_value(s: &str) -> Result<(u32, Feel)> {
69 + let t = s.trim().to_lowercase();
70 + let (body, feel) = if let Some(b) = t.strip_suffix('.').or_else(|| t.strip_suffix("d")) {
71 + (b.to_string(), Feel::Dotted)
72 + } else if let Some(b) = t.strip_suffix('t') {
73 + (b.to_string(), Feel::Triplet)
74 + } else {
75 + (t, Feel::Straight)
76 + };
77 + let denominator = body
78 + .strip_prefix("1/")
79 + .unwrap_or(&body)
80 + .parse::<u32>()
81 + .ok()
82 + .filter(|d| NOTE_VALUES.iter().any(|(_, n)| n == d));
83 + match denominator {
84 + Some(d) => Ok((d, feel)),
85 + None => bail!("bad note value `{s}` (use 1/4, 1/8., 1/8t, 1/16)"),
86 + }
87 +}
88 +
89 +/// Tempo from a loop or section of `bars` bars lasting `seconds` seconds.
90 +pub fn bpm_from_duration(bars: f64, numerator: u32, denominator: u32, seconds: f64) -> f64 {
91 + let quarter_beats = bars * numerator as f64 * 4.0 / denominator as f64;
92 + quarter_beats * 60.0 / seconds
93 +}
94 +
95 +#[cfg(test)]
96 +mod tests {
97 + use super::*;
98 +
99 + fn close(a: f64, b: f64) -> bool {
100 + (a - b).abs() < 1e-9
101 + }
102 +
103 + #[test]
104 + fn dotted_eighth_at_120_is_375_ms() {
105 + assert!(close(note_ms(120.0, 8, Feel::Dotted), 375.0));
106 + assert!(close(note_ms(120.0, 4, Feel::Straight), 500.0));
107 + assert!(close(note_ms(120.0, 4, Feel::Triplet), 1000.0 / 3.0));
108 + assert!(close(note_ms(120.0, 16, Feel::Straight), 125.0));
109 + assert!(close(note_ms(60.0, 1, Feel::Straight), 4000.0));
110 + }
111 +
112 + #[test]
113 + fn bars_and_samples() {
114 + assert!(close(bar_ms(120.0, 4, 4), 2000.0));
115 + assert!(close(bar_ms(120.0, 3, 4), 1500.0));
116 + assert!(close(bar_ms(120.0, 6, 8), 1500.0));
117 + assert!(close(bar_ms(140.0, 7, 8), 7.0 * 60_000.0 / 140.0 / 2.0));
118 + assert!(close(samples(375.0, 48_000.0), 18_000.0));
119 + assert!(close(hz_from_ms(500.0), 2.0));
120 + }
121 +
122 + #[test]
123 + fn parsing() {
124 + assert_eq!(parse_time_signature("7/8").unwrap(), (7, 8));
125 + assert!(parse_time_signature("4/3").is_err());
126 + assert!(parse_time_signature("x").is_err());
127 + assert_eq!(parse_note_value("1/8.").unwrap(), (8, Feel::Dotted));
128 + assert_eq!(parse_note_value("1/4t").unwrap(), (4, Feel::Triplet));
129 + assert_eq!(parse_note_value("16").unwrap(), (16, Feel::Straight));
130 + assert!(parse_note_value("1/3").is_err());
131 + }
132 +
133 + #[test]
134 + fn tempo_from_loop_length() {
135 + // 4 bars of 4/4 in 8 seconds is 120 bpm.
136 + assert!(close(bpm_from_duration(4.0, 4, 4, 8.0), 120.0));
137 + assert!(close(bpm_from_duration(2.0, 3, 4, 3.0), 120.0));
138 + }
139 +}
src/tools/audio.rs new
+55
@@ -0,0 +1,55 @@
1 +//! `analyze_audio`: format, levels, loudness, and tempo/key estimates for a local audio file.
2 +
3 +use anyhow::{Result, bail};
4 +use serde::Deserialize;
5 +use serde_json::{Value, json};
6 +
7 +use super::{ToolCtx, ToolOutcome, absolutize, function_def};
8 +use crate::audio::analysis;
9 +
10 +pub const NAME: &str = "analyze_audio";
11 +const MAX_FILE_BYTES: u64 = 1 << 30;
12 +
13 +pub fn definition() -> Value {
14 + function_def(
15 + NAME,
16 + "Analyze a local audio file (WAV, FLAC, MP3, OGG, M4A/AAC, AIFF): duration, sample rate, channels, peak/RMS, integrated loudness (LUFS), and estimated tempo (with half/double-time readings) and key. Tempo and key are estimates; say so.",
17 + json!({
18 + "type": "object",
19 + "properties": {
20 + "path": { "type": "string", "description": "Audio file path; relative paths resolve against the working directory" },
21 + "root": { "type": "string", "description": "Workspace root to resolve a relative path against" }
22 + },
23 + "required": ["path"]
24 + }),
25 + )
26 +}
27 +
28 +#[derive(Deserialize)]
29 +struct Args {
30 + path: String,
31 + root: Option<String>,
32 +}
33 +
34 +pub async fn execute(ctx: &ToolCtx, args: Value) -> Result<ToolOutcome> {
35 + let a: Args = serde_json::from_value(args)?;
36 + let path = absolutize(&ctx.resolve_with(a.root.as_deref(), &a.path)?);
37 + let meta = tokio::fs::metadata(&path).await;
38 + match meta {
39 + Ok(m) if m.is_file() && m.len() <= MAX_FILE_BYTES => {}
40 + Ok(m) if m.is_file() => bail!("{} is larger than 1 GiB", path.display()),
41 + Ok(_) => bail!("{} is not a file", path.display()),
42 + Err(e) => bail!("cannot read {}: {e}", path.display()),
43 + }
44 + let work = {
45 + let path = path.clone();
46 + tokio::task::spawn_blocking(move || analysis::analyze_and_report(&path))
47 + };
48 + let text = tokio::select! {
49 + r = work => r??,
50 + () = ctx.cancel.cancelled() => return Ok(ToolOutcome::err("Analysis cancelled.")),
51 + };
52 + let mut out = ToolOutcome::ok(text);
53 + out.locations.push(path);
54 + Ok(out)
55 +}
src/tools/library.rs new
+361
@@ -0,0 +1,361 @@
1 +//! `list_separations`: the SplitFire app's own separation history, read from disk.
2 +//!
3 +//! The app stores each song as a folder of WAV stems plus a `splitfire.json` sidecar, under
4 +//! either its iCloud Drive Documents container or the pre-iCloud app-data `separated/` tree.
5 +//! It rebuilds its history by scanning exactly that layout, so this tool is read-only by
6 +//! design: writing new stems belongs to `separate_stems` and deleting belongs to the app.
7 +
8 +use std::path::{Path, PathBuf};
9 +
10 +use anyhow::Result;
11 +use serde::Deserialize;
12 +use serde_json::{Value, json};
13 +
14 +use super::{ToolCtx, ToolOutcome, function_def};
15 +
16 +pub const NAME: &str = "list_separations";
17 +
18 +/// The app's sidecar, as written by `stems_storage::SeparationSidecar`.
19 +#[derive(Deserialize)]
20 +struct Sidecar {
21 + model_id: String,
22 + /// Unix millis at separation time on the originating device.
23 + separated_at_ms: u64,
24 + /// Original input file name, with extension.
25 + source_file: String,
26 + /// Stem ids written, e.g. "vocals", "drums".
27 + stems: Vec<String>,
28 +}
29 +
30 +/// One discovered song folder.
31 +struct Separation {
32 + dir: PathBuf,
33 + /// Which root it came from, in the app's vocabulary.
34 + backend: &'static str,
35 + sidecar: Sidecar,
36 + /// Stem files actually present, paired with their size in bytes.
37 + files: Vec<(PathBuf, u64)>,
38 +}
39 +
40 +pub fn definition() -> Value {
41 + function_def(
42 + NAME,
43 + "List the songs the SplitFire app has already separated, with the model used, when it ran, \
44 + and the stem files on disk. Read-only: it reports the app's existing library and never \
45 + changes it. Use it to answer questions about past separations or to find a stem to analyze.",
46 + json!({
47 + "type": "object",
48 + "properties": {
49 + "song": {
50 + "type": "string",
51 + "description": "Only report songs whose folder or source file name contains this text (case-insensitive)"
52 + }
53 + }
54 + }),
55 + )
56 +}
57 +
58 +#[derive(Deserialize)]
59 +struct Args {
60 + song: Option<String>,
61 +}
62 +
63 +pub async fn execute(ctx: &ToolCtx, args: Value) -> Result<ToolOutcome> {
64 + let a: Args = serde_json::from_value(args)?;
65 + let roots = stem_roots();
66 + let filter = a.song.as_deref().map(str::to_lowercase);
67 +
68 + let found = {
69 + let roots = roots.clone();
70 + let filter = filter.clone();
71 + tokio::task::spawn_blocking(move || collect(&roots, filter.as_deref()))
72 + };
73 + let mut found = tokio::select! {
74 + r = found => r?,
75 + () = ctx.cancel.cancelled() => return Ok(ToolOutcome::err("Listing cancelled.")),
76 + };
77 + // Newest first; the app presents history the same way.
78 + found.sort_by_key(|f| std::cmp::Reverse(f.sidecar.separated_at_ms));
79 +
80 + let mut out = ToolOutcome::ok(report(&roots, &found, filter.as_deref()));
81 + out.locations = found.iter().map(|s| s.dir.clone()).collect();
82 + Ok(out)
83 +}
84 +
85 +/// Where the app keeps stems, most-specific first. `SPLITFIRE_STEMS_DIR` replaces the
86 +/// defaults outright: discovery is platform- and sandbox-specific, so it is the escape hatch
87 +/// when the real roots live somewhere this code would not guess.
88 +fn stem_roots() -> Vec<(PathBuf, &'static str)> {
89 + if let Some(raw) = std::env::var_os("SPLITFIRE_STEMS_DIR")
90 + && !raw.is_empty()
91 + {
92 + return std::env::split_paths(&raw)
93 + .filter(|p| !p.as_os_str().is_empty())
94 + .map(|p| (p, "configured"))
95 + .collect();
96 + }
97 + let Some(dirs) = directories::BaseDirs::new() else {
98 + return Vec::new();
99 + };
100 + let home = dirs.home_dir();
101 + let mut roots = Vec::new();
102 + // Apple platforms: the app's iCloud Drive Documents container.
103 + if cfg!(target_os = "macos") {
104 + roots.push((
105 + home.join("Library/Mobile Documents/iCloud~ai~splitfire~SplitfireAI/Documents"),
106 + "icloud",
107 + ));
108 + }
109 + // Everywhere else, and for pre-iCloud separations: `<app data>/separated`.
110 + // Both the release and the debug bundle identifier, since either may have run here.
111 + for id in ["ai.splitfire.SplitfireAI", "ai.splitfire.SplitfireAI-Debug"] {
112 + roots.push((dirs.data_dir().join(id).join("separated"), "local"));
113 + }
114 + roots
115 +}
116 +
117 +/// Scan each root's immediate children for a sidecar. Best-effort throughout: a missing or
118 +/// unreadable root, or a folder without a readable sidecar, is simply not reported.
119 +fn collect(roots: &[(PathBuf, &'static str)], filter: Option<&str>) -> Vec<Separation> {
120 + let mut out = Vec::new();
121 + for (root, backend) in roots {
122 + let Ok(entries) = std::fs::read_dir(root) else {
123 + continue;
124 + };
125 + for entry in entries.flatten() {
126 + let dir = entry.path();
127 + if !dir.is_dir() {
128 + continue;
129 + }
130 + let Ok(bytes) = std::fs::read(dir.join("splitfire.json")) else {
131 + continue;
132 + };
133 + let Ok(sidecar) = serde_json::from_slice::<Sidecar>(&bytes) else {
134 + continue;
135 + };
136 + if let Some(needle) = filter {
137 + let name = dir
138 + .file_name()
139 + .map(|n| n.to_string_lossy().to_lowercase())
140 + .unwrap_or_default();
141 + if !name.contains(needle) && !sidecar.source_file.to_lowercase().contains(needle) {
142 + continue;
143 + }
144 + }
145 + let files = stem_files(&dir, &sidecar);
146 + out.push(Separation {
147 + dir,
148 + backend,
149 + sidecar,
150 + files,
151 + });
152 + }
153 + }
154 + out
155 +}
156 +
157 +/// The stem files for a song, in the sidecar's order. A stem listed but absent is reported as
158 +/// missing rather than silently dropped: on iCloud it may simply not be downloaded yet.
159 +fn stem_files(dir: &Path, sidecar: &Sidecar) -> Vec<(PathBuf, u64)> {
160 + sidecar
161 + .stems
162 + .iter()
163 + .map(|stem| {
164 + let path = dir.join(format!("{stem}.wav"));
165 + let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
166 + (path, size)
167 + })
168 + .collect()
169 +}
170 +
171 +fn report(roots: &[(PathBuf, &'static str)], found: &[Separation], filter: Option<&str>) -> String {
172 + if found.is_empty() {
173 + let mut s = match filter {
174 + Some(f) => format!("No separated songs matching `{f}`."),
175 + None => "No separated songs found.".to_string(),
176 + };
177 + s.push_str("\n\nSearched:");
178 + for (root, _) in roots {
179 + s.push_str(&format!("\n- {}", root.display()));
180 + }
181 + if roots.is_empty() {
182 + s.push_str("\n- (no stem roots could be resolved)");
183 + }
184 + s.push_str(
185 + "\n\nIf the app stores stems somewhere else, set SPLITFIRE_STEMS_DIR to that directory.",
186 + );
187 + return s;
188 + }
189 +
190 + let plural = if found.len() == 1 { "song" } else { "songs" };
191 + let mut s = format!("{} separated {plural}:\n", found.len());
192 + for sep in found {
193 + let name = sep
194 + .dir
195 + .file_name()
196 + .map(|n| n.to_string_lossy().to_string())
197 + .unwrap_or_else(|| sep.dir.display().to_string());
198 + s.push_str(&format!(
199 + "\n{name}\n model: {}\n separated: {} ({})\n source: {}\n folder: {}\n",
200 + sep.sidecar.model_id,
201 + iso8601_ms(sep.sidecar.separated_at_ms),
202 + sep.backend,
203 + sep.sidecar.source_file,
204 + sep.dir.display(),
205 + ));
206 + for (path, size) in &sep.files {
207 + let stem = path
208 + .file_stem()
209 + .map(|n| n.to_string_lossy().to_string())
210 + .unwrap_or_default();
211 + if *size == 0 {
212 + s.push_str(&format!(
213 + " - {stem}: not on this device ({})\n",
214 + path.display()
215 + ));
216 + } else {
217 + s.push_str(&format!(
218 + " - {stem}: {} ({})\n",
219 + human_bytes(*size),
220 + path.display()
221 + ));
222 + }
223 + }
224 + }
225 + s
226 +}
227 +
228 +fn human_bytes(n: u64) -> String {
229 + const MIB: f64 = (1 << 20) as f64;
230 + if n >= (1 << 20) {
231 + format!("{:.1} MiB", n as f64 / MIB)
232 + } else {
233 + format!("{:.0} KiB", (n as f64 / 1024.0).max(1.0))
234 + }
235 +}
236 +
237 +/// Unix millis as a UTC timestamp, via the same civil-date maths as `crate::agent::iso8601`.
238 +fn iso8601_ms(ms: u64) -> String {
239 + let secs = (ms / 1000) as i64;
240 + let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
241 + // Days from 1970-01-01 to a civil date (Howard Hinnant's algorithm).
242 + let z = days + 719_468;
243 + let era = z.div_euclid(146_097);
244 + let doe = z.rem_euclid(146_097);
245 + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
246 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
247 + let mp = (5 * doy + 2) / 153;
248 + let d = doy - (153 * mp + 2) / 5 + 1;
249 + let m = if mp < 10 { mp + 3 } else { mp - 9 };
250 + let y = era * 400 + yoe + i64::from(m <= 2);
251 + format!(
252 + "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
253 + rem / 3600,
254 + (rem % 3600) / 60,
255 + rem % 60
256 + )
257 +}
258 +
259 +#[cfg(test)]
260 +mod tests {
261 + use super::*;
262 +
263 + fn write_song(root: &Path, name: &str, sidecar: &str, stems: &[&str]) {
264 + let dir = root.join(name);
265 + std::fs::create_dir_all(&dir).unwrap();
266 + std::fs::write(dir.join("splitfire.json"), sidecar).unwrap();
267 + for stem in stems {
268 + std::fs::write(dir.join(format!("{stem}.wav")), vec![0u8; 2048]).unwrap();
269 + }
270 + }
271 +
272 + /// The real sidecar the app writes, copied from a separation on disk.
273 + const REAL_SIDECAR: &str = r#"{
274 + "model_id": "htdemucs",
275 + "separated_at_ms": 1783395299798,
276 + "source_file": "splitfire-test-song.wav",
277 + "stems": ["drums", "bass", "other", "vocals"]
278 + }"#;
279 +
280 + #[test]
281 + fn reads_a_real_shaped_sidecar_tree() {
282 + let tmp = std::env::temp_dir().join(format!("sf-lib-{}", uuid::Uuid::new_v4()));
283 + let root = tmp.join("Documents");
284 + write_song(
285 + &root,
286 + "splitfire-test-song",
287 + REAL_SIDECAR,
288 + &["drums", "bass", "other", "vocals"],
289 + );
290 + // A folder with no sidecar, and one with a corrupt sidecar: both skipped, not fatal.
291 + std::fs::create_dir_all(root.join("not-a-separation")).unwrap();
292 + write_song(&root, "corrupt", "{ not json", &[]);
293 +
294 + let roots = vec![(root.clone(), "icloud")];
295 + let found = collect(&roots, None);
296 + assert_eq!(found.len(), 1, "only the valid sidecar is reported");
297 + let sep = &found[0];
298 + assert_eq!(sep.sidecar.model_id, "htdemucs");
299 + assert_eq!(sep.sidecar.source_file, "splitfire-test-song.wav");
300 + assert_eq!(sep.sidecar.stems.len(), 4);
301 + assert_eq!(sep.files.len(), 4);
302 + assert!(sep.files.iter().all(|(_, size)| *size == 2048));
303 +
304 + let text = report(&roots, &found, None);
305 + assert!(text.contains("1 separated song"), "{text}");
306 + assert!(text.contains("htdemucs"), "{text}");
307 + assert!(text.contains("2026-"), "timestamp rendered: {text}");
308 + assert!(text.contains("vocals"), "{text}");
309 +
310 + // A filter that matches the source file but not the folder name still hits.
311 + assert_eq!(collect(&roots, Some("test-song")).len(), 1);
312 + assert_eq!(collect(&roots, Some("nothing")).len(), 0);
313 + std::fs::remove_dir_all(&tmp).ok();
314 + }
315 +
316 + #[test]
317 + fn missing_roots_are_not_an_error() {
318 + let roots = vec![(PathBuf::from("/nonexistent/splitfire/stems"), "local")];
319 + let found = collect(&roots, None);
320 + assert!(found.is_empty());
321 + let text = report(&roots, &found, None);
322 + assert!(text.contains("No separated songs found"), "{text}");
323 + assert!(text.contains("SPLITFIRE_STEMS_DIR"), "{text}");
324 + }
325 +
326 + #[test]
327 + fn a_stem_absent_from_disk_is_reported_as_not_downloaded() {
328 + let tmp = std::env::temp_dir().join(format!("sf-lib-{}", uuid::Uuid::new_v4()));
329 + let root = tmp.join("Documents");
330 + // Sidecar lists four stems; only two were downloaded from iCloud.
331 + write_song(&root, "half-synced", REAL_SIDECAR, &["drums", "bass"]);
332 + let roots = vec![(root, "icloud")];
333 + let found = collect(&roots, None);
334 + let text = report(&roots, &found, None);
335 + assert!(text.contains("vocals: not on this device"), "{text}");
336 + assert!(text.contains("drums: 2 KiB"), "{text}");
337 + std::fs::remove_dir_all(&tmp).ok();
338 + }
339 +
340 + #[test]
341 + fn stems_dir_env_var_replaces_the_defaults() {
342 + // Safety: single-threaded assertion on process env, restored before returning.
343 + let key = "SPLITFIRE_STEMS_DIR";
344 + let prev = std::env::var_os(key);
345 + unsafe { std::env::set_var(key, "/tmp/sf-one") };
346 + let roots = stem_roots();
347 + assert_eq!(roots.len(), 1);
348 + assert_eq!(roots[0].0, PathBuf::from("/tmp/sf-one"));
349 + assert_eq!(roots[0].1, "configured");
350 + match prev {
351 + Some(v) => unsafe { std::env::set_var(key, v) },
352 + None => unsafe { std::env::remove_var(key) },
353 + }
354 + }
355 +
356 + #[test]
357 + fn epoch_millis_render_as_utc() {
358 + assert_eq!(iso8601_ms(0), "1970-01-01T00:00:00Z");
359 + assert_eq!(iso8601_ms(1_783_395_299_798), "2026-07-07T03:34:59Z");
360 + }
361 +}
src/tools/mod.rs new
+417
@@ -0,0 +1,417 @@
1 +//! Tools exposed to the model: workspace files/shell, the music theory engine, audio
2 +//! analysis, and tools from MCP servers (client-supplied plus the optional demucs server).
3 +
4 +pub mod audio;
5 +pub mod library;
6 +pub mod theory;
7 +mod workspace;
8 +
9 +use std::collections::HashSet;
10 +use std::path::{Component, Path, PathBuf};
11 +use std::sync::atomic::{AtomicBool, Ordering};
12 +use std::sync::{Arc, Mutex};
13 +
14 +use agent_client_protocol::schema::v1::{
15 + ClientCapabilities, PermissionOption, PermissionOptionKind, RequestPermissionOutcome,
16 + RequestPermissionRequest, SessionId, SessionNotification, SessionUpdate, ToolCallContent,
17 + ToolCallLocation, ToolCallUpdate, ToolCallUpdateFields, ToolKind,
18 +};
19 +use agent_client_protocol::{Client, ConnectionTo};
20 +use anyhow::{Result, anyhow, bail};
21 +use serde_json::{Value, json};
22 +use tokio_util::sync::CancellationToken;
23 +
24 +use crate::mcp::{self, McpToolset};
25 +
26 +pub(crate) const MAX_OUTPUT_BYTES: usize = 32 * 1024;
27 +
28 +/// Everything a tool needs to act on behalf of one session.
29 +pub struct ToolCtx {
30 + pub connection: ConnectionTo<Client>,
31 + pub session_id: SessionId,
32 + /// Primary working directory: the base for relative paths and `run_command`.
33 + pub cwd: PathBuf,
34 + /// Additional workspace roots the model may address with the `root` parameter.
35 + pub roots: Vec<PathBuf>,
36 + pub caps: ClientCapabilities,
37 + pub cancel: CancellationToken,
38 + /// Approve tool calls without asking: `--yolo`, or the session's `auto_approve` option.
39 + pub auto_approve: Arc<AtomicBool>,
40 + /// Tool names the user chose "always allow" for in this session.
41 + pub always_allowed: Arc<Mutex<HashSet<String>>>,
42 + /// Tool names the user chose "always reject" for in this session.
43 + pub always_rejected: Arc<Mutex<HashSet<String>>>,
44 + /// Tools from MCP servers available to this session.
45 + pub mcp: Arc<McpToolset>,
46 +}
47 +
48 +pub struct ToolOutcome {
49 + /// Text returned to the model.
50 + pub text: String,
51 + /// Rich content shown to the user in the client.
52 + pub content: Vec<ToolCallContent>,
53 + pub failed: bool,
54 + /// Files the tool produced or touched, surfaced as tool-call locations.
55 + pub locations: Vec<PathBuf>,
56 +}
57 +
58 +impl ToolOutcome {
59 + pub fn ok(text: impl Into<String>) -> Self {
60 + let text = text.into();
61 + Self {
62 + content: vec![ToolCallContent::from(text.clone())],
63 + text,
64 + failed: false,
65 + locations: Vec::new(),
66 + }
67 + }
68 + pub fn err(text: impl Into<String>) -> Self {
69 + let text = text.into();
70 + Self {
71 + content: vec![ToolCallContent::from(text.clone())],
72 + text,
73 + failed: true,
74 + locations: Vec::new(),
75 + }
76 + }
77 +}
78 +
79 +pub(crate) fn function_def(name: &str, desc: &str, params: Value) -> Value {
80 + json!({ "type": "function", "function": { "name": name, "description": desc, "parameters": params } })
81 +}
82 +
83 +/// All tool definitions for one session, in OpenAI `tools` format.
84 +pub fn definitions(mcp: &McpToolset) -> Value {
85 + let mut defs = workspace::definitions();
86 + defs.extend(theory::definitions());
87 + defs.push(audio::definition());
88 + defs.push(library::definition());
89 + defs.extend(mcp.definitions());
90 + Value::Array(defs)
91 +}
92 +
93 +/// Title, kind and affected locations shown in the client when the call starts.
94 +pub fn describe(
95 + cwd: &Path,
96 + roots: &[PathBuf],
97 + name: &str,
98 + args: &Value,
99 +) -> (String, ToolKind, Vec<ToolCallLocation>) {
100 + let str_arg = |k: &str| args.get(k).and_then(Value::as_str);
101 + if let Some((server, tool)) = mcp::split_qualified(name) {
102 + let loc = ["input", "path", "file"]
103 + .iter()
104 + .filter_map(|k| str_arg(k))
105 + .map(PathBuf::from)
106 + .filter(|p| p.is_absolute())
107 + .map(|p| ToolCallLocation::new(absolutize(&p)))
108 + .collect();
109 + return (format!("{server}: {tool}"), ToolKind::Other, loc);
110 + }
111 + if name.starts_with("theory_") {
112 + return (theory::title(name, args), ToolKind::Think, vec![]);
113 + }
114 + if name == library::NAME {
115 + return ("List separated songs".to_string(), ToolKind::Search, vec![]);
116 + }
117 + if name == audio::NAME {
118 + let file = str_arg("path").unwrap_or("audio file");
119 + let loc = str_arg("path")
120 + .map(|p| absolutize(&resolve_in(cwd, p)))
121 + .map(ToolCallLocation::new)
122 + .into_iter()
123 + .collect();
124 + return (format!("Analyze {file}"), ToolKind::Read, loc);
125 + }
126 + let base = base_for(cwd, roots, str_arg("root")).unwrap_or_else(|_| cwd.to_path_buf());
127 + // ACP v1: ToolCallLocation.path must be an absolute path.
128 + let path = str_arg("path").map(|p| absolutize(&resolve_in(&base, p)));
129 + let loc = path
130 + .clone()
131 + .map(ToolCallLocation::new)
132 + .into_iter()
133 + .collect();
134 + let shown = path
135 + .as_deref()
136 + .map_or_else(|| base.display().to_string(), |p| p.display().to_string());
137 + match name {
138 + "read_file" => (format!("Read {shown}"), ToolKind::Read, loc),
139 + "write_file" => (format!("Write {shown}"), ToolKind::Edit, loc),
140 + "edit_file" => (format!("Edit {shown}"), ToolKind::Edit, loc),
141 + "list_directory" => (format!("List {shown}"), ToolKind::Search, loc),
142 + "run_command" => {
143 + let cmd = str_arg("command").unwrap_or("");
144 + let where_ = if base == cwd {
145 + String::new()
146 + } else {
147 + format!(" in {}", base.display())
148 + };
149 + (format!("`{cmd}`{where_}"), ToolKind::Execute, vec![])
150 + }
151 + _ => (name.to_string(), ToolKind::Other, vec![]),
152 + }
153 +}
154 +
155 +pub async fn execute(ctx: &ToolCtx, tool_call_id: &str, name: &str, args: Value) -> ToolOutcome {
156 + let result = if workspace::NAMES.contains(&name) {
157 + workspace::execute(ctx, tool_call_id, name, args).await
158 + } else if name.starts_with("theory_") {
159 + theory::execute(name, args)
160 + } else if name == audio::NAME {
161 + audio::execute(ctx, args).await
162 + } else if name == library::NAME {
163 + library::execute(ctx, args).await
164 + } else if mcp::split_qualified(name).is_some() {
165 + execute_mcp(ctx, tool_call_id, name, args).await
166 + } else {
167 + Err(anyhow!("unknown tool `{name}`"))
168 + };
169 + result.unwrap_or_else(|e| ToolOutcome::err(format!("Error: {e:#}")))
170 +}
171 +
172 +async fn execute_mcp(
173 + ctx: &ToolCtx,
174 + tool_call_id: &str,
175 + name: &str,
176 + args: Value,
177 +) -> Result<ToolOutcome> {
178 + if !ctx.mcp.has(name) {
179 + bail!("unknown tool `{name}`");
180 + }
181 + if !ctx.mcp.is_read_only(name) {
182 + let preview = ctx.mcp.permission_preview(name, &args).await;
183 + if !ctx.permit(tool_call_id, name, preview).await? {
184 + return Ok(ToolOutcome::err("User rejected running this tool."));
185 + }
186 + }
187 + ctx.mcp.call(ctx, tool_call_id, name, args).await
188 +}
189 +
190 +/// The base directory for a tool call's `root` parameter: a workspace root or any directory
191 +/// beneath one (so the model can pass e.g. `root/crate/src`). Paths that escape a root via
192 +/// `..` are rejected.
193 +pub(crate) fn base_for(cwd: &Path, roots: &[PathBuf], root: Option<&str>) -> Result<PathBuf> {
194 + let Some(root) = root else {
195 + return Ok(cwd.to_path_buf());
196 + };
197 + // Lexical normalization resolves `..`, so `root/../elsewhere` can't pass the prefix check.
198 + let candidate = normalize(Path::new(root));
199 + let all_roots = all_roots(cwd, roots);
200 + for ws_root in &all_roots {
201 + if candidate == *ws_root {
202 + return Ok(ws_root.clone());
203 + }
204 + // Allow any directory below a root (e.g. a crate dir like `root/crate/src`),
205 + // as long as it doesn't escape the root via `..`.
206 + if let Ok(rest) = candidate.strip_prefix(ws_root)
207 + && rest.components().all(|c| matches!(c, Component::Normal(_)))
208 + {
209 + return Ok(candidate);
210 + }
211 + }
212 + let known = all_roots
213 + .iter()
214 + .map(|r| r.display().to_string())
215 + .collect::<Vec<_>>()
216 + .join(", ");
217 + bail!(
218 + "unknown root `{}` (workspace roots: {known})",
219 + candidate.display()
220 + )
221 +}
222 +
223 +fn all_roots(cwd: &Path, roots: &[PathBuf]) -> Vec<PathBuf> {
224 + std::iter::once(cwd.to_path_buf())
225 + .chain(roots.iter().filter(|r| *r != cwd).cloned())
226 + .collect()
227 +}
228 +
229 +impl ToolCtx {
230 + pub(crate) fn base_for(&self, root: Option<&str>) -> Result<PathBuf> {
231 + base_for(&self.cwd, &self.roots, root)
232 + }
233 +
234 + pub(crate) fn resolve_with(&self, root: Option<&str>, path: &str) -> Result<PathBuf> {
235 + Ok(resolve_in(&self.base_for(root)?, path))
236 + }
237 +
238 + /// Ask the user for permission. Returns `Ok(false)` if rejected or cancelled.
239 + pub(crate) async fn permit(
240 + &self,
241 + id: &str,
242 + tool: &str,
243 + content: Vec<ToolCallContent>,
244 + ) -> Result<bool> {
245 + if self.always_rejected.lock().unwrap().contains(tool) {
246 + return Ok(false);
247 + }
248 + if self.auto_approve.load(Ordering::Relaxed)
249 + || self.always_allowed.lock().unwrap().contains(tool)
250 + {
251 + return Ok(true);
252 + }
253 + let mut fields = ToolCallUpdateFields::new();
254 + if !content.is_empty() {
255 + fields = fields.content(content);
256 + }
257 + let req = RequestPermissionRequest::new(
258 + self.session_id.clone(),
259 + ToolCallUpdate::new(id.to_string(), fields),
260 + vec![
261 + PermissionOption::new("allow_once", "Allow", PermissionOptionKind::AllowOnce),
262 + PermissionOption::new(
263 + "allow_always",
264 + "Always allow",
265 + PermissionOptionKind::AllowAlways,
266 + ),
267 + PermissionOption::new("reject_once", "Reject", PermissionOptionKind::RejectOnce),
268 + PermissionOption::new(
269 + "reject_always",
270 + "Always reject",
271 + PermissionOptionKind::RejectAlways,
272 + ),
273 + ],
274 + );
275 + let resp = tokio::select! {
276 + r = self.connection.send_request(req).block_task() => r?,
277 + () = self.cancel.cancelled() => return Ok(false),
278 + };
279 + Ok(match resp.outcome {
280 + RequestPermissionOutcome::Selected(sel) => match &*sel.option_id.0 {
281 + "allow_always" => {
282 + self.always_allowed.lock().unwrap().insert(tool.to_string());
283 + true
284 + }
285 + "allow_once" => true,
286 + "reject_always" => {
287 + self.always_rejected
288 + .lock()
289 + .unwrap()
290 + .insert(tool.to_string());
291 + false
292 + }
293 + _ => false,
294 + },
295 + _ => false,
296 + })
297 + }
298 +
299 + pub fn update(&self, id: &str, fields: ToolCallUpdateFields) -> Result<()> {
300 + let update = SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(id.to_string(), fields));
301 + self.connection
302 + .send_notification(SessionNotification::new(self.session_id.clone(), update))?;
303 + Ok(())
304 + }
305 +}
306 +
307 +/// Join `path` onto `base`, keeping absolute paths as-is.
308 +pub(crate) fn resolve_in(base: &Path, path: &str) -> PathBuf {
309 + let p = Path::new(path);
310 + if p.is_absolute() {
311 + p.to_path_buf()
312 + } else {
313 + base.join(p)
314 + }
315 +}
316 +
317 +pub(crate) fn truncate(mut s: String) -> String {
318 + if s.len() > MAX_OUTPUT_BYTES {
319 + let mut cut = MAX_OUTPUT_BYTES;
320 + while !s.is_char_boundary(cut) {
321 + cut -= 1;
322 + }
323 + s.truncate(cut);
324 + s.push_str("\n[truncated]");
325 + }
326 + s
327 +}
328 +
329 +/// Make `path` absolute and free of `.`/`..` components, as the ACP v1 spec requires
330 +/// absolute paths everywhere. Existing paths are canonicalized (resolving symlinks
331 +/// and giving the client the true on-disk location); paths that don't exist yet
332 +/// are normalized lexically with the existing parent canonicalized when possible.
333 +pub(crate) fn absolutize(path: &Path) -> PathBuf {
334 + if let Ok(c) = std::fs::canonicalize(path) {
335 + return c;
336 + }
337 + // Not on disk (yet): normalize lexically, anchoring at the canonicalized parent.
338 + let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
339 + let base = parent.and_then(|p| std::fs::canonicalize(p).ok());
340 + let file_name = path.file_name().map(|f| f.to_os_string());
341 + match (base, file_name) {
342 + (Some(mut b), Some(f)) => {
343 + b.push(f);
344 + b
345 + }
346 + _ => normalize(path),
347 + }
348 +}
349 +
350 +/// Lexically normalize a path: resolve `.` and `..` without touching the filesystem.
351 +/// Absolute paths stay absolute; relative paths are made absolute against the cwd.
352 +pub(crate) fn normalize(path: &Path) -> PathBuf {
353 + let absolute = if path.is_absolute() {
354 + path.to_path_buf()
355 + } else {
356 + std::env::current_dir()
357 + .unwrap_or_else(|_| PathBuf::from("."))
358 + .join(path)
359 + };
360 + let mut out: Vec<std::ffi::OsString> = Vec::new();
361 + for c in absolute.components() {
362 + match c {
363 + Component::CurDir => {}
364 + Component::ParentDir => {
365 + out.pop();
366 + }
367 + other => out.push(other.as_os_str().to_os_string()),
368 + }
369 + }
370 + out.into_iter().collect()
371 +}
372 +
373 +#[cfg(test)]
374 +mod fs_path_tests {
375 + use super::*;
376 +
377 + #[test]
378 + fn normalize_resolves_dot_dot_lexically() {
379 + assert_eq!(normalize(Path::new("/a/b/../c")), PathBuf::from("/a/c"));
380 + assert_eq!(normalize(Path::new("/a/./b")), PathBuf::from("/a/b"));
381 + assert_eq!(normalize(Path::new("/a/b/..")), PathBuf::from("/a"));
382 + }
383 +
384 + #[test]
385 + fn normalize_makes_relative_paths_absolute() {
386 + let n = normalize(Path::new("src/../README.md"));
387 + assert!(n.is_absolute());
388 + assert!(n.ends_with("README.md"));
389 + assert!(!n.components().any(|c| c == Component::ParentDir));
390 + }
391 +
392 + #[test]
393 + fn absolutize_canonicalizes_existing_paths() {
394 + let n = absolutize(&std::env::temp_dir());
395 + assert!(n.is_absolute());
396 + assert!(!n.components().any(|c| c == Component::ParentDir));
397 + }
398 +
399 + #[test]
400 + fn absolutize_normalizes_missing_paths() {
401 + let n = absolutize(Path::new("/definitely/does/not/../exist.txt"));
402 + assert_eq!(n, PathBuf::from("/definitely/does/exist.txt"));
403 + }
404 +
405 + #[test]
406 + fn base_for_rejects_escapes_and_accepts_subdirs() {
407 + let cwd = PathBuf::from("/ws/a");
408 + let roots = vec![PathBuf::from("/ws/b")];
409 + assert_eq!(base_for(&cwd, &roots, None).unwrap(), cwd);
410 + assert_eq!(
411 + base_for(&cwd, &roots, Some("/ws/b/sub/../x")).unwrap(),
412 + PathBuf::from("/ws/b/x")
413 + );
414 + assert!(base_for(&cwd, &roots, Some("/ws/b/../c")).is_err());
415 + assert!(base_for(&cwd, &roots, Some("/etc")).is_err());
416 + }
417 +}
src/tools/theory.rs new
+703
@@ -0,0 +1,703 @@
1 +//! Music theory tools: thin wrappers that format the deterministic engine in `crate::theory`.
2 +//! All are read-only and run inline, so they never prompt for permission.
3 +
4 +use anyhow::{Result, anyhow, bail};
5 +use serde::Deserialize;
6 +use serde_json::{Value, json};
7 +
8 +use super::{ToolOutcome, function_def};
9 +use crate::theory::chord::{self, Chord};
10 +use crate::theory::interval::Interval;
11 +use crate::theory::key::{self, Key, Kind};
12 +use crate::theory::pitch::{Note, Pitch, cents_between, hz_to_midi, midi_to_hz, spell_pc};
13 +use crate::theory::scale;
14 +use crate::theory::tempo::{self, Feel};
15 +
16 +pub fn definitions() -> Vec<Value> {
17 + vec![
18 + function_def(
19 + "theory_scale",
20 + "Spell a scale or mode from a root note, with intervals, key signature and the diatonic chords. Exact; use instead of computing from memory. Omit `scale` to list the supported scales.",
21 + json!({
22 + "type": "object",
23 + "properties": {
24 + "root": { "type": "string", "description": "Root note, e.g. C#, Bb" },
25 + "scale": { "type": "string", "description": "e.g. major, dorian, harmonic minor, minor pentatonic, blues, altered" }
26 + },
27 + "required": ["root"]
28 + }),
29 + ),
30 + function_def(
31 + "theory_chord",
32 + "Parse a chord symbol (F#m7b5, Bb13#11/D, C6/9) into spelled notes, formula, quality and inversion, OR identify chords from notes (first note is treated as the bass). Exact.",
33 + json!({
34 + "type": "object",
35 + "properties": {
36 + "symbol": { "type": "string", "description": "Chord symbol to spell" },
37 + "notes": { "type": "array", "items": { "type": "string" }, "description": "Note names to identify, bass first" }
38 + }
39 + }),
40 + ),
41 + function_def(
42 + "theory_transpose",
43 + "Transpose chord symbols or note names with correct enharmonic spelling, by semitones or from one key to another.",
44 + json!({
45 + "type": "object",
46 + "properties": {
47 + "items": { "type": "string", "description": "Chord symbols or notes separated by spaces or bars, e.g. 'Dm7 G7 Cmaj7'" },
48 + "semitones": { "type": "integer", "description": "Positive up, negative down" },
49 + "from_key": { "type": "string", "description": "e.g. C major (use with to_key)" },
50 + "to_key": { "type": "string", "description": "e.g. Db major" },
51 + "prefer": { "type": "string", "enum": ["sharps", "flats"], "description": "Force accidental style when using semitones" }
52 + },
53 + "required": ["items"]
54 + }),
55 + ),
56 + function_def(
57 + "theory_progression",
58 + "Analyse a chord progression (Roman numerals, function, secondary dominants, borrowed chords, ranked key candidates) or realize Roman numerals in a key as chord symbols.",
59 + json!({
60 + "type": "object",
61 + "properties": {
62 + "chords": { "type": "string", "description": "Chord symbols separated by spaces, bars or commas" },
63 + "key": { "type": "string", "description": "Key to analyse in (optional for chords; required for numerals), e.g. C major, F# minor" },
64 + "numerals": { "type": "string", "description": "Roman numerals to realize, e.g. 'ii7 V7 Imaj7' or 'V7/V'" }
65 + }
66 + }),
67 + ),
68 + function_def(
69 + "theory_tempo",
70 + "Tempo maths: note-value durations (straight, dotted, triplet) in ms and Hz for delays and LFOs, bar length for a time signature, sample counts, or the bpm of a loop from its length.",
71 + json!({
72 + "type": "object",
73 + "properties": {
74 + "bpm": { "type": "number" },
75 + "time_signature": { "type": "string", "description": "e.g. 4/4, 6/8, 7/8 (default 4/4)" },
76 + "sample_rate": { "type": "number", "description": "Adds sample counts, e.g. 44100 or 48000" },
77 + "bars": { "type": "number", "description": "Loop length in bars (with duration_seconds, to derive bpm)" },
78 + "duration_seconds": { "type": "number" },
79 + "quarter_note_ms": { "type": "number", "description": "Derive bpm from a quarter-note time in ms" },
80 + "note_value": { "type": "string", "description": "A single duration to answer, e.g. 1/8., 1/4t, 1/16 (. dotted, t triplet)" }
81 + }
82 + }),
83 + ),
84 + function_def(
85 + "theory_pitch",
86 + "Convert between note names, MIDI numbers and frequencies (A4 reference adjustable). For a frequency, returns the nearest note and cents offset.",
87 + json!({
88 + "type": "object",
89 + "properties": {
90 + "name": { "type": "string", "description": "e.g. A4, C#3, Bb2" },
91 + "midi": { "type": "number" },
92 + "hz": { "type": "number" },
93 + "a4": { "type": "number", "description": "Reference tuning, default 440" }
94 + }
95 + }),
96 + ),
97 + ]
98 +}
99 +
100 +pub fn title(name: &str, args: &Value) -> String {
101 + let s = |k: &str| args.get(k).and_then(Value::as_str);
102 + match name {
103 + "theory_scale" => format!(
104 + "Scale: {} {}",
105 + s("root").unwrap_or("?"),
106 + s("scale").unwrap_or("")
107 + )
108 + .trim()
109 + .to_string(),
110 + "theory_chord" => match s("symbol") {
111 + Some(c) => format!("Chord: {c}"),
112 + None => "Identify chord".into(),
113 + },
114 + "theory_transpose" => format!("Transpose {}", s("items").unwrap_or("")),
115 + "theory_progression" => match s("numerals") {
116 + Some(n) => format!("Realize {n}"),
117 + None => format!("Analyse {}", s("chords").unwrap_or("progression")),
118 + },
119 + "theory_tempo" => match args.get("bpm").and_then(Value::as_f64) {
120 + Some(b) => format!("Tempo: {b} bpm"),
121 + None => "Tempo".into(),
122 + },
123 + "theory_pitch" => "Pitch conversion".into(),
124 + _ => name.to_string(),
125 + }
126 +}
127 +
128 +pub fn execute(name: &str, args: Value) -> Result<ToolOutcome> {
129 + let text = match name {
130 + "theory_scale" => scale_tool(serde_json::from_value(args)?)?,
131 + "theory_chord" => chord_tool(serde_json::from_value(args)?)?,
132 + "theory_transpose" => transpose_tool(serde_json::from_value(args)?)?,
133 + "theory_progression" => progression_tool(serde_json::from_value(args)?)?,
134 + "theory_tempo" => tempo_tool(serde_json::from_value(args)?)?,
135 + "theory_pitch" => pitch_tool(serde_json::from_value(args)?)?,
136 + other => bail!("unknown tool `{other}`"),
137 + };
138 + Ok(ToolOutcome::ok(text))
139 +}
140 +
141 +fn join_names(notes: &[Note]) -> String {
142 + notes.iter().map(|n| n.name()).collect::<Vec<_>>().join(" ")
143 +}
144 +
145 +#[derive(Deserialize)]
146 +struct ScaleArgs {
147 + root: String,
148 + scale: Option<String>,
149 +}
150 +
151 +fn scale_tool(a: ScaleArgs) -> Result<String> {
152 + let root = Note::parse(&a.root)?;
153 + let Some(name) = a.scale else {
154 + return Ok(format!("Supported scales: {}", scale::names().join(", ")));
155 + };
156 + let def = scale::parse_scale(&name)?;
157 + let notes = def.spell(root);
158 + let intervals = def.intervals();
159 + let mut out = format!(
160 + "{} {}\nNotes: {}\nFormula: {}\nIntervals: {}",
161 + root.name(),
162 + def.name,
163 + join_names(&notes),
164 + def.formula,
165 + intervals
166 + .iter()
167 + .map(|i| i.name())
168 + .collect::<Vec<_>>()
169 + .join(" "),
170 + );
171 + if !def.aliases.is_empty() {
172 + out.push_str(&format!("\nAlso called: {}", def.aliases.join(", ")));
173 + }
174 + if matches!(def.name, "major" | "natural minor") {
175 + out.push_str(&format!(
176 + "\nKey signature: {}",
177 + scale::signature_text(scale::signature(&notes))
178 + ));
179 + }
180 + if notes.len() == 7 {
181 + let stack = |n: usize| -> Vec<String> {
182 + (0..7)
183 + .map(|i| {
184 + let tones: Vec<Note> = (0..n).map(|k| notes[(i + 2 * k) % 7]).collect();
185 + chord::identify(&tones)
186 + .into_iter()
187 + .find(|c| c.chord.bass.is_none() && c.chord.root.pc() == tones[0].pc())
188 + .map_or_else(|| "n/a".into(), |c| c.chord.name())
189 + })
190 + .collect()
191 + };
192 + out.push_str(&format!("\nDiatonic triads: {}", stack(3).join(" ")));
193 + out.push_str(&format!("\nDiatonic sevenths: {}", stack(4).join(" ")));
194 + }
195 + Ok(out)
196 +}
197 +
198 +#[derive(Deserialize)]
199 +struct ChordArgs {
200 + symbol: Option<String>,
201 + notes: Option<Vec<String>>,
202 +}
203 +
204 +fn chord_report(c: &Chord) -> String {
205 + let notes = c.notes();
206 + let mut out = format!(
207 + "{}\nNotes: {}\nFormula: {}\nQuality: {}",
208 + c.name(),
209 + join_names(&notes),
210 + c.formula(),
211 + c.quality(),
212 + );
213 + if let Some(b) = c.bass {
214 + out.push_str(&format!(
215 + "\nBass: {} ({})",
216 + b.name(),
217 + c.bass_description().unwrap_or_default()
218 + ));
219 + }
220 + out
221 +}
222 +
223 +fn chord_tool(a: ChordArgs) -> Result<String> {
224 + match (a.symbol, a.notes) {
225 + (Some(sym), _) => Ok(chord_report(&Chord::parse(&sym)?)),
226 + (None, Some(notes)) if !notes.is_empty() => {
227 + let parsed = notes
228 + .iter()
229 + .map(|n| Note::parse(n))
230 + .collect::<Result<Vec<_>>>()?;
231 + let found = chord::identify(&parsed);
232 + if found.is_empty() {
233 + return Ok(format!(
234 + "No common chord matches {}. It may be a cluster, a polychord or an incomplete voicing.",
235 + join_names(&parsed)
236 + ));
237 + }
238 + let lines: Vec<String> = found
239 + .iter()
240 + .take(6)
241 + .enumerate()
242 + .map(|(i, c)| format!("{}. {} ({})", i + 1, c.chord.name(), c.chord.quality()))
243 + .collect();
244 + Ok(format!(
245 + "Notes: {} (bass {})\nCandidates, most likely first:\n{}",
246 + join_names(&parsed),
247 + parsed[0].name(),
248 + lines.join("\n")
249 + ))
250 + }
251 + _ => bail!("give either `symbol` or a non-empty `notes` list"),
252 + }
253 +}
254 +
255 +#[derive(Deserialize)]
256 +struct TransposeArgs {
257 + items: String,
258 + semitones: Option<i32>,
259 + from_key: Option<String>,
260 + to_key: Option<String>,
261 + prefer: Option<String>,
262 +}
263 +
264 +/// Default spelling of an upward interval of `n` semitones (0..12): minor seconds, thirds,
265 +/// sixths and sevenths, a perfect fourth/fifth, and a diminished fifth for the tritone.
266 +fn default_interval(n: i32) -> (usize, i32) {
267 + const TABLE: [(usize, i32); 12] = [
268 + (0, 0),
269 + (1, 1),
270 + (1, 2),
271 + (2, 3),
272 + (2, 4),
273 + (3, 5),
274 + (4, 6),
275 + (4, 7),
276 + (5, 8),
277 + (5, 9),
278 + (6, 10),
279 + (6, 11),
280 + ];
281 + TABLE[n.rem_euclid(12) as usize]
282 +}
283 +
284 +fn transpose_note(n: Note, steps: usize, semis: i32, prefer_flats: Option<bool>) -> Note {
285 + if let Some(flats) = prefer_flats {
286 + return spell_pc(n.pc() + semis, flats);
287 + }
288 + let t = n.up(steps, semis);
289 + if t.acc.abs() > 1 {
290 + spell_pc(t.pc(), n.acc < 0)
291 + } else {
292 + t
293 + }
294 +}
295 +
296 +fn transpose_tool(a: TransposeArgs) -> Result<String> {
297 + let (steps, semis, to_key) = match (&a.from_key, &a.to_key, a.semitones) {
298 + (Some(f), Some(t), _) => {
299 + let (from, to) = (Key::parse(f)?, Key::parse(t)?);
300 + let steps = (to.tonic.letter as usize + 7 - from.tonic.letter as usize) % 7;
301 + (steps, from.tonic.semis_to(to.tonic), Some(to))
302 + }
303 + (None, None, Some(n)) => {
304 + let (steps, semis) = default_interval(n);
305 + (steps, semis, None)
306 + }
307 + _ => bail!("give `semitones`, or both `from_key` and `to_key`"),
308 + };
309 + let prefer_flats = match a.prefer.as_deref() {
310 + Some("flats") => Some(true),
311 + Some("sharps") => Some(false),
312 + None => None,
313 + Some(other) => bail!("prefer must be sharps or flats, not `{other}`"),
314 + };
315 + let tokens = key::split_progression(&a.items);
316 + if tokens.is_empty() {
317 + bail!("`items` is empty");
318 + }
319 + let prefer = if to_key.is_some() { None } else { prefer_flats };
320 + let out: Vec<String> = tokens
321 + .iter()
322 + .map(|t| {
323 + let c = Chord::parse(t)?;
324 + let root = transpose_note(c.root, steps, semis, prefer);
325 + let bass = c.bass.map(|b| transpose_note(b, steps, semis, prefer));
326 + Ok(c.with_root(root, bass).name())
327 + })
328 + .collect::<Result<_>>()?;
329 + let interval = Interval::new(steps, semis).name();
330 + let mut text = format!(
331 + "Transposed up a {interval} ({semis} semitones):\n{}",
332 + out.join(" ")
333 + );
334 + if let Some(k) = to_key {
335 + text.push_str(&format!(
336 + "\n{} key signature: {}",
337 + k.name(),
338 + scale::signature_text(k.signature())
339 + ));
340 + }
341 + Ok(text)
342 +}
343 +
344 +#[derive(Deserialize)]
345 +struct ProgressionArgs {
346 + chords: Option<String>,
347 + key: Option<String>,
348 + numerals: Option<String>,
349 +}
350 +
351 +fn kind_note(k: Kind) -> &'static str {
352 + match k {
353 + Kind::Diatonic | Kind::DiatonicHarmonic => "",
354 + _ => " *",
355 + }
356 +}
357 +
358 +fn progression_tool(a: ProgressionArgs) -> Result<String> {
359 + if let Some(numerals) = a.numerals {
360 + let key = Key::parse(
361 + a.key
362 + .as_deref()
363 + .ok_or_else(|| anyhow!("`key` is required with `numerals`"))?,
364 + )?;
365 + let tokens = key::split_progression(&numerals);
366 + let rows: Vec<String> = tokens
367 + .iter()
368 + .map(|n| {
369 + let c = key::realize(&key, n)?;
370 + Ok(format!(
371 + "| {n} | {} | {} |",
372 + c.name(),
373 + join_names(&c.notes())
374 + ))
375 + })
376 + .collect::<Result<_>>()?;
377 + return Ok(format!(
378 + "In {} ({}):\n| Numeral | Chord | Notes |\n|---|---|---|\n{}",
379 + key.name(),
380 + scale::signature_text(key.signature()),
381 + rows.join("\n")
382 + ));
383 + }
384 + let Some(chords) = a.chords else {
385 + bail!("give `chords` to analyse or `numerals` (with `key`) to realize");
386 + };
387 + let parsed: Vec<Chord> = key::split_progression(&chords)
388 + .iter()
389 + .map(|c| Chord::parse(c))
390 + .collect::<Result<_>>()?;
391 + if parsed.is_empty() {
392 + bail!("`chords` is empty");
393 + }
394 + let guesses = key::detect(&parsed);
395 + let mut out = String::new();
396 + let used = match a.key {
397 + Some(k) => {
398 + let k = Key::parse(&k)?;
399 + out.push_str(&format!("Analysing in {} (as requested).\n", k.name()));
400 + k
401 + }
402 + None => {
403 + let top = &guesses[0];
404 + out.push_str(&format!(
405 + "Most likely key: {} (confidence {:.0}%, {}/{} chords diatonic).\n",
406 + top.key.name(),
407 + top.confidence * 100.0,
408 + top.diatonic,
409 + parsed.len()
410 + ));
411 + let alts: Vec<String> = guesses[1..]
412 + .iter()
413 + .take(3)
414 + .filter(|g| g.confidence > top.confidence - 0.35)
415 + .map(|g| format!("{} ({:.0}%)", g.key.name(), g.confidence * 100.0))
416 + .collect();
417 + if !alts.is_empty() {
418 + out.push_str(&format!("Alternatives: {}.\n", alts.join(", ")));
419 + }
420 + if guesses[1].key == top.key.relative() && top.confidence - guesses[1].confidence < 0.2
421 + {
422 + out.push_str(&format!(
423 + "Note: {} and {} share the same notes; the choice rests on where the progression resolves.\n",
424 + top.key.name(),
425 + guesses[1].key.name()
426 + ));
427 + }
428 + top.key
429 + }
430 + };
431 + out.push_str("| Chord | Numeral | Function |\n|---|---|---|\n");
432 + let mut any_outside = false;
433 + for c in &parsed {
434 + let an = key::analyze(&used, c);
435 + any_outside |= !kind_note(an.kind).is_empty();
436 + out.push_str(&format!(
437 + "| {} | {} | {}{} |\n",
438 + c.name(),
439 + an.numeral,
440 + an.role,
441 + kind_note(an.kind)
442 + ));
443 + }
444 + if any_outside {
445 + out.push_str("\\* outside the plain diatonic scale of the key.\n");
446 + }
447 + Ok(out.trim_end().to_string())
448 +}
449 +
450 +#[derive(Deserialize)]
451 +struct TempoArgs {
452 + bpm: Option<f64>,
453 + time_signature: Option<String>,
454 + sample_rate: Option<f64>,
455 + bars: Option<f64>,
456 + duration_seconds: Option<f64>,
457 + quarter_note_ms: Option<f64>,
458 + note_value: Option<String>,
459 +}
460 +
461 +fn positive(name: &str, v: f64) -> Result<f64> {
462 + if v.is_finite() && v > 0.0 {
463 + Ok(v)
464 + } else {
465 + bail!("{name} must be a positive number")
466 + }
467 +}
468 +
469 +fn tempo_tool(a: TempoArgs) -> Result<String> {
470 + let (num, den) = tempo::parse_time_signature(a.time_signature.as_deref().unwrap_or("4/4"))?;
471 + let mut derived = None;
472 + let bpm = match (a.bpm, a.bars, a.duration_seconds, a.quarter_note_ms) {
473 + (Some(b), ..) => positive("bpm", b)?,
474 + (None, Some(bars), Some(secs), _) => {
475 + let b = tempo::bpm_from_duration(
476 + positive("bars", bars)?,
477 + num,
478 + den,
479 + positive("duration_seconds", secs)?,
480 + );
481 + derived = Some(format!(
482 + "{bars} bars of {num}/{den} in {secs} s is {b:.3} bpm (quarter-note beats)."
483 + ));
484 + b
485 + }
486 + (None, _, _, Some(ms)) => {
487 + let b = 60_000.0 / positive("quarter_note_ms", ms)?;
488 + derived = Some(format!("A {ms} ms quarter note is {b:.3} bpm."));
489 + b
490 + }
491 + _ => bail!("give `bpm`, or `bars` with `duration_seconds`, or `quarter_note_ms`"),
492 + };
493 + let mut out = String::new();
494 + if let Some(d) = derived {
495 + out.push_str(&d);
496 + out.push('\n');
497 + }
498 + let bar = tempo::bar_ms(bpm, num, den);
499 + out.push_str(&format!(
500 + "{bpm:.3} bpm in {num}/{den}: one bar = {bar:.2} ms ({:.3} s)",
501 + bar / 1000.0
502 + ));
503 + if let Some(sr) = a.sample_rate {
504 + let sr = positive("sample_rate", sr)?;
505 + out.push_str(&format!(
506 + " = {:.0} samples at {sr:.0} Hz",
507 + tempo::samples(bar, sr)
508 + ));
509 + }
510 + if let Some(nv) = &a.note_value {
511 + let (den, feel) = tempo::parse_note_value(nv)?;
512 + let ms = tempo::note_ms(bpm, den, feel);
513 + out.push_str(&format!(
514 + "\n{nv} at {bpm:.3} bpm = {ms:.2} ms ({:.3} Hz)",
515 + tempo::hz_from_ms(ms)
516 + ));
517 + if let Some(sr) = a.sample_rate {
518 + out.push_str(&format!(
519 + " = {:.0} samples at {sr:.0} Hz",
520 + tempo::samples(ms, sr)
521 + ));
522 + }
523 + }
524 + out.push_str("\n| Note | Straight | Dotted | Triplet |\n|---|---|---|---|\n");
525 + for (label, den) in tempo::NOTE_VALUES {
526 + let cell = |feel: Feel| {
527 + let ms = tempo::note_ms(bpm, den, feel);
528 + let mut s = format!("{ms:.2} ms ({:.3} Hz)", tempo::hz_from_ms(ms));
529 + if let Some(sr) = a.sample_rate {
530 + s.push_str(&format!(", {:.0} smp", tempo::samples(ms, sr)));
531 + }
532 + s
533 + };
534 + out.push_str(&format!(
535 + "| {label} | {} | {} | {} |\n",
536 + cell(Feel::Straight),
537 + cell(Feel::Dotted),
538 + cell(Feel::Triplet)
539 + ));
540 + }
541 + Ok(out.trim_end().to_string())
542 +}
543 +
544 +#[derive(Deserialize)]
545 +struct PitchArgs {
546 + name: Option<String>,
547 + midi: Option<f64>,
548 + hz: Option<f64>,
549 + a4: Option<f64>,
550 +}
551 +
552 +fn pitch_tool(a: PitchArgs) -> Result<String> {
553 + let a4 = positive("a4", a.a4.unwrap_or(440.0))?;
554 + let (midi, exact) = match (&a.name, a.midi, a.hz) {
555 + (Some(n), ..) => (Pitch::parse(n)?.midi() as f64, true),
556 + (None, Some(m), _) if m.is_finite() => (m, m.fract() == 0.0),
557 + (None, None, Some(h)) => (hz_to_midi(positive("hz", h)?, a4), false),
558 + _ => bail!("give one of `name`, `midi` or `hz`"),
559 + };
560 + let nearest = midi.round() as i32;
561 + let hz = midi_to_hz(midi, a4);
562 + let sharp = Pitch::from_midi(nearest, false);
563 + let flat = Pitch::from_midi(nearest, true);
564 + let names = if sharp.name() == flat.name() {
565 + sharp.name()
566 + } else {
567 + format!("{} / {}", sharp.name(), flat.name())
568 + };
569 + let mut out = format!(
570 + "MIDI {nearest} = {names}, {:.3} Hz (A4 = {a4} Hz)",
571 + midi_to_hz(nearest as f64, a4)
572 + );
573 + if !exact {
574 + let cents = cents_between(midi_to_hz(nearest as f64, a4), hz);
575 + out =
576 + format!("{hz:.3} Hz is nearest to {names} (MIDI {nearest}), {cents:+.1} cents. {out}");
577 + }
578 + Ok(out)
579 +}
580 +
581 +#[cfg(test)]
582 +mod tests {
583 + use super::*;
584 +
585 + fn run(name: &str, args: Value) -> String {
586 + execute(name, args).unwrap().text
587 + }
588 +
589 + #[test]
590 + fn scale_output() {
591 + let t = run("theory_scale", json!({"root": "C#", "scale": "major"}));
592 + assert!(t.contains("Notes: C# D# E# F# G# A# B#"), "{t}");
593 + assert!(t.contains("7 sharps"), "{t}");
594 + assert!(t.contains("Diatonic triads: C# D#m E#m F# G# A#m"), "{t}");
595 + assert!(
596 + t.contains("Diatonic sevenths: C#maj7 D#m7 E#m7 F#maj7 G#7 A#m7"),
597 + "{t}"
598 + );
599 + assert!(run("theory_scale", json!({"root": "C"})).contains("dorian"));
600 + assert!(execute("theory_scale", json!({"root": "C", "scale": "nope"})).is_err());
601 + }
602 +
603 + #[test]
604 + fn chord_output() {
605 + let t = run("theory_chord", json!({"symbol": "Bb13#11/D"}));
606 + assert!(t.contains("Notes: Bb D F Ab C E G"), "{t}");
607 + assert!(t.contains("Formula: 1 3 5 b7 9 #11 13"), "{t}");
608 + assert!(t.contains("first inversion"), "{t}");
609 + let t = run("theory_chord", json!({"notes": ["C", "E", "G", "Bb"]}));
610 + assert!(t.contains("1. C7"), "{t}");
611 + assert!(execute("theory_chord", json!({})).is_err());
612 + }
613 +
614 + #[test]
615 + fn transpose_by_key_keeps_spelling() {
616 + let t = run(
617 + "theory_transpose",
618 + json!({"items": "Dm7 G7 Cmaj7", "from_key": "C major", "to_key": "Db major"}),
619 + );
620 + assert!(t.contains("Ebm7 Ab7 Dbmaj7"), "{t}");
621 + assert!(t.contains("5 flats"), "{t}");
622 + let t = run(
623 + "theory_transpose",
624 + json!({"items": "C F G7/B", "semitones": 2}),
625 + );
626 + assert!(t.contains("D G A7/C#"), "{t}");
627 + let t = run(
628 + "theory_transpose",
629 + json!({"items": "Am F", "semitones": -3}),
630 + );
631 + assert!(t.contains("F#m D"), "{t}");
632 + let t = run(
633 + "theory_transpose",
634 + json!({"items": "C", "semitones": 1, "prefer": "sharps"}),
635 + );
636 + assert!(t.contains("C#"), "{t}");
637 + assert!(execute("theory_transpose", json!({"items": "C"})).is_err());
638 + }
639 +
640 + #[test]
641 + fn progression_analysis_and_realization() {
642 + let t = run("theory_progression", json!({"chords": "Dm7 G7 Cmaj7"}));
643 + assert!(t.contains("Most likely key: C major"), "{t}");
644 + assert!(t.contains("| Dm7 | ii7 |"), "{t}");
645 + assert!(t.contains("| G7 | V7 | dominant |"), "{t}");
646 + let t = run(
647 + "theory_progression",
648 + json!({"chords": "C A7 Dm G7", "key": "C"}),
649 + );
650 + assert!(t.contains("V7/ii"), "{t}");
651 + let t = run(
652 + "theory_progression",
653 + json!({"numerals": "ii7 V7 Imaj7", "key": "Eb major"}),
654 + );
655 + assert!(
656 + t.contains("Fm7") && t.contains("Bb7") && t.contains("Ebmaj7"),
657 + "{t}"
658 + );
659 + assert!(execute("theory_progression", json!({"numerals": "V"})).is_err());
660 + }
661 +
662 + #[test]
663 + fn tempo_output() {
664 + let t = run("theory_tempo", json!({"bpm": 120, "sample_rate": 48000}));
665 + assert!(t.contains("one bar = 2000.00 ms"), "{t}");
666 + assert!(
667 + t.contains("| 1/8 | 250.00 ms (4.000 Hz), 12000 smp | 375.00 ms"),
668 + "{t}"
669 + );
670 + let t = run("theory_tempo", json!({"bars": 4, "duration_seconds": 8}));
671 + assert!(t.contains("120.000 bpm"), "{t}");
672 + let t = run("theory_tempo", json!({"bpm": 120, "note_value": "1/8."}));
673 + assert!(t.contains("1/8. at 120.000 bpm = 375.00 ms"), "{t}");
674 + assert!(execute("theory_tempo", json!({"bpm": 120, "note_value": "1/3"})).is_err());
675 + assert!(execute("theory_tempo", json!({})).is_err());
676 + assert!(execute("theory_tempo", json!({"bpm": -4})).is_err());
677 + }
678 +
679 + #[test]
680 + fn pitch_output() {
681 + let t = run("theory_pitch", json!({"name": "A4"}));
682 + assert!(t.contains("MIDI 69 = A4, 440.000 Hz"), "{t}");
683 + let t = run("theory_pitch", json!({"hz": 450}));
684 + assert!(t.contains("A4") && t.contains("+38.9 cents"), "{t}");
685 + let t = run("theory_pitch", json!({"midi": 61}));
686 + assert!(t.contains("C#4 / Db4"), "{t}");
687 + let t = run("theory_pitch", json!({"name": "A4", "a4": 432}));
688 + assert!(t.contains("432.000 Hz"), "{t}");
689 + assert!(execute("theory_pitch", json!({})).is_err());
690 + }
691 +
692 + #[test]
693 + fn titles() {
694 + assert_eq!(
695 + title("theory_chord", &json!({"symbol": "Cm7"})),
696 + "Chord: Cm7"
697 + );
698 + assert_eq!(
699 + title("theory_scale", &json!({"root": "D", "scale": "dorian"})),
700 + "Scale: D dorian"
701 + );
702 + }
703 +}
src/tools/workspace.rs new
+400
@@ -0,0 +1,400 @@
1 +//! Workspace tools: files and shell, routed through the ACP client when it advertises
2 +//! support and falling back to the local filesystem/process otherwise.
3 +
4 +use std::path::Path;
5 +use std::time::Duration;
6 +
7 +use agent_client_protocol::ErrorCode;
8 +use agent_client_protocol::schema::v1::{
9 + CreateTerminalRequest, Diff, KillTerminalRequest, ReadTextFileRequest, ReleaseTerminalRequest,
10 + Terminal, TerminalOutputRequest, ToolCallContent, ToolCallUpdateFields,
11 + WaitForTerminalExitRequest, WriteTextFileRequest,
12 +};
13 +use anyhow::{Context, Result, anyhow, bail};
14 +use serde::Deserialize;
15 +use serde_json::{Value, json};
16 +
17 +use super::{MAX_OUTPUT_BYTES, ToolCtx, ToolOutcome, absolutize, resolve_in, truncate};
18 +
19 +const COMMAND_TIMEOUT: Duration = Duration::from_secs(120);
20 +
21 +pub fn definitions() -> Vec<Value> {
22 + let f = |name: &str, desc: &str, params: Value| super::function_def(name, desc, params);
23 + let root_prop = || {
24 + json!({
25 + "type": "string",
26 + "description": "Workspace root to act in: absolute path of a session root or a directory beneath one. Defaults to the primary working directory."
27 + })
28 + };
29 + vec![
30 + f(
31 + "read_file",
32 + "Read a text file. Paths may be relative to the working directory.",
33 + json!({
34 + "type": "object",
35 + "properties": {
36 + "path": { "type": "string" },
37 + "line": { "type": "integer", "description": "1-based line to start from" },
38 + "limit": { "type": "integer", "description": "Max lines to read" },
39 + "root": root_prop()
40 + },
41 + "required": ["path"]
42 + }),
43 + ),
44 + f(
45 + "write_file",
46 + "Create or overwrite a file with the given content.",
47 + json!({
48 + "type": "object",
49 + "properties": { "path": { "type": "string" }, "content": { "type": "string" }, "root": root_prop() },
50 + "required": ["path", "content"]
51 + }),
52 + ),
53 + f(
54 + "edit_file",
55 + "Replace an exact, unique occurrence of old_string with new_string in a file.",
56 + json!({
57 + "type": "object",
58 + "properties": {
59 + "path": { "type": "string" },
60 + "old_string": { "type": "string" },
61 + "new_string": { "type": "string" },
62 + "root": root_prop()
63 + },
64 + "required": ["path", "old_string", "new_string"]
65 + }),
66 + ),
67 + f(
68 + "list_directory",
69 + "List entries of a directory (directories end with '/').",
70 + json!({
71 + "type": "object",
72 + "properties": { "path": { "type": "string", "description": "Defaults to the working directory" }, "root": root_prop() }
73 + }),
74 + ),
75 + f(
76 + "run_command",
77 + "Run a shell command (sh -c) in the working directory and return its output.",
78 + json!({
79 + "type": "object",
80 + "properties": { "command": { "type": "string" }, "root": root_prop() },
81 + "required": ["command"]
82 + }),
83 + ),
84 + ]
85 +}
86 +
87 +pub const NAMES: &[&str] = &[
88 + "read_file",
89 + "write_file",
90 + "edit_file",
91 + "list_directory",
92 + "run_command",
93 +];
94 +
95 +pub async fn execute(
96 + ctx: &ToolCtx,
97 + tool_call_id: &str,
98 + name: &str,
99 + args: Value,
100 +) -> Result<ToolOutcome> {
101 + match name {
102 + "read_file" => ctx.read_file(args).await,
103 + "write_file" => ctx.write_file(tool_call_id, args).await,
104 + "edit_file" => ctx.edit_file(tool_call_id, args).await,
105 + "list_directory" => ctx.list_directory(args).await,
106 + "run_command" => ctx.run_command(tool_call_id, args).await,
107 + other => Err(anyhow!("unknown tool `{other}`")),
108 + }
109 +}
110 +
111 +#[derive(Deserialize)]
112 +struct ReadArgs {
113 + path: String,
114 + line: Option<u32>,
115 + limit: Option<u32>,
116 + root: Option<String>,
117 +}
118 +#[derive(Deserialize)]
119 +struct WriteArgs {
120 + path: String,
121 + content: String,
122 + root: Option<String>,
123 +}
124 +#[derive(Deserialize)]
125 +struct EditArgs {
126 + path: String,
127 + old_string: String,
128 + new_string: String,
129 + root: Option<String>,
130 +}
131 +#[derive(Deserialize)]
132 +struct ListArgs {
133 + path: Option<String>,
134 + root: Option<String>,
135 +}
136 +#[derive(Deserialize)]
137 +struct CommandArgs {
138 + command: String,
139 + root: Option<String>,
140 +}
141 +
142 +impl ToolCtx {
143 + pub(super) async fn read_file(&self, args: Value) -> Result<ToolOutcome> {
144 + let a: ReadArgs = serde_json::from_value(args)?;
145 + let path = self.resolve_with(a.root.as_deref(), &a.path)?;
146 + let text = if self.caps.fs.read_text_file {
147 + // ACP v1 fs/read_text_file requires an absolute path.
148 + let mut req = ReadTextFileRequest::new(self.session_id.clone(), absolutize(&path));
149 + if let Some(l) = a.line {
150 + req = req.line(l);
151 + }
152 + if let Some(l) = a.limit {
153 + req = req.limit(l);
154 + }
155 + self.connection
156 + .send_request(req)
157 + .block_task()
158 + .await?
159 + .content
160 + } else {
161 + let full = tokio::fs::read_to_string(&path)
162 + .await
163 + .with_context(|| format!("reading {}", path.display()))?;
164 + let skip = a.line.map_or(0, |l| l.saturating_sub(1) as usize);
165 + let take = a.limit.map_or(usize::MAX, |l| l as usize);
166 + full.lines()
167 + .skip(skip)
168 + .take(take)
169 + .collect::<Vec<_>>()
170 + .join("\n")
171 + };
172 + // Show the user a short summary; give the model the full text.
173 + let mut out = ToolOutcome::ok(format!("{} lines", text.lines().count()));
174 + out.text = truncate(text);
175 + Ok(out)
176 + }
177 +
178 + /// Read the current file contents for diffs/edits; `Ok(None)` if it doesn't exist.
179 + /// A client-fs read failure is an error, distinct from a missing file (ACP
180 + /// `ResourceNotFound`), so callers don't mistake a failed read for a new file.
181 + pub(super) async fn read_existing(&self, path: &Path) -> Result<Option<String>> {
182 + if self.caps.fs.read_text_file {
183 + let req = ReadTextFileRequest::new(self.session_id.clone(), absolutize(path));
184 + match self.connection.send_request(req).block_task().await {
185 + Ok(r) => Ok(Some(r.content)),
186 + Err(e) if e.code == ErrorCode::ResourceNotFound => Ok(None),
187 + Err(e) => Err(anyhow!("client read of {} failed: {e}", path.display())),
188 + }
189 + } else {
190 + match tokio::fs::read_to_string(path).await {
191 + Ok(content) => Ok(Some(content)),
192 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
193 + Err(e) => Err(anyhow!("reading {}: {e}", path.display())),
194 + }
195 + }
196 + }
197 +
198 + pub(super) async fn write_text(&self, path: &Path, content: &str) -> Result<()> {
199 + if self.caps.fs.write_text_file {
200 + let req = WriteTextFileRequest::new(self.session_id.clone(), absolutize(path), content);
201 + self.connection.send_request(req).block_task().await?;
202 + } else {
203 + if let Some(parent) = path.parent() {
204 + tokio::fs::create_dir_all(parent).await?;
205 + }
206 + tokio::fs::write(path, content).await?;
207 + }
208 + Ok(())
209 + }
210 +
211 + pub(super) async fn write_file(&self, id: &str, args: Value) -> Result<ToolOutcome> {
212 + let a: WriteArgs = serde_json::from_value(args)?;
213 + let path = self.resolve_with(a.root.as_deref(), &a.path)?;
214 + let old = self.read_existing(&path).await?;
215 + // ACP v1: Diff.path must be an absolute file path.
216 + let diff = Diff::new(absolutize(&path), a.content.clone()).old_text(old);
217 + self.apply_edit(id, "write_file", &path, &a.content, diff)
218 + .await
219 + }
220 +
221 + pub(super) async fn edit_file(&self, id: &str, args: Value) -> Result<ToolOutcome> {
222 + let a: EditArgs = serde_json::from_value(args)?;
223 + let path = self.resolve_with(a.root.as_deref(), &a.path)?;
224 + let old = self
225 + .read_existing(&path)
226 + .await?
227 + .ok_or_else(|| anyhow!("{} does not exist", path.display()))?;
228 + match old.matches(&a.old_string).count() {
229 + 0 => bail!("old_string not found in {}", path.display()),
230 + 1 => {}
231 + n => bail!(
232 + "old_string occurs {n} times in {}; make it unique",
233 + path.display()
234 + ),
235 + }
236 + let new = old.replacen(&a.old_string, &a.new_string, 1);
237 + let diff = Diff::new(absolutize(&path), new.clone()).old_text(old);
238 + self.apply_edit(id, "edit_file", &path, &new, diff).await
239 + }
240 +
241 + pub(super) async fn apply_edit(
242 + &self,
243 + id: &str,
244 + tool: &str,
245 + path: &Path,
246 + content: &str,
247 + diff: Diff,
248 + ) -> Result<ToolOutcome> {
249 + let diff = ToolCallContent::from(diff);
250 + if !self.permit(id, tool, vec![diff.clone()]).await? {
251 + return Ok(ToolOutcome::err("User rejected this change."));
252 + }
253 + self.write_text(path, content).await?;
254 + Ok(ToolOutcome {
255 + text: format!("Wrote {}", path.display()),
256 + content: vec![diff],
257 + failed: false,
258 + locations: Vec::new(),
259 + })
260 + }
261 +
262 + pub(super) async fn list_directory(&self, args: Value) -> Result<ToolOutcome> {
263 + let a: ListArgs = serde_json::from_value(args)?;
264 + let base = self.base_for(a.root.as_deref())?;
265 + let path = a
266 + .path
267 + .map_or_else(|| base.clone(), |p| resolve_in(&base, &p));
268 + let mut entries = Vec::new();
269 + let mut rd = tokio::fs::read_dir(&path)
270 + .await
271 + .with_context(|| format!("listing {}", path.display()))?;
272 + while let Some(e) = rd.next_entry().await? {
273 + let mut name = e.file_name().to_string_lossy().into_owned();
274 + if e.file_type().await.is_ok_and(|t| t.is_dir()) {
275 + name.push('/');
276 + }
277 + entries.push(name);
278 + }
279 + entries.sort();
280 + Ok(ToolOutcome::ok(entries.join("\n")))
281 + }
282 +
283 + pub(super) async fn run_command(&self, id: &str, args: Value) -> Result<ToolOutcome> {
284 + let a: CommandArgs = serde_json::from_value(args)?;
285 + let base = self.base_for(a.root.as_deref())?;
286 + if !self.permit(id, "run_command", vec![]).await? {
287 + return Ok(ToolOutcome::err("User rejected running this command."));
288 + }
289 + if self.caps.terminal {
290 + self.run_in_client_terminal(id, &a.command, &base).await
291 + } else {
292 + self.run_locally(&a.command, &base).await
293 + }
294 + }
295 +
296 + pub(super) async fn run_in_client_terminal(
297 + &self,
298 + id: &str,
299 + command: &str,
300 + cwd: &Path,
301 + ) -> Result<ToolOutcome> {
302 + let sid = self.session_id.clone();
303 + let req = CreateTerminalRequest::new(sid.clone(), "sh")
304 + .args(vec!["-c".into(), command.into()])
305 + // ACP v1: the terminal cwd must be an absolute path.
306 + .cwd(absolutize(cwd))
307 + .output_byte_limit(MAX_OUTPUT_BYTES as u64);
308 + let terminal_id = self
309 + .connection
310 + .send_request(req)
311 + .block_task()
312 + .await?
313 + .terminal_id;
314 + // Embed the live terminal in the tool call so the user can watch it.
315 + let content = vec![ToolCallContent::Terminal(Terminal::new(
316 + terminal_id.clone(),
317 + ))];
318 + self.update(id, ToolCallUpdateFields::new().content(content.clone()))?;
319 +
320 + let wait = self
321 + .connection
322 + .send_request(WaitForTerminalExitRequest::new(
323 + sid.clone(),
324 + terminal_id.clone(),
325 + ))
326 + .block_task();
327 + let exit = tokio::select! {
328 + r = wait => Some(r?.exit_status),
329 + () = tokio::time::sleep(COMMAND_TIMEOUT) => None,
330 + () = self.cancel.cancelled() => None,
331 + };
332 + if exit.is_none() {
333 + let _ = self
334 + .connection
335 + .send_request(KillTerminalRequest::new(sid.clone(), terminal_id.clone()))
336 + .block_task()
337 + .await;
338 + }
339 + let output = self
340 + .connection
341 + .send_request(TerminalOutputRequest::new(sid.clone(), terminal_id.clone()))
342 + .block_task()
343 + .await?;
344 + let _ = self
345 + .connection
346 + .send_request(ReleaseTerminalRequest::new(sid, terminal_id))
347 + .block_task()
348 + .await;
349 +
350 + let status = match &exit {
351 + Some(s) => match (s.exit_code, &s.signal) {
352 + (Some(c), _) => format!("exit code {c}"),
353 + (None, Some(sig)) => format!("killed by {sig}"),
354 + _ => "exited".into(),
355 + },
356 + None if self.cancel.is_cancelled() => "cancelled".into(),
357 + None => format!("timed out after {}s", COMMAND_TIMEOUT.as_secs()),
358 + };
359 + let failed = !matches!(exit.as_ref().and_then(|s| s.exit_code), Some(0));
360 + let trunc = if output.truncated {
361 + "\n[output truncated]"
362 + } else {
363 + ""
364 + };
365 + Ok(ToolOutcome {
366 + text: format!("{}{trunc}\n[{status}]", output.output),
367 + content,
368 + failed,
369 + locations: Vec::new(),
370 + })
371 + }
372 +
373 + pub(super) async fn run_locally(&self, command: &str, cwd: &Path) -> Result<ToolOutcome> {
374 + let child = tokio::process::Command::new("sh")
375 + .arg("-c")
376 + .arg(command)
377 + .current_dir(cwd)
378 + .stdin(std::process::Stdio::null())
379 + .kill_on_drop(true)
380 + .output();
381 + let out = tokio::select! {
382 + r = tokio::time::timeout(COMMAND_TIMEOUT, child) => match r {
383 + Ok(r) => r?,
384 + Err(_) => return Ok(ToolOutcome::err(format!("Command timed out after {}s", COMMAND_TIMEOUT.as_secs()))),
385 + },
386 + () = self.cancel.cancelled() => return Ok(ToolOutcome::err("Command cancelled")),
387 + };
388 + let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
389 + text.push_str(&String::from_utf8_lossy(&out.stderr));
390 + let status = out
391 + .status
392 + .code()
393 + .map_or_else(|| "killed by signal".into(), |c| format!("exit code {c}"));
394 + let text = format!("{}\n[{status}]", truncate(text));
395 + let mut outcome = ToolOutcome::ok(format!("```\n{text}\n```"));
396 + outcome.text = text;
397 + outcome.failed = !out.status.success();
398 + Ok(outcome)
399 + }
400 +}
src/tui.rs new
+747
@@ -0,0 +1,747 @@
1 +//! Terminal UI. It is a regular ACP client: it launches this same binary with `--acp` as the
2 +//! agent subprocess and renders the session with ratatui, so it exercises exactly the protocol
3 +//! path an editor would.
4 +
5 +use std::path::{Path, PathBuf};
6 +use std::time::Duration;
7 +
8 +use agent_client_protocol::schema::ProtocolVersion;
9 +use agent_client_protocol::schema::v1::{
10 + CancelNotification, ClientCapabilities, ContentBlock, FileSystemCapabilities,
11 + InitializeRequest, NewSessionRequest, PermissionOptionKind, PromptRequest, ReadTextFileRequest,
12 + ReadTextFileResponse, RequestPermissionOutcome, RequestPermissionRequest,
13 + RequestPermissionResponse, SelectedPermissionOutcome, SessionId, SessionNotification,
14 + SessionUpdate, StopReason, TextContent, ToolCallContent, ToolCallId, ToolCallStatus,
15 + WriteTextFileRequest, WriteTextFileResponse,
16 +};
17 +use agent_client_protocol::{AcpAgent, AcpAgentConfig, Agent, ConnectionTo};
18 +use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
19 +use futures::StreamExt;
20 +use ratatui::layout::{Constraint, Layout, Rect};
21 +use ratatui::style::{Color, Modifier, Style, Stylize};
22 +use ratatui::text::{Line, Span};
23 +use ratatui::widgets::{Block, BorderType, Clear, Paragraph, Wrap};
24 +use ratatui::{DefaultTerminal, Frame};
25 +use tokio::sync::{mpsc, oneshot};
26 +
27 +const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
28 +const MAX_TOOL_OUTPUT_LINES: usize = 8;
29 +
30 +enum AppEvent {
31 + Update(SessionUpdate),
32 + Permission(
33 + RequestPermissionRequest,
34 + oneshot::Sender<RequestPermissionOutcome>,
35 + ),
36 + TurnDone(Result<StopReason, String>),
37 +}
38 +
39 +pub async fn run(yolo: bool, extra_roots: Vec<PathBuf>) -> anyhow::Result<()> {
40 + let mut config = AcpAgentConfig::new(std::env::current_exe()?).arg("--acp");
41 + config = config.env("SPLITFIRE_SURFACE", "tui");
42 + if yolo {
43 + config = config.arg("--yolo");
44 + }
45 + let cwd = std::env::current_dir()?;
46 + let llm = crate::llm::LlmConfig::from_env();
47 + let model = llm.model.clone();
48 + let (tx, rx) = mpsc::unbounded_channel();
49 +
50 + agent_client_protocol::Client
51 + .builder()
52 + .on_receive_notification(
53 + {
54 + let tx = tx.clone();
55 + async move |n: SessionNotification, _cx| {
56 + let _ = tx.send(AppEvent::Update(n.update));
57 + Ok(())
58 + }
59 + },
60 + agent_client_protocol::on_receive_notification!(),
61 + )
62 + .on_receive_request(
63 + {
64 + let tx = tx.clone();
65 + async move |req: RequestPermissionRequest, responder, cx| {
66 + // Wait for the user's answer off the dispatch loop so updates keep flowing.
67 + let (answer_tx, answer_rx) = oneshot::channel();
68 + let _ = tx.send(AppEvent::Permission(req, answer_tx));
69 + cx.spawn(async move {
70 + let outcome = answer_rx
71 + .await
72 + .unwrap_or(RequestPermissionOutcome::Cancelled);
73 + responder.respond(RequestPermissionResponse::new(outcome))
74 + })
75 + }
76 + },
77 + agent_client_protocol::on_receive_request!(),
78 + )
79 + // ACP v1 fs/read_text_file: serve file contents from the session cwd,
80 + // honoring the optional 1-based `line` and `limit` params.
81 + .on_receive_request(
82 + async move |req: ReadTextFileRequest, responder, _cx| match tokio::task::spawn_blocking(
83 + {
84 + let path = req.path.clone();
85 + move || read_text_file_blocking(&path, req.line, req.limit)
86 + },
87 + )
88 + .await
89 + {
90 + Ok(Ok(r)) => responder.respond(r),
91 + Ok(Err(e)) => responder.respond_with_error(e),
92 + Err(e) => responder.respond_with_internal_error(e.to_string()),
93 + },
94 + agent_client_protocol::on_receive_request!(),
95 + )
96 + // ACP v1 fs/write_text_file: the client MUST create the file (and parent
97 + // directories) if it doesn't exist.
98 + .on_receive_request(
99 + async move |req: WriteTextFileRequest, responder, _cx| {
100 + match tokio::task::spawn_blocking({
101 + let path = req.path.clone();
102 + let content = req.content.clone();
103 + move || write_text_file_blocking(&path, &content)
104 + })
105 + .await
106 + {
107 + Ok(Ok(r)) => responder.respond(r),
108 + Ok(Err(e)) => responder.respond_with_error(e),
109 + Err(e) => responder.respond_with_internal_error(e.to_string()),
110 + }
111 + },
112 + agent_client_protocol::on_receive_request!(),
113 + )
114 + .connect_with(
115 + AcpAgent::new(config),
116 + |conn: ConnectionTo<Agent>| async move {
117 + conn.send_request(
118 + InitializeRequest::new(ProtocolVersion::V1).client_capabilities(
119 + ClientCapabilities::new().fs(FileSystemCapabilities::new()
120 + .read_text_file(true)
121 + .write_text_file(true)),
122 + ),
123 + )
124 + .block_task()
125 + .await?;
126 + let session = conn
127 + .send_request(
128 + NewSessionRequest::new(cwd.clone())
129 + .additional_directories(extra_roots.clone()),
130 + )
131 + .block_task()
132 + .await?;
133 + let mut app = App::new(conn, session.session_id, tx, model, cwd, extra_roots);
134 + let mut terminal = ratatui::init();
135 + let result = app.run(&mut terminal, rx).await;
136 + ratatui::restore();
137 + result.map_err(|e| {
138 + agent_client_protocol::Error::internal_error().data(format!("{e:#}"))
139 + })
140 + },
141 + )
142 + .await?;
143 + Ok(())
144 +}
145 +
146 +/// `fs/read_text_file` handler body: reads `path`, applying the spec's optional
147 +/// 1-based `line` and `limit` params.
148 +fn read_text_file_blocking(
149 + path: &Path,
150 + line: Option<u32>,
151 + limit: Option<u32>,
152 +) -> agent_client_protocol::Result<ReadTextFileResponse> {
153 + let content = std::fs::read_to_string(path).map_err(|e| {
154 + if e.kind() == std::io::ErrorKind::NotFound {
155 + agent_client_protocol::Error::resource_not_found(Some(path.display().to_string()))
156 + } else {
157 + agent_client_protocol::Error::internal_error().data(e.to_string())
158 + }
159 + })?;
160 + let skip = line.map_or(0, |l| l.saturating_sub(1) as usize);
161 + let take = limit.map_or(usize::MAX, |l| l as usize);
162 + let content = content
163 + .lines()
164 + .skip(skip)
165 + .take(take)
166 + .collect::<Vec<_>>()
167 + .join("\n");
168 + Ok(ReadTextFileResponse::new(content))
169 +}
170 +
171 +/// `fs/write_text_file` handler body: the client MUST create the file if it
172 +/// doesn't exist (spec: https://agentclientprotocol.com/protocol/v1/file-system).
173 +fn write_text_file_blocking(
174 + path: &Path,
175 + content: &str,
176 +) -> agent_client_protocol::Result<WriteTextFileResponse> {
177 + let write = || {
178 + if let Some(parent) = path.parent() {
179 + std::fs::create_dir_all(parent)?;
180 + }
181 + std::fs::write(path, content)
182 + };
183 + write().map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string()))?;
184 + Ok(WriteTextFileResponse::new())
185 +}
186 +
187 +enum Entry {
188 + User(String),
189 + Agent(String),
190 + Thought(String),
191 + Tool {
192 + id: ToolCallId,
193 + title: String,
194 + status: ToolCallStatus,
195 + output: String,
196 + },
197 + Info(String),
198 + Error(String),
199 +}
200 +
201 +struct App {
202 + conn: ConnectionTo<Agent>,
203 + session_id: SessionId,
204 + tx: mpsc::UnboundedSender<AppEvent>,
205 + model: String,
206 + /// Workspace roots, primary working directory first, then any additional roots.
207 + roots: Vec<PathBuf>,
208 + entries: Vec<Entry>,
209 + input: Vec<char>,
210 + cursor: usize,
211 + busy: bool,
212 + permission: Option<(
213 + RequestPermissionRequest,
214 + oneshot::Sender<RequestPermissionOutcome>,
215 + )>,
216 + /// Lines scrolled up from the bottom of the transcript; 0 follows new output.
217 + scroll_up: usize,
218 + tick: usize,
219 + quit: bool,
220 +}
221 +
222 +impl App {
223 + fn new(
224 + conn: ConnectionTo<Agent>,
225 + session_id: SessionId,
226 + tx: mpsc::UnboundedSender<AppEvent>,
227 + model: String,
228 + cwd: PathBuf,
229 + extra_roots: Vec<PathBuf>,
230 + ) -> Self {
231 + let roots = std::iter::once(cwd.clone())
232 + .chain(extra_roots.into_iter().filter(|r| *r != cwd))
233 + .collect();
234 + Self {
235 + conn,
236 + session_id,
237 + tx,
238 + model,
239 + roots,
240 + entries: vec![Entry::Info(
241 + "Ask for a change, or type /quit to exit.".into(),
242 + )],
243 + input: Vec::new(),
244 + cursor: 0,
245 + busy: false,
246 + permission: None,
247 + scroll_up: 0,
248 + tick: 0,
249 + quit: false,
250 + }
251 + }
252 +
253 + async fn run(
254 + &mut self,
255 + terminal: &mut DefaultTerminal,
256 + mut rx: mpsc::UnboundedReceiver<AppEvent>,
257 + ) -> anyhow::Result<()> {
258 + let mut keys = EventStream::new();
259 + let mut ticker = tokio::time::interval(Duration::from_millis(80));
260 + while !self.quit {
261 + terminal.draw(|f| self.draw(f))?;
262 + tokio::select! {
263 + ev = keys.next() => match ev {
264 + Some(Ok(Event::Key(key))) if key.kind == KeyEventKind::Press => self.on_key(key),
265 + Some(Err(e)) => return Err(e.into()),
266 + None => break,
267 + _ => {}
268 + },
269 + Some(ev) = rx.recv() => self.on_event(ev),
270 + _ = ticker.tick(), if self.busy => self.tick += 1,
271 + }
272 + }
273 + Ok(())
274 + }
275 +
276 + fn on_event(&mut self, ev: AppEvent) {
277 + match ev {
278 + AppEvent::Update(update) => self.on_update(update),
279 + AppEvent::Permission(req, answer) => self.permission = Some((req, answer)),
280 + AppEvent::TurnDone(result) => {
281 + self.busy = false;
282 + match result {
283 + Ok(StopReason::EndTurn) => {}
284 + Ok(StopReason::Cancelled) => {
285 + self.entries.push(Entry::Info("Cancelled.".into()))
286 + }
287 + Ok(other) => self
288 + .entries
289 + .push(Entry::Info(format!("Stopped: {other:?}"))),
290 + Err(e) => self.entries.push(Entry::Error(e)),
291 + }
292 + }
293 + }
294 + }
295 +
296 + fn on_update(&mut self, update: SessionUpdate) {
297 + match update {
298 + SessionUpdate::AgentMessageChunk(chunk) => match self.entries.last_mut() {
299 + Some(Entry::Agent(text)) => text.push_str(&block_text(&chunk.content)),
300 + _ => self.entries.push(Entry::Agent(block_text(&chunk.content))),
301 + },
302 + SessionUpdate::AgentThoughtChunk(chunk) => match self.entries.last_mut() {
303 + Some(Entry::Thought(text)) => text.push_str(&block_text(&chunk.content)),
304 + _ => self
305 + .entries
306 + .push(Entry::Thought(block_text(&chunk.content))),
307 + },
308 + SessionUpdate::ToolCall(call) => self.entries.push(Entry::Tool {
309 + id: call.tool_call_id,
310 + title: call.title,
311 + status: call.status,
312 + output: summarize(&call.content),
313 + }),
314 + SessionUpdate::ToolCallUpdate(update) => {
315 + let entry =
316 + self.entries.iter_mut().rev().find(
317 + |e| matches!(e, Entry::Tool { id, .. } if *id == update.tool_call_id),
318 + );
319 + if let Some(Entry::Tool {
320 + title,
321 + status,
322 + output,
323 + ..
324 + }) = entry
325 + {
326 + let fields = update.fields;
327 + if let Some(s) = fields.status {
328 + *status = s;
329 + }
330 + if let Some(t) = fields.title {
331 + *title = t;
332 + }
333 + if let Some(c) = fields.content {
334 + *output = summarize(&c);
335 + }
336 + }
337 + }
338 + _ => {}
339 + }
340 + }
341 +
342 + fn on_key(&mut self, key: KeyEvent) {
343 + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
344 + if self.permission.is_some() {
345 + match key.code {
346 + KeyCode::Char('y') => self.answer_permission(Some(PermissionOptionKind::AllowOnce)),
347 + KeyCode::Char('a') => {
348 + self.answer_permission(Some(PermissionOptionKind::AllowAlways))
349 + }
350 + KeyCode::Char('r') => {
351 + self.answer_permission(Some(PermissionOptionKind::RejectAlways))
352 + }
353 + KeyCode::Char('n') | KeyCode::Esc => {
354 + self.answer_permission(Some(PermissionOptionKind::RejectOnce))
355 + }
356 + KeyCode::Char('c') if ctrl => self.cancel_turn(),
357 + _ => {}
358 + }
359 + return;
360 + }
361 + match key.code {
362 + KeyCode::Char('c') if ctrl => {
363 + if self.busy {
364 + self.cancel_turn();
365 + } else if !self.input.is_empty() {
366 + self.input.clear();
367 + self.cursor = 0;
368 + } else {
369 + self.quit = true;
370 + }
371 + }
372 + KeyCode::Char('d') if ctrl && self.input.is_empty() => self.quit = true,
373 + KeyCode::Char('a') if ctrl => self.cursor = 0,
374 + KeyCode::Char('e') if ctrl => self.cursor = self.input.len(),
375 + KeyCode::Char('u') if ctrl => {
376 + self.input.drain(..self.cursor);
377 + self.cursor = 0;
378 + }
379 + KeyCode::Esc if self.busy => self.cancel_turn(),
380 + KeyCode::Enter if !self.busy => self.submit(),
381 + KeyCode::Char(c) if !ctrl => {
382 + self.input.insert(self.cursor, c);
383 + self.cursor += 1;
384 + }
385 + KeyCode::Backspace if self.cursor > 0 => {
386 + self.cursor -= 1;
387 + self.input.remove(self.cursor);
388 + }
389 + KeyCode::Delete if self.cursor < self.input.len() => {
390 + self.input.remove(self.cursor);
391 + }
392 + KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
393 + KeyCode::Right => self.cursor = (self.cursor + 1).min(self.input.len()),
394 + KeyCode::Home => self.cursor = 0,
395 + KeyCode::End => self.cursor = self.input.len(),
396 + KeyCode::PageUp => self.scroll_up += 10,
397 + KeyCode::PageDown => self.scroll_up = self.scroll_up.saturating_sub(10),
398 + KeyCode::Up => self.scroll_up += 1,
399 + KeyCode::Down => self.scroll_up = self.scroll_up.saturating_sub(1),
400 + _ => {}
401 + }
402 + }
403 +
404 + fn submit(&mut self) {
405 + let text: String = self.input.iter().collect::<String>().trim().to_string();
406 + if text.is_empty() {
407 + return;
408 + }
409 + self.input.clear();
410 + self.cursor = 0;
411 + if text == "/quit" || text == "/exit" {
412 + self.quit = true;
413 + return;
414 + }
415 + self.entries.push(Entry::User(text.clone()));
416 + self.busy = true;
417 + self.scroll_up = 0;
418 +
419 + let conn = self.conn.clone();
420 + let request = PromptRequest::new(
421 + self.session_id.clone(),
422 + vec![ContentBlock::Text(TextContent::new(text))],
423 + );
424 + let tx = self.tx.clone();
425 + tokio::spawn(async move {
426 + let result = conn.send_request(request).block_task().await;
427 + let _ = tx.send(AppEvent::TurnDone(
428 + result.map(|r| r.stop_reason).map_err(|e| format!("{e}")),
429 + ));
430 + });
431 + }
432 +
433 + fn cancel_turn(&mut self) {
434 + if let Some((_, answer)) = self.permission.take() {
435 + let _ = answer.send(RequestPermissionOutcome::Cancelled);
436 + }
437 + if let Err(e) = self
438 + .conn
439 + .send_notification(CancelNotification::new(self.session_id.clone()))
440 + {
441 + self.entries
442 + .push(Entry::Error(format!("cancel failed: {e}")));
443 + }
444 + }
445 +
446 + fn answer_permission(&mut self, kind: Option<PermissionOptionKind>) {
447 + let Some((req, answer)) = self.permission.take() else {
448 + return;
449 + };
450 + let outcome = req
451 + .options
452 + .iter()
453 + .find(|o| Some(o.kind) == kind)
454 + .map(|o| {
455 + RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(
456 + o.option_id.clone(),
457 + ))
458 + })
459 + .unwrap_or(RequestPermissionOutcome::Cancelled);
460 + let _ = answer.send(outcome);
461 + }
462 +
463 + /// Show paths under a workspace root relative to it; secondary roots get a `name/` prefix.
464 + fn short(&self, text: &str) -> String {
465 + let mut out = text.to_string();
466 + for (i, root) in self.roots.iter().enumerate() {
467 + let mut prefix = root.display().to_string();
468 + prefix.push('/');
469 + let replacement = if i == 0 {
470 + String::new()
471 + } else {
472 + root_name(root)
473 + };
474 + out = out.replace(&prefix, &replacement);
475 + }
476 + out
477 + }
478 +
479 + fn draw(&self, f: &mut Frame) {
480 + let [body, input, status] = Layout::vertical([
481 + Constraint::Min(1),
482 + Constraint::Length(3),
483 + Constraint::Length(1),
484 + ])
485 + .areas(f.area());
486 + self.draw_transcript(f, body);
487 + self.draw_input(f, input);
488 + self.draw_status(f, status);
489 + if let Some((req, _)) = &self.permission {
490 + let title = req.tool_call.fields.title.clone().or_else(|| {
491 + self.entries.iter().rev().find_map(|e| match e {
492 + Entry::Tool { id, title, .. } if *id == req.tool_call.tool_call_id => {
493 + Some(title.clone())
494 + }
495 + _ => None,
496 + })
497 + });
498 + draw_permission(f, &self.short(&title.unwrap_or_else(|| "Run tool".into())));
499 + }
500 + }
501 +
502 + fn draw_transcript(&self, f: &mut Frame, area: Rect) {
503 + let mut lines: Vec<Line> = Vec::new();
504 + for entry in &self.entries {
505 + match entry {
506 + Entry::User(text) => {
507 + for (i, l) in text.lines().enumerate() {
508 + let prefix = if i == 0 { "› " } else { " " };
509 + lines.push(Line::from(vec![prefix.cyan().bold(), l.to_string().bold()]));
510 + }
511 + }
512 + Entry::Agent(text) => {
513 + lines.extend(text.trim().lines().map(|l| Line::from(l.to_string())));
514 + }
515 + Entry::Thought(text) => {
516 + let style = Style::new()
517 + .fg(Color::DarkGray)
518 + .add_modifier(Modifier::ITALIC);
519 + lines.extend(
520 + text.trim()
521 + .lines()
522 + .map(|l| Line::styled(format!(" {l}"), style)),
523 + );
524 + }
525 + Entry::Tool {
526 + title,
527 + status,
528 + output,
529 + ..
530 + } => {
531 + let icon = match status {
532 + ToolCallStatus::Completed => "✓".green(),
533 + ToolCallStatus::Failed => "✗".red(),
534 + _ => SPINNER[self.tick % SPINNER.len()].yellow(),
535 + };
536 + lines.push(Line::from(vec![icon, " ".into(), self.short(title).bold()]));
537 + let output = self.short(output);
538 + let out: Vec<&str> = output
539 + .lines()
540 + .filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with("```"))
541 + .collect();
542 + for l in out.iter().take(MAX_TOOL_OUTPUT_LINES) {
543 + lines.push(Line::from(vec![
544 + " │ ".dark_gray(),
545 + l.to_string().dark_gray(),
546 + ]));
547 + }
548 + if out.len() > MAX_TOOL_OUTPUT_LINES {
549 + let more = out.len() - MAX_TOOL_OUTPUT_LINES;
550 + lines.push(Line::from(format!(" │ … {more} more lines").dark_gray()));
551 + }
552 + }
553 + Entry::Info(text) => lines.push(Line::from(text.clone().dark_gray())),
554 + Entry::Error(text) => lines.push(Line::from(format!("error: {text}").red())),
555 + }
556 + lines.push(Line::default());
557 + }
558 +
559 + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
560 + let total = paragraph.line_count(area.width);
561 + let max_offset = total.saturating_sub(area.height as usize);
562 + let offset = max_offset.saturating_sub(self.scroll_up);
563 + f.render_widget(
564 + paragraph.scroll((offset.min(u16::MAX as usize) as u16, 0)),
565 + area,
566 + );
567 + }
568 +
569 + fn draw_input(&self, f: &mut Frame, area: Rect) {
570 + let border = if self.busy {
571 + Color::DarkGray
572 + } else {
573 + Color::Cyan
574 + };
575 + let block = Block::bordered()
576 + .border_type(BorderType::Rounded)
577 + .border_style(Style::new().fg(border))
578 + .title(" splitfire-agent ");
579 + let inner = block.inner(area);
580 + // Scroll horizontally so the cursor stays visible.
581 + let width = inner.width.saturating_sub(1) as usize;
582 + let start = self.cursor.saturating_sub(width);
583 + let visible: String = self.input[start..].iter().take(width + 1).collect();
584 + let text = if self.input.is_empty() && !self.busy {
585 + Line::from("Ask splitfire-agent to do something…".dark_gray())
586 + } else {
587 + Line::from(visible)
588 + };
589 + f.render_widget(Paragraph::new(text).block(block), area);
590 + if self.permission.is_none() {
591 + f.set_cursor_position((inner.x + (self.cursor - start) as u16, inner.y));
592 + }
593 + }
594 +
595 + fn draw_status(&self, f: &mut Frame, area: Rect) {
596 + let state = if self.permission.is_some() {
597 + "waiting for approval".yellow()
598 + } else if self.busy {
599 + format!("{} working", SPINNER[self.tick % SPINNER.len()]).yellow()
600 + } else {
601 + "ready".green()
602 + };
603 + let hints = if self.busy {
604 + "Esc cancel"
605 + } else {
606 + "Enter send · Ctrl-C quit"
607 + };
608 + let roots = self
609 + .roots
610 + .iter()
611 + .map(|r| r.display().to_string())
612 + .collect::<Vec<_>>()
613 + .join(", ");
614 + let line = Line::from(vec![
615 + " ".into(),
616 + state,
617 + " · ".dark_gray(),
618 + self.model.clone().cyan(),
619 + " · ".dark_gray(),
620 + roots.dark_gray(),
621 + " · ".dark_gray(),
622 + hints.dark_gray(),
623 + " · ↑↓/PgUp/PgDn scroll".dark_gray(),
624 + ]);
625 + f.render_widget(Paragraph::new(line), area);
626 + }
627 +}
628 +
629 +fn draw_permission(f: &mut Frame, title: &str) {
630 + let area = f.area();
631 + let width = (title.chars().count() as u16 + 6).clamp(44, area.width.saturating_sub(4).max(1));
632 + let popup = Rect {
633 + x: area.x + area.width.saturating_sub(width) / 2,
634 + y: area.y + area.height.saturating_sub(7) / 2,
635 + width: width.min(area.width),
636 + height: 7.min(area.height),
637 + };
638 + let block = Block::bordered()
639 + .border_type(BorderType::Rounded)
640 + .border_style(Style::new().fg(Color::Yellow))
641 + .title(" Allow this? ");
642 + let text = vec![
643 + Line::from(title.to_string().bold()),
644 + Line::default(),
645 + Line::from(vec![
646 + Span::from("[y]").green().bold(),
647 + " allow ".into(),
648 + Span::from("[a]").green().bold(),
649 + " always ".into(),
650 + Span::from("[n]").red().bold(),
651 + " reject ".into(),
652 + Span::from("[r]").red().bold(),
653 + " never".into(),
654 + ]),
655 + ];
656 + f.render_widget(Clear, popup);
657 + f.render_widget(
658 + Paragraph::new(text).block(block).wrap(Wrap { trim: false }),
659 + popup,
660 + );
661 +}
662 +
663 +/// Display name for a workspace root: its directory name with a trailing slash.
664 +fn root_name(root: &Path) -> String {
665 + match root.file_name() {
666 + Some(name) => format!("{}/", name.to_string_lossy()),
667 + None => root.display().to_string(),
668 + }
669 +}
670 +
671 +fn block_text(block: &ContentBlock) -> String {
672 + match block {
673 + ContentBlock::Text(t) => t.text.clone(),
674 + _ => String::new(),
675 + }
676 +}
677 +
678 +/// Short, display-friendly summary of tool output.
679 +fn summarize(content: &[ToolCallContent]) -> String {
680 + content
681 + .iter()
682 + .map(|c| match c {
683 + ToolCallContent::Content(c) => block_text(&c.content),
684 + ToolCallContent::Diff(d) => {
685 + let old = d.old_text.as_deref().map_or(0, |t| t.lines().count());
686 + format!(
687 + "{} (-{old} +{} lines)",
688 + d.path.display(),
689 + d.new_text.lines().count()
690 + )
691 + }
692 + _ => String::new(),
693 + })
694 + .filter(|s| !s.is_empty())
695 + .collect::<Vec<_>>()
696 + .join("\n")
697 +}
698 +
699 +#[cfg(test)]
700 +mod fs_handler_tests {
701 + use super::*;
702 +
703 + #[test]
704 + fn read_text_file_slices_by_1_based_line_and_limit() {
705 + let dir = std::env::temp_dir().join("splitfire-agent-tui-fs-test");
706 + std::fs::create_dir_all(&dir).unwrap();
707 + let file = dir.join("lines.txt");
708 + std::fs::write(&file, "one\ntwo\nthree\nfour\n").unwrap();
709 +
710 + let all = read_text_file_blocking(&file, None, None).unwrap();
711 + assert_eq!(all.content, "one\ntwo\nthree\nfour");
712 +
713 + let from_line = read_text_file_blocking(&file, Some(2), None).unwrap();
714 + assert_eq!(from_line.content, "two\nthree\nfour");
715 +
716 + let limited = read_text_file_blocking(&file, Some(2), Some(1)).unwrap();
717 + assert_eq!(limited.content, "two");
718 +
719 + let past_end = read_text_file_blocking(&file, Some(99), None).unwrap();
720 + assert_eq!(past_end.content, "");
721 +
722 + std::fs::remove_dir_all(&dir).ok();
723 + }
724 +
725 + #[test]
726 + fn read_text_file_missing_is_resource_not_found() {
727 + let err = read_text_file_blocking(Path::new("/definitely/does/not/exist.txt"), None, None)
728 + .unwrap_err();
729 + assert_eq!(err.code, agent_client_protocol::ErrorCode::ResourceNotFound);
730 + }
731 +
732 + #[test]
733 + fn write_text_file_creates_missing_file_and_parents() {
734 + let dir = std::env::temp_dir()
735 + .join("splitfire-agent-tui-fs-test-2")
736 + .join("nested");
737 + let file = dir.join("created.txt");
738 + write_text_file_blocking(&file, "hello").unwrap();
739 + assert_eq!(std::fs::read_to_string(&file).unwrap(), "hello");
740 +
741 + // Overwrite existing.
742 + write_text_file_blocking(&file, "again").unwrap();
743 + assert_eq!(std::fs::read_to_string(&file).unwrap(), "again");
744 +
745 + std::fs::remove_dir_all(dir.parent().unwrap()).ok();
746 + }
747 +}
tests/acp_compliance.rs new
+1491
@@ -0,0 +1,1491 @@
1 +//! ACP v1 compliance and feature tests, driven over raw JSON-RPC against the real binary:
2 +//! version negotiation, auth, session persistence across a killed process, session
3 +//! lifecycle, audio blocks, MCP servers, and the built-in stem separator.
4 +
5 +mod common;
6 +
7 +use std::collections::VecDeque;
8 +use std::path::{Path, PathBuf};
9 +use std::process::Stdio;
10 +use std::sync::Arc;
11 +use std::time::Duration;
12 +
13 +use common::{BASE_TOOL_COUNT, MODEL, MockStats, Script, Step, TEST_KEY, start_mock_llm, temp_dir};
14 +use serde_json::{Value, json};
15 +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
16 +use tokio::process::{Child, ChildStdin, ChildStdout, Command};
17 +
18 +const BIN: &str = env!("CARGO_BIN_EXE_splitfire-agent");
19 +const STUB: &str = env!("CARGO_BIN_EXE_mcp_stdio_stub");
20 +const TIMEOUT: Duration = Duration::from_secs(30);
21 +
22 +/// What the fake client does when the agent asks for permission.
23 +#[derive(Clone, Copy)]
24 +enum Perm {
25 + Allow,
26 + #[allow(dead_code)]
27 + Reject,
28 + RejectAlways,
29 +}
30 +
31 +struct Raw {
32 + child: Child,
33 + stdin: Option<ChildStdin>,
34 + lines: tokio::io::Lines<BufReader<ChildStdout>>,
35 + next_id: u64,
36 + /// Notifications (`session/update` etc.), in arrival order.
37 + notes: Vec<Value>,
38 + /// Permission requests seen, as the full JSON-RPC `params`.
39 + permissions: Vec<Value>,
40 + perm: Perm,
41 + queued: VecDeque<Value>,
42 +}
43 +
44 +struct Env {
45 + vars: Vec<(String, String)>,
46 +}
47 +
48 +impl Env {
49 + fn new(data_dir: &Path, base_url: &str) -> Self {
50 + let mut e = Env { vars: Vec::new() };
51 + e.set("SPLITFIRE_DATA_DIR", data_dir.display().to_string());
52 + e.set("SPLITFIRE_BASE_URL", base_url);
53 + e.set("SPLITFIRE_MODEL", MODEL);
54 + e.set("ONDE_API_KEY", TEST_KEY);
55 + e.set("SPLITFIRE_MCP_DEMUCS", "off");
56 + // No `demucs` on PATH, wherever the tests run.
57 + e.set("PATH", data_dir.display().to_string());
58 + e
59 + }
60 + fn set(&mut self, k: &str, v: impl Into<String>) -> &mut Self {
61 + self.vars.retain(|(n, _)| n != k);
62 + self.vars.push((k.to_string(), v.into()));
63 + self
64 + }
65 + fn unset(&mut self, k: &str) -> &mut Self {
66 + self.vars.retain(|(n, _)| n != k);
67 + self
68 + }
69 +}
70 +
71 +impl Raw {
72 + fn spawn(env: &Env) -> Raw {
73 + let mut cmd = Command::new(BIN);
74 + cmd.arg("--acp")
75 + .env_clear()
76 + .stdin(Stdio::piped())
77 + .stdout(Stdio::piped())
78 + .stderr(Stdio::inherit())
79 + .kill_on_drop(true);
80 + for (k, v) in &env.vars {
81 + cmd.env(k, v);
82 + }
83 + let mut child = cmd.spawn().expect("spawn agent");
84 + let stdin = child.stdin.take();
85 + let stdout = child.stdout.take().unwrap();
86 + Raw {
87 + child,
88 + stdin,
89 + lines: BufReader::new(stdout).lines(),
90 + next_id: 1,
91 + notes: Vec::new(),
92 + permissions: Vec::new(),
93 + perm: Perm::Allow,
94 + queued: VecDeque::new(),
95 + }
96 + }
97 +
98 + async fn send(&mut self, msg: Value) {
99 + let stdin = self.stdin.as_mut().expect("stdin closed");
100 + stdin
101 + .write_all(format!("{msg}\n").as_bytes())
102 + .await
103 + .unwrap();
104 + stdin.flush().await.unwrap();
105 + }
106 +
107 + async fn notify(&mut self, method: &str, params: Value) {
108 + self.send(json!({"jsonrpc": "2.0", "method": method, "params": params}))
109 + .await;
110 + }
111 +
112 + async fn next_message(&mut self) -> Value {
113 + if let Some(m) = self.queued.pop_front() {
114 + return m;
115 + }
116 + let line = tokio::time::timeout(TIMEOUT, self.lines.next_line())
117 + .await
118 + .expect("timed out waiting for the agent")
119 + .unwrap()
120 + .expect("agent closed stdout");
121 + serde_json::from_str(&line).unwrap_or_else(|e| panic!("non-JSON on stdout ({e}): {line}"))
122 + }
123 +
124 + /// Send a request and wait for its response, serving agent→client requests meanwhile.
125 + async fn call(&mut self, method: &str, params: Value) -> Result<Value, Value> {
126 + let id = self.next_id;
127 + self.next_id += 1;
128 + self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}))
129 + .await;
130 + loop {
131 + let msg = self.next_message().await;
132 + if msg.get("method").is_some() {
133 + if msg.get("id").is_some() {
134 + self.serve(&msg).await;
135 + } else {
136 + self.notes.push(msg);
137 + }
138 + continue;
139 + }
140 + if msg["id"] == json!(id) {
141 + return match msg.get("error") {
142 + Some(e) => Err(e.clone()),
143 + None => Ok(msg["result"].clone()),
144 + };
145 + }
146 + }
147 + }
148 +
149 + async fn serve(&mut self, req: &Value) {
150 + let id = req["id"].clone();
151 + let reply = match req["method"].as_str().unwrap() {
152 + "session/request_permission" => {
153 + self.permissions.push(req["params"].clone());
154 + match self.perm {
155 + Perm::Allow => {
156 + json!({"outcome": {"outcome": "selected", "optionId": "allow_once"}})
157 + }
158 + Perm::Reject => {
159 + json!({"outcome": {"outcome": "selected", "optionId": "reject_once"}})
160 + }
161 + Perm::RejectAlways => {
162 + json!({"outcome": {"outcome": "selected", "optionId": "reject_always"}})
163 + }
164 + }
165 + }
166 + other => {
167 + self.send(json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32601, "message": format!("unsupported {other}")}})).await;
168 + return;
169 + }
170 + };
171 + self.send(json!({"jsonrpc": "2.0", "id": id, "result": reply}))
172 + .await;
173 + }
174 +
175 + /// Read whatever the agent sends right after a response (e.g. the commands update that
176 + /// follows `session/new`) until it has been quiet for a moment.
177 + async fn settle(&mut self) {
178 + while let Ok(line) =
179 + tokio::time::timeout(Duration::from_millis(300), self.lines.next_line()).await
180 + {
181 + let Ok(Some(line)) = line else { return };
182 + let msg: Value = serde_json::from_str(&line).unwrap();
183 + if msg.get("id").is_some() && msg.get("method").is_some() {
184 + self.serve(&msg).await;
185 + } else {
186 + self.notes.push(msg);
187 + }
188 + }
189 + }
190 +
191 + async fn ok(&mut self, method: &str, params: Value) -> Value {
192 + self.call(method, params)
193 + .await
194 + .unwrap_or_else(|e| panic!("{method} failed: {e}"))
195 + }
196 +
197 + async fn initialize(&mut self) -> Value {
198 + self.ok(
199 + "initialize",
200 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
201 + )
202 + .await
203 + }
204 +
205 + async fn new_session(&mut self, cwd: &Path, mcp: Value) -> String {
206 + let r = self
207 + .ok("session/new", json!({"cwd": cwd, "mcpServers": mcp}))
208 + .await;
209 + self.settle().await;
210 + r["sessionId"].as_str().unwrap().to_string()
211 + }
212 +
213 + async fn prompt(&mut self, sid: &str, blocks: Value) -> Value {
214 + self.ok(
215 + "session/prompt",
216 + json!({"sessionId": sid, "prompt": blocks}),
217 + )
218 + .await
219 + }
220 +
221 + async fn text_prompt(&mut self, sid: &str, text: &str) -> Value {
222 + self.prompt(sid, json!([{"type": "text", "text": text}]))
223 + .await
224 + }
225 +
226 + /// Notifications for a session update kind, e.g. `tool_call_update`.
227 + fn updates(&self, kind: &str) -> Vec<Value> {
228 + self.notes
229 + .iter()
230 + .filter(|n| {
231 + n["method"] == "session/update" && n["params"]["update"]["sessionUpdate"] == kind
232 + })
233 + .map(|n| n["params"]["update"].clone())
234 + .collect()
235 + }
236 +
237 + /// Close stdin and wait for the agent to exit on its own.
238 + async fn finish(mut self) {
239 + drop(self.stdin.take());
240 + let _ = tokio::time::timeout(TIMEOUT, self.child.wait())
241 + .await
242 + .expect("agent did not exit");
243 + }
244 +}
245 +
246 +fn error_code(e: &Value) -> i64 {
247 + e["code"].as_i64().unwrap()
248 +}
249 +
250 +async fn mock(steps: Vec<Step>) -> (String, Arc<MockStats>) {
251 + let stats = Arc::new(MockStats::default());
252 + let url = start_mock_llm(Script(steps), stats.clone()).await;
253 + (url, stats)
254 +}
255 +
256 +fn tool_text(update: &Value) -> String {
257 + update["content"]
258 + .as_array()
259 + .into_iter()
260 + .flatten()
261 + .filter_map(|c| c.pointer("/content/text").and_then(Value::as_str))
262 + .collect::<Vec<_>>()
263 + .join("\n")
264 +}
265 +
266 +fn alive(pid: u32) -> bool {
267 + std::process::Command::new("kill")
268 + .args(["-0", &pid.to_string()])
269 + .stderr(Stdio::null())
270 + .status()
271 + .is_ok_and(|s| s.success())
272 +}
273 +
274 +async fn wait_dead(pid: u32) -> bool {
275 + for _ in 0..100 {
276 + if !alive(pid) {
277 + return true;
278 + }
279 + tokio::time::sleep(Duration::from_millis(50)).await;
280 + }
281 + false
282 +}
283 +
284 +fn read_pid(file: &Path) -> u32 {
285 + std::fs::read_to_string(file)
286 + .expect("stub pid file")
287 + .trim()
288 + .parse()
289 + .unwrap()
290 +}
291 +
292 +fn wav_bytes() -> Vec<u8> {
293 + let mut cursor = std::io::Cursor::new(Vec::new());
294 + let spec = hound::WavSpec {
295 + channels: 1,
296 + sample_rate: 22_050,
297 + bits_per_sample: 16,
298 + sample_format: hound::SampleFormat::Int,
299 + };
300 + let mut w = hound::WavWriter::new(&mut cursor, spec).unwrap();
301 + for i in 0..22_050 {
302 + let s = (i as f32 * 2.0 * std::f32::consts::PI * 440.0 / 22_050.0).sin();
303 + w.write_sample((s * 0.5 * i16::MAX as f32) as i16).unwrap();
304 + }
305 + w.finalize().unwrap();
306 + cursor.into_inner()
307 +}
308 +
309 +fn stub_server(name: &str, pid_file: Option<&Path>) -> Value {
310 + let env: Vec<Value> = pid_file
311 + .map(|p| vec![json!({"name": "STUB_PID_FILE", "value": p})])
312 + .unwrap_or_default();
313 + json!({"name": name, "command": STUB, "args": [], "env": env})
314 +}
315 +
316 +// ---------------------------------------------------------------------------
317 +// initialize / auth
318 +// ---------------------------------------------------------------------------
319 +
320 +#[tokio::test]
321 +async fn initialize_negotiates_v1_and_advertises_capabilities() {
322 + let data = temp_dir("sf-data");
323 + let (url, _) = mock(vec![Step::Final("x")]).await;
324 + let mut agent = Raw::spawn(&Env::new(&data, &url));
325 + // A client from the future gets v1 back, not an echo of its own version.
326 + let r = agent
327 + .ok(
328 + "initialize",
329 + json!({"protocolVersion": 99, "clientCapabilities": {"auth": {"terminal": true}}}),
330 + )
331 + .await;
332 + assert_eq!(r["protocolVersion"], 1);
333 + let caps = &r["agentCapabilities"];
334 + assert_eq!(caps["loadSession"], true);
335 + assert_eq!(caps["promptCapabilities"]["audio"], true);
336 + assert_eq!(caps["promptCapabilities"]["image"], true);
337 + assert_eq!(caps["promptCapabilities"]["embeddedContext"], true);
338 + let sc = &caps["sessionCapabilities"];
339 + for k in ["list", "resume", "close", "delete", "additionalDirectories"] {
340 + assert!(sc.get(k).is_some(), "sessionCapabilities.{k} missing: {sc}");
341 + }
342 + assert_eq!(r["agentInfo"]["name"], "splitfire-agent");
343 + assert!(
344 + caps["auth"].get("logout").is_some(),
345 + "auth.logout missing: {caps}"
346 + );
347 + // Terminal auth is offered to clients that declare they can run it...
348 + assert_eq!(r["authMethods"][0]["id"], "terminal-setup");
349 + assert_eq!(r["authMethods"][0]["args"][0], "--setup");
350 + agent.finish().await;
351 +
352 + let mut agent = Raw::spawn(&Env::new(&data, &url));
353 + let r = agent
354 + .ok(
355 + "initialize",
356 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
357 + )
358 + .await;
359 + assert_eq!(r["protocolVersion"], 1);
360 + // ...and not to clients that can't.
361 + assert!(
362 + r.get("authMethods")
363 + .and_then(Value::as_array)
364 + .is_none_or(|m| m.is_empty()),
365 + "{r}"
366 + );
367 + agent.finish().await;
368 +}
369 +
370 +#[tokio::test]
371 +async fn auth_required_then_authenticate_reloads_config() {
372 + let data = temp_dir("sf-data");
373 + let (url, _) = mock(vec![Step::Final("hello")]).await;
374 + let mut env = Env::new(&data, &url);
375 + env.unset("ONDE_API_KEY");
376 + let mut agent = Raw::spawn(&env);
377 + agent.initialize().await;
378 + let cwd = temp_dir("sf-work");
379 +
380 + // No key anywhere: session/new and session/prompt are refused with auth_required (-32000).
381 + let e = agent
382 + .call("session/new", json!({"cwd": cwd, "mcpServers": []}))
383 + .await
384 + .unwrap_err();
385 + assert_eq!(error_code(&e), -32000, "{e}");
386 + let e = agent
387 + .call("session/prompt", json!({"sessionId": "x", "prompt": []}))
388 + .await
389 + .unwrap_err();
390 + assert_eq!(error_code(&e), -32000, "{e}");
391 + // authenticate fails while there is still no key, and rejects unknown methods.
392 + let e = agent
393 + .call("authenticate", json!({"methodId": "terminal-setup"}))
394 + .await
395 + .unwrap_err();
396 + assert_eq!(error_code(&e), -32000, "{e}");
397 + let e = agent
398 + .call("authenticate", json!({"methodId": "nope"}))
399 + .await
400 + .unwrap_err();
401 + assert_eq!(error_code(&e), -32602, "{e}");
402 +
403 + // load/resume also answer auth_required, so an editor reopening a thread shows sign-in.
404 + let e = agent
405 + .call(
406 + "session/load",
407 + json!({"sessionId": "x", "cwd": cwd, "mcpServers": []}),
408 + )
409 + .await
410 + .unwrap_err();
411 + assert_eq!(error_code(&e), -32000, "{e}");
412 +
413 + // `--setup` stored a key in the config file; authenticate picks it up without a restart.
414 + std::fs::write(data.join("env"), format!("ONDE_API_KEY={TEST_KEY}\n")).unwrap();
415 + agent
416 + .ok("authenticate", json!({"methodId": "terminal-setup"}))
417 + .await;
418 + let sid = agent.new_session(&cwd, json!([])).await;
419 + let r = agent.text_prompt(&sid, "hi").await;
420 + assert_eq!(r["stopReason"], "end_turn");
421 +
422 + // logout removes the stored key; the agent is signed out without a restart.
423 + agent.ok("logout", json!({})).await;
424 + assert!(!data.join("env").exists());
425 + let e = agent
426 + .call("session/new", json!({"cwd": cwd, "mcpServers": []}))
427 + .await
428 + .unwrap_err();
429 + assert_eq!(error_code(&e), -32000, "{e}");
430 +
431 + // Zed's flow: terminal auth runs `--setup` in another process, then retries session/new
432 + // on this one without calling authenticate. The stored key must be picked up anyway.
433 + std::fs::write(data.join("env"), format!("ONDE_API_KEY={TEST_KEY}\n")).unwrap();
434 + agent.new_session(&cwd, json!([])).await;
435 + agent.finish().await;
436 +}
437 +
438 +// ---------------------------------------------------------------------------
439 +// sessions: persistence, replay, lifecycle
440 +// ---------------------------------------------------------------------------
441 +
442 +#[tokio::test]
443 +async fn sessions_survive_a_killed_process_and_replay_tool_output() {
444 + let data = temp_dir("sf-data");
445 + let work = temp_dir("sf-work");
446 + std::fs::write(work.join("notes.txt"), "chorus in Dm").unwrap();
447 + let (url, _) = mock(vec![
448 + Step::ToolCall {
449 + name: "read_file",
450 + arguments: json!({"path": "notes.txt"}),
451 + },
452 + Step::ToolCall {
453 + name: "read_file",
454 + arguments: json!({"path": "missing.txt"}),
455 + },
456 + Step::Final("The chorus is in Dm."),
457 + ])
458 + .await;
459 + let env = Env::new(&data, &url);
460 +
461 + let mut agent = Raw::spawn(&env);
462 + agent.initialize().await;
463 + let sid = agent.new_session(&work, json!([])).await;
464 + let r = agent.text_prompt(&sid, "What key is the chorus in?").await;
465 + assert_eq!(r["stopReason"], "end_turn");
466 + // Title is pushed as a session_info_update.
467 + let infos = agent.updates("session_info_update");
468 + assert!(
469 + infos
470 + .iter()
471 + .any(|u| u["title"] == "What key is the chorus in?"),
472 + "no title update: {infos:?}"
473 + );
474 + // The process dies without a graceful close.
475 + agent.child.kill().await.unwrap();
476 + drop(agent);
477 +
478 + let mut agent = Raw::spawn(&env);
479 + agent.initialize().await;
480 + let list = agent.ok("session/list", json!({})).await;
481 + let sessions = list["sessions"].as_array().unwrap();
482 + assert_eq!(sessions.len(), 1, "{list}");
483 + assert_eq!(sessions[0]["sessionId"], sid.as_str());
484 + assert_eq!(sessions[0]["title"], "What key is the chorus in?");
485 + assert!(sessions[0]["updatedAt"].as_str().unwrap().ends_with('Z'));
486 + // cwd filter.
487 + let other = agent.ok("session/list", json!({"cwd": "/nowhere"})).await;
488 + assert!(other["sessions"].as_array().unwrap().is_empty());
489 +
490 + agent.notes.clear();
491 + agent
492 + .ok(
493 + "session/load",
494 + json!({"sessionId": sid, "cwd": work, "mcpServers": []}),
495 + )
496 + .await;
497 + agent.settle().await;
498 + let user = agent.updates("user_message_chunk");
499 + assert_eq!(user[0]["content"]["text"], "What key is the chorus in?");
500 + let said: String = agent
501 + .updates("agent_message_chunk")
502 + .iter()
503 + .filter_map(|u| u["content"]["text"].as_str())
504 + .collect();
505 + assert!(said.contains("The chorus is in Dm."), "{said}");
506 + let calls = agent.updates("tool_call");
507 + assert_eq!(calls.len(), 2, "{calls:?}");
508 + assert_eq!(calls[0]["status"], "completed");
509 + assert!(
510 + tool_text(&calls[0]).contains("chorus in Dm"),
511 + "{}",
512 + calls[0]
513 + );
514 + assert_eq!(
515 + calls[1]["status"], "failed",
516 + "failed call must replay as failed: {}",
517 + calls[1]
518 + );
519 + assert!(
520 + calls[0]["locations"][0]["path"]
521 + .as_str()
522 + .unwrap()
523 + .ends_with("notes.txt")
524 + );
525 + // Commands are advertised after the load response.
526 + let cmds = agent.updates("available_commands_update");
527 + assert!(!cmds.is_empty());
528 + agent.finish().await;
529 +}
530 +
531 +#[tokio::test]
532 +async fn resume_close_delete_and_unknown_sessions() {
533 + let data = temp_dir("sf-data");
534 + let work = temp_dir("sf-work");
535 + let work2 = temp_dir("sf-work");
536 + let (url, stats) = mock(vec![Step::Final("ok")]).await;
537 + let env = Env::new(&data, &url);
538 + let mut agent = Raw::spawn(&env);
539 + agent.initialize().await;
540 +
541 + let sid = agent.new_session(&work, json!([])).await;
542 + agent.text_prompt(&sid, "first").await;
543 +
544 + // Unknown sessions are invalid_params where nothing can be reopened. load/resume of a
545 + // well-formed id reopen it instead (covered below); a malformed id can't name a session.
546 + for (method, params) in [
547 + (
548 + "session/load",
549 + json!({"sessionId": "../nope", "cwd": work, "mcpServers": []}),
550 + ),
551 + (
552 + "session/resume",
553 + json!({"sessionId": "../nope", "cwd": work, "mcpServers": []}),
554 + ),
555 + (
556 + "session/load",
557 + json!({"sessionId": "relative-cwd", "cwd": "work", "mcpServers": []}),
558 + ),
559 + ("session/new", json!({"cwd": "work", "mcpServers": []})),
560 + ("session/close", json!({"sessionId": "nope"})),
561 + ("session/list", json!({"cursor": "stale"})),
562 + (
563 + "session/prompt",
564 + json!({"sessionId": "nope", "prompt": [{"type": "text", "text": "x"}]}),
565 + ),
566 + (
567 + "session/set_config_option",
568 + json!({"sessionId": "nope", "configId": "model", "value": "other-model"}),
569 + ),
570 + ] {
571 + let e = agent.call(method, params).await.unwrap_err();
572 + assert_eq!(error_code(&e), -32602, "{method}: {e}");
573 + }
574 +
575 + // Config options: model picker lists the endpoint's models; bad ids are rejected.
576 + let created = agent
577 + .ok("session/new", json!({"cwd": work, "mcpServers": []}))
578 + .await;
579 + let opts = created["configOptions"].as_array().unwrap();
580 + assert_eq!(opts[0]["id"], "model");
581 + assert_eq!(opts[0]["category"], "model");
582 + assert_eq!(opts[0]["currentValue"], MODEL);
583 + let values: Vec<&str> = opts[0]["options"]
584 + .as_array()
585 + .unwrap()
586 + .iter()
587 + .map(|o| o["value"].as_str().unwrap())
588 + .collect();
589 + assert!(
590 + values.contains(&MODEL) && values.contains(&"other-model"),
591 + "{values:?}"
592 + );
593 + let sid2 = created["sessionId"].as_str().unwrap().to_string();
594 + let e = agent
595 + .call(
596 + "session/set_config_option",
597 + json!({"sessionId": sid2, "configId": "model", "value": "bogus"}),
598 + )
599 + .await
600 + .unwrap_err();
601 + assert_eq!(error_code(&e), -32602, "{e}");
602 + let e = agent
603 + .call(
604 + "session/set_config_option",
605 + json!({"sessionId": sid2, "configId": "temperature", "value": "x"}),
606 + )
607 + .await
608 + .unwrap_err();
609 + assert_eq!(error_code(&e), -32602, "{e}");
610 + let r = agent
611 + .ok(
612 + "session/set_config_option",
613 + json!({"sessionId": sid2, "configId": "model", "value": "other-model"}),
614 + )
615 + .await;
616 + assert_eq!(r["configOptions"][0]["currentValue"], "other-model");
617 + agent.text_prompt(&sid2, "second").await;
618 + let last = stats.payloads.lock().unwrap().last().cloned().unwrap();
619 + assert_eq!(last["model"], "other-model");
620 +
621 + // Resume rebinds the cwd without replaying anything.
622 + agent.notes.clear();
623 + agent
624 + .ok(
625 + "session/resume",
626 + json!({"sessionId": sid, "cwd": work2, "mcpServers": []}),
627 + )
628 + .await;
629 + assert!(
630 + agent.updates("user_message_chunk").is_empty(),
631 + "resume must not replay"
632 + );
633 + let listed = agent.ok("session/list", json!({"cwd": work2})).await;
634 + assert_eq!(listed["sessions"].as_array().unwrap().len(), 1);
635 +
636 + // Close keeps the stored conversation; delete removes it.
637 + agent.ok("session/close", json!({"sessionId": sid})).await;
638 + let e = agent.call("session/close", json!({"sessionId": sid})).await;
639 + assert!(
640 + e.is_ok(),
641 + "close of a stored-only session is a no-op, not an error"
642 + );
643 + let all = agent.ok("session/list", json!({})).await;
644 + assert_eq!(all["sessions"].as_array().unwrap().len(), 2);
645 + agent.ok("session/delete", json!({"sessionId": sid})).await;
646 + let all = agent.ok("session/list", json!({})).await;
647 + assert_eq!(all["sessions"].as_array().unwrap().len(), 1);
648 + assert!(!data.join("sessions").join(format!("{sid}.json")).exists());
649 + // Delete is idempotent: a second delete, or one for a session that never existed, succeeds.
650 + agent.ok("session/delete", json!({"sessionId": sid})).await;
651 + agent
652 + .ok("session/delete", json!({"sessionId": "nope"}))
653 + .await;
654 +
655 + // A well-formed id with no saved history (a thread from a process that never saved it)
656 + // reopens empty under the same id, with a notice, instead of failing to launch.
657 + agent.notes.clear();
658 + let r = agent
659 + .ok(
660 + "session/load",
661 + json!({"sessionId": "lost-thread", "cwd": work, "mcpServers": []}),
662 + )
663 + .await;
664 + assert_eq!(r["configOptions"][0]["id"], "model");
665 + let notice = agent.updates("agent_message_chunk");
666 + assert_eq!(notice.len(), 1, "{notice:?}");
667 + assert!(
668 + notice[0]["content"]["text"]
669 + .as_str()
670 + .unwrap()
671 + .contains("starts fresh"),
672 + "{notice:?}"
673 + );
674 + assert!(notice[0]["messageId"].is_string());
675 + agent.text_prompt("lost-thread", "hello again").await;
676 + agent.finish().await;
677 +}
678 +
679 +#[tokio::test]
680 +async fn streaming_reports_message_ids_usage_and_inlines_file_links() {
681 + let data = temp_dir("sf-data");
682 + let work = temp_dir("sf-work");
683 + std::fs::write(
684 + work.join("chart.md"),
685 + "Verse: Am F C G\nChorus: F G Em Am\nBridge: Dm E\n",
686 + )
687 + .unwrap();
688 + std::fs::write(work.join("take.wav"), wav_bytes()).unwrap();
689 + let (url, stats) = mock(vec![Step::Final("Looks like A minor.")]).await;
690 + let mut env = Env::new(&data, &url);
691 + env.set("SPLITFIRE_CONTEXT_WINDOW", "1000");
692 + let mut agent = Raw::spawn(&env);
693 + agent.initialize().await;
694 + let sid = agent.new_session(&work, json!([])).await;
695 + let chart = format!("file://{}?column=1#L2:2", work.join("chart.md").display());
696 + let take = format!("file://{}", work.join("take.wav").display());
697 + agent
698 + .prompt(
699 + &sid,
700 + json!([
701 + {"type": "text", "text": "What key is the chorus in?"},
702 + {"type": "resource_link", "name": "chart.md", "uri": chart},
703 + {"type": "resource_link", "name": "take.wav", "uri": take},
704 + ]),
705 + )
706 + .await;
707 +
708 + // The selection link is inlined as just that line; the audio link points at the analyzer.
709 + let payload = stats.payloads.lock().unwrap()[0].clone();
710 + assert!(payload["stream_options"]["include_usage"] == true);
711 + let user = payload["messages"].as_array().unwrap().last().unwrap()["content"]
712 + .as_str()
713 + .unwrap()
714 + .to_string();
715 + assert!(user.contains("Chorus: F G Em Am"), "{user}");
716 + assert!(
717 + !user.contains("Verse:") && !user.contains("Bridge:"),
718 + "{user}"
719 + );
720 + assert!(
721 + user.contains("Referenced audio file") && user.contains("analyze_audio"),
722 + "{user}"
723 + );
724 +
725 + // Every chunk of one assistant message carries the same message id.
726 + let chunks = agent.updates("agent_message_chunk");
727 + assert!(!chunks.is_empty());
728 + assert!(
729 + chunks.iter().all(|c| c["messageId"].is_string()),
730 + "{chunks:?}"
731 + );
732 + // Usage from the endpoint, sized by SPLITFIRE_CONTEXT_WINDOW.
733 + let usage = agent.updates("usage_update");
734 + assert_eq!(usage.len(), 1, "{usage:?}");
735 + assert_eq!(
736 + (usage[0]["used"].as_u64(), usage[0]["size"].as_u64()),
737 + (Some(150), Some(1000))
738 + );
739 + agent.finish().await;
740 +}
741 +
742 +#[tokio::test]
743 +async fn permissions_reject_always_raw_output_and_auto_approve() {
744 + let data = temp_dir("sf-data");
745 + let work = temp_dir("sf-work");
746 + let write = |path: &'static str| Step::ToolCall {
747 + name: "write_file",
748 + arguments: json!({"path": path, "content": "x"}),
749 + };
750 + let (url, _) = mock(vec![write("a.txt"), write("b.txt"), Step::Final("done")]).await;
751 + let mut agent = Raw::spawn(&Env::new(&data, &url));
752 + // A client that supports boolean config options gets the auto-approve toggle.
753 + let init = agent
754 + .ok(
755 + "initialize",
756 + json!({"protocolVersion": 1, "clientCapabilities": {"session": {"configOptions": {"boolean": {}}}}}),
757 + )
758 + .await;
759 + assert_eq!(init["protocolVersion"], 1);
760 + let created = agent
761 + .ok("session/new", json!({"cwd": work, "mcpServers": []}))
762 + .await;
763 + agent.settle().await;
764 + let sid = created["sessionId"].as_str().unwrap().to_string();
765 + let opts = created["configOptions"].as_array().unwrap();
766 + assert_eq!(opts[1]["id"], "auto_approve");
767 + assert_eq!(opts[1]["type"], "boolean");
768 + assert_eq!(opts[1]["currentValue"], false);
769 +
770 + // "Always reject" is offered, and once chosen the tool is refused without asking again.
771 + agent.perm = Perm::RejectAlways;
772 + agent.text_prompt(&sid, "write two files").await;
773 + assert_eq!(
774 + agent.permissions.len(),
775 + 1,
776 + "asked again after reject_always"
777 + );
778 + let kinds: Vec<&str> = agent.permissions[0]["options"]
779 + .as_array()
780 + .unwrap()
781 + .iter()
782 + .map(|o| o["kind"].as_str().unwrap())
783 + .collect();
784 + assert!(kinds.contains(&"reject_always"), "{kinds:?}");
785 + assert!(!work.join("a.txt").exists() && !work.join("b.txt").exists());
786 + // Tool calls carry the tool name; updates carry the raw output the model saw.
787 + let calls = agent.updates("tool_call");
788 + assert!(calls.iter().all(|c| c["name"] == "write_file"), "{calls:?}");
789 + let done = agent.updates("tool_call_update");
790 + let failed: Vec<&Value> = done.iter().filter(|u| u["status"] == "failed").collect();
791 + assert_eq!(failed.len(), 2, "{done:?}");
792 + assert_eq!(failed[0]["rawOutput"]["failed"], true);
793 + assert!(
794 + failed[0]["rawOutput"]["output"]
795 + .as_str()
796 + .unwrap()
797 + .contains("rejected")
798 + );
799 +
800 + // Turning auto-approve on is broadcast as a config_option_update and skips the prompts.
801 + agent.notes.clear();
802 + let r = agent
803 + .ok(
804 + "session/set_config_option",
805 + json!({"sessionId": sid, "configId": "auto_approve", "type": "boolean", "value": true}),
806 + )
807 + .await;
808 + assert_eq!(r["configOptions"][1]["currentValue"], true);
809 + let broadcast = agent.updates("config_option_update");
810 + assert_eq!(broadcast.len(), 1, "{broadcast:?}");
811 + let e = agent
812 + .call(
813 + "session/set_config_option",
814 + json!({"sessionId": sid, "configId": "auto_approve", "value": "yes"}),
815 + )
816 + .await
817 + .unwrap_err();
818 + assert_eq!(error_code(&e), -32602, "{e}");
819 +
820 + // A fresh session (no reject_always grant) now writes without asking.
821 + let sid2 = agent.new_session(&work, json!([])).await;
822 + agent
823 + .ok(
824 + "session/set_config_option",
825 + json!({"sessionId": sid2, "configId": "auto_approve", "type": "boolean", "value": true}),
826 + )
827 + .await;
828 + agent.permissions.clear();
829 + agent.text_prompt(&sid2, "write two files").await;
830 + assert!(agent.permissions.is_empty(), "{:?}", agent.permissions);
831 + assert!(work.join("a.txt").exists() && work.join("b.txt").exists());
832 + agent.finish().await;
833 +}
834 +
835 +#[tokio::test]
836 +async fn auto_approve_is_hidden_from_clients_without_boolean_options() {
837 + let data = temp_dir("sf-data");
838 + let work = temp_dir("sf-work");
839 + let (url, _) = mock(vec![Step::Final("x")]).await;
840 + let mut agent = Raw::spawn(&Env::new(&data, &url));
841 + agent.initialize().await;
842 + let created = agent
843 + .ok("session/new", json!({"cwd": work, "mcpServers": []}))
844 + .await;
845 + agent.settle().await;
846 + let opts = created["configOptions"].as_array().unwrap();
847 + assert_eq!(opts.len(), 1, "{opts:?}");
848 + let e = agent
849 + .call(
850 + "session/set_config_option",
851 + json!({"sessionId": created["sessionId"], "configId": "auto_approve", "type": "boolean", "value": true}),
852 + )
853 + .await
854 + .unwrap_err();
855 + assert_eq!(error_code(&e), -32602, "{e}");
856 + agent.finish().await;
857 +}
858 +
859 +#[tokio::test]
860 +async fn content_filter_is_a_refusal_and_private_flags_stay_off_the_wire() {
861 + let data = temp_dir("sf-data");
862 + let work = temp_dir("sf-work");
863 + let (url, stats) = mock(vec![
864 + Step::ToolCall {
865 + name: "read_file",
866 + arguments: json!({"path": "missing.txt"}),
867 + },
868 + Step::ContentFilter,
869 + ])
870 + .await;
871 + let mut agent = Raw::spawn(&Env::new(&data, &url));
872 + agent.initialize().await;
873 + let sid = agent.new_session(&work, json!([])).await;
874 + let r = agent.text_prompt(&sid, "read it").await;
875 + assert_eq!(r["stopReason"], "refusal");
876 + let payloads = stats.payloads.lock().unwrap().clone();
877 + let second = payloads.last().unwrap()["messages"]
878 + .as_array()
879 + .unwrap()
880 + .clone();
881 + let tool = second.iter().find(|m| m["role"] == "tool").unwrap();
882 + assert!(
883 + tool["content"].as_str().unwrap().starts_with("Error:"),
884 + "{tool}"
885 + );
886 + assert!(
887 + tool.get("x_failed").is_none(),
888 + "private field leaked to the model: {tool}"
889 + );
890 + // The system prompt is rebuilt each turn and never stored with the history.
891 + assert_eq!(second[0]["role"], "system");
892 + let stored: Value = serde_json::from_slice(
893 + &std::fs::read(data.join("sessions").join(format!("{sid}.json"))).unwrap(),
894 + )
895 + .unwrap();
896 + assert!(
897 + stored["messages"]
898 + .as_array()
899 + .unwrap()
900 + .iter()
901 + .all(|m| m["role"] != "system")
902 + );
903 + agent.finish().await;
904 +}
905 +
906 +#[tokio::test]
907 +async fn slash_commands_are_advertised_and_expanded() {
908 + let data = temp_dir("sf-data");
909 + let work = temp_dir("sf-work");
910 + let (url, stats) = mock(vec![Step::Final("ok")]).await;
911 + let mut agent = Raw::spawn(&Env::new(&data, &url));
912 + agent.initialize().await;
913 + let sid = agent.new_session(&work, json!([])).await;
914 + let cmds = agent.updates("available_commands_update");
915 + let names: Vec<&str> = cmds[0]["availableCommands"]
916 + .as_array()
917 + .unwrap()
918 + .iter()
919 + .map(|c| c["name"].as_str().unwrap())
920 + .collect();
921 + assert_eq!(
922 + names,
923 + ["analyze", "chords", "library", "practice"],
924 + "no /stems without a separator"
925 + );
926 +
927 + agent.text_prompt(&sid, "/chords Dm7 G7 Cmaj7").await;
928 + let payload = stats.payloads.lock().unwrap()[0].clone();
929 + let msgs = payload["messages"].as_array().unwrap();
930 + let system = msgs[0]["content"].as_str().unwrap();
931 + // Disconnected: the separation tool is not offered, but the prompt says how to enable it.
932 + assert!(
933 + !system.contains("mcp__demucs__separate_stems"),
934 + "separation tool offered while disconnected"
935 + );
936 + assert!(
937 + system.contains("SPLITFIRE_DEMUCS_BIN"),
938 + "no hint on enabling stems"
939 + );
940 + assert!(
941 + system.contains("Co-Authored-By: SplitFire v")
942 + && system.contains("-acp <noreply@splitfire.ai>")
943 + );
944 + let user = msgs.last().unwrap()["content"].as_str().unwrap();
945 + assert!(
946 + user.contains("theory_progression") && user.contains("Dm7 G7 Cmaj7"),
947 + "{user}"
948 + );
949 + assert_eq!(payload["tools"].as_array().unwrap().len(), BASE_TOOL_COUNT);
950 + agent.finish().await;
951 +}
952 +
953 +// ---------------------------------------------------------------------------
954 +// audio
955 +// ---------------------------------------------------------------------------
956 +
957 +#[tokio::test]
958 +async fn audio_blocks_are_saved_to_scratch_and_cleaned_on_close() {
959 + use base64::Engine;
960 + let data = temp_dir("sf-data");
961 + let work = temp_dir("sf-work");
962 + let (url, stats) = mock(vec![Step::Final("heard it")]).await;
963 + let mut agent = Raw::spawn(&Env::new(&data, &url));
964 + agent.initialize().await;
965 + let sid = agent.new_session(&work, json!([])).await;
966 + let b64 = base64::engine::general_purpose::STANDARD.encode(wav_bytes());
967 + let r = agent
968 + .prompt(
969 + &sid,
970 + json!([
971 + {"type": "text", "text": "what is this?"},
972 + {"type": "audio", "data": b64, "mimeType": "audio/wav"}
973 + ]),
974 + )
975 + .await;
976 + assert_eq!(r["stopReason"], "end_turn");
977 + let payload = stats.payloads.lock().unwrap()[0].clone();
978 + let user = payload["messages"].as_array().unwrap().last().unwrap()["content"]
979 + .as_str()
980 + .unwrap()
981 + .to_string();
982 + let scratch = data.join("scratch").join(&sid);
983 + let saved: Vec<PathBuf> = std::fs::read_dir(&scratch)
984 + .unwrap()
985 + .map(|e| e.unwrap().path())
986 + .collect();
987 + assert_eq!(
988 + saved.len(),
989 + 1,
990 + "one scratch file expected in {}",
991 + scratch.display()
992 + );
993 + assert!(
994 + user.contains(&saved[0].display().to_string()),
995 + "model must see the path: {user}"
996 + );
997 + assert_eq!(std::fs::read(&saved[0]).unwrap(), wav_bytes());
998 + agent.ok("session/close", json!({"sessionId": sid})).await;
999 + assert!(!scratch.exists(), "scratch dir must be removed on close");
1000 + agent.finish().await;
1001 +}
1002 +
1003 +#[tokio::test]
1004 +async fn analyze_audio_runs_through_the_agent() {
1005 + let data = temp_dir("sf-data");
1006 + let work = temp_dir("sf-work");
1007 + std::fs::write(work.join("tone.wav"), wav_bytes()).unwrap();
1008 + let (url, stats) = mock(vec![
1009 + Step::ToolCall {
1010 + name: "analyze_audio",
1011 + arguments: json!({"path": "tone.wav"}),
1012 + },
1013 + Step::Final("done"),
1014 + ])
1015 + .await;
1016 + let mut agent = Raw::spawn(&Env::new(&data, &url));
1017 + agent.initialize().await;
1018 + let sid = agent.new_session(&work, json!([])).await;
1019 + agent.text_prompt(&sid, "analyze tone.wav").await;
1020 + assert!(
1021 + agent.permissions.is_empty(),
1022 + "read-only analysis must not prompt"
1023 + );
1024 + let calls = agent.updates("tool_call");
1025 + assert_eq!(calls[0]["kind"], "read");
1026 + assert!(
1027 + calls[0]["locations"][0]["path"]
1028 + .as_str()
1029 + .unwrap()
1030 + .ends_with("tone.wav")
1031 + );
1032 + let payloads = stats.payloads.lock().unwrap().clone();
1033 + let tool = payloads[1]["messages"]
1034 + .as_array()
1035 + .unwrap()
1036 + .iter()
1037 + .find(|m| m["role"] == "tool")
1038 + .unwrap()
1039 + .clone();
1040 + let text = tool["content"].as_str().unwrap();
1041 + assert!(
1042 + text.contains("22050 Hz") && text.contains("Peak:") && text.contains("estimate"),
1043 + "{text}"
1044 + );
1045 + agent.finish().await;
1046 +}
1047 +
1048 +#[tokio::test]
1049 +async fn theory_tools_are_ungated_and_exact() {
1050 + let data = temp_dir("sf-data");
1051 + let work = temp_dir("sf-work");
1052 + let (url, stats) = mock(vec![
1053 + Step::ToolCall {
1054 + name: "theory_scale",
1055 + arguments: json!({"root": "C#", "scale": "major"}),
1056 + },
1057 + Step::Final("done"),
1058 + ])
1059 + .await;
1060 + let mut agent = Raw::spawn(&Env::new(&data, &url));
1061 + agent.initialize().await;
1062 + let sid = agent.new_session(&work, json!([])).await;
1063 + agent.text_prompt(&sid, "C# major scale?").await;
1064 + assert!(agent.permissions.is_empty());
1065 + let calls = agent.updates("tool_call");
1066 + assert_eq!(calls[0]["kind"], "think");
1067 + let payloads = stats.payloads.lock().unwrap().clone();
1068 + let tool = payloads[1]["messages"]
1069 + .as_array()
1070 + .unwrap()
1071 + .iter()
1072 + .find(|m| m["role"] == "tool")
1073 + .unwrap()
1074 + .clone();
1075 + assert!(
1076 + tool["content"]
1077 + .as_str()
1078 + .unwrap()
1079 + .contains("C# D# E# F# G# A# B#")
1080 + );
1081 + agent.finish().await;
1082 +}
1083 +
1084 +// ---------------------------------------------------------------------------
1085 +// MCP
1086 +// ---------------------------------------------------------------------------
1087 +
1088 +#[tokio::test]
1089 +async fn client_supplied_mcp_server_is_offered_gated_and_streams_progress() {
1090 + let data = temp_dir("sf-data");
1091 + let work = temp_dir("sf-work");
1092 + let pid_file = data.join("stub.pid");
1093 + let (url, stats) = mock(vec![
1094 + Step::ToolCall {
1095 + name: "mcp__stub__echo",
1096 + arguments: json!({"text": "hi"}),
1097 + },
1098 + Step::ToolCall {
1099 + name: "mcp__stub__slow_progress",
1100 + arguments: json!({}),
1101 + },
1102 + Step::Final("all done"),
1103 + ])
1104 + .await;
1105 + let mut agent = Raw::spawn(&Env::new(&data, &url));
1106 + agent.initialize().await;
1107 + let sid = agent
1108 + .new_session(&work, json!([stub_server("stub", Some(&pid_file))]))
1109 + .await;
1110 + let r = agent.text_prompt(&sid, "use the stub").await;
1111 + assert_eq!(r["stopReason"], "end_turn");
1112 +
1113 + // Offered: the namespaced tools ride along with the built-ins.
1114 + let payload = stats.payloads.lock().unwrap()[0].clone();
1115 + let names: Vec<String> = payload["tools"]
1116 + .as_array()
1117 + .unwrap()
1118 + .iter()
1119 + .map(|t| t["function"]["name"].as_str().unwrap().to_string())
1120 + .collect();
1121 + assert!(names.contains(&"mcp__stub__echo".to_string()), "{names:?}");
1122 + assert_eq!(names.len(), BASE_TOOL_COUNT + 5, "{names:?}");
1123 +
1124 + // Gated: only the tool without readOnlyHint asked for permission.
1125 + assert_eq!(agent.permissions.len(), 1, "{:?}", agent.permissions);
1126 + let perm = &agent.permissions[0];
1127 + assert_eq!(perm["toolCall"]["toolCallId"], "call_2");
1128 +
1129 + // Callable, with the result fed back to the model.
1130 + let payloads = stats.payloads.lock().unwrap().clone();
1131 + let tools: Vec<&Value> = payloads[1]["messages"]
1132 + .as_array()
1133 + .unwrap()
1134 + .iter()
1135 + .filter(|m| m["role"] == "tool")
1136 + .collect();
1137 + assert_eq!(tools[0]["content"], "echo: hi");
1138 +
1139 + // Progress: MCP notifications/progress became tool_call_update content.
1140 + let progress: Vec<String> = agent
1141 + .updates("tool_call_update")
1142 + .iter()
1143 + .filter(|u| u["toolCallId"] == "call_2")
1144 + .map(tool_text)
1145 + .collect();
1146 + assert!(
1147 + progress.iter().any(|t| t == "halfway (50%)"),
1148 + "{progress:?}"
1149 + );
1150 + let done = agent
1151 + .updates("tool_call_update")
1152 + .into_iter()
1153 + .rfind(|u| u["toolCallId"] == "call_2")
1154 + .unwrap();
1155 + assert_eq!(done["status"], "completed");
1156 +
1157 + // Children are killed when the session closes.
1158 + let pid = read_pid(&pid_file);
1159 + assert!(alive(pid));
1160 + agent.ok("session/close", json!({"sessionId": sid})).await;
1161 + assert!(
1162 + wait_dead(pid).await,
1163 + "stub server still running after session/close"
1164 + );
1165 + agent.finish().await;
1166 +}
1167 +
1168 +#[tokio::test]
1169 +async fn mcp_children_die_when_the_agent_exits() {
1170 + let data = temp_dir("sf-data");
1171 + let work = temp_dir("sf-work");
1172 + let pid_file = data.join("stub.pid");
1173 + let (url, _) = mock(vec![Step::Final("x")]).await;
1174 + let mut agent = Raw::spawn(&Env::new(&data, &url));
1175 + agent.initialize().await;
1176 + agent
1177 + .new_session(&work, json!([stub_server("stub", Some(&pid_file))]))
1178 + .await;
1179 + let pid = read_pid(&pid_file);
1180 + assert!(alive(pid));
1181 + agent.finish().await;
1182 + assert!(wait_dead(pid).await, "stub server outlived the agent");
1183 +}
1184 +
1185 +#[tokio::test]
1186 +async fn broken_mcp_server_is_not_fatal() {
1187 + let data = temp_dir("sf-data");
1188 + let work = temp_dir("sf-work");
1189 + let (url, stats) = mock(vec![Step::Final("fine")]).await;
1190 + let mut agent = Raw::spawn(&Env::new(&data, &url));
1191 + agent.initialize().await;
1192 + let sid = agent
1193 + .new_session(
1194 + &work,
1195 + json!([
1196 + {"name": "ghost", "command": "/definitely/not/here", "args": [], "env": []},
1197 + {"name": "http", "type": "http", "url": "http://127.0.0.1:1/mcp", "headers": []}
1198 + ]),
1199 + )
1200 + .await;
1201 + let r = agent.text_prompt(&sid, "hello").await;
1202 + assert_eq!(r["stopReason"], "end_turn");
1203 + let payload = stats.payloads.lock().unwrap()[0].clone();
1204 + assert_eq!(payload["tools"].as_array().unwrap().len(), BASE_TOOL_COUNT);
1205 + agent.finish().await;
1206 +}
1207 +
1208 +#[tokio::test]
1209 +async fn cancelling_a_turn_cancels_the_mcp_call() {
1210 + let data = temp_dir("sf-data");
1211 + let work = temp_dir("sf-work");
1212 + let (url, _) = mock(vec![
1213 + Step::ToolCall {
1214 + name: "mcp__stub__wait_for_cancel",
1215 + arguments: json!({}),
1216 + },
1217 + Step::Final("should not get here"),
1218 + ])
1219 + .await;
1220 + let mut agent = Raw::spawn(&Env::new(&data, &url));
1221 + agent.initialize().await;
1222 + let sid = agent
1223 + .new_session(&work, json!([stub_server("stub", None)]))
1224 + .await;
1225 + // Fire the prompt without waiting, then cancel once the call is in flight.
1226 + let id = agent.next_id;
1227 + agent.next_id += 1;
1228 + agent
1229 + .send(
1230 + json!({"jsonrpc": "2.0", "id": id, "method": "session/prompt",
1231 + "params": {"sessionId": sid, "prompt": [{"type": "text", "text": "wait"}]}}),
1232 + )
1233 + .await;
1234 + let mut cancelled = false;
1235 + let stop = loop {
1236 + let msg = agent.next_message().await;
1237 + if msg.get("method").is_some() {
1238 + if msg.get("id").is_some() {
1239 + agent.serve(&msg).await;
1240 + // The permission request means the tool call is about to start.
1241 + tokio::time::sleep(Duration::from_millis(300)).await;
1242 + agent
1243 + .notify("session/cancel", json!({"sessionId": sid}))
1244 + .await;
1245 + cancelled = true;
1246 + } else {
1247 + agent.notes.push(msg);
1248 + }
1249 + } else if msg["id"] == json!(id) {
1250 + break msg["result"]["stopReason"].clone();
1251 + }
1252 + };
1253 + assert!(cancelled, "the call should have asked for permission first");
1254 + assert_eq!(stop, "cancelled");
1255 + let last = agent
1256 + .updates("tool_call_update")
1257 + .into_iter()
1258 + .last()
1259 + .unwrap();
1260 + assert_eq!(last["status"], "failed");
1261 + agent.finish().await;
1262 +}
1263 +
1264 +// ---------------------------------------------------------------------------
1265 +// built-in demucs
1266 +// ---------------------------------------------------------------------------
1267 +
1268 +#[tokio::test]
1269 +async fn builtin_demucs_appears_only_when_configured() {
1270 + let data = temp_dir("sf-data");
1271 + let work = temp_dir("sf-work");
1272 + let pid_file = data.join("stub.pid");
1273 + let input = work.join("song.wav");
1274 + std::fs::write(&input, wav_bytes()).unwrap();
1275 + let (url, stats) = mock(vec![
1276 + Step::ToolCall {
1277 + name: "mcp__demucs__separate_stems",
1278 + arguments: json!({"input": input}),
1279 + },
1280 + Step::Final("separated"),
1281 + ])
1282 + .await;
1283 +
1284 + // Configured: SPLITFIRE_DEMUCS_BIN points at a server speaking `demucs --mcp`.
1285 + let mut env = Env::new(&data, &url);
1286 + env.set("SPLITFIRE_MCP_DEMUCS", "on");
1287 + env.set("SPLITFIRE_DEMUCS_BIN", STUB);
1288 + env.set("STUB_PID_FILE", pid_file.display().to_string());
1289 + let mut agent = Raw::spawn(&env);
1290 + agent.initialize().await;
1291 + let sid = agent.new_session(&work, json!([])).await;
1292 + let cmds = agent.updates("available_commands_update");
1293 + let names: Vec<&str> = cmds[0]["availableCommands"]
1294 + .as_array()
1295 + .unwrap()
1296 + .iter()
1297 + .map(|c| c["name"].as_str().unwrap())
1298 + .collect();
1299 + assert!(names.contains(&"stems"), "{names:?}");
1300 + let r = agent.text_prompt(&sid, "split song.wav").await;
1301 + assert_eq!(r["stopReason"], "end_turn");
1302 + let payload = stats.payloads.lock().unwrap()[0].clone();
1303 + let system = payload["messages"][0]["content"].as_str().unwrap();
1304 + assert!(system.contains("mcp__demucs__separate_stems"));
1305 + let tool_names: Vec<&str> = payload["tools"]
1306 + .as_array()
1307 + .unwrap()
1308 + .iter()
1309 + .map(|t| t["function"]["name"].as_str().unwrap())
1310 + .collect();
1311 + assert!(
1312 + tool_names.contains(&"mcp__demucs__separate_stems")
1313 + && tool_names.contains(&"mcp__demucs__list_models")
1314 + );
1315 +
1316 + // The permission prompt states the download size from list_models.
1317 + assert_eq!(agent.permissions.len(), 1);
1318 + let prompt_text = agent.permissions[0]["toolCall"]["content"][0]["content"]["text"]
1319 + .as_str()
1320 + .unwrap()
1321 + .to_string();
1322 + assert!(prompt_text.contains("80 MB"), "{prompt_text}");
1323 + assert!(prompt_text.contains("song.wav"), "{prompt_text}");
1324 +
1325 + // Stem paths become tool-call locations.
1326 + let update = agent
1327 + .updates("tool_call_update")
1328 + .into_iter()
1329 + .rfind(|u| u["status"] == "completed")
1330 + .unwrap();
1331 + let locs: Vec<&str> = update["locations"]
1332 + .as_array()
1333 + .unwrap()
1334 + .iter()
1335 + .map(|l| l["path"].as_str().unwrap())
1336 + .collect();
1337 + assert_eq!(locs.len(), 4, "{update}");
1338 + assert!(
1339 + locs.iter()
1340 + .all(|p| Path::new(p).is_absolute() && p.ends_with(".wav"))
1341 + );
1342 +
1343 + // One demucs per process: a second session shares it.
1344 + let pid = read_pid(&pid_file);
1345 + agent.new_session(&work, json!([])).await;
1346 + assert_eq!(read_pid(&pid_file), pid);
1347 + agent.finish().await;
1348 + assert!(wait_dead(pid).await, "demucs outlived the agent");
1349 +
1350 + // Unset (no binary, nothing on PATH): no tools, no /stems, and the prompt offers no
1351 + // separation tool, only how to enable it.
1352 + for mode in ["off", "on"] {
1353 + let mut env = Env::new(&data, &url);
1354 + env.set("SPLITFIRE_MCP_DEMUCS", mode);
1355 + let (url2, stats2) = mock(vec![Step::Final("x")]).await;
1356 + env.set("SPLITFIRE_BASE_URL", url2);
1357 + let mut agent = Raw::spawn(&env);
1358 + agent.initialize().await;
1359 + let sid = agent.new_session(&work, json!([])).await;
1360 + let cmds = agent.updates("available_commands_update");
1361 + assert!(!cmds[0]["availableCommands"].to_string().contains("stems"));
1362 + agent.text_prompt(&sid, "hi").await;
1363 + let payload = stats2.payloads.lock().unwrap()[0].clone();
1364 + assert_eq!(
1365 + payload["tools"].as_array().unwrap().len(),
1366 + BASE_TOOL_COUNT,
1367 + "mode {mode}"
1368 + );
1369 + let system = payload["messages"][0]["content"].as_str().unwrap();
1370 + assert!(!system.contains("mcp__demucs__"), "mode {mode}");
1371 + assert!(system.contains("SPLITFIRE_DEMUCS_BIN"), "mode {mode}");
1372 + agent.finish().await;
1373 + }
1374 +}
1375 +
1376 +#[tokio::test]
1377 +async fn client_supplied_demucs_wins_over_the_builtin() {
1378 + let data = temp_dir("sf-data");
1379 + let work = temp_dir("sf-work");
1380 + let builtin_pid = data.join("builtin.pid");
1381 + let client_pid = data.join("client.pid");
1382 + let (url, _) = mock(vec![Step::Final("x")]).await;
1383 + let mut env = Env::new(&data, &url);
1384 + env.set("SPLITFIRE_MCP_DEMUCS", "on");
1385 + env.set("SPLITFIRE_DEMUCS_BIN", STUB);
1386 + env.set("STUB_PID_FILE", builtin_pid.display().to_string());
1387 + let mut agent = Raw::spawn(&env);
1388 + agent.initialize().await;
1389 + agent
1390 + .new_session(&work, json!([stub_server("demucs", Some(&client_pid))]))
1391 + .await;
1392 + assert!(client_pid.exists(), "the client's server should run");
1393 + assert!(
1394 + !builtin_pid.exists(),
1395 + "the built-in must not start when the client supplies demucs"
1396 + );
1397 + agent.finish().await;
1398 +}
1399 +
1400 +// ---------------------------------------------------------------------------
1401 +// Real demucs (opt-in)
1402 +// ---------------------------------------------------------------------------
1403 +
1404 +/// End to end with a real `demucs --mcp`. Skipped unless `SPLITFIRE_E2E_DEMUCS_BIN` points at a
1405 +/// demucs binary (model weights are downloaded on first use):
1406 +/// `SPLITFIRE_E2E_DEMUCS_BIN=$(which demucs) cargo test --test acp_compliance real_demucs -- --nocapture`
1407 +#[tokio::test]
1408 +async fn real_demucs_end_to_end() {
1409 + let Some(bin) = std::env::var("SPLITFIRE_E2E_DEMUCS_BIN")
1410 + .ok()
1411 + .filter(|b| !b.is_empty())
1412 + else {
1413 + eprintln!("skipping: SPLITFIRE_E2E_DEMUCS_BIN not set");
1414 + return;
1415 + };
1416 + let data = temp_dir("sf-data");
1417 + let work = temp_dir("sf-work");
1418 + // Four seconds of stereo 44.1 kHz: a low note plus a decaying high one.
1419 + let input = work.join("short.wav");
1420 + let spec = hound::WavSpec {
1421 + channels: 2,
1422 + sample_rate: 44_100,
1423 + bits_per_sample: 16,
1424 + sample_format: hound::SampleFormat::Int,
1425 + };
1426 + let mut w = hound::WavWriter::create(&input, spec).unwrap();
1427 + for i in 0..44_100 * 4 {
1428 + let t = i as f32 / 44_100.0;
1429 + let s = 0.3 * (2.0 * std::f32::consts::PI * 110.0 * t).sin()
1430 + + 0.2 * (2.0 * std::f32::consts::PI * 1_320.0 * t).sin() * (-(t % 0.5) * 8.0).exp();
1431 + let v = (s * i16::MAX as f32) as i16;
1432 + w.write_sample(v).unwrap();
1433 + w.write_sample(v).unwrap();
1434 + }
1435 + w.finalize().unwrap();
1436 +
1437 + let (url, stats) = mock(vec![
1438 + Step::ToolCall {
1439 + name: "mcp__demucs__separate_stems",
1440 + arguments: json!({"input": input}),
1441 + },
1442 + Step::Final("separated"),
1443 + ])
1444 + .await;
1445 + let mut env = Env::new(&data, &url);
1446 + env.set("SPLITFIRE_MCP_DEMUCS", "on");
1447 + env.set("SPLITFIRE_DEMUCS_BIN", bin);
1448 + let mut agent = Raw::spawn(&env);
1449 + agent.initialize().await;
1450 + let sid = agent.new_session(&work, json!([])).await;
1451 + let r = agent.text_prompt(&sid, "/stems short.wav").await;
1452 + assert_eq!(r["stopReason"], "end_turn");
1453 + let prompt_text = agent.permissions[0]["toolCall"]["content"][0]["content"]["text"]
1454 + .as_str()
1455 + .unwrap()
1456 + .to_string();
1457 + eprintln!("permission prompt: {prompt_text}");
1458 + let updates = agent.updates("tool_call_update");
1459 + let progress: Vec<String> = updates
1460 + .iter()
1461 + .map(tool_text)
1462 + .filter(|t| t.contains('%'))
1463 + .collect();
1464 + eprintln!("progress: {progress:?}");
1465 + assert!(!progress.is_empty(), "no progress reached the tool call");
1466 + let done = updates
1467 + .iter()
1468 + .rev()
1469 + .find(|u| u["status"] == "completed")
1470 + .expect("completed update");
1471 + let paths: Vec<&str> = done["locations"]
1472 + .as_array()
1473 + .unwrap()
1474 + .iter()
1475 + .map(|l| l["path"].as_str().unwrap())
1476 + .collect();
1477 + assert_eq!(paths.len(), 4, "{done}");
1478 + for p in &paths {
1479 + assert!(Path::new(p).is_file(), "missing stem {p}");
1480 + }
1481 + let payloads = stats.payloads.lock().unwrap().clone();
1482 + let tool = payloads[1]["messages"]
1483 + .as_array()
1484 + .unwrap()
1485 + .iter()
1486 + .find(|m| m["role"] == "tool")
1487 + .unwrap()
1488 + .clone();
1489 + eprintln!("model saw: {}", tool["content"]);
1490 + agent.finish().await;
1491 +}
tests/acp_integration.rs new
+683
@@ -0,0 +1,683 @@
1 +//! End-to-end ACP protocol tests for splitfire-agent.
2 +//!
3 +//! Each test spawns the real `splitfire-agent` binary over stdio, drives it with the official
4 +//! SDK's `Client`, and points the agent at a mock OpenAI-compatible `/chat/completions` server
5 +//! that plays a scripted model. No real LLM or API key is needed: a dummy `ONDE_API_KEY` is
6 +//! sent and the mock asserts it arrives as the bearer token.
7 +
8 +mod common;
9 +
10 +use std::path::{Path, PathBuf};
11 +use std::sync::atomic::Ordering;
12 +use std::sync::{Arc, Mutex};
13 +
14 +use agent_client_protocol::schema::ProtocolVersion;
15 +use agent_client_protocol::schema::v1::{
16 + CancelNotification, ClientCapabilities, ContentBlock, FileSystemCapabilities,
17 + InitializeRequest, NewSessionRequest, PermissionOptionId, PromptRequest, ReadTextFileRequest,
18 + ReadTextFileResponse, RequestPermissionOutcome, RequestPermissionRequest,
19 + RequestPermissionResponse, SelectedPermissionOutcome, SessionNotification, SessionUpdate,
20 + StopReason, TextContent, ToolCallContent, ToolCallStatus, WriteTextFileRequest,
21 + WriteTextFileResponse,
22 +};
23 +use agent_client_protocol::{AcpAgent, Agent, Client, ConnectionTo};
24 +use common::{MockStats, Script, Step, agent_config, start_mock_llm, temp_dir};
25 +use serde_json::json;
26 +
27 +// ---------------------------------------------------------------------------
28 +// ACP client harness
29 +// ---------------------------------------------------------------------------
30 +
31 +#[derive(Debug, Default)]
32 +struct Captured {
33 + text: String,
34 + tool_calls: Vec<(String, ToolCallStatus)>,
35 + tool_updates: Vec<(String, ToolCallStatus)>,
36 + permission_requests: usize,
37 + /// Paths of diffs attached to permission requests (ACP v1: must be absolute).
38 + diff_paths: Vec<PathBuf>,
39 + /// Tool calls seen as `session/update` ToolCall notifications: (title, locations).
40 + tool_call_details: Vec<(String, Vec<PathBuf>)>,
41 +}
42 +
43 +/// What the fake user does when asked for permission.
44 +#[derive(Clone, Copy)]
45 +enum PermissionPolicy {
46 + AllowAlways,
47 + RejectOnce,
48 +}
49 +
50 +/// Whether the fake client advertises the ACP v1 fs methods, and what its
51 +/// `fs/read_text_file` should serve.
52 +#[derive(Clone, Default)]
53 +enum FsPolicy {
54 + /// No fs capabilities: the agent MUST fall back to the local filesystem and
55 + /// MUST NOT send fs/* requests (spec: https://agentclientprotocol.com/protocol/v1/file-system).
56 + #[default]
57 + NotAdvertised,
58 + /// Advertise readTextFile/writeTextFile and serve `content` for every read.
59 + Advertise { content: String },
60 +}
61 +
62 +/// Records what fs methods the fake client received, if any.
63 +/// A recorded `fs/read_text_file`: path, 1-based line, limit.
64 +type ReadCall = (PathBuf, Option<u32>, Option<u32>);
65 +
66 +#[derive(Debug, Default)]
67 +struct FsCalls {
68 + reads: Mutex<Vec<ReadCall>>,
69 + writes: Mutex<Vec<(PathBuf, String)>>,
70 +}
71 +
72 +struct Harness {
73 + captured: Arc<Mutex<Captured>>,
74 + permission_policy: PermissionPolicy,
75 + fs_policy: FsPolicy,
76 + fs_calls: Arc<FsCalls>,
77 +}
78 +
79 +impl Harness {
80 + async fn run_prompt(
81 + &self,
82 + base_url: &str,
83 + script_workdir: &Path,
84 + prompt: &str,
85 + ) -> (StopReason, Arc<Mutex<Captured>>) {
86 + self.run_prompt_maybe_cancel(base_url, script_workdir, prompt, false)
87 + .await
88 + }
89 +
90 + async fn run_prompt_maybe_cancel(
91 + &self,
92 + base_url: &str,
93 + workdir: &Path,
94 + prompt: &str,
95 + cancel_midway: bool,
96 + ) -> (StopReason, Arc<Mutex<Captured>>) {
97 + let data_dir = temp_dir("sf-data");
98 + let agent = AcpAgent::new(agent_config(
99 + env!("CARGO_BIN_EXE_splitfire-agent"),
100 + base_url,
101 + &data_dir,
102 + ));
103 + let captured = self.captured.clone();
104 + let captured_notify = captured.clone();
105 + let policy = self.permission_policy;
106 + let fs_calls = self.fs_calls.clone();
107 + // Owned per-connection values: what fs capabilities to advertise, and what
108 + // the fake fs/read_text_file should serve.
109 + let advertise_fs = matches!(self.fs_policy, FsPolicy::Advertise { .. });
110 + let serve_content = match &self.fs_policy {
111 + FsPolicy::Advertise { content } => content.clone(),
112 + FsPolicy::NotAdvertised => String::new(),
113 + };
114 + let prompt = prompt.to_string();
115 +
116 + let stop_reason = Client
117 + .builder()
118 + .on_receive_notification(
119 + async move |n: SessionNotification, _cx| {
120 + let mut c = captured_notify.lock().unwrap();
121 + match n.update {
122 + SessionUpdate::AgentMessageChunk(chunk) => {
123 + if let ContentBlock::Text(t) = chunk.content {
124 + c.text.push_str(&t.text);
125 + }
126 + }
127 + SessionUpdate::ToolCall(tc) => {
128 + c.tool_calls.push((tc.title.clone(), tc.status));
129 + c.tool_call_details.push((
130 + tc.title.clone(),
131 + tc.locations
132 + .iter()
133 + .map(|l| l.path.clone())
134 + .collect::<Vec<_>>(),
135 + ));
136 + }
137 + SessionUpdate::ToolCallUpdate(upd) => {
138 + if let Some(status) = upd.fields.status {
139 + c.tool_updates
140 + .push((upd.tool_call_id.0.to_string(), status));
141 + }
142 + }
143 + _ => {}
144 + }
145 + Ok(())
146 + },
147 + agent_client_protocol::on_receive_notification!(),
148 + )
149 + .on_receive_request(
150 + async move |req: RequestPermissionRequest, responder, _cx| {
151 + {
152 + let mut c = captured.lock().unwrap();
153 + c.permission_requests += 1;
154 + // Record the diff paths shown with the permission prompt so
155 + // tests can assert the ACP v1 "absolute path" requirement.
156 + for content in req.tool_call.fields.content.iter().flatten() {
157 + if let ToolCallContent::Diff(d) = content {
158 + c.diff_paths.push(d.path.clone());
159 + }
160 + }
161 + }
162 + let wanted = match policy {
163 + PermissionPolicy::AllowAlways => "allow_always",
164 + PermissionPolicy::RejectOnce => "reject_once",
165 + };
166 + // Sanity: the agent must offer the expected choice.
167 + let option = req
168 + .options
169 + .iter()
170 + .find(|o| o.option_id.0.as_ref() == wanted)
171 + .unwrap_or_else(|| {
172 + panic!("agent did not offer '{wanted}' option: {:?}", req.options)
173 + });
174 + responder.respond(RequestPermissionResponse::new(
175 + RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(
176 + PermissionOptionId::new(option.option_id.0.clone()),
177 + )),
178 + ))
179 + },
180 + agent_client_protocol::on_receive_request!(),
181 + )
182 + .on_receive_request(
183 + {
184 + let fs_calls = fs_calls.clone();
185 + let serve_content = serve_content.clone();
186 + async move |req: ReadTextFileRequest, responder, _cx| {
187 + fs_calls.reads.lock().unwrap().push((
188 + req.path.clone(),
189 + req.line,
190 + req.limit,
191 + ));
192 + responder.respond(ReadTextFileResponse::new(serve_content.clone()))
193 + }
194 + },
195 + agent_client_protocol::on_receive_request!(),
196 + )
197 + .on_receive_request(
198 + {
199 + let fs_calls = fs_calls.clone();
200 + async move |req: WriteTextFileRequest, responder, _cx| {
201 + fs_calls
202 + .writes
203 + .lock()
204 + .unwrap()
205 + .push((req.path.clone(), req.content.clone()));
206 + responder.respond(WriteTextFileResponse::new())
207 + }
208 + },
209 + agent_client_protocol::on_receive_request!(),
210 + )
211 + .connect_with(agent, |connection: ConnectionTo<Agent>| async move {
212 + let init = connection
213 + .send_request(
214 + InitializeRequest::new(ProtocolVersion::V1).client_capabilities(
215 + ClientCapabilities::new().fs(if advertise_fs {
216 + FileSystemCapabilities::new()
217 + .read_text_file(true)
218 + .write_text_file(true)
219 + } else {
220 + FileSystemCapabilities::new()
221 + }),
222 + ),
223 + )
224 + .block_task()
225 + .await?;
226 + let agent_info = init.agent_info.expect("agent must report its info");
227 + assert_eq!(agent_info.name, "splitfire-agent");
228 + assert!(init.agent_capabilities.prompt_capabilities.embedded_context);
229 +
230 + let session = connection
231 + .send_request(NewSessionRequest::new(workdir))
232 + .block_task()
233 + .await?;
234 + let session_id = session.session_id;
235 +
236 + if cancel_midway {
237 + // Fire the prompt, cancel shortly after, and observe the stop reason.
238 + let connection2 = connection.clone();
239 + let sid = session_id.clone();
240 + tokio::spawn(async move {
241 + tokio::time::sleep(std::time::Duration::from_millis(150)).await;
242 + let _ = connection2.send_notification(CancelNotification::new(sid));
243 + });
244 + }
245 +
246 + let resp = connection
247 + .send_request(PromptRequest::new(
248 + session_id,
249 + vec![ContentBlock::Text(TextContent::new(prompt))],
250 + ))
251 + .block_task()
252 + .await?;
253 + Ok(resp.stop_reason)
254 + })
255 + .await
256 + .expect("ACP session failed");
257 +
258 + (stop_reason, self.captured.clone())
259 + }
260 +}
261 +
262 +fn temp_workdir() -> PathBuf {
263 + temp_dir("splitfire-test")
264 +}
265 +
266 +// ---------------------------------------------------------------------------
267 +// Tests
268 +// ---------------------------------------------------------------------------
269 +
270 +/// Full happy path: model calls list_directory → write_file → read_file →
271 +/// run_command → final answer, with the user choosing "Always allow".
272 +#[tokio::test]
273 +async fn acp_full_agent_loop_with_tools() {
274 + let script = Script(vec![
275 + Step::ToolCall {
276 + name: "list_directory",
277 + arguments: json!({}),
278 + },
279 + Step::ToolCall {
280 + name: "write_file",
281 + arguments: json!({"path": "hello.txt", "content": "hello from acp test"}),
282 + },
283 + Step::ToolCall {
284 + name: "read_file",
285 + arguments: json!({"path": "hello.txt"}),
286 + },
287 + Step::ToolCall {
288 + name: "run_command",
289 + arguments: json!({"command": "cat hello.txt"}),
290 + },
291 + Step::Final("Done: created hello.txt and verified its contents."),
292 + ]);
293 + let stats = Arc::new(MockStats::default());
294 + let base_url = start_mock_llm(script, stats.clone()).await;
295 + let workdir = temp_workdir();
296 +
297 + let harness = Harness {
298 + captured: Arc::default(),
299 + permission_policy: PermissionPolicy::AllowAlways,
300 + fs_policy: FsPolicy::NotAdvertised,
301 + fs_calls: Arc::default(),
302 + };
303 + let (stop, captured) = harness
304 + .run_prompt(&base_url, &workdir, "Create hello.txt")
305 + .await;
306 +
307 + assert_eq!(stop, StopReason::EndTurn);
308 + let c = captured.lock().unwrap();
309 + assert!(
310 + c.text.contains("Done: created hello.txt"),
311 + "streamed text missing: {}",
312 + c.text
313 + );
314 + // 4 tool calls started; the 2 writes asked for permission (reads/list don't).
315 + assert_eq!(
316 + c.tool_calls.len(),
317 + 4,
318 + "tool call notifications: {:?}",
319 + c.tool_calls
320 + );
321 + assert_eq!(
322 + c.permission_requests, 2,
323 + "write_file + run_command should each ask once"
324 + );
325 + assert!(
326 + c.tool_updates
327 + .iter()
328 + .filter(|(_, s)| *s == ToolCallStatus::Completed)
329 + .count()
330 + == 4,
331 + "all 4 tools should complete: {:?}",
332 + c.tool_updates
333 + );
334 + drop(c);
335 +
336 + // The agent really executed the tools against the local filesystem.
337 + let written = std::fs::read_to_string(workdir.join("hello.txt")).unwrap();
338 + assert_eq!(written, "hello from acp test");
339 +
340 + // The mock endpoint saw the Onde key as the bearer token, and every request carried
341 + // the full tool set (workspace + theory + audio).
342 + assert!(stats.requests.load(Ordering::SeqCst) >= 5);
343 + assert!(
344 + *stats.saw_bearer.lock().unwrap(),
345 + "Authorization: Bearer <ONDE_API_KEY> missing"
346 + );
347 + for payload in stats.payloads.lock().unwrap().iter() {
348 + assert_eq!(payload["model"], common::MODEL);
349 + assert_eq!(
350 + payload["tools"].as_array().unwrap().len(),
351 + common::BASE_TOOL_COUNT
352 + );
353 + }
354 +
355 + std::fs::remove_dir_all(&workdir).ok();
356 +}
357 +
358 +/// When the user rejects a write, the agent must report the tool as failed,
359 +/// tell the model, and the file must not be created.
360 +#[tokio::test]
361 +async fn acp_permission_rejection_blocks_write() {
362 + let script = Script(vec![
363 + Step::ToolCall {
364 + name: "write_file",
365 + arguments: json!({"path": "nope.txt", "content": "should not exist"}),
366 + },
367 + Step::Final("Understood, I won't create the file."),
368 + ]);
369 + let stats = Arc::new(MockStats::default());
370 + let base_url = start_mock_llm(script, stats).await;
371 + let workdir = temp_workdir();
372 +
373 + let harness = Harness {
374 + captured: Arc::default(),
375 + permission_policy: PermissionPolicy::RejectOnce,
376 + fs_policy: FsPolicy::NotAdvertised,
377 + fs_calls: Arc::default(),
378 + };
379 + let (stop, captured) = harness
380 + .run_prompt(&base_url, &workdir, "Create nope.txt")
381 + .await;
382 +
383 + assert_eq!(stop, StopReason::EndTurn);
384 + let c = captured.lock().unwrap();
385 + assert_eq!(c.permission_requests, 1);
386 + assert!(
387 + c.tool_updates
388 + .iter()
389 + .any(|(_, s)| *s == ToolCallStatus::Failed),
390 + "rejected write must fail: {:?}",
391 + c.tool_updates
392 + );
393 + drop(c);
394 + assert!(
395 + !workdir.join("nope.txt").exists(),
396 + "rejected write must not touch disk"
397 + );
398 +
399 + std::fs::remove_dir_all(&workdir).ok();
400 +}
401 +
402 +/// A plain text answer with no tool calls still streams agent_message_chunk.
403 +#[tokio::test]
404 +async fn acp_plain_text_turn() {
405 + let script = Script(vec![Step::Final("The answer is 42.")]);
406 + let stats = Arc::new(MockStats::default());
407 + let base_url = start_mock_llm(script, stats).await;
408 + let workdir = temp_workdir();
409 +
410 + let harness = Harness {
411 + captured: Arc::default(),
412 + permission_policy: PermissionPolicy::AllowAlways,
413 + fs_policy: FsPolicy::NotAdvertised,
414 + fs_calls: Arc::default(),
415 + };
416 + let (stop, captured) = harness
417 + .run_prompt(&base_url, &workdir, "What is the answer?")
418 + .await;
419 +
420 + assert_eq!(stop, StopReason::EndTurn);
421 + let c = captured.lock().unwrap();
422 + assert_eq!(c.text, "The answer is 42.");
423 + assert!(c.tool_calls.is_empty());
424 + assert_eq!(c.permission_requests, 0);
425 +
426 + std::fs::remove_dir_all(&workdir).ok();
427 +}
428 +
429 +/// session/cancel while a command runs must end the turn with Cancelled and
430 +/// still close out every pending tool call.
431 +#[tokio::test]
432 +async fn acp_cancel_stops_turn() {
433 + let script = Script(vec![
434 + Step::ToolCall {
435 + name: "run_command",
436 + arguments: json!({"command": "sleep 30"}),
437 + },
438 + Step::Final("should never get here"),
439 + ]);
440 + let stats = Arc::new(MockStats::default());
441 + let base_url = start_mock_llm(script, stats).await;
442 + let workdir = temp_workdir();
443 +
444 + let harness = Harness {
445 + captured: Arc::default(),
446 + permission_policy: PermissionPolicy::AllowAlways,
447 + fs_policy: FsPolicy::NotAdvertised,
448 + fs_calls: Arc::default(),
449 + };
450 + let (stop, _captured) = harness
451 + .run_prompt_maybe_cancel(&base_url, &workdir, "Run a long command", true)
452 + .await;
453 +
454 + assert_eq!(stop, StopReason::Cancelled);
455 +
456 + std::fs::remove_dir_all(&workdir).ok();
457 +}
458 +
459 +// ---------------------------------------------------------------------------
460 +// ACP v1 fs routing (https://agentclientprotocol.com/protocol/v1/file-system)
461 +// ---------------------------------------------------------------------------
462 +
463 +/// When the client advertises fs.readTextFile/writeTextFile, the agent must
464 +/// route reads and writes through the client instead of the local filesystem:
465 +/// the read returns the client-served (e.g. unsaved-buffer) content, and the
466 +/// write lands as an fs/write_text_file request with an absolute path.
467 +#[tokio::test]
468 +async fn acp_fs_advertised_routes_through_client() {
469 + let script = Script(vec![
470 + Step::ToolCall {
471 + name: "read_file",
472 + arguments: json!({"path": "buffer.txt"}),
473 + },
474 + Step::ToolCall {
475 + name: "write_file",
476 + arguments: json!({"path": "out.txt", "content": "written via client"}),
477 + },
478 + Step::Final("done"),
479 + ]);
480 + let stats = Arc::new(MockStats::default());
481 + let base_url = start_mock_llm(script, stats).await;
482 + let workdir = temp_workdir();
483 + // Disk content differs from what the client serves, like an unsaved buffer.
484 + std::fs::write(workdir.join("buffer.txt"), "stale on-disk content").unwrap();
485 +
486 + let harness = Harness {
487 + captured: Arc::default(),
488 + permission_policy: PermissionPolicy::AllowAlways,
489 + fs_policy: FsPolicy::Advertise {
490 + content: "live buffer content".to_string(),
491 + },
492 + fs_calls: Arc::default(),
493 + };
494 + let (stop, _captured) = harness
495 + .run_prompt(&base_url, &workdir, "Read and write files")
496 + .await;
497 + assert_eq!(stop, StopReason::EndTurn);
498 +
499 + let f = harness.fs_calls;
500 + let reads = f.reads.lock().unwrap();
501 + assert_eq!(
502 + reads.len(),
503 + 2,
504 + "read_file on buffer.txt + write_file's diff pre-read of out.txt: {:?}",
505 + reads
506 + );
507 + for (path, _line, _limit) in reads.iter() {
508 + assert!(
509 + path.is_absolute(),
510 + "fs/read_text_file path must be absolute, got {}",
511 + path.display()
512 + );
513 + }
514 + // The model's read_file went to buffer.txt, canonicalized to its true path.
515 + assert_eq!(
516 + reads[0].0,
517 + workdir.join("buffer.txt").canonicalize().unwrap()
518 + );
519 + // write_file's diff pre-read went to the not-yet-existing out.txt, absolutized.
520 + assert!(reads[1].0.ends_with("out.txt"));
521 +
522 + let writes = f.writes.lock().unwrap();
523 + assert_eq!(writes.len(), 1);
524 + let (path, content) = &writes[0];
525 + assert!(
526 + path.is_absolute(),
527 + "fs/write_text_file path must be absolute, got {}",
528 + path.display()
529 + );
530 + assert!(path.ends_with("out.txt"));
531 + assert_eq!(content, "written via client");
532 +
533 + // The agent must NOT have written to the local filesystem directly: with
534 + // fs.writeTextFile advertised, the write went through the client.
535 + assert!(
536 + !workdir.join("out.txt").exists(),
537 + "agent wrote to local fs despite advertising fs.writeTextFile"
538 + );
539 +
540 + std::fs::remove_dir_all(&workdir).ok();
541 +}
542 +
543 +/// The read_file result fed back to the model must be the client-served
544 +/// content, proving the agent read through fs/read_text_file rather than disk.
545 +#[tokio::test]
546 +async fn acp_fs_read_uses_client_content_in_conversation() {
547 + let script = Script(vec![
548 + Step::ToolCall {
549 + name: "read_file",
550 + arguments: json!({"path": "buffer.txt", "line": 1, "limit": 5}),
551 + },
552 + Step::Final("done"),
553 + ]);
554 + let stats = Arc::new(MockStats::default());
555 + let base_url = start_mock_llm(script, stats).await;
556 + let workdir = temp_workdir();
557 + std::fs::write(
558 + workdir.join("buffer.txt"),
559 + "one\ntwo\nthree\nfour\nfive\nsix\n",
560 + )
561 + .unwrap();
562 +
563 + let harness = Harness {
564 + captured: Arc::default(),
565 + permission_policy: PermissionPolicy::AllowAlways,
566 + fs_policy: FsPolicy::Advertise {
567 + content: "client-served line".to_string(),
568 + },
569 + fs_calls: Arc::default(),
570 + };
571 + let (stop, _captured) = harness.run_prompt(&base_url, &workdir, "Read a file").await;
572 + assert_eq!(stop, StopReason::EndTurn);
573 +
574 + let reads = harness.fs_calls.reads.lock().unwrap();
575 + assert_eq!(reads.len(), 1);
576 + let (path, line, limit) = &reads[0];
577 + assert!(path.is_absolute());
578 + assert_eq!(*line, Some(1), "1-based `line` must be forwarded");
579 + assert_eq!(*limit, Some(5), "`limit` must be forwarded");
580 +
581 + std::fs::remove_dir_all(&workdir).ok();
582 +}
583 +
584 +/// With fs capabilities absent, the agent MUST NOT send any fs/* requests
585 +/// (spec: "If readTextFile or writeTextFile is false or not present, the Agent
586 +/// MUST NOT attempt to call the corresponding filesystem method") and must fall
587 +/// back to the local filesystem.
588 +#[tokio::test]
589 +async fn acp_fs_not_advertised_falls_back_to_local() {
590 + let script = Script(vec![
591 + Step::ToolCall {
592 + name: "read_file",
593 + arguments: json!({"path": "hello.txt"}),
594 + },
595 + Step::Final("done"),
596 + ]);
597 + let stats = Arc::new(MockStats::default());
598 + let base_url = start_mock_llm(script, stats).await;
599 + let workdir = temp_workdir();
600 + std::fs::write(workdir.join("hello.txt"), "local content").unwrap();
601 +
602 + let harness = Harness {
603 + captured: Arc::default(),
604 + permission_policy: PermissionPolicy::AllowAlways,
605 + fs_policy: FsPolicy::NotAdvertised,
606 + fs_calls: Arc::default(),
607 + };
608 + let (stop, _captured) = harness.run_prompt(&base_url, &workdir, "Read a file").await;
609 + assert_eq!(stop, StopReason::EndTurn);
610 +
611 + assert!(
612 + harness.fs_calls.reads.lock().unwrap().is_empty(),
613 + "agent sent fs/read_text_file despite no advertised fs capability"
614 + );
615 + assert!(
616 + harness.fs_calls.writes.lock().unwrap().is_empty(),
617 + "agent sent fs/write_text_file despite no advertised fs capability"
618 + );
619 +
620 + std::fs::remove_dir_all(&workdir).ok();
621 +}
622 +
623 +/// ACP v1 requires absolute paths on every path the agent sends to the client:
624 +/// `ToolCallLocation.path`, `Diff.path`, `fs/read_text_file` and
625 +/// `fs/write_text_file` paths (spec: https://agentclientprotocol.com/protocol/v1).
626 +/// The model passes relative paths; the agent must absolutize them.
627 +#[tokio::test]
628 +async fn acp_tool_metadata_paths_are_absolute() {
629 + let script = Script(vec![
630 + Step::ToolCall {
631 + name: "write_file",
632 + arguments: json!({"path": "new/dir/created.txt", "content": "hi"}),
633 + },
634 + Step::Final("done"),
635 + ]);
636 + let stats = Arc::new(MockStats::default());
637 + let base_url = start_mock_llm(script, stats).await;
638 + let workdir = temp_workdir();
639 +
640 + let harness = Harness {
641 + captured: Arc::default(),
642 + permission_policy: PermissionPolicy::AllowAlways,
643 + fs_policy: FsPolicy::NotAdvertised,
644 + fs_calls: Arc::default(),
645 + };
646 + let (stop, captured) = harness
647 + .run_prompt(&base_url, &workdir, "Write a file")
648 + .await;
649 + assert_eq!(stop, StopReason::EndTurn);
650 +
651 + let c = captured.lock().unwrap();
652 + // ToolCallLocation.path must be absolute, even for a file that doesn't exist yet.
653 + let locations = c
654 + .tool_call_details
655 + .iter()
656 + .flat_map(|(_, locs)| locs.iter())
657 + .collect::<Vec<_>>();
658 + assert_eq!(locations.len(), 1, "write_file should report one location");
659 + assert!(
660 + locations[0].is_absolute(),
661 + "ToolCallLocation.path must be absolute, got {}",
662 + locations[0].display()
663 + );
664 + assert!(locations[0].ends_with("new/dir/created.txt"));
665 +
666 + // Diff.path on the permission prompt must be absolute too.
667 + assert_eq!(c.diff_paths.len(), 1, "write_file asks for permission once");
668 + let diff = &c.diff_paths[0];
669 + assert!(
670 + diff.is_absolute(),
671 + "Diff.path must be absolute, got {}",
672 + diff.display()
673 + );
674 + assert!(diff.ends_with("new/dir/created.txt"));
675 + drop(c);
676 +
677 + assert_eq!(
678 + std::fs::read_to_string(workdir.join("new/dir/created.txt")).unwrap(),
679 + "hi"
680 + );
681 +
682 + std::fs::remove_dir_all(&workdir).ok();
683 +}
tests/common/mod.rs new
+210
@@ -0,0 +1,210 @@
1 +//! Shared test support: a scripted OpenAI-compatible streaming endpoint and agent launch config.
2 +#![allow(dead_code)]
3 +
4 +use std::path::{Path, PathBuf};
5 +use std::sync::atomic::{AtomicUsize, Ordering};
6 +use std::sync::{Arc, Mutex};
7 +
8 +use agent_client_protocol::AcpAgentConfig;
9 +use serde_json::{Value, json};
10 +use tokio::io::{AsyncReadExt, AsyncWriteExt};
11 +use tokio::net::TcpListener;
12 +
13 +/// Dummy Onde credentials; the mock asserts they arrive as the bearer token.
14 +pub const TEST_KEY: &str = "test-app:test-secret";
15 +pub const MODEL: &str = "scripted-test-model";
16 +/// 5 workspace tools + 6 theory tools + analyze_audio + list_separations.
17 +pub const BASE_TOOL_COUNT: usize = 13;
18 +
19 +#[derive(Debug, Default)]
20 +pub struct MockStats {
21 + pub requests: AtomicUsize,
22 + pub saw_bearer: Mutex<bool>,
23 + /// Every `/chat/completions` request body, in arrival order.
24 + pub payloads: Mutex<Vec<Value>>,
25 +}
26 +
27 +/// What the scripted model does on its next completion.
28 +#[derive(Clone)]
29 +pub enum Step {
30 + /// Emit a tool call, then wait for the tool result.
31 + ToolCall {
32 + name: &'static str,
33 + arguments: Value,
34 + },
35 + /// Emit final assistant text and finish.
36 + Final(&'static str),
37 + /// Stop with `finish_reason: content_filter`.
38 + ContentFilter,
39 +}
40 +
41 +#[derive(Clone)]
42 +pub struct Script(pub Vec<Step>);
43 +
44 +impl Script {
45 + /// Count completed tool results to know how far into the script we are.
46 + fn step_for(&self, messages: &[Value]) -> (usize, Step) {
47 + let done = messages.iter().filter(|m| m["role"] == "tool").count();
48 + (
49 + done,
50 + self.0
51 + .get(done)
52 + .unwrap_or_else(|| self.0.last().expect("empty script"))
53 + .clone(),
54 + )
55 + }
56 +}
57 +
58 +fn sse_chunk(
59 + content: Option<&str>,
60 + tool_call: Option<(&str, &str, &str)>,
61 + finish: Option<&str>,
62 +) -> String {
63 + let mut delta = json!({});
64 + if let Some(c) = content {
65 + delta["content"] = json!(c);
66 + }
67 + if let Some((id, name, args)) = tool_call {
68 + delta["tool_calls"] = json!([{
69 + "index": 0,
70 + "id": id,
71 + "function": { "name": name, "arguments": args },
72 + }]);
73 + }
74 + let chunk = json!({ "choices": [{ "delta": delta, "finish_reason": finish }] });
75 + format!("data: {chunk}\n\n")
76 +}
77 +
78 +async fn read_http_request(socket: &mut tokio::net::TcpStream) -> (String, String) {
79 + let mut buf = Vec::new();
80 + let mut tmp = [0u8; 4096];
81 + let header_end;
82 + let mut content_length = 0usize;
83 + loop {
84 + let n = socket.read(&mut tmp).await.unwrap();
85 + if n == 0 {
86 + panic!("connection closed before headers complete");
87 + }
88 + buf.extend_from_slice(&tmp[..n]);
89 + if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") {
90 + header_end = pos + 4;
91 + let headers = String::from_utf8_lossy(&buf[..pos]).to_string();
92 + for line in headers.lines() {
93 + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") {
94 + content_length = v.trim().parse().unwrap();
95 + }
96 + }
97 + break;
98 + }
99 + }
100 + while buf.len() < header_end + content_length {
101 + let n = socket.read(&mut tmp).await.unwrap();
102 + if n == 0 {
103 + break;
104 + }
105 + buf.extend_from_slice(&tmp[..n]);
106 + }
107 + let head = String::from_utf8_lossy(&buf[..header_end]).to_string();
108 + let body = String::from_utf8_lossy(&buf[header_end..header_end + content_length]).to_string();
109 + (head, body)
110 +}
111 +
112 +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
113 + haystack.windows(needle.len()).position(|w| w == needle)
114 +}
115 +
116 +async fn respond(socket: &mut tokio::net::TcpStream, content_type: &str, body: &str) {
117 + let resp = format!(
118 + "HTTP/1.1 200 OK\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
119 + body.len()
120 + );
121 + socket.write_all(resp.as_bytes()).await.unwrap();
122 + socket.shutdown().await.unwrap();
123 +}
124 +
125 +/// Spawn the mock server; returns its base URL (e.g. `http://127.0.0.1:PORT/v1`).
126 +pub async fn start_mock_llm(script: Script, stats: Arc<MockStats>) -> String {
127 + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
128 + let port = listener.local_addr().unwrap().port();
129 + tokio::spawn(async move {
130 + loop {
131 + let Ok((mut socket, _)) = listener.accept().await else {
132 + return;
133 + };
134 + let stats = stats.clone();
135 + let script = script.clone();
136 + tokio::spawn(async move {
137 + let (head, body) = read_http_request(&mut socket).await;
138 + let first = head.lines().next().unwrap_or("").to_string();
139 + if first.starts_with("GET /v1/models") {
140 + let list =
141 + json!({"object": "list", "data": [{"id": MODEL}, {"id": "other-model"}]});
142 + return respond(&mut socket, "application/json", &list.to_string()).await;
143 + }
144 + stats.requests.fetch_add(1, Ordering::SeqCst);
145 + if head
146 + .to_ascii_lowercase()
147 + .contains(&format!("authorization: bearer {TEST_KEY}").to_ascii_lowercase())
148 + {
149 + *stats.saw_bearer.lock().unwrap() = true;
150 + }
151 + assert!(
152 + first.starts_with("POST /v1/chat/completions"),
153 + "unexpected request: {first}"
154 + );
155 + let payload: Value = serde_json::from_str(&body).unwrap();
156 + assert_eq!(payload["stream"], true);
157 + stats.payloads.lock().unwrap().push(payload.clone());
158 + let messages = payload["messages"].as_array().unwrap();
159 + let (done, step) = script.step_for(messages);
160 +
161 + let mut data = String::new();
162 + match step {
163 + Step::ToolCall { name, arguments } => {
164 + let id = format!("call_{}", done + 1);
165 + data.push_str(&sse_chunk(
166 + None,
167 + Some((&id, name, &arguments.to_string())),
168 + None,
169 + ));
170 + data.push_str(&sse_chunk(None, None, Some("tool_calls")));
171 + }
172 + Step::Final(text) => {
173 + data.push_str(&sse_chunk(Some(text), None, None));
174 + data.push_str(&sse_chunk(None, None, Some("stop")));
175 + // `stream_options.include_usage`: a final chunk with no choices.
176 + if payload.get("stream_options").is_some() {
177 + let usage = json!({"choices": [], "usage": {
178 + "prompt_tokens": 120, "completion_tokens": 30, "total_tokens": 150
179 + }});
180 + data.push_str(&format!("data: {usage}\n\n"));
181 + }
182 + }
183 + Step::ContentFilter => {
184 + data.push_str(&sse_chunk(None, None, Some("content_filter")));
185 + }
186 + }
187 + data.push_str("data: [DONE]\n\n");
188 + respond(&mut socket, "text/event-stream", &data).await;
189 + });
190 + }
191 + });
192 + format!("http://127.0.0.1:{port}/v1")
193 +}
194 +
195 +pub fn temp_dir(prefix: &str) -> PathBuf {
196 + let dir = std::env::temp_dir().join(format!("{prefix}-{}", uuid::Uuid::new_v4()));
197 + std::fs::create_dir_all(&dir).unwrap();
198 + dir
199 +}
200 +
201 +/// Launch config for the real binary: mock endpoint, dummy key, isolated data dir, and no
202 +/// built-in demucs unless a test opts in.
203 +pub fn agent_config(binary: &str, base_url: &str, data_dir: &Path) -> AcpAgentConfig {
204 + AcpAgentConfig::new(binary)
205 + .env("SPLITFIRE_BASE_URL", base_url)
206 + .env("SPLITFIRE_MODEL", MODEL)
207 + .env("ONDE_API_KEY", TEST_KEY)
208 + .env("SPLITFIRE_DATA_DIR", data_dir.display().to_string())
209 + .env("SPLITFIRE_MCP_DEMUCS", "off")
210 +}
tests/live.rs new
+141
@@ -0,0 +1,141 @@
1 +//! Live smoke tests against the real Onde Cloud endpoint (cloud.ondeinference.com).
2 +//!
3 +//! Skipped unless `ONDE_API_KEY` (format `app-id:app-secret`) is set: they hit the production
4 +//! service and cost real tokens, so they are for manual or scheduled verification.
5 +//!
6 +//! Run with: ONDE_API_KEY=app-id:app-secret cargo test --test live -- --nocapture
7 +
8 +use std::sync::{Arc, Mutex};
9 +
10 +const BIN: &str = env!("CARGO_BIN_EXE_splitfire-agent");
11 +
12 +fn live_key() -> Option<String> {
13 + std::env::var("ONDE_API_KEY").ok().filter(|k| !k.is_empty())
14 +}
15 +
16 +/// A clean command: no stored config, no overrides leaking in from the developer's shell.
17 +fn clean_cmd(data_dir: &std::path::Path) -> std::process::Command {
18 + let mut cmd = std::process::Command::new(BIN);
19 + cmd.env("SPLITFIRE_DATA_DIR", data_dir)
20 + .env("SPLITFIRE_MCP_DEMUCS", "off")
21 + .env_remove("SPLITFIRE_BASE_URL")
22 + .env_remove("SPLITFIRE_MODEL")
23 + .env_remove("SPLITFIRE_MODELS");
24 + cmd
25 +}
26 +
27 +#[test]
28 +fn onde_lists_models() {
29 + if live_key().is_none() {
30 + eprintln!("skipping: ONDE_API_KEY not set");
31 + return;
32 + }
33 + let dir = std::env::temp_dir().join(format!("sf-live-{}", uuid::Uuid::new_v4()));
34 + let out = clean_cmd(&dir)
35 + .arg("--list-models")
36 + .output()
37 + .expect("failed to run splitfire-agent --list-models");
38 + let stdout = String::from_utf8_lossy(&out.stdout);
39 + let stderr = String::from_utf8_lossy(&out.stderr);
40 + assert!(
41 + out.status.success(),
42 + "--list-models failed\nstdout: {stdout}\nstderr: {stderr}"
43 + );
44 + let ids: Vec<&str> = stdout
45 + .lines()
46 + .map(|l| l.split('\t').next().unwrap())
47 + .collect();
48 + assert!(!ids.is_empty(), "endpoint returned no models");
49 + assert!(
50 + ids.iter().any(|id| id.starts_with("onde")),
51 + "expected at least one onde-* model, got: {ids:?}"
52 + );
53 +}
54 +
55 +/// One completion through the ACP path that must use a theory tool: ask for the C# major
56 +/// scale and expect the exact spelling (E# and B#) in the answer.
57 +#[tokio::test(flavor = "multi_thread")]
58 +async fn onde_answers_with_theory_tool() {
59 + use agent_client_protocol::schema::ProtocolVersion;
60 + use agent_client_protocol::schema::v1::{
61 + ContentBlock, InitializeRequest, NewSessionRequest, PromptRequest,
62 + RequestPermissionRequest, SessionNotification, SessionUpdate, StopReason, TextContent,
63 + };
64 + use agent_client_protocol::{AcpAgent, AcpAgentConfig, Agent, Client, ConnectionTo};
65 +
66 + if live_key().is_none() {
67 + eprintln!("skipping: ONDE_API_KEY not set");
68 + return;
69 + }
70 + let dir = std::env::temp_dir().join(format!("sf-live-{}", uuid::Uuid::new_v4()));
71 + let text: Arc<Mutex<String>> = Arc::default();
72 + let text_notify = text.clone();
73 + let tools: Arc<Mutex<Vec<String>>> = Arc::default();
74 + let tools_notify = tools.clone();
75 +
76 + let stop_reason = Client
77 + .builder()
78 + .on_receive_notification(
79 + async move |n: SessionNotification, _cx| {
80 + match n.update {
81 + SessionUpdate::AgentMessageChunk(chunk) => {
82 + if let ContentBlock::Text(t) = chunk.content {
83 + text_notify.lock().unwrap().push_str(&t.text);
84 + }
85 + }
86 + SessionUpdate::ToolCall(tc) => tools_notify.lock().unwrap().push(tc.title),
87 + _ => {}
88 + }
89 + Ok(())
90 + },
91 + agent_client_protocol::on_receive_notification!(),
92 + )
93 + .on_receive_request(
94 + // Theory tools are read-only; a permission request means something else ran.
95 + async move |_req: RequestPermissionRequest, responder, _cx| {
96 + responder.respond_with_error(agent_client_protocol::Error::internal_error())
97 + },
98 + agent_client_protocol::on_receive_request!(),
99 + )
100 + .connect_with(
101 + AcpAgent::new(
102 + AcpAgentConfig::new(BIN)
103 + .env("SPLITFIRE_DATA_DIR", dir.display().to_string())
104 + .env("SPLITFIRE_MCP_DEMUCS", "off"),
105 + ),
106 + |connection: ConnectionTo<Agent>| async move {
107 + connection
108 + .send_request(InitializeRequest::new(ProtocolVersion::V1))
109 + .block_task()
110 + .await?;
111 + let session = connection
112 + .send_request(NewSessionRequest::new(std::env::current_dir().unwrap()))
113 + .block_task()
114 + .await?;
115 + let resp = connection
116 + .send_request(PromptRequest::new(
117 + session.session_id,
118 + vec![ContentBlock::Text(TextContent::new(
119 + "Spell the C# major scale. Use your theory tool and list the seven notes.",
120 + ))],
121 + ))
122 + .block_task()
123 + .await?;
124 + Ok(resp.stop_reason)
125 + },
126 + )
127 + .await
128 + .expect("ACP session failed");
129 +
130 + assert_eq!(stop_reason, StopReason::EndTurn);
131 + let text = text.lock().unwrap();
132 + eprintln!(
133 + "tool calls: {:?}\nmodel replied: {}",
134 + tools.lock().unwrap(),
135 + text.trim()
136 + );
137 + assert!(
138 + text.contains("E#") && text.contains("B#"),
139 + "expected exact spelling: {text}"
140 + );
141 +}