diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 3b627ced..93e80370 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,10 +1,10 @@ # Rust Code Quality CI # Created by Vetcoders (c)2026 # -# NOTE: Full build requires macOS with Swift 6.0 (CoreML, Metal). -# GitHub runners have Swift 5.10, so we only run format checks here. -# Full quality gate (clippy, tests) runs locally via: make check -# Or via pre-commit hooks: make hooks +# Runs on self-hosted macOS runners (Swift 6.0 / CoreML / Metal available). +# format: cargo fmt --check +# quality: cargo clippy -D warnings + cargo test (workspace). +# Full local gate (incl. real-API / heavy e2e tests) still via: make check name: Rust Quality @@ -35,13 +35,27 @@ jobs: - name: cargo fmt --check run: cargo fmt --all -- --check - # NOTE: clippy and check disabled - requires macOS + Swift 6.0 - # Run locally: make lint - # - # To enable full CI, use a self-hosted macOS runner: - # quality: - # runs-on: [self-hosted, macos, arm64] - # steps: - # - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - # - run: cargo clippy -- -D warnings - # - run: cargo test --lib + quality: + name: Clippy + Tests + runs-on: self-hosted + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + components: clippy + + - name: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets -- -D warnings + + # Runs the default (non-#[ignore]) workspace test set. + # CODESCRIBE_DISABLE_KEYCHAIN=1: keychain-backed tests must bypass the + # macOS Keychain (no interactive unlock / signed host on the runner) — + # see core/config/keychain.rs and the Makefile `test` target. + # #[ignore]-gated tests (real LLM APIs, Whisper/STT models, audio devices, + # live SSE) are intentionally NOT run here — they need secrets/hardware + # absent on CI and stay behind `make test` / `make test-e2e-real` locally. + - name: cargo test --workspace + env: + CODESCRIBE_DISABLE_KEYCHAIN: "1" + run: cargo test --workspace diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e9e9aa..7bc0bcf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **API key liveness probe in Settings → Keys** (PR #50) — per-key **Test** button runs a background probe and shows a result chip (`Key OK` / `Invalid key` / `No credits (check billing)` / `Network error` / `Not set` / `Unsupported`). LLM keys are probed with a minimal generation request rather than an auth-only endpoint, so exhausted billing (`insufficient_quota`) is distinguished from an invalid key. +- **Guided MCP onboarding + reset app data** (PR #52) — fresh installs with no `mcp.json` now get a short explainer with **Set up MCP servers** (deep-links into Settings → Engine) and **Skip for now**, instead of a dead-end wall; Settings → Engine gains a danger-zone **Reset app data…** action with a two-step destructive confirmation and an opt-in checkbox to also remove API keys from the Keychain. + ### Changed - **Public release hygiene** — release packaging, repository metadata, and public-facing docs are being aligned for a current `v0.12.x` public release. @@ -15,6 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Legacy fallback threads persisted on AI failure** (PR #51) — when the AI runtime is unavailable, the legacy assistive fallback no longer persists a conversation thread for `Failed`/`Skipped` attempts (previously created repeated "AI Failed" junk threads cluttering the history rail); `Applied`/`AiNoop` outcomes are still persisted as before. +- **MCP setup prompt never appeared** (PR #53) — `probe_mcp_status` now reports a `configured` flag so onboarding can tell "no `mcp.json`" apart from "servers configured," fixing the guided MCP setup prompt from PR #52 that was dead code because the row-count check was always true. +- **MCP setup deep-link did nothing** (PR #54) — the onboarding "Set up MCP servers" action now opens Settings via the SwiftUI `openSettings` environment action instead of a dead AppKit `showSettingsWindow:` selector that has no responder in this accessory (LSUIElement) app. - **Silero VAD reload leak** — the Silero ONNX session is now compiled once and shared process-wide instead of being rebuilt per recording (which leaked native ORT memory over long sessions). - **Allocator retention** — freed transient buffers are returned to the OS after each recording (`malloc_zone_pressure_relief` on macOS) instead of inflating the resident footprint across a session. diff --git a/Makefile b/Makefile index db6a4eca..38b5112d 100644 --- a/Makefile +++ b/Makefile @@ -264,17 +264,21 @@ test-quick: test-e2e: @$(TEST_SETUP); \ + set -o pipefail; \ echo "=== E2E Tests (mock) ===" | tee -a "$$LOG"; \ $(ENV_LOAD); $(APPLY_TEST_LLM); \ - cargo test e2e --release -- --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test e2e --release -- --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "Done. Log: $$LOG" | tee -a "$$LOG" test-e2e-real: @$(TEST_SETUP); \ + set -o pipefail; \ echo "=== E2E Tests (real API) ===" | tee -a "$$LOG"; \ echo "Requires: LLM_API_KEY, LLM_ASSISTIVE_API_KEY" | tee -a "$$LOG"; \ $(ENV_LOAD); $(APPLY_TEST_LLM); \ - cargo test e2e --release -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test e2e --release -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "Done. Log: $$LOG" | tee -a "$$LOG" test-sse: @@ -284,11 +288,13 @@ test-sse: TEST_SSE_PROFILE="$(TEST_SSE_PROFILE)" CARGO_BUILD_JOBS="$(TEST_SSE_CARGO_JOBS)" ./scripts/test-sse-preflight.sh 2>&1 | tee -a "$$LOG"; \ $(ENV_LOAD); $(APPLY_TEST_LLM); \ CARGO_BUILD_JOBS="$(TEST_SSE_CARGO_JOBS)" \ - cargo test --test e2e_sse_streaming $(TEST_SSE_PROFILE_ARGS) -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test --test e2e_sse_streaming $(TEST_SSE_PROFILE_ARGS) -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ if [[ "$${CODESCRIBE_TEST_SSE_RESPONSES:-0}" == "1" ]]; then \ echo "=== Responses Live Chain/Resume Tests ===" | tee -a "$$LOG"; \ $(ENV_LOAD); CODESCRIBE_E2E_RESPONSES=1 CARGO_BUILD_JOBS="$(TEST_SSE_CARGO_JOBS)" \ - cargo test --test e2e_retry_responses -- --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test --test e2e_retry_responses -- --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ else \ echo "Skipping Responses Live Chain/Resume Tests (set CODESCRIBE_TEST_SSE_RESPONSES=1)." | tee -a "$$LOG"; \ fi; \ @@ -305,25 +311,32 @@ test-sse-heavy: test-formatting: @$(TEST_SETUP); \ + set -o pipefail; \ echo "=== AI Formatting Tests ===" | tee -a "$$LOG"; \ $(ENV_LOAD); $(APPLY_TEST_LLM); \ - cargo test formatting --release -- --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test formatting --release -- --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "Done. Log: $$LOG" | tee -a "$$LOG" test-all: @$(TEST_SETUP); \ + set -o pipefail; \ echo "=== Full Test Suite ===" | tee -a "$$LOG"; \ $(ENV_LOAD); $(APPLY_TEST_LLM); \ - cargo test --workspace --all-targets -- --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test --workspace --all-targets -- --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "=== Ignored / Real API ===" | tee -a "$$LOG"; \ $(ENV_LOAD); $(APPLY_TEST_LLM); \ - cargo test --workspace --all-targets -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test --workspace --all-targets -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "=== Full Pipeline (STT) ===" | tee -a "$$LOG"; \ $(ENV_LOAD); CODESCRIBE_E2E_STT=1 \ - cargo test --test e2e_full_pipeline -- --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test --test e2e_full_pipeline -- --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "=== SSE Streaming ===" | tee -a "$$LOG"; \ $(ENV_LOAD); $(APPLY_TEST_LLM); \ - cargo test e2e_sse --release -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test e2e_sse --release -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "Done. Log: $$LOG" | tee -a "$$LOG" demo: @@ -342,7 +355,7 @@ check: @echo "=== Format Check (Rust) ===" @cargo fmt --all -- --check @echo "=== Format Check (non-Rust) ===" - @npx --yes prettier@2.7.1 --check . --ignore-path .prettierignore --ignore-unknown || true + @npx --yes prettier@2.7.1 --check . --ignore-path .prettierignore --ignore-unknown @echo "=== Clippy (workspace, all targets) ===" @cargo clippy --workspace --all-targets -- -D warnings @echo "=== Semgrep ===" diff --git a/NOTICE b/NOTICE index 989e2ec3..d577f9d4 100644 --- a/NOTICE +++ b/NOTICE @@ -1,4 +1,4 @@ -CodeScribe Notices +Codescribe Notices ================== Portions of the account authentication foundation are derived from @@ -10,5 +10,5 @@ Derived files: - core/llm/account_auth/device_code.rs - core/llm/account_auth/server.rs -The derived portions retain Apache-2.0 attribution. CodeScribe's repository +The derived portions retain Apache-2.0 attribution. Codescribe's repository license metadata remains authoritative for this project. diff --git a/app/os/clipboard.rs b/app/os/clipboard.rs index 5b57c0f6..ed64dbb9 100644 --- a/app/os/clipboard.rs +++ b/app/os/clipboard.rs @@ -414,9 +414,15 @@ mod tests { fn test_set_and_get_clipboard() { let _guard = ClipboardTestGuard::capture(); let test_text = "Test clipboard content"; - set_clipboard(test_text).expect("Failed to set clipboard"); - - let retrieved = get_clipboard().expect("Failed to get clipboard"); + let Some(()) = skip_if_clipboard_unavailable(set_clipboard(test_text), "set clipboard") + else { + return; + }; + + let Some(retrieved) = skip_if_clipboard_unavailable(get_clipboard(), "get clipboard") + else { + return; + }; assert_eq!(retrieved, test_text); } @@ -434,10 +440,19 @@ mod tests { fn test_clipboard_snapshot_capture() { let _guard = ClipboardTestGuard::capture(); // Set some text - set_clipboard("Test snapshot content").expect("Failed to set clipboard"); + let Some(()) = skip_if_clipboard_unavailable( + set_clipboard("Test snapshot content"), + "set snapshot clipboard", + ) else { + return; + }; // Capture snapshot - let snapshot = ClipboardSnapshot::capture().expect("Failed to capture snapshot"); + let Some(snapshot) = + skip_if_clipboard_unavailable(ClipboardSnapshot::capture(), "capture snapshot") + else { + return; + }; // Should have text assert!(snapshot.text.is_some()); @@ -451,22 +466,53 @@ mod tests { let _guard = ClipboardTestGuard::capture(); // Set original content let original = "Original clipboard text"; - set_clipboard(original).expect("Failed to set clipboard"); + let Some(()) = + skip_if_clipboard_unavailable(set_clipboard(original), "set original clipboard") + else { + return; + }; // Capture snapshot - let snapshot = ClipboardSnapshot::capture().expect("Failed to capture snapshot"); + let Some(snapshot) = + skip_if_clipboard_unavailable(ClipboardSnapshot::capture(), "capture snapshot") + else { + return; + }; // Change clipboard - set_clipboard("Different text").expect("Failed to change clipboard"); + let Some(()) = + skip_if_clipboard_unavailable(set_clipboard("Different text"), "change clipboard") + else { + return; + }; // Restore snapshot - snapshot.restore().expect("Failed to restore snapshot"); + let Some(()) = skip_if_clipboard_unavailable(snapshot.restore(), "restore snapshot") else { + return; + }; // Should match original - let restored = get_clipboard().expect("Failed to get clipboard"); + let Some(restored) = skip_if_clipboard_unavailable(get_clipboard(), "get clipboard") else { + return; + }; assert_eq!(restored, original); } + fn skip_if_clipboard_unavailable(result: Result, action: &str) -> Option { + match result { + Ok(value) => Some(value), + Err(error) if is_clipboard_unavailable(&error) => { + eprintln!("skipping clipboard integration test: {action}: {error:#}"); + None + } + Err(error) => panic!("{action}: {error:#}"), + } + } + + fn is_clipboard_unavailable(error: &anyhow::Error) -> bool { + format!("{error:#}").contains("not supported with the current system configuration") + } + struct ClipboardTestGuard(Option); impl ClipboardTestGuard { diff --git a/bridge/src/recording.rs b/bridge/src/recording.rs index 91cd4189..dd00dd0c 100644 --- a/bridge/src/recording.rs +++ b/bridge/src/recording.rs @@ -742,15 +742,23 @@ mod tests { /// composer UI can never hang indefinitely on a stalled scheduler. #[test] fn compose_stop_timeout_scales_and_clamps() { + fn assert_duration_close(actual: Duration, expected: Duration) { + let drift = actual.abs_diff(expected); + assert!( + drift <= Duration::from_micros(1), + "duration drift {drift:?} exceeded tolerance: actual={actual:?}, expected={expected:?}" + ); + } + // Short note: floored so a cold commit + tail patch still fits. assert_eq!( compose_stop_timeout(Duration::from_secs(3)), Duration::from_secs(8) ); // Mid-length: proportional (20s * 0.6 = 12s) inside the band. - assert_eq!( + assert_duration_close( compose_stop_timeout(Duration::from_secs(20)), - Duration::from_secs(12) + Duration::from_secs(12), ); // Long note: capped so the UI never waits unboundedly. assert_eq!( diff --git a/examples/README.md b/examples/README.md index 3232817c..92a7102d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,45 +1,86 @@ # Codescribe Examples -This directory contains practical examples demonstrating how to use the codescribe audio recording module. +This directory contains practical examples demonstrating how to use the codescribe audio, config, and transcription modules. ## Available Examples -### 1. Basic Recording (`record_test.rs`) +### `config_demo.rs` -Demonstrates basic Recorder usage with auto-silence detection. +Demonstrates the `Config` module: loading config from `.env`/defaults, parsing the `Language` enum, and saving a single value back via `save_to_env`. ```bash -cargo run --example record_test +cargo run --example config_demo ``` -**Features shown:** +### `demo_full_pipeline.rs` -- Creating a Recorder with default config -- Starting/stopping recording -- Auto-silence detection (stops after 0.8s of silence) -- Saving to WAV file -- Getting recording duration and diagnostics +Full pipeline demo covering local Whisper STT transcription, AI formatting (normal mode), and AI assistive mode (kurier/enhancer) on a given audio file. -### 2. Streaming Mode (`record_streaming.rs`) +```bash +cargo run --release --example demo_full_pipeline -- +cargo run --release --example demo_full_pipeline -- --assistive +``` + +Requires a local Whisper model (default `~/.codescribe/models/whisper-large-v3-turbo-mlx-q8`, override with `--model`) and `LLM_ENDPOINT`/`LLM_MODEL` (or `LLM_FORMATTING_*` overrides) for the formatting step. + +### `e2e_stt.rs` + +End-to-end STT smoke check against sample audio files, verifying the model/tokenizer are present before transcribing. + +```bash +cargo run --example e2e_stt +``` + +Sample audio paths are configurable via `CODESCRIBE_E2E_AUDIO_MEDIUM`, `CODESCRIBE_E2E_AUDIO_SHORT`, and `CODESCRIBE_E2E_LANG`. + +### `roundtrip_live.rs` + +Interactive round-trip demo: speak into the mic, transcribe with Whisper, synthesize with TTS, play back through the speaker, transcribe again, and compare the two transcripts. + +```bash +cargo run --release --example roundtrip_live +cargo run --release --example roundtrip_live -- --text "Hello world" +``` -Demonstrates advanced usage with live snapshots for streaming STT. +### `test_audio_long.rs` + +Batch-transcribes one or more long audio files with the local Whisper engine and reports load/transcription timing. + +```bash +cargo run --release --example test_audio_long -- [--model PATH] ... +``` + +### `test_audio.rs` + +Transcribes one or more audio files with language detection, printing detected language and transcription timing per file. + +```bash +cargo run --release --example test_audio -- ... +``` + +### `test_clipboard_snapshot.rs` + +Demonstrates clipboard snapshot/restore and smart-paste behavior (`ClipboardSnapshot`, `paste_text_smart`, `paste_and_restore`). ```bash -cargo run --example record_streaming +cargo run --example test_clipboard_snapshot ``` -**Features shown:** +### `transcribe_file.rs` -- Custom configuration (disabling auto-silence) -- Taking periodic snapshots while recording -- Manual control over recording lifecycle -- Use case: Real-time transcription with streaming API +Quick transcription utility for a single (typically large) audio file, with optional explicit language. + +```bash +cargo run --release --example transcribe_file -- /path/to/audio.wav [language] +``` ## Environment Variables -Both examples respect these environment variables: +Several examples respect these environment variables: - `AUTO_SILENCE` - Enable/disable silence detection (default: true) +- `CODESCRIBE_E2E_AUDIO_MEDIUM`, `CODESCRIBE_E2E_AUDIO_SHORT`, `CODESCRIBE_E2E_LANG` - sample inputs for `e2e_stt` +- `LLM_ENDPOINT`, `LLM_MODEL`, `LLM_FORMATTING_*` - formatting provider config for `demo_full_pipeline` VAD internals are hardcoded in `core/vad/config.rs` (Silero defaults). @@ -47,7 +88,7 @@ VAD internals are hardcoded in `core/vad/config.rs` (Silero defaults). - macOS (uses CoreAudio via cpal) - Microphone access permissions -- Rust 1.70+ with tokio runtime +- Rust 1.85+ (edition 2024) with tokio runtime --- diff --git a/scripts/download-embedder.sh b/scripts/download-embedder.sh index a8cc204b..952144de 100755 --- a/scripts/download-embedder.sh +++ b/scripts/download-embedder.sh @@ -36,16 +36,24 @@ elif "$HF_BIN" auth whoami &>/dev/null; then echo "▶ Using cached HuggingFace credentials" else echo "⚠ No HuggingFace authentication found" - echo "" - echo " If the model is gated, you need to authenticate:" - echo " 1. Create token at https://huggingface.co/settings/tokens" - echo " 2. Run: hf auth login" - echo " 3. Or set: export HF_TOKEN=hf_xxx" - echo "" - read -p " Continue without auth? (y/n) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - exit 1 + if [[ "${CI:-}" == "true" || ! -t 0 ]]; then + # Non-interactive (CI or no TTY): never block on a prompt. + # The default embedder is public and downloads without auth; a gated + # model needs HF_TOKEN, in which case the download below fails clearly. + echo " Non-interactive mode: proceeding without auth." + echo " If the model is gated, set HF_TOKEN=hf_xxx and re-run." + else + echo "" + echo " If the model is gated, you need to authenticate:" + echo " 1. Create token at https://huggingface.co/settings/tokens" + echo " 2. Run: hf auth login" + echo " 3. Or set: export HF_TOKEN=hf_xxx" + echo "" + read -p " Continue without auth? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi fi fi diff --git a/scripts/download-model.sh b/scripts/download-model.sh index d22a9e65..23a119d8 100755 --- a/scripts/download-model.sh +++ b/scripts/download-model.sh @@ -53,16 +53,24 @@ elif "$HF_BIN" auth whoami &>/dev/null; then echo "▶ Using cached HuggingFace credentials" else echo "⚠ No HuggingFace authentication found" - echo "" - echo " For gated models, you need to authenticate:" - echo " 1. Create token at https://huggingface.co/settings/tokens" - echo " 2. Run: hf auth login" - echo " 3. Or set: export HF_TOKEN=hf_xxx" - echo "" - read -p " Continue without auth? (y/n) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - exit 1 + if [[ "${CI:-}" == "true" || ! -t 0 ]]; then + # Non-interactive (CI or no TTY): never block on a prompt. + # The default model is public and downloads without auth; a gated + # model needs HF_TOKEN, in which case the download below fails clearly. + echo " Non-interactive mode: proceeding without auth." + echo " If the model is gated, set HF_TOKEN=hf_xxx and re-run." + else + echo "" + echo " For gated models, you need to authenticate:" + echo " 1. Create token at https://huggingface.co/settings/tokens" + echo " 2. Run: hf auth login" + echo " 3. Or set: export HF_TOKEN=hf_xxx" + echo "" + read -p " Continue without auth? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi fi fi