From e088d4a6a5c5b626cc446f135cb7ad81d3263214 Mon Sep 17 00:00:00 2001 From: jettwang Date: Fri, 24 Jul 2026 14:58:04 +0800 Subject: [PATCH 1/4] feat(scorekit): settings-pinned binary path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Settings → scorekit binary: an absolute-path pin that beats PATH and well-known prefixes (SCOREBENCH_SCOREKIT still overrides), so machines with several scorekit versions can choose one without env-var gymnastics. The startup handshake reports which discovery channel won, the tested range widens to >=0.3.0, <0.5.0, and the pin is re-seeded on every settings save. Docs and acceptance matrix updated. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 4 +- docs-site/src/getting-started.md | 4 +- docs-site/src/troubleshooting.md | 2 +- docs/roadmap.md | 4 +- src-tauri/src/lib.rs | 21 ++- src-tauri/src/scorekit.rs | 166 +++++++++++++++++++--- src-tauri/src/settings.rs | 39 +++++ src-tauri/tests/fixtures/doctor.json | 2 +- src-tauri/tests/fixtures/forest.meta.json | 63 ++++++++ src/lib/api.ts | 4 + src/lib/components/SettingsModal.svelte | 37 +++++ src/lib/i18n.svelte.ts | 9 ++ 12 files changed, 325 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 44cfba1..b51da2f 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Install scorebench from [Releases](https://github.com/talkincode/scorebench/rele - Linux: use the `.deb` or `.AppImage` artifact. - Windows: use the `.msi` or `-setup.exe` artifact. -Install ScoreKit 0.3.x separately (for example with Homebrew `brew install talkincode/tap/scorekit`, or from [ScoreKit Releases](https://github.com/talkincode/scorekit/releases)), then confirm: +Install ScoreKit 0.3.x–0.4.x separately (for example with Homebrew `brew install talkincode/tap/scorekit`, or from [ScoreKit Releases](https://github.com/talkincode/scorekit/releases)), then confirm: ```bash brew trust --tap talkincode/tap # when installing ScoreKit via Homebrew tap @@ -89,7 +89,7 @@ scorekit --version scorekit doctor ``` -If scorebench starts but cannot find ScoreKit, set `SCOREBENCH_SCOREKIT` to the absolute path of the ScoreKit executable and restart the app. +If scorebench starts but cannot find ScoreKit, pin the executable's absolute path in Settings → scorekit binary (also the way to choose between several installed versions), or set `SCOREBENCH_SCOREKIT` and restart the app. ## Development (Contributors) diff --git a/docs-site/src/getting-started.md b/docs-site/src/getting-started.md index ea50cf2..7dcd68c 100644 --- a/docs-site/src/getting-started.md +++ b/docs-site/src/getting-started.md @@ -4,7 +4,7 @@ This chapter takes the shortest path through the complete loop: install the depe ## 1. Install scorebench and ScoreKit -scorebench needs the ScoreKit CLI at runtime. The desktop app does not bundle the CLI. This version is tested with ScoreKit 0.3.x. +scorebench needs the ScoreKit CLI at runtime. The desktop app does not bundle the CLI. This version is tested with ScoreKit 0.3.x–0.4.x. ### macOS (Homebrew, recommended) @@ -50,7 +50,7 @@ scorekit doctor `scorekit doctor` should confirm FFmpeg, at least one renderer, and the default sound source status. -> If scorebench cannot locate the command, set `SCOREBENCH_SCOREKIT` to the absolute path of the ScoreKit executable. GUI applications on macOS often receive a shorter `PATH` than terminal shells, so a command can work in Terminal and still be invisible to the app. +> If scorebench cannot locate the command, pin the ScoreKit executable's absolute path in Settings → scorekit binary (or set `SCOREBENCH_SCOREKIT`). GUI applications on macOS often receive a shorter `PATH` than terminal shells, so a command can work in Terminal and still be invisible to the app. ## 2. Create a project directory diff --git a/docs-site/src/troubleshooting.md b/docs-site/src/troubleshooting.md index a59dc31..4cf7457 100644 --- a/docs-site/src/troubleshooting.md +++ b/docs-site/src/troubleshooting.md @@ -12,7 +12,7 @@ scorekit --version scorekit doctor ``` -If the CLI works in a terminal but not in the desktop application, set `SCOREBENCH_SCOREKIT` to the executable's absolute path and restart scorebench. Confirm that the current user can execute the file. +If the CLI works in a terminal but not in the desktop application, pin the executable's absolute path in Settings → scorekit binary. The same field selects one copy when several ScoreKit versions are installed; the version block underneath shows which path and discovery channel are active. `SCOREBENCH_SCOREKIT` still works and overrides the settings pin, but requires restarting scorebench. Confirm that the current user can execute the file. ## Homebrew says `untrusted tap` diff --git a/docs/roadmap.md b/docs/roadmap.md index 062667a..bf0ed20 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -38,7 +38,7 @@ Technique is *relocated*, not eliminated: the user is freed from craft, but the One window that opens a project directory, shows a chat panel wired to a stub agent loop, invokes `scorekit doctor --json` / `build --json` as subprocess tools, and plays a rendered OGG with a canvas spectrum. Proves the four seams: chat ↔ agent core, agent ↔ scorekit subprocess, disk ↔ project state, audio ↔ WebAudio. -Findings: scorekit's machine contract is *failure-side* JSON — success is exit 0 plus (for `build`) the atomically-written `.meta.json`, which scorebench treats as the build result; human stdout is never parsed. The binary is located via `SCOREBENCH_SCOREKIT` > PATH > well-known prefixes (GUI apps on macOS launch with a stripped PATH). Asset bytes cross IPC as binary (`tauri::ipc::Response`) with a containment check pinning reads inside the project root; playback, FFT, and the spectrum stay entirely in the webview (WebAudio `AnalyserNode`), honoring the no-in-house-audio rule. The July 2026 GUI smoke covered native project opening, both renderers, OGG/WAV playback, seek/pause/loop, live spectrum switching, filesystem watching, scorekit-missing guidance, and the 960×640 minimum layout contract. M1 replaced the temporary slash-command stub with the real ReACT loop while preserving the tagged `AgentEvent` frontend contract. +Findings: scorekit's machine contract is *failure-side* JSON — success is exit 0 plus (for `build`) the atomically-written `.meta.json`, which scorebench treats as the build result; human stdout is never parsed. The binary is located via `SCOREBENCH_SCOREKIT` > the settings pin (Settings → scorekit binary, for machines carrying several scorekit versions; the handshake reports which channel won) > PATH > well-known prefixes (GUI apps on macOS launch with a stripped PATH). Asset bytes cross IPC as binary (`tauri::ipc::Response`) with a containment check pinning reads inside the project root; playback, FFT, and the spectrum stay entirely in the webview (WebAudio `AnalyserNode`), honoring the no-in-house-audio rule. The July 2026 GUI smoke covered native project opening, both renderers, OGG/WAV playback, seek/pause/loop, live spectrum switching, filesystem watching, scorekit-missing guidance, and the 960×640 minimum layout contract. M1 replaced the temporary slash-command stub with the real ReACT loop while preserving the tagged `AgentEvent` frontend contract. ### M1 — Agent core (status: complete) @@ -129,7 +129,7 @@ Rules (MUST): | Feature | Tier | Happy path | Failure path | | --- | --- | --- | --- | -| scorekit binary discovery | 1 | integration tests run via PATH-located binary | `locate_missing_everywhere_is_typed_error`, `locate_env_override_must_be_executable` (`scorekit.rs`) | +| scorekit binary discovery | 1 | integration tests run via PATH-located binary | `locate_missing_everywhere_is_typed_error`, `locate_env_override_must_be_executable`, `locate_settings_pin_wins_over_path_and_well_known`, `locate_settings_pin_must_be_executable` (`scorekit.rs`) | | scorekit error contract (`--json` stderr) | 1 | `doctor_fixture_shape_holds`, `meta_fixture_shape_holds` (recorded fixtures) | `parses_recorded_io_error`, `falls_back_on_non_json_stderr` | | Build param → CLI arg mapping | 1 | `build_params_render_full_arg_set` covers renderer and `--texture-profile` | n/a (pure function, no state) | | Project directory scan | 1 | `scan_finds_scenes_and_assets` | `scan_rejects_non_directory` | diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 80642b3..c00df44 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -687,9 +687,14 @@ async fn get_settings(app: AppHandle) -> Result Result<(), BenchError> { let config_dir = app.path().app_config_dir().map_err(BenchError::io)?; - tauri::async_runtime::spawn_blocking(move || settings::save(&config_dir, &value)) - .await - .map_err(BenchError::io)? + tauri::async_runtime::spawn_blocking(move || { + settings::save(&config_dir, &value)?; + // Keep the process-wide scorekit pin in sync with what was persisted. + scorekit::set_configured_path(value.scorekit_path.map(PathBuf::from)); + Ok(()) + }) + .await + .map_err(BenchError::io)? } #[tauri::command] @@ -722,6 +727,16 @@ pub fn run() { .manage(agent::AgentState::default()) .manage(watcher::ProjectWatcher::default()) .manage(RecordingSink::default()) + .setup(|app| { + // Seed the scorekit settings pin before the first locate() call. + // A corrupt settings file degrades to auto-discovery; it must + // never block startup. + let config_dir = app.path().app_config_dir()?; + if let Ok((settings, _)) = settings::load(&config_dir) { + scorekit::set_configured_path(settings.scorekit_path.map(PathBuf::from)); + } + Ok(()) + }) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ diff --git a/src-tauri/src/scorekit.rs b/src-tauri/src/scorekit.rs index 3a60c4b..c008777 100644 --- a/src-tauri/src/scorekit.rs +++ b/src-tauri/src/scorekit.rs @@ -1,6 +1,6 @@ //! Subprocess boundary to the `scorekit` CLI. //! -//! Contract (recorded through scorekit 0.3.0, see `tests/fixtures/`): +//! Contract (recorded through scorekit 0.4.0, see `tests/fixtures/`): //! - success: exit 0; `build` writes `.meta.json` as the machine-readable result //! - failure: stderr carries one JSON object `{code, exit_code, field, location, message}` //! - `doctor --json`: stdout JSON report @@ -9,6 +9,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::RwLock; use semver::{Version, VersionReq}; use serde::{Deserialize, Serialize}; @@ -18,13 +19,42 @@ use crate::error::BenchError; /// Environment variable that pins the scorekit binary explicitly. pub const SCOREKIT_ENV: &str = "SCOREBENCH_SCOREKIT"; -pub const TESTED_SCOREKIT_RANGE: &str = ">=0.3.0, <0.4.0"; +pub const TESTED_SCOREKIT_RANGE: &str = ">=0.3.0, <0.5.0"; + +/// Settings-pinned binary path, seeded by the host layer at startup and +/// whenever settings are saved. Held here (not re-read from disk) so core +/// callers stay synchronous and framework-free. +static CONFIGURED_PATH: RwLock> = RwLock::new(None); + +pub fn set_configured_path(path: Option) { + *CONFIGURED_PATH.write().expect("configured scorekit lock") = path; +} + +fn configured_path() -> Option { + CONFIGURED_PATH + .read() + .expect("configured scorekit lock") + .clone() +} + +/// Which channel `locate` resolved the binary through — surfaced in the +/// settings panel so a machine with several scorekit installs shows which +/// copy is active and why. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum LocateSource { + Env, + Settings, + Path, + WellKnown, +} #[derive(Debug, Clone, Serialize)] pub struct Handshake { pub found: bool, pub ready: bool, pub path: Option, + pub source: Option, pub version: Option, pub tested_range: String, pub compatible: Option, @@ -34,30 +64,32 @@ pub struct Handshake { } pub fn handshake() -> Handshake { - let path = match locate() { - Ok(path) => path, + let (path, source) = match locate_traced() { + Ok(found) => found, Err(error) => { return Handshake { found: false, ready: false, path: None, + source: None, version: None, tested_range: TESTED_SCOREKIT_RANGE.into(), compatible: None, doctor: None, hints: vec![ - "Install scorekit, then restart scorebench or set SCOREBENCH_SCOREKIT.".into(), + "Install scorekit, then pin its path in Settings or restart scorebench.".into(), ], warning: Some(error.to_string()), }; } }; match doctor() { - Ok(report) => handshake_from_report(path, report), + Ok(report) => handshake_from_report(path, source, report), Err(error) => Handshake { found: true, ready: false, path: Some(path), + source: Some(source), version: None, tested_range: TESTED_SCOREKIT_RANGE.into(), compatible: None, @@ -68,7 +100,7 @@ pub fn handshake() -> Handshake { } } -fn handshake_from_report(path: PathBuf, report: Value) -> Handshake { +fn handshake_from_report(path: PathBuf, source: LocateSource, report: Value) -> Handshake { let ready = report .get("ready") .and_then(Value::as_bool) @@ -120,6 +152,7 @@ fn handshake_from_report(path: PathBuf, report: Value) -> Handshake { found: true, ready, path: Some(path), + source: Some(source), version, tested_range: TESTED_SCOREKIT_RANGE.into(), compatible, @@ -129,13 +162,20 @@ fn handshake_from_report(path: PathBuf, report: Value) -> Handshake { } } -/// Locate the scorekit binary: explicit env override, then PATH, then the -/// well-known install prefixes (GUI apps on macOS get a stripped PATH). +/// Locate the scorekit binary: explicit env override, then the settings pin, +/// then PATH, then the well-known install prefixes (GUI apps on macOS get a +/// stripped PATH). pub fn locate() -> Result { + locate_traced().map(|(path, _)| path) +} + +/// Like [`locate`], but also reports which channel won. +pub fn locate_traced() -> Result<(PathBuf, LocateSource), BenchError> { let home = std::env::var_os("HOME").map(PathBuf::from); let candidates = well_known_candidates(home.as_deref()); locate_with( std::env::var_os(SCOREKIT_ENV).map(PathBuf::from), + configured_path(), std::env::var_os("PATH"), &candidates, ) @@ -152,14 +192,18 @@ fn well_known_candidates(home: Option<&Path>) -> Vec { } /// Pure locator core, unit-testable without touching the real environment. +/// A pinned-but-invalid path (env or settings) is a hard error rather than a +/// silent fallback: with several local versions installed, running the wrong +/// one is worse than running none. pub fn locate_with( env_override: Option, + configured: Option, path_var: Option, well_known: &[PathBuf], -) -> Result { +) -> Result<(PathBuf, LocateSource), BenchError> { if let Some(explicit) = env_override { if is_executable(&explicit) { - return Ok(explicit); + return Ok((explicit, LocateSource::Env)); } return Err(BenchError::ScorekitMissing { message: format!( @@ -168,23 +212,35 @@ pub fn locate_with( ), }); } + if let Some(pinned) = configured { + if is_executable(&pinned) { + return Ok((pinned, LocateSource::Settings)); + } + return Err(BenchError::ScorekitMissing { + message: format!( + "Settings pin scorekit to `{}` but it is not an executable file; \ + fix the path in Settings or clear it to use auto-discovery", + pinned.display() + ), + }); + } if let Some(path_var) = path_var { for dir in std::env::split_paths(&path_var) { let candidate = dir.join("scorekit"); if is_executable(&candidate) { - return Ok(candidate); + return Ok((candidate, LocateSource::Path)); } } } for candidate in well_known { if is_executable(candidate) { - return Ok(candidate.clone()); + return Ok((candidate.clone(), LocateSource::WellKnown)); } } Err(BenchError::ScorekitMissing { message: format!( "scorekit not found on PATH or in ~/.local/bin, /opt/homebrew/bin, /usr/local/bin; \ - install it (`make install` in the scorekit repo) or set {SCOREKIT_ENV}" + install it (`make install` in the scorekit repo), pin its path in Settings, or set {SCOREKIT_ENV}" ), }) } @@ -458,7 +514,7 @@ mod tests { ); assert_eq!( value.get("scorekit_version").and_then(Value::as_str), - Some("0.3.0") + Some("0.4.0") ); } @@ -472,13 +528,14 @@ mod tests { #[test] fn locate_missing_everywhere_is_typed_error() { - let err = locate_with(None, None, &[]).unwrap_err(); + let err = locate_with(None, None, None, &[]).unwrap_err(); assert!(matches!(err, BenchError::ScorekitMissing { .. })); } #[test] fn locate_env_override_must_be_executable() { - let err = locate_with(Some(PathBuf::from("/definitely/not/here")), None, &[]).unwrap_err(); + let err = + locate_with(Some(PathBuf::from("/definitely/not/here")), None, None, &[]).unwrap_err(); match err { BenchError::ScorekitMissing { message } => { assert!(message.contains(SCOREKIT_ENV)); @@ -487,6 +544,65 @@ mod tests { } } + /// Creates a real executable file so `is_executable` passes. + fn temp_executable(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("scorebench-locate-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("scorekit"); + std::fs::write(&path, "#!/bin/sh\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + path + } + + #[test] + fn locate_settings_pin_wins_over_path_and_well_known() { + let pinned = temp_executable("pinned"); + let fallback = temp_executable("fallback"); + + let (found, source) = locate_with( + None, + Some(pinned.clone()), + Some(std::env::join_paths([fallback.parent().unwrap()]).unwrap()), + std::slice::from_ref(&fallback), + ) + .unwrap(); + assert_eq!(found, pinned); + assert_eq!(source, LocateSource::Settings); + + // The explicit env override still outranks the settings pin. + let (found, source) = + locate_with(Some(fallback.clone()), Some(pinned.clone()), None, &[]).unwrap(); + assert_eq!(found, fallback); + assert_eq!(source, LocateSource::Env); + + // Without a pin, discovery falls through to the well-known prefixes. + let (found, source) = + locate_with(None, None, None, std::slice::from_ref(&fallback)).unwrap(); + assert_eq!(found, fallback); + assert_eq!(source, LocateSource::WellKnown); + + for path in [pinned, fallback] { + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + } + + #[test] + fn locate_settings_pin_must_be_executable() { + let err = + locate_with(None, Some(PathBuf::from("/definitely/not/here")), None, &[]).unwrap_err(); + match err { + BenchError::ScorekitMissing { message } => { + assert!(message.contains("Settings"), "message was: {message}"); + } + other => panic!("expected ScorekitMissing, got {other:?}"), + } + } + #[test] fn build_params_render_full_arg_set() { let params = BuildParams { @@ -533,15 +649,26 @@ mod tests { fn handshake_gates_machine_readable_version() { let report = serde_json::json!({ "ready": true, - "scorekit_version": "0.3.0", + "scorekit_version": "0.4.0", "hints": ["install a renderer"] }); - let handshake = handshake_from_report(PathBuf::from("scorekit"), report); + let handshake = + handshake_from_report(PathBuf::from("scorekit"), LocateSource::Path, report); assert_eq!(handshake.compatible, Some(true)); assert_eq!(handshake.hints, vec!["install a renderer"]); + assert_eq!(handshake.source, Some(LocateSource::Path)); + + // 0.3.x stays inside the tested range: both recorded contracts hold. + let floor = handshake_from_report( + PathBuf::from("scorekit"), + LocateSource::Path, + serde_json::json!({"ready":true,"scorekit_version":"0.3.0","hints":[]}), + ); + assert_eq!(floor.compatible, Some(true)); let outdated = handshake_from_report( PathBuf::from("scorekit"), + LocateSource::Settings, serde_json::json!({"ready":true,"scorekit_version":"0.2.3","hints":[]}), ); assert_eq!(outdated.compatible, Some(false)); @@ -552,6 +679,7 @@ mod tests { let legacy = handshake_from_report( PathBuf::from("scorekit"), + LocateSource::WellKnown, serde_json::json!({"ready":true,"hints":[]}), ); assert_eq!(legacy.compatible, None); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index c82762a..57d6671 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -26,6 +26,10 @@ pub struct Settings { pub model: String, pub context_budget_tokens: u64, pub max_turns: u32, + /// Absolute path pinning the scorekit binary; `None` means auto-discovery + /// (PATH, then well-known prefixes). Lets a machine with several scorekit + /// versions choose one without env-var gymnastics. + pub scorekit_path: Option, pub spectrum_style: String, pub spectrum_bars: u16, /// Spectrum palette hue override in degrees; `None` follows `theme_hue`. @@ -46,6 +50,7 @@ impl Default for Settings { model: "gpt-5.6".into(), context_budget_tokens: 128_000, max_turns: 16, + scorekit_path: None, spectrum_style: "bars".into(), spectrum_bars: 64, spectrum_hue: None, @@ -85,6 +90,22 @@ impl Settings { "max turns must be between 1 and 128", )); } + if let Some(path) = &self.scorekit_path { + // Shape-only checks: a binary deleted later must not make the + // settings file unloadable. Existence is verified at locate time. + if path.trim().is_empty() { + return Err(BenchError::settings( + "invalid_scorekit_path", + "scorekit path cannot be blank; remove it to use auto-discovery", + )); + } + if !Path::new(path.as_str()).is_absolute() { + return Err(BenchError::settings( + "invalid_scorekit_path", + "scorekit path must be an absolute path to the binary", + )); + } + } if self.spectrum_style.trim().is_empty() { return Err(BenchError::settings( "invalid_spectrum_style", @@ -600,6 +621,7 @@ mod tests { model: "local-model".into(), context_budget_tokens: 32_000, max_turns: 8, + scorekit_path: Some("/usr/local/bin/scorekit-0.3".into()), spectrum_style: "mood".into(), spectrum_bars: 96, spectrum_hue: Some(318), @@ -627,6 +649,23 @@ mod tests { let _ = fs::remove_dir_all(dir); } + #[test] + fn rejects_relative_or_blank_scorekit_path() { + let dir = test_dir("settings-scorekit-path"); + for bad in ["scorekit", " "] { + let value = Settings { + scorekit_path: Some(bad.into()), + ..Settings::default() + }; + let error = save(&dir, &value).unwrap_err(); + assert!( + matches!(error, BenchError::Settings { code, .. } if code == "invalid_scorekit_path") + ); + } + assert!(!dir.join(SETTINGS_FILE).exists()); + let _ = fs::remove_dir_all(dir); + } + #[test] fn rejects_theme_hue_outside_css_hue_range() { let dir = test_dir("settings-theme-hue"); diff --git a/src-tauri/tests/fixtures/doctor.json b/src-tauri/tests/fixtures/doctor.json index ebb781f..b7fcdeb 100644 --- a/src-tauri/tests/fixtures/doctor.json +++ b/src-tauri/tests/fixtures/doctor.json @@ -1 +1 @@ -{"hints":["Platform macos/aarch64: use release asset scorekit-aarch64-apple-darwin.tar.gz.","Install the standard toolchain with `brew install fluid-synth timidity ffmpeg`.","Apple Silicon: sfizz has no native Homebrew formula or official arm64 renderer; from a scorekit source checkout run `make sfizz`.","Run `make install-default-soundfont` when MuseScore_General.sf2 is missing; SFZ rendering still requires a user-supplied library/profile."],"platform":{"arch":"aarch64","os":"macos","release_asset":"scorekit-aarch64-apple-darwin.tar.gz","target":"aarch64-apple-darwin"},"ready":true,"requirements":{"ffmpeg":true,"renderer":true},"scorekit_version":"0.3.0","sound_library":{"default_soundfont":{"path":"/Users/wangjuntao/.local/share/scorekit/sounds/sf2/MuseScore_General.sf2","status":"ok"},"path":"/Users/wangjuntao/.local/share/scorekit/sounds","source":"platform default"},"tools":[{"name":"ffmpeg","path":"/opt/homebrew/bin/ffmpeg","role":"audio export","status":"ok","version":"ffmpeg version 8.1.1 Copyright (c) 2000-2026 the FFmpeg developers"},{"name":"fluidsynth","path":"/opt/homebrew/bin/fluidsynth","role":"SF2 renderer","status":"ok","version":"FluidSynth runtime version 2.5.6"},{"name":"timidity","path":"/opt/homebrew/bin/timidity","role":"alternate SF2 renderer","status":"ok","version":"TiMidity++ version 2.15.0"},{"name":"sfizz_render","path":"/Users/wangjuntao/.local/bin/sfizz_render","role":"SFZ renderer","status":"ok","version":"Render a midi file through an SFZ file using the sfizz library."}]} +{"hints":["Platform macos/aarch64: use release asset scorekit-aarch64-apple-darwin.tar.gz.","Install the standard toolchain with `brew install fluid-synth timidity ffmpeg`.","Install optional SFZ rendering with `brew install talkincode/tap/scorekit-sfizz` or build from source with `make sfizz`.","Apple Silicon: scorekit-sfizz builds sfizz_render from source because upstream macOS binaries are x86_64-only.","Run `make install-default-soundfont` when MuseScore_General.sf2 is missing; SFZ rendering still requires a user-supplied library/profile."],"platform":{"arch":"aarch64","os":"macos","release_asset":"scorekit-aarch64-apple-darwin.tar.gz","target":"aarch64-apple-darwin"},"ready":true,"requirements":{"ffmpeg":true,"renderer":true},"scorekit_version":"0.4.0","sound_library":{"default_soundfont":{"path":"/Volumes/WT9/ScoreData/sf2/MuseScore_General.sf2","status":"ok"},"path":"/Volumes/WT9/ScoreData","source":"SCOREKIT_SOUND_LIBRARY_DIR"},"tools":[{"name":"ffmpeg","path":"/opt/homebrew/bin/ffmpeg","role":"audio export","status":"ok","version":"ffmpeg version 8.1.2 Copyright (c) 2000-2026 the FFmpeg developers"},{"name":"fluidsynth","path":"/opt/homebrew/bin/fluidsynth","role":"SF2 renderer","status":"ok","version":"FluidSynth runtime version 2.5.6"},{"name":"timidity","path":"/opt/homebrew/bin/timidity","role":"alternate SF2 renderer","status":"ok","version":"TiMidity++ version 2.15.0"},{"name":"sfizz_render","path":"/Users/wangjuntao/.local/bin/sfizz_render","role":"SFZ renderer","status":"ok","version":"Render a midi file through an SFZ file using the sfizz library."}]} diff --git a/src-tauri/tests/fixtures/forest.meta.json b/src-tauri/tests/fixtures/forest.meta.json index 997763c..aea577b 100644 --- a/src-tauri/tests/fixtures/forest.meta.json +++ b/src-tauri/tests/fixtures/forest.meta.json @@ -2,12 +2,75 @@ "audio": "forest.ogg", "bars": 8, "crossfade_samples": 2205, + "instrument_resolution": { + "fallbacks": [], + "missing_instruments": [], + "mode": "conservative", + "summary": { + "alias": 0, + "exact": 4, + "fallback": 0, + "missing": 0, + "rejected": 0 + }, + "tracks": [ + { + "canonical": "strings", + "reasons": [ + "available" + ], + "requested": "strings", + "resolved": "strings", + "score": 1.0, + "status": "exact", + "track": 0, + "warnings": [] + }, + { + "canonical": "piano", + "reasons": [ + "available" + ], + "requested": "piano", + "resolved": "piano", + "score": 1.0, + "status": "exact", + "track": 1, + "warnings": [] + }, + { + "canonical": "bass", + "reasons": [ + "available" + ], + "requested": "bass", + "resolved": "bass", + "score": 1.0, + "status": "exact", + "track": 2, + "warnings": [] + }, + { + "canonical": "drums", + "reasons": [ + "available" + ], + "requested": "drums", + "resolved": "drums", + "score": 1.0, + "status": "exact", + "track": 3, + "warnings": [] + } + ] + }, "key": "D_minor", "loop": true, "loop_samples": 920347, "sample_rate": 44100, "seconds": 20.86954648526077, "stems": [], + "story": null, "tempo": 92, "time_signature": "4/4", "title": "Forest Theme", diff --git a/src/lib/api.ts b/src/lib/api.ts index abf9b8c..9784856 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -45,6 +45,8 @@ export interface Settings { model: string; context_budget_tokens: number; max_turns: number; + /** Absolute path pinning the scorekit binary; null = auto-discovery. */ + scorekit_path: string | null; spectrum_style: string; spectrum_bars: number; /** Spectrum palette hue override in degrees; null follows `theme_hue`. */ @@ -180,6 +182,8 @@ export interface ScorekitHandshake { found: boolean; ready: boolean; path?: string | null; + /** Which discovery channel won: env pin, settings pin, PATH, or a well-known prefix. */ + source?: "env" | "settings" | "path" | "well-known" | null; version?: string | null; tested_range: string; compatible?: boolean | null; diff --git a/src/lib/components/SettingsModal.svelte b/src/lib/components/SettingsModal.svelte index 386d156..e585251 100644 --- a/src/lib/components/SettingsModal.svelte +++ b/src/lib/components/SettingsModal.svelte @@ -1,4 +1,5 @@ @@ -288,13 +357,14 @@ {:else}
{#each bench.messages as message} -
+
{#if message.role === "tool"} {message.text} {#if message.detail}
{t("chat.output")}
{message.detail}
{/if} {:else} {message.text} + {#if message.provisional}{t("chat.pendingVerification")}{/if} {#if message.attachments?.length} {#each message.attachments as name}⎘ {name}{/each} @@ -303,7 +373,9 @@ {/if}
{/each} - {#if bench.agentBusy}
{/if} + {#if bench.agentBusy && !bench.messages.some((message) => message.provisional)} +
+ {/if}
{/if}
@@ -406,6 +478,8 @@ .msg { max-width: 82%; padding: 9px 12px; border-radius: 9px; font-size: 12px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; } .msg.user { align-self: flex-end; background: var(--accent-soft); border: 1px solid var(--accent-line); } .msg.agent { align-self: flex-start; background: var(--panel-raised); border: 1px solid var(--line); } + .msg.agent.provisional { display: flex; flex-direction: column; gap: 6px; border-style: dashed; } + .msg.agent.provisional small { color: var(--fg-dim); font: 9px var(--mono); letter-spacing: .04em; text-transform: uppercase; } .msg.tool { align-self: stretch; max-width: 100%; color: var(--fg-dim); background: var(--control-bg); border: 1px solid var(--line); border-radius: 5px; font: 11px var(--mono); } .msg.tool .dot { display: inline-block; width: 5px; height: 5px; margin-right: 7px; border-radius: 50%; background: var(--fg-muted); vertical-align: 1px; } .msg.tool.run .dot { background: var(--warning); box-shadow: 0 0 7px var(--warning); } diff --git a/src/lib/components/ScenePanel.svelte b/src/lib/components/ScenePanel.svelte index f371436..cff479f 100644 --- a/src/lib/components/ScenePanel.svelte +++ b/src/lib/components/ScenePanel.svelte @@ -354,6 +354,14 @@ profile: inspection.texture_profile.profile_name ?? inspection.texture_profile.profile, sources: inspection.texture_profile.missing.join(", "), })}

+ {:else} + {#each inspection.texture_profile.mode_mismatches ?? [] as mismatch} +

{t("panel.textureModeMismatch", { + source: mismatch.source, + requested: mismatch.requested, + available: mismatch.available.join(", "), + })}

+ {/each} {/if} {/if} {#if inspection.validation.error}

{errorText(inspection.validation.error)}

{/if} @@ -432,6 +440,14 @@ profile: inspection.texture_profile.profile_name ?? inspection.texture_profile.profile ?? "—", sources: inspection.texture_profile.missing.join(", "), })}

+ {:else} + {#each inspection.texture_profile.mode_mismatches ?? [] as mismatch} +

{t("panel.textureModeMismatch", { + source: mismatch.source, + requested: mismatch.requested, + available: mismatch.available.join(", "), + })}

+ {/each} {/if} {/if}